Compare commits

...

46 Commits

Author SHA1 Message Date
Shantur Rathore
06be455358 bump version to 0.4.0 2025-12-15 16:32:29 +00:00
Alex Crouch
450f5bf0b4 change copy to act only on individual assitant/user blocks 2025-12-15 16:10:19 +00:00
Alex Crouch
997d4f4129 feat(ui): add copy button to message items
Add a Copy button that allows users to copy raw message contents
(text and reasoning parts) to clipboard. The button appears on all
messages - alongside Revert/Fork for user messages, and standalone
for assistant messages.
2025-12-15 16:10:19 +00:00
Shantur Rathore
ff5c698131 Refactor instance metadata handling 2025-12-15 16:08:28 +00:00
Shantur Rathore
14497f2082 Limit instance info scroll area 2025-12-15 10:37:08 +00:00
Shantur Rathore
f3e1966b5d Rename control panel to status panel 2025-12-15 10:09:51 +00:00
Shantur Rathore
78592f229e Fix long plan item layout 2025-12-15 10:09:18 +00:00
Shantur Rathore
c8161669ac Add shared instance metadata context 2025-12-15 00:42:16 +00:00
Shantur Rathore
8ec57da275 Tweak control panel plan styling 2025-12-14 23:36:38 +00:00
Shantur Rathore
c00b29145a limit cached session views on tab switch 2025-12-14 17:07:17 +00:00
Shantur Rathore
7d2a349e95 Lower AppBar z-index for timeline tooltips 2025-12-14 16:43:43 +00:00
Shantur Rathore
6c326b18ca Close floating drawers on escape key 2025-12-14 16:42:31 +00:00
Shantur Rathore
09229259d1 Ensure agent selector popover overlays drawer 2025-12-14 16:39:28 +00:00
Shantur Rathore
b20bfc34b2 Fix selector shortcut popovers with floating drawer 2025-12-14 16:36:34 +00:00
Shantur Rathore
4e1f08bfcf Trigger selector popups after auto-opening drawer 2025-12-14 16:33:53 +00:00
Shantur Rathore
ef4f8ac45f Route agent/model shortcuts through sidebar events 2025-12-14 16:30:31 +00:00
Shantur Rathore
6a7255d9d2 Auto-open left drawer for selector shortcuts 2025-12-14 16:26:37 +00:00
Shantur Rathore
f37fcaed3d Open left drawer for selector and session shortcuts 2025-12-14 16:22:30 +00:00
Shantur Rathore
d9fd22c29f Raise selector popover layer above drawers 2025-12-14 16:16:55 +00:00
Shantur Rathore
3fcab5b80a Add timeline divider and fix session scroll 2025-12-14 16:13:22 +00:00
Shantur Rathore
4ed2361387 Reduce scroll jitter from virtual items 2025-12-14 15:55:09 +00:00
Shantur Rathore
75b3699649 Show latest todowrite plan in control panel 2025-12-14 15:05:09 +00:00
Shantur Rathore
a6404f25d9 Add control panel accordion for session sidebar 2025-12-14 14:09:07 +00:00
Shantur Rathore
7591e5c1c9 Add MCP toggle control 2025-12-14 13:40:32 +00:00
Shantur Rathore
5e8b3fd5c9 Keep session chrome in info view 2025-12-14 13:24:47 +00:00
Shantur Rathore
20b82496a1 Persist drawer pin preferences 2025-12-14 13:13:43 +00:00
Shantur Rathore
542b59940a Add resizable session drawers 2025-12-14 13:01:29 +00:00
Shantur Rathore
8d5c6b37e9 Prevent welcome resume list overflow 2025-12-14 12:50:00 +00:00
Shantur Rathore
8155fc9956 Ensure welcome and palette layouts wrap on phone 2025-12-14 12:40:00 +00:00
Shantur Rathore
cd4afb5314 Clamp phone shell horizontal overflow 2025-12-14 12:31:26 +00:00
Shantur Rathore
557c2500c7 Clean up legacy instance shell and theme additions 2025-12-14 01:55:50 +00:00
Shantur Rathore
74f8b6c31f Remove instance shell overflow scroll 2025-12-14 01:54:32 +00:00
Shantur Rathore
da517416a5 Hide app bar during folder selection and tighten toolbar 2025-12-14 01:52:34 +00:00
Shantur Rathore
b8f93bf768 Tighten phone app bar and compact palette control 2025-12-14 01:43:04 +00:00
Shantur Rathore
0110052758 Add mobile-friendly instance shell app bar 2025-12-14 01:34:31 +00:00
Shantur Rathore
0e0da1a142 Show diagnostics only for edited file 2025-12-13 22:23:37 +00:00
Shantur Rathore
da3b66a3bd Ensure Tauri CLI locates server in AppImage 2025-12-13 21:54:04 +00:00
Shantur Rathore
088e5f1eea Align prompt input area with action column 2025-12-13 13:20:33 +00:00
Shantur Rathore
0da2e1d7bb Sync tool-call titles and task summaries 2025-12-12 13:51:40 +00:00
Shantur Rathore
90c6835ee7 Defer tool markdown render while running 2025-12-12 12:00:42 +00:00
Shantur Rathore
92bef8bfb8 Memoize markdown renders per part revision 2025-12-12 12:00:31 +00:00
Shantur Rathore
766be00ded Make message list bottom-first with append-only timeline 2025-12-12 12:00:19 +00:00
Shantur Rathore
ce5eaa1841 Use async prompt API and SDK bump 2025-12-09 21:42:29 +00:00
Shantur Rathore
c323667729 Preserve session scroll when returning 2025-12-09 21:29:48 +00:00
Shantur Rathore
67a12d6126 Add session rename dialogs and API wiring 2025-12-09 20:13:35 +00:00
Shantur Rathore
bd0cb04b78 Avoid queued badge in timeline previews 2025-12-09 18:20:38 +00:00
53 changed files with 3344 additions and 1139 deletions

145
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -1276,9 +1276,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.0.133",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.133.tgz",
"integrity": "sha512-kM+VJJ09SU51aruQ78DSy+6CjNc4wMytvGBrZ1IIJ8etUIdGA59wrnIOSxBVs4u/Gb9pjjgsF8sWp59UdLWP9w=="
"version": "1.0.138",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.138.tgz",
"integrity": "sha512-9vXmpiAVVrhMZ3YNr7BGScyULFLyN0vnRx7iCDtN5qQDKxtsdQcXSQCz35XiVyD3A8lH5KOf5Zn0ByLYXuNeFQ=="
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
@@ -1296,6 +1296,16 @@
"node": ">=14"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz",
@@ -1531,6 +1541,109 @@
"solid-js": "^1.8.6"
}
},
"node_modules/@suid/base": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@suid/base/-/base-0.11.0.tgz",
"integrity": "sha512-jNe+LlXuxfkSZo8/MP9koqYYWswucDWSCwc7ViqUhQ0Y/V7sP2RiQ/Bnms+ePSMBZsk5k1b9fAjvj7DtNbbHXw==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.11.8",
"@suid/css": "0.4.1",
"@suid/system": "0.14.0",
"@suid/types": "0.8.0",
"@suid/utils": "0.11.0",
"clsx": "^2.1.1"
},
"peerDependencies": {
"solid-js": "^1.9.7"
}
},
"node_modules/@suid/css": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@suid/css/-/css-0.4.1.tgz",
"integrity": "sha512-Hsi4O3dBOm7rrlqKoWfNoTeRFAXm/7TPaeEmyxNx+wFaT3eROjMVdhadAIiagFT+PsHrq/6fDauUI5TkL+5Zvg==",
"license": "MIT"
},
"node_modules/@suid/icons-material": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@suid/icons-material/-/icons-material-0.9.0.tgz",
"integrity": "sha512-2idgaT/JARd12dwDfocZBQizaiZVgR0ujRsVc61OlAuPZbeH+3TrSxUJkE3Z7+TPftw9+6p0A24GhJjJLvi6RQ==",
"license": "MIT",
"dependencies": {
"@suid/material": "0.19.0"
},
"peerDependencies": {
"solid-js": "^1.9.7"
}
},
"node_modules/@suid/material": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@suid/material/-/material-0.19.0.tgz",
"integrity": "sha512-vfudxYpHdur5CWTjd3eBb7q1b6A9X/pDWTEf2twc0gXVTcErS9VtY/VPBLa65AzO2SPJsdjAE+BCdVZiXASBbA==",
"license": "MIT",
"dependencies": {
"@suid/base": "0.11.0",
"@suid/css": "0.4.1",
"@suid/system": "0.14.0",
"@suid/types": "0.8.0",
"@suid/utils": "0.11.0",
"clsx": "^2.1.1"
},
"peerDependencies": {
"solid-js": "^1.9.7"
}
},
"node_modules/@suid/styled-engine": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@suid/styled-engine/-/styled-engine-0.9.0.tgz",
"integrity": "sha512-IfNHjQ3Im63mFIjFl/doiwdn5qbwgcwi/vUXnX7dmIUC/Cw1f3LPhzVT9V8Z3eqyvvFToy53O+BsuLy2e/WmDw==",
"license": "MIT",
"dependencies": {
"@suid/css": "0.4.1",
"@suid/utils": "0.11.0"
},
"peerDependencies": {
"solid-js": "^1.9.7"
}
},
"node_modules/@suid/system": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@suid/system/-/system-0.14.0.tgz",
"integrity": "sha512-aRVilPP53hHkqyAyQp2pasT/u8aQCcELwU4kFDnt3b+rj4fsPQRlhMumlX5mZ5aijIboH1CngU6TDG6Z9Mr3UA==",
"license": "MIT",
"dependencies": {
"@suid/css": "0.4.1",
"@suid/styled-engine": "0.9.0",
"@suid/types": "0.8.0",
"@suid/utils": "0.11.0",
"clsx": "^2.1.1",
"csstype": "^3.1.3"
},
"peerDependencies": {
"solid-js": "^1.9.7"
}
},
"node_modules/@suid/types": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@suid/types/-/types-0.8.0.tgz",
"integrity": "sha512-/Z2abkbypMjF6ygSpnjqnWohcmPqvgw8Xpx1wPPHeh+LajBP2imNT6uEa5dBqNEkJY8O3wEUCVqErAad/rmn5Q==",
"license": "MIT",
"peerDependencies": {
"solid-js": "^1.9.7"
}
},
"node_modules/@suid/utils": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@suid/utils/-/utils-0.11.0.tgz",
"integrity": "sha512-dk+6YJkex9kcU2qQHCOk8J0/zkOKKbng0SsjC0LBLyBrf2OC3OtDQq7o22pH3m/8CU/0M6uyM7tnyzZA4eWF3Q==",
"license": "MIT",
"dependencies": {
"@suid/types": "0.8.0"
},
"peerDependencies": {
"solid-js": "^1.9.7"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
@@ -3102,6 +3215,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -8815,7 +8937,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -8843,7 +8965,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -8882,19 +9004,22 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.3.0",
"version": "0.4.0",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "^1.0.133",
"@opencode-ai/sdk": "^1.0.138",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.3.0",
"version": "0.4.0",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.3.0",
"version": "0.4.0",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",

View File

@@ -622,6 +622,18 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots {
candidates.push(Some(root.join("server/dist/bin.js")));
candidates.push(Some(root.join("server/dist/index.js")));
candidates.push(Some(root.join("server/dist/server/bin.js")));
candidates.push(Some(root.join("server/dist/server/index.js")));
candidates.push(Some(root.join("resources/server/dist/bin.js")));
candidates.push(Some(root.join("resources/server/dist/index.js")));
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
@@ -12,8 +12,11 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "^1.0.133",
"@opencode-ai/sdk": "^1.0.138",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",

View File

@@ -6,9 +6,11 @@ import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell"
import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
@@ -23,6 +25,7 @@ import {
showFolderSelection,
setShowFolderSelection,
} from "./stores/ui"
import { instances as instanceStore } from "./stores/instances"
import { useConfig } from "./stores/preferences"
import {
createInstance,
@@ -65,6 +68,13 @@ const App: Component = () => {
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
}
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
@@ -74,6 +84,19 @@ const App: Component = () => {
initReleaseNotifications()
})
createEffect(() => {
instances()
hasInstances()
requestAnimationFrame(() => updateInstanceTabBarHeight())
})
onMount(() => {
updateInstanceTabBarHeight()
const handleResize = () => updateInstanceTabBarHeight()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
const activeInstance = createMemo(() => getActiveInstance())
const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance()
@@ -328,20 +351,26 @@ const App: Component = () => {
<For each={Array.from(instances().values())}>
{(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id
return (
<div class="flex-1 min-h-0" style={{ display: isActiveInstance() ? "flex" : "none" }}>
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
/>
</div>
)
const isVisible = () => isActiveInstance() && !showFolderSelection()
return (
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
<InstanceMetadataProvider instance={instance}>
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={instanceTabBarHeight()}
/>
</InstanceMetadataProvider>
</div>
)
}}
</For>

View File

@@ -114,7 +114,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover max-h-80 overflow-auto p-1 z-50">
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>

View File

@@ -224,11 +224,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return (
<>
<div
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 relative"
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
style="background-color: var(--surface-secondary)"
>
<div
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<Show when={props.onOpenRemoteAccess}>

View File

@@ -1,134 +1,26 @@
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
import type { Instance, RawMcpStatus } from "../types/instance"
import { fetchLspStatus, updateInstance } from "../stores/instances"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
import { Component, For, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
interface InstanceInfoProps {
instance: Instance
compact?: boolean
}
type ParsedMcpStatus = {
name: string
status: "running" | "stopped" | "error"
error?: string
}
function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] {
if (!status || typeof status !== "object") return []
const result: ParsedMcpStatus[] = []
for (const [name, value] of Object.entries(status)) {
if (!value || typeof value !== "object") continue
const rawStatus = (value as { status?: string }).status
if (!rawStatus) continue
let mappedStatus: ParsedMcpStatus["status"]
if (rawStatus === "connected") {
mappedStatus = "running"
} else if (rawStatus === "failed") {
mappedStatus = "error"
} else {
mappedStatus = "stopped"
}
result.push({
name,
status: mappedStatus,
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
})
}
return result
}
const pendingMetadataRequests = new Set<string>()
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
const metadataContext = useOptionalInstanceMetadataContext()
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const metadata = () => props.instance.metadata
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
const mcpServers = () => {
const status = metadata()?.mcpStatus
return status ? parseMcpStatus(status) : []
}
const lspServers = () => metadata()?.lspStatus ?? []
createEffect(() => {
const instance = props.instance
const instanceId = instance.id
const client = instance.client
const hasMetadata = Boolean(instance.metadata)
if (!client) {
setIsLoadingMetadata(false)
pendingMetadataRequests.delete(instanceId)
return
}
if (hasMetadata) {
setIsLoadingMetadata(false)
pendingMetadataRequests.delete(instanceId)
return
}
if (pendingMetadataRequests.has(instanceId)) {
setIsLoadingMetadata(true)
return
}
let cancelled = false
pendingMetadataRequests.add(instanceId)
setIsLoadingMetadata(true)
void (async () => {
try {
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
client.project.current(),
client.mcp.status(),
fetchLspStatus(instanceId),
])
if (cancelled) {
return
}
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
const nextMetadata = {
...(instance.metadata ?? {}),
...(project ? { project } : {}),
...(mcpStatus ? { mcpStatus } : {}),
...(lspStatus ? { lspStatus } : {}),
}
if (!nextMetadata.version && instance.binaryVersion) {
nextMetadata.version = instance.binaryVersion
}
updateInstance(instanceId, { metadata: nextMetadata })
} catch (error) {
if (!cancelled) {
log.error("Failed to load instance metadata", error)
}
} finally {
pendingMetadataRequests.delete(instanceId)
if (!cancelled) {
setIsLoadingMetadata(false)
}
}
})()
onCleanup(() => {
cancelled = true
})
const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
const environmentVariables = () => currentInstance().environmentVariables
const environmentEntries = createMemo(() => {
const env = environmentVariables()
return env ? Object.entries(env) : []
})
return (
@@ -140,7 +32,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{props.instance.folder}
{currentInstance().folder}
</div>
</div>
@@ -189,24 +81,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</Show>
<Show when={props.instance.binaryPath}>
<Show when={currentInstance().binaryPath}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Binary Path
</div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{props.instance.binaryPath}
{currentInstance().binaryPath}
</div>
</div>
</Show>
<Show when={props.instance.environmentVariables && Object.keys(props.instance.environmentVariables).length > 0}>
<Show when={environmentEntries().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
Environment Variables ({Object.keys(props.instance.environmentVariables!).length})
Environment Variables ({environmentEntries().length})
</div>
<div class="space-y-1">
<For each={Object.entries(props.instance.environmentVariables!)}>
<For each={environmentEntries()}>
{([key, value]) => (
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
@@ -222,79 +114,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</Show>
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
LSP Servers
</div>
<div class="space-y-1.5">
<For each={lspServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
<span class="text-[11px] text-secondary truncate" title={server.root}>
{server.root}
</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
MCP Servers
</div>
<div class="space-y-1.5">
<For each={mcpServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div
class={`status-dot ${
server.status === "running"
? "ready animate-pulse"
: server.status === "error"
? "error"
: "stopped"
}`}
/>
<span>
{
server.status === "running"
? "Connected"
: server.status === "error"
? "Error"
: "Disabled"
}
</span>
</div>
</div>
<Show when={server.error}>
{(error) => (
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
{error()}
</div>
)}
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
<Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1">
@@ -317,21 +137,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="space-y-1 text-xs">
<div class="flex justify-between items-center">
<span class="text-secondary">Port:</span>
<span class="text-primary font-mono">{props.instance.port}</span>
<span class="text-primary font-mono">{currentInstance().port}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">PID:</span>
<span class="text-primary font-mono">{props.instance.pid}</span>
<span class="text-primary font-mono">{currentInstance().pid}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">Status:</span>
<span
class={`status-badge ${props.instance.status}`}
>
<span class={`status-badge ${currentInstance().status}`}>
<div
class={`status-dot ${props.instance.status === "ready" ? "ready" : props.instance.status === "starting" ? "starting" : props.instance.status === "error" ? "error" : "stopped"} ${props.instance.status === "ready" || props.instance.status === "starting" ? "animate-pulse" : ""}`}
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
/>
{props.instance.status}
{currentInstance().status}
</span>
</div>
</div>

View File

@@ -0,0 +1,224 @@
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
import Switch from "@suid/material/Switch"
import type { Instance, RawMcpStatus } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
type ServiceSection = "lsp" | "mcp"
interface InstanceServiceStatusProps {
sections?: ServiceSection[]
showSectionHeadings?: boolean
class?: string
initialInstance?: Instance
}
type ParsedMcpStatus = {
name: string
status: "running" | "stopped" | "error"
error?: string
}
function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
if (!status || typeof status !== "object") return []
const result: ParsedMcpStatus[] = []
for (const [name, value] of Object.entries(status)) {
if (!value || typeof value !== "object") continue
const rawStatus = (value as { status?: string }).status
if (!rawStatus) continue
let mapped: ParsedMcpStatus["status"]
if (rawStatus === "connected") mapped = "running"
else if (rawStatus === "failed") mapped = "error"
else mapped = "stopped"
result.push({
name,
status: mapped,
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
})
}
return result
}
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
const metadataContext = useOptionalInstanceMetadataContext()
const instance = metadataContext?.instance ?? (() => {
if (props.initialInstance) {
return props.initialInstance
}
throw new Error("InstanceServiceStatus requires InstanceMetadataProvider or initialInstance prop")
})
const isLoading = metadataContext?.isLoading ?? (() => false)
const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve())
const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp"])
const includeLsp = createMemo(() => sections().includes("lsp"))
const includeMcp = createMemo(() => sections().includes("mcp"))
const showHeadings = () => props.showSectionHeadings !== false
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
const metadata = createMemo(() => metadataAccessor())
const hasLspMetadata = () => metadata()?.lspStatus !== undefined
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
const isLspLoading = () => isLoading() || !hasLspMetadata()
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
setPendingMcpActions((prev) => {
const next = { ...prev }
if (action) next[name] = action
else delete next[name]
return next
})
}
const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => {
const client = instance().client
if (!client?.mcp) return
const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect"
setPendingMcpAction(serverName, action)
try {
if (shouldEnable) {
await client.mcp.connect({ path: { name: serverName } })
} else {
await client.mcp.disconnect({ path: { name: serverName } })
}
await refreshMetadata()
} catch (error) {
log.error("Failed to toggle MCP server", { serverName, action, error })
} finally {
setPendingMcpAction(serverName)
}
}
const renderEmptyState = (message: string) => (
<p class="text-[11px] text-secondary italic" role="status">
{message}
</p>
)
const renderLspSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
LSP Servers
</div>
</Show>
<Show
when={!isLspLoading() && lspServers().length > 0}
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
>
<div class="space-y-1.5">
<For each={lspServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
<span class="text-[11px] text-secondary truncate" title={server.root}>
{server.root}
</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</section>
)
const renderMcpSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
MCP Servers
</div>
</Show>
<Show
when={!isMcpLoading() && mcpServers().length > 0}
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
>
<div class="space-y-1.5">
<For each={mcpServers()}>
{(server) => {
const pendingAction = () => pendingMcpActions()[server.name]
const isPending = () => Boolean(pendingAction())
const isRunning = () => server.status === "running"
const switchDisabled = () => isPending() || !instance().client
const statusDotClass = () => {
if (isPending()) return "status-dot animate-pulse"
if (server.status === "running") return "status-dot ready animate-pulse"
if (server.status === "error") return "status-dot error"
return "status-dot stopped"
}
const statusDotStyle = () => (isPending() ? { background: "var(--status-warning)" } : undefined)
return (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
<div class="flex items-center gap-3 flex-shrink-0">
<div class="flex items-center gap-1.5 text-xs text-secondary">
<Show when={isPending()}>
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</Show>
<div class={statusDotClass()} style={statusDotStyle()} />
</div>
<div class="flex items-center gap-1.5">
<Switch
checked={isRunning()}
disabled={switchDisabled()}
color="success"
size="small"
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
onChange={(_, checked) => {
if (switchDisabled()) return
void toggleMcpServer(server.name, Boolean(checked))
}}
/>
</div>
</div>
</div>
<Show when={server.error}>
{(error) => (
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
{error()}
</div>
)}
</Show>
</div>
)
}}
</For>
</div>
</Show>
</section>
)
return (
<div class={props.class}>
<Show when={includeLsp()}>{renderLspSection()}</Show>
<Show when={includeMcp()}>{renderMcpSection()}</Show>
</div>
)
}
export default InstanceServiceStatus

View File

@@ -1,12 +1,14 @@
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
import { Loader2, Trash2 } from "lucide-solid"
import { Loader2, Pencil, Trash2 } from "lucide-solid"
import type { Instance } from "../types/instance"
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading } from "../stores/sessions"
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading, renameSession } from "../stores/sessions"
import InstanceInfo from "./instance-info"
import Kbd from "./kbd"
import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
import { isMac } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
@@ -24,6 +26,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
)
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isRenaming, setIsRenaming] = createSignal(false)
const parentSessions = () => getParentSessions(props.instance.id)
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
@@ -74,6 +78,25 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
function handleKeyDown(e: KeyboardEvent) {
let activeElement: HTMLElement | null = null
if (typeof document !== "undefined") {
activeElement = document.activeElement as HTMLElement | null
}
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
const isEditingField =
activeElement &&
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) ||
activeElement.isContentEditable ||
Boolean(insideModal))
if (isEditingField) {
if (insideModal && e.key === "Escape" && renameTarget()) {
e.preventDefault()
closeRenameDialog()
}
return
}
if (showInstanceInfoOverlay()) {
if (e.key === "Escape") {
e.preventDefault()
@@ -81,53 +104,67 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
return
}
const sessions = parentSessions()
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault()
handleNewSession()
return
}
if (sessions.length === 0) return
const listFocused = focusMode() === "sessions"
if (e.key === "ArrowDown") {
if (!listFocused) {
setFocusMode("sessions")
setSelectedIndex(0)
}
e.preventDefault()
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "ArrowUp") {
return
}
if (e.key === "ArrowUp") {
if (!listFocused) {
setFocusMode("sessions")
setSelectedIndex(Math.max(parentSessions().length - 1, 0))
}
e.preventDefault()
const newIndex = Math.max(selectedIndex() - 1, 0)
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "PageDown") {
return
}
if (!listFocused) {
return
}
if (e.key === "PageDown") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "PageUp") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.max(selectedIndex() - pageSize, 0)
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "Home") {
e.preventDefault()
setSelectedIndex(0)
setFocusMode("sessions")
scrollToIndex(0)
} else if (e.key === "End") {
e.preventDefault()
const newIndex = sessions.length - 1
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "Enter") {
e.preventDefault()
@@ -138,6 +175,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
}
async function handleEnterKey() {
const sessions = parentSessions()
const index = selectedIndex()
@@ -234,6 +272,31 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
}
function openRenameDialogForSession(sessionId: string, title: string) {
const label = title && title.trim() ? title : sessionId
setRenameTarget({ id: sessionId, title: title ?? "", label })
}
function closeRenameDialog() {
setRenameTarget(null)
}
async function handleRenameSubmit(nextTitle: string) {
const target = renameTarget()
if (!target) return
setIsRenaming(true)
try {
await renameSession(props.instance.id, target.id, nextTitle)
setRenameTarget(null)
} catch (error) {
log.error("Failed to rename session:", error)
showToastNotification({ message: "Unable to rename session", variant: "error" })
} finally {
setIsRenaming(false)
}
}
async function handleNewSession() {
if (isCreating()) return
@@ -251,8 +314,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
return (
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto">
<div class="flex-1 flex flex-col gap-4 min-h-0">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto min-w-0">
<div class="flex-1 flex flex-col gap-4 min-h-0 min-w-0">
<Show
when={parentSessions().length > 0}
fallback={
@@ -336,7 +399,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-primary truncate transition-colors"
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
classList={{
"text-accent": isFocused(),
}}
@@ -355,6 +418,18 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<Show when={isFocused()}>
<div class="flex items-center gap-2 flex-shrink-0">
<kbd class="kbd flex-shrink-0"></kbd>
<button
type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Rename session"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
openRenameDialogForSession(session.id, session.title || "")
}}
>
<Pencil class="w-4 h-4" />
</button>
<button
type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
@@ -431,7 +506,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
<div class="hidden lg:block lg:w-80 flex-shrink-0">
<div class="sticky top-0">
<div class="sticky top-0 max-h-full overflow-y-auto pr-1">
<InstanceInfo instance={props.instance} />
</div>
</div>
@@ -488,10 +563,17 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
</div>
</div>
<SessionRenameDialog
open={Boolean(renameTarget())}
currentTitle={renameTarget()?.title ?? ""}
sessionLabel={renameTarget()?.label}
isSubmitting={isRenaming()}
onRename={handleRenameSubmit}
onClose={closeRenameDialog}
/>
</div>
)
}
export default InstanceWelcomeView
export default InstanceWelcomeView

View File

@@ -1,356 +0,0 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js"
import type { Accessor } from "solid-js"
import type { Instance } from "../../types/instance"
import type { Command } from "../../lib/commands"
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../message-block"
import { buildCustomCommandEntries } from "../../lib/command-utils"
import { getCommands as getInstanceCommands } from "../../stores/commands"
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint"
import InstanceWelcomeView from "../instance-welcome-view"
import InfoView from "../info-view"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette"
import Kbd from "../kbd"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
import { getLogger } from "../../lib/logger"
const log = getLogger("session")
interface InstanceShellProps {
instance: Instance
escapeInDebounce: boolean
paletteCommands: Accessor<Command[]>
onCloseSession: (sessionId: string) => Promise<void> | void
onNewSession: () => Promise<void> | void
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onExecuteCommand: (command: Command) => void
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
const MOBILE_SIDEBAR_BREAKPOINT = 1024
const SESSION_CACHE_LIMIT = 2
const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
const sidebarId = `session-sidebar-${props.instance.id}`
let previousIsCompact = false
const shouldShowSidebarToggle = () => isCompactLayout() && !isSidebarOpen()
onMount(() => {
if (typeof window === "undefined") return
const handleResize = () => {
const compact = window.innerWidth < MOBILE_SIDEBAR_BREAKPOINT
setIsCompactLayout(compact)
if (!compact) {
setIsSidebarOpen(true)
} else if (!previousIsCompact && compact) {
setIsSidebarOpen(false)
}
previousIsCompact = compact
}
handleResize()
window.addEventListener("resize", handleResize)
onCleanup(() => {
window.removeEventListener("resize", handleResize)
})
})
const activeSessions = createMemo(() => {
const parentId = activeParentSessionId().get(props.instance.id)
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
const sessionFamily = getSessionFamily(props.instance.id, parentId)
return new Map(sessionFamily.map((s) => [s.id, s]))
})
const activeSessionIdForInstance = createMemo(() => {
return activeSessionMap().get(props.instance.id) || null
})
const parentSessionIdForInstance = createMemo(() => {
return activeParentSessionId().get(props.instance.id) || null
})
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null
})
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
const keyboardShortcuts = createMemo(() =>
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
),
)
const handleSessionSelect = (sessionId: string) => {
setActiveSession(props.instance.id, sessionId)
}
const evictSession = (sessionId: string) => {
if (!sessionId) return
log.info("Evicting cached session", { instanceId: props.instance.id, sessionId })
const store = messageStoreBus.getInstance(props.instance.id)
store?.clearSession(sessionId)
clearSessionRenderCache(props.instance.id, sessionId)
}
const scheduleEvictions = (ids: string[]) => {
if (!ids.length) return
setPendingEvictions((current) => {
const existing = new Set(current)
const next = [...current]
ids.forEach((id) => {
if (!existing.has(id)) {
next.push(id)
existing.add(id)
}
})
return next
})
}
createEffect(() => {
const pending = pendingEvictions()
if (!pending.length) return
const cached = new Set(cachedSessionIds())
const remaining: string[] = []
pending.forEach((id) => {
if (cached.has(id)) {
remaining.push(id)
} else {
evictSession(id)
}
})
if (remaining.length !== pending.length) {
setPendingEvictions(remaining)
}
})
createEffect(() => {
const sessionsMap = activeSessions()
const parentId = parentSessionIdForInstance()
const activeId = activeSessionIdForInstance()
setCachedSessionIds((current) => {
const next: string[] = []
const append = (id: string | null) => {
if (!id || id === "info") return
if (!sessionsMap.has(id)) return
if (next.includes(id)) return
next.push(id)
}
append(parentId)
append(activeId)
current.forEach((id) => append(id))
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
const trimmed = next.length > limit ? next.slice(0, limit) : next
const trimmedSet = new Set(trimmed)
const removed = current.filter((id) => !trimmedSet.has(id))
if (removed.length) {
scheduleEvictions(removed)
}
return trimmed
})
})
return (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
<div
class="flex flex-1 min-h-0 relative"
classList={{ "session-layout-compact": isCompactLayout() }}
>
<div
id={sidebarId}
class="session-sidebar flex flex-col bg-surface-secondary"
classList={{
"session-sidebar-overlay": isCompactLayout(),
"session-sidebar-collapsed": isCompactLayout() && !isSidebarOpen(),
}}
style={!isCompactLayout() ? { width: `${sessionSidebarWidth()}px` } : undefined}
aria-hidden={isCompactLayout() && !isSidebarOpen()}
>
<SessionList
instanceId={props.instance.id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={handleSessionSelect}
onClose={(id) => {
const result = props.onCloseSession(id)
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to close session:", error))
}
}}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
showHeader
showFooter={false}
headerContent={
<div class="session-sidebar-header">
<div class="session-sidebar-header-row">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<Show when={isCompactLayout()}>
<button
type="button"
class="session-sidebar-close"
onClick={() => setIsSidebarOpen(false)}
aria-label="Close session sidebar"
>
Close
</button>
</Show>
</div>
<div class="session-sidebar-shortcuts">
{keyboardShortcuts().length ? (
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
) : null}
</div>
</div>
}
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
<AgentSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<div class="sidebar-selector-hints" aria-hidden="true">
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
<ModelSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
</div>
</>
)}
</Show>
</div>
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={shouldShowSidebarToggle() && (!activeSessionIdForInstance() || activeSessionIdForInstance() === "info")}
>
<button
type="button"
class="session-sidebar-menu-button session-sidebar-menu-button--floating"
onClick={() => setIsSidebarOpen(true)}
aria-controls={sidebarId}
aria-expanded={isSidebarOpen()}
aria-label="Open session list"
>
<span aria-hidden="true" class="session-sidebar-menu-icon"></span>
</button>
</Show>
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
<For each={cachedSessionIds()}>
{(sessionId) => {
const isActive = () => activeSessionIdForInstance() === sessionId
return (
<div
class="session-cache-pane flex flex-col flex-1 min-h-0"
style={{ display: isActive() ? "flex" : "none" }}
data-session-id={sessionId}
aria-hidden={!isActive()}
>
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
showSidebarToggle={shouldShowSidebarToggle()}
onSidebarToggle={() => setIsSidebarOpen(true)}
forceCompactStatusLayout={shouldShowSidebarToggle()}
isActive={isActive()}
/>
</div>
)
}}
</For>
</Show>
}
>
<InfoView instanceId={props.instance.id} />
</Show>
</div>
<Show when={isCompactLayout() && isSidebarOpen()}>
<button
type="button"
class="session-sidebar-backdrop"
aria-label="Close session sidebar"
onClick={() => setIsSidebarOpen(false)}
/>
</Show>
</div>
</Show>
<CommandPalette
open={paletteOpen()}
onClose={() => hideCommandPalette(props.instance.id)}
commands={instancePaletteCommands()}
onExecute={props.onExecuteCommand}
/>
</>
)
}
export default InstanceShell

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,14 @@
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
import type { TextPart } from "../types/message"
import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
const markdownRenderCache = new Map<string, RenderCache>()
function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean) {
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
}
interface MarkdownProps {
part: TextPart
@@ -29,11 +34,25 @@ export function Markdown(props: MarkdownProps) {
const dark = Boolean(props.isDark)
const themeKey = dark ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "__anonymous__"
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled)
latestRequestedText = text
// Markdown initialization is now handled globally in App.
// initMarkdown is idempotent but we avoid per-part calls here.
const localCache = part.renderCache
if (localCache && localCache.text === text && localCache.theme === themeKey) {
setHtml(localCache.html)
notifyRendered()
return
}
const globalCache = markdownRenderCache.get(cacheKey)
if (globalCache && globalCache.text === text) {
setHtml(globalCache.html)
part.renderCache = globalCache
notifyRendered()
return
}
if (!highlightEnabled) {
part.renderCache = undefined
@@ -42,39 +61,42 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
}
}
return
}
const cache = part.renderCache
if (cache && cache.text === text && cache.theme === themeKey) {
setHtml(cache.html)
notifyRendered()
return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
setHtml(rendered)
part.renderCache = { text, html: rendered, theme: themeKey }
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text)
part.renderCache = { text, html: text, theme: themeKey }
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
}
}

View File

@@ -1,4 +1,4 @@
import { Index, createEffect, createSignal, type Accessor } from "solid-js"
import { Index, type Accessor } from "solid-js"
import VirtualItem from "./virtual-item"
import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -10,12 +10,10 @@ export function getMessageAnchorId(messageId: string) {
const VIRTUAL_ITEM_MARGIN_PX = 800
interface MessageBlockListProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageIds: () => string[]
messageIndexMap: () => Map<string, number>
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
@@ -27,62 +25,38 @@ interface MessageBlockListProps {
onContentRendered?: () => void
setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean
onInitialRenderComplete?: () => void
}
export default function MessageBlockList(props: MessageBlockListProps) {
const totalMessages = () => props.messageIds().length
let renderedCount = 0
let initialRenderReported = false
const handleBlockRendered = () => {
if (initialRenderReported) return
renderedCount += 1
if (renderedCount >= totalMessages() && totalMessages() > 0) {
initialRenderReported = true
renderedCount = 0
props.onInitialRenderComplete?.()
}
}
createEffect(() => {
if (props.loading) {
renderedCount = 0
initialRenderReported = false
}
})
return (
<>
<Index each={props.messageIds()}>
{(messageId) => {
return (
<VirtualItem
id={getMessageAnchorId(messageId())}
cacheKey={messageId()}
scrollContainer={props.scrollContainer}
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements}
onMeasured={handleBlockRendered}
>
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndexMap={props.messageIndexMap}
lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
showUsageMetrics={props.showUsageMetrics}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</VirtualItem>
)
}}
{(messageId, index) => (
<VirtualItem
id={getMessageAnchorId(messageId())}
cacheKey={messageId()}
scrollContainer={props.scrollContainer}
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements}
>
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndex={index}
lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
showUsageMetrics={props.showUsageMetrics}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</VirtualItem>
)}
</Index>
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</>

View File

@@ -204,7 +204,7 @@ interface MessageBlockProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageIndexMap: () => Map<string, number>
messageIndex: number
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
@@ -223,7 +223,7 @@ export default function MessageBlock(props: MessageBlockProps) {
const current = record()
if (!current) return null
const index = props.messageIndexMap().get(current.id) ?? 0
const index = props.messageIndex
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const info = messageInfo()

View File

@@ -1,4 +1,4 @@
import { For, Show } from "solid-js"
import { For, Show, createSignal } from "solid-js"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
@@ -16,8 +16,9 @@ interface MessageItemProps {
showAgentMeta?: boolean
onContentRendered?: () => void
}
export default function MessageItem(props: MessageItemProps) {
const [copied, setCopied] = createSignal(false)
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -36,7 +37,7 @@ interface MessageItemProps {
}
const messageParts = () => props.parts
const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
@@ -143,6 +144,22 @@ interface MessageItemProps {
}
}
const getRawContent = () => {
return props.parts
.filter(part => part.type === "text")
.map(part => (part as { text?: string }).text || "")
.filter(text => text.trim().length > 0)
.join("\n\n")
}
const handleCopy = async () => {
const content = getRawContent()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (!isUser() && !hasContent()) {
return null
}
@@ -218,8 +235,30 @@ interface MessageItemProps {
Fork
</button>
</Show>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</div>
</Show>
<Show when={!isUser()}>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>

View File

@@ -1,4 +1,4 @@
import { createMemo, type Component } from "solid-js"
import type { Component } from "solid-js"
import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -10,14 +10,7 @@ interface MessagePreviewProps {
}
const MessagePreview: Component<MessagePreviewProps> = (props) => {
const indexMap = createMemo(() => new Map([[props.messageId, 0]]))
const lastAssistantIndex = createMemo(() => {
const record = props.store().getMessage(props.messageId)
if (record?.role === "assistant") {
return 0
}
return -1
})
const lastAssistantIndex = () => 0
return (
<div class="message-preview message-stream">
@@ -26,7 +19,7 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndexMap={indexMap}
messageIndex={0}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => false}
thinkingDefaultExpanded={() => false}

View File

@@ -1,15 +1,11 @@
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import Kbd from "./kbd"
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
import MessageListHeader from "./message-list-header"
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
import { useConfig } from "../stores/preferences"
import { getSessionInfo } from "../stores/sessions"
import { showCommandPalette } from "../stores/command-palette"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { sseManager } from "../lib/sse-manager"
import { formatTokenTotal } from "../lib/formatters"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
const SCROLL_SCOPE = "session"
@@ -19,10 +15,6 @@ const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"
const QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
function formatTokens(tokens: number): string {
return formatTokenTotal(tokens)
}
export interface MessageSectionProps {
instanceId: string
sessionId: string
@@ -77,25 +69,12 @@ export default function MessageSection(props: MessageSectionProps) {
return `${showThinking}|${thinkingExpansion}|${showUsage}`
})
const connectionStatus = () => sseManager.getStatus(props.instanceId)
const handleCommandPaletteClick = () => {
showCommandPalette(props.instanceId)
}
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
if (typeof document === "undefined") return
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
}
const messageIndexMap = createMemo(() => {
const map = new Map<string, number>()
const ids = messageIds()
ids.forEach((id, index) => map.set(id, index))
return map
})
const lastAssistantIndex = createMemo(() => {
const ids = messageIds()
const resolvedStore = store()
@@ -108,23 +87,57 @@ export default function MessageSection(props: MessageSectionProps) {
return -1
})
const timelineSegments = createMemo<TimelineSegment[]>(() => {
const ids = messageIds()
const resolvedStore = store()
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
const hasTimelineSegments = () => timelineSegments().length > 0
const seenTimelineMessageIds = new Set<string>()
const seenTimelineSegmentKeys = new Set<string>()
function makeTimelineKey(segment: TimelineSegment) {
return `${segment.messageId}:${segment.id}:${segment.type}`
}
function seedTimeline() {
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
const ids = untrack(messageIds)
const resolvedStore = untrack(store)
const segments: TimelineSegment[] = []
ids.forEach((messageId) => {
const record = resolvedStore.getMessage(messageId)
if (!record) return
seenTimelineMessageIds.add(messageId)
const built = buildTimelineSegments(props.instanceId, record)
segments.push(...built)
built.forEach((segment) => {
const key = makeTimelineKey(segment)
if (seenTimelineSegmentKeys.has(key)) return
seenTimelineSegmentKeys.add(key)
segments.push(segment)
})
})
return segments
})
const hasTimelineSegments = () => timelineSegments().length > 0
setTimelineSegments(segments)
}
function appendTimelineForMessage(messageId: string) {
const record = untrack(() => store().getMessage(messageId))
if (!record) return
const built = buildTimelineSegments(props.instanceId, record)
if (built.length === 0) return
const newSegments: TimelineSegment[] = []
built.forEach((segment) => {
const key = makeTimelineKey(segment)
if (seenTimelineSegmentKeys.has(key)) return
seenTimelineSegmentKeys.add(key)
newSegments.push(segment)
})
if (newSegments.length > 0) {
setTimelineSegments((prev) => [...prev, ...newSegments])
}
}
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
const changeToken = createMemo(() => String(sessionRevision()))
const isActive = createMemo(() => props.isActive !== false)
const scrollCache = useScrollCache({
@@ -164,8 +177,6 @@ export default function MessageSection(props: MessageSectionProps) {
let scrollToBottomDelayedFrame: number | null = null
let pendingInitialScroll = true
const [initialRenderComplete, setInitialRenderComplete] = createSignal(false)
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
@@ -236,11 +247,12 @@ export default function MessageSection(props: MessageSectionProps) {
})
}
function scrollToBottom(immediate = false) {
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
if (!containerRef) return
const sentinel = bottomSentinel()
const behavior = immediate ? "auto" : "smooth"
if (!immediate) {
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true
}
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
@@ -260,6 +272,10 @@ export default function MessageSection(props: MessageSectionProps) {
}
function requestScrollToBottom(immediate = true) {
if (!isActive()) {
pendingActiveScroll = true
return
}
if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true
return
@@ -277,7 +293,7 @@ export default function MessageSection(props: MessageSectionProps) {
function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return
if (!props.isActive) return
if (!isActive()) return
requestScrollToBottom(true)
}
@@ -292,8 +308,15 @@ export default function MessageSection(props: MessageSectionProps) {
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
if (!isActive()) {
pendingActiveScroll = true
return
}
const sentinel = bottomSentinel()
if (!sentinel) return
if (!sentinel) {
pendingActiveScroll = true
return
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
@@ -377,10 +400,6 @@ export default function MessageSection(props: MessageSectionProps) {
scheduleAnchorScroll()
}
function handleInitialRenderComplete() {
setInitialRenderComplete(true)
}
function handleScroll() {
if (!containerRef) return
@@ -404,6 +423,7 @@ export default function MessageSection(props: MessageSectionProps) {
clearQuoteSelection()
scheduleScrollPersist()
})
}
@@ -415,9 +435,14 @@ export default function MessageSection(props: MessageSectionProps) {
let lastActiveState = false
createEffect(() => {
const active = Boolean(props.isActive)
if (active && !lastActiveState) {
requestScrollToBottom(true)
const active = isActive()
if (active) {
resolvePendingActiveScroll()
if (!lastActiveState && autoScroll()) {
requestScrollToBottom(true)
}
} else if (autoScroll()) {
pendingActiveScroll = true
}
lastActiveState = active
})
@@ -426,12 +451,123 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading)
if (loading) {
pendingInitialScroll = true
setInitialRenderComplete(false)
return
}
if (pendingInitialScroll && initialRenderComplete()) {
pendingInitialScroll = false
requestScrollToBottom(false)
if (!pendingInitialScroll) {
return
}
const container = scrollElement()
const sentinel = bottomSentinel()
if (!container || !sentinel || messageIds().length === 0) {
return
}
pendingInitialScroll = false
requestScrollToBottom(true)
})
let previousTimelineIds: string[] = []
let previousLastTimelineMessageId: string | null = null
let previousLastTimelinePartCount = 0
createEffect(() => {
const loading = Boolean(props.loading)
const ids = messageIds()
if (loading) {
previousTimelineIds = []
previousLastTimelineMessageId = null
previousLastTimelinePartCount = 0
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
return
}
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
}
}
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
previousTimelineIds = ids.slice()
return
}
}
}
const newIds: string[] = []
ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) {
newIds.push(id)
}
})
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
})
}
previousTimelineIds = ids.slice()
})
createEffect(() => {
if (props.loading) return
const ids = messageIds()
if (ids.length === 0) return
const lastId = ids[ids.length - 1]
if (!lastId) return
const record = store().getMessage(lastId)
if (!record) return
const partCount = record.partIds.length
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
return
}
previousLastTimelineMessageId = lastId
previousLastTimelinePartCount = partCount
const built = buildTimelineSegments(props.instanceId, record)
const newSegments: TimelineSegment[] = []
built.forEach((segment) => {
const key = makeTimelineKey(segment)
if (seenTimelineSegmentKeys.has(key)) return
seenTimelineSegmentKeys.add(key)
newSegments.push(segment)
})
if (newSegments.length > 0) {
setTimelineSegments((prev) => [...prev, ...newSegments])
}
})
@@ -609,17 +745,6 @@ export default function MessageSection(props: MessageSectionProps) {
return (
<div class="message-stream-container">
<MessageListHeader
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
connectionStatus={connectionStatus()}
onCommandPalette={handleCommandPaletteClick}
formatTokens={formatTokens}
showSidebarToggle={props.showSidebarToggle}
onSidebarToggle={props.onSidebarToggle}
forceCompactStatusLayout={props.forceCompactStatusLayout}
/>
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
<div class="message-stream-shell" ref={setShellElement}>
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
@@ -659,7 +784,6 @@ export default function MessageSection(props: MessageSectionProps) {
sessionId={props.sessionId}
store={store}
messageIds={messageIds}
messageIndexMap={messageIndexMap}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
@@ -670,8 +794,7 @@ export default function MessageSection(props: MessageSectionProps) {
onFork={props.onFork}
onContentRendered={handleContentRendered}
setBottomSentinel={setBottomSentinel}
suspendMeasurements={() => props.isActive === false}
onInitialRenderComplete={handleInitialRenderComplete}
suspendMeasurements={() => !isActive()}
/>
@@ -688,7 +811,7 @@ export default function MessageSection(props: MessageSectionProps) {
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom()}
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label="Scroll to latest message"
>
<span class="message-scroll-icon" aria-hidden="true"></span>

View File

@@ -1086,8 +1086,9 @@ export default function PromptInput(props: PromptInputProps) {
</For>
</div>
</Show>
<div class="prompt-input-field">
<textarea
<div class="prompt-input-field-container">
<div class="prompt-input-field">
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
@@ -1167,6 +1168,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
</div>
</div>
</div>
<div class="prompt-input-actions">
<button

View File

@@ -1,13 +1,14 @@
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js"
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
import type { Session, SessionStatus } from "../types/session"
import { getSessionStatus } from "../stores/session-status"
import { MessageSquare, Info, X, Copy, Trash2 } from "lucide-solid"
import { MessageSquare, Info, X, Copy, Trash2, Pencil } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
import Kbd from "./kbd"
import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { formatShortcut } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications"
import { deleteSession, loading } from "../stores/sessions"
import { deleteSession, loading, renameSession } from "../stores/sessions"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
@@ -24,14 +25,8 @@ interface SessionListProps {
showFooter?: boolean
headerContent?: JSX.Element
footerContent?: JSX.Element
onWidthChange?: (width: number) => void
}
const MIN_WIDTH = 200
const MAX_WIDTH = 520
const DEFAULT_WIDTH = 360
const STORAGE_KEY = "opencode-session-sidebar-width-v7"
function formatSessionStatus(status: SessionStatus): string {
switch (status) {
case "working":
@@ -62,10 +57,8 @@ function arraysEqual(prev: readonly string[] | undefined, next: readonly string[
}
const SessionList: Component<SessionListProps> = (props) => {
const [sidebarWidth, setSidebarWidth] = createSignal(DEFAULT_WIDTH)
const [isResizing, setIsResizing] = createSignal(false)
const [startX, setStartX] = createSignal(0)
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isRenaming, setIsRenaming] = createSignal(false)
const infoShortcut = keyboardRegistry.get("switch-to-info")
const isSessionDeleting = (sessionId: string) => {
@@ -76,34 +69,6 @@ const SessionList: Component<SessionListProps> = (props) => {
const selectSession = (sessionId: string) => {
props.onSelect(sessionId)
}
let mouseMoveHandler: ((event: MouseEvent) => void) | null = null
let mouseUpHandler: (() => void) | null = null
let touchMoveHandler: ((event: TouchEvent) => void) | null = null
let touchEndHandler: (() => void) | null = null
onMount(() => {
if (typeof window === "undefined") return
const saved = window.localStorage.getItem(STORAGE_KEY)
if (!saved) return
const width = Number.parseInt(saved, 10)
if (Number.isFinite(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
setSidebarWidth(width)
setStartWidth(width)
}
})
createEffect(() => {
if (typeof window === "undefined") return
const width = sidebarWidth()
window.localStorage.setItem(STORAGE_KEY, width.toString())
})
createEffect(() => {
props.onWidthChange?.(sidebarWidth())
})
const copySessionId = async (event: MouseEvent, sessionId: string) => {
event.stopPropagation()
@@ -132,96 +97,35 @@ const SessionList: Component<SessionListProps> = (props) => {
showToastNotification({ message: "Unable to delete session", variant: "error" })
}
}
const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
const openRenameDialog = (sessionId: string) => {
const session = props.sessions.get(sessionId)
if (!session) return
const label = session.title && session.title.trim() ? session.title : sessionId
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
}
const closeRenameDialog = () => {
setRenameTarget(null)
}
const handleRenameSubmit = async (nextTitle: string) => {
const target = renameTarget()
if (!target) return
setIsRenaming(true)
try {
await renameSession(props.instanceId, target.id, nextTitle)
setRenameTarget(null)
} catch (error) {
log.error(`Failed to rename session ${target.id}:`, error)
showToastNotification({ message: "Unable to rename session", variant: "error" })
} finally {
setIsRenaming(false)
}
}
const removeMouseListeners = () => {
if (mouseMoveHandler) {
document.removeEventListener("mousemove", mouseMoveHandler)
mouseMoveHandler = null
}
if (mouseUpHandler) {
document.removeEventListener("mouseup", mouseUpHandler)
mouseUpHandler = null
}
}
const removeTouchListeners = () => {
if (touchMoveHandler) {
document.removeEventListener("touchmove", touchMoveHandler)
touchMoveHandler = null
}
if (touchEndHandler) {
document.removeEventListener("touchend", touchEndHandler)
touchEndHandler = null
}
}
const stopResizing = () => {
setIsResizing(false)
removeMouseListeners()
removeTouchListeners()
}
const handleMouseMove = (event: MouseEvent) => {
if (!isResizing()) return
const diff = event.clientX - startX()
const newWidth = clampWidth(startWidth() + diff)
setSidebarWidth(newWidth)
}
const handleMouseUp = () => {
stopResizing()
}
const handleTouchMove = (event: TouchEvent) => {
if (!isResizing()) return
const touch = event.touches[0]
if (!touch) return
const diff = touch.clientX - startX()
const newWidth = clampWidth(startWidth() + diff)
setSidebarWidth(newWidth)
}
const handleTouchEnd = () => {
stopResizing()
}
const handleMouseDown = (event: MouseEvent) => {
event.preventDefault()
setIsResizing(true)
setStartX(event.clientX)
setStartWidth(sidebarWidth())
mouseMoveHandler = handleMouseMove
mouseUpHandler = handleMouseUp
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
}
const handleTouchStart = (event: TouchEvent) => {
event.preventDefault()
const touch = event.touches[0]
if (!touch) return
setIsResizing(true)
setStartX(touch.clientX)
setStartWidth(sidebarWidth())
touchMoveHandler = handleTouchMove
touchEndHandler = handleTouchEnd
document.addEventListener("touchmove", handleTouchMove)
document.addEventListener("touchend", handleTouchEnd)
}
onCleanup(() => {
removeMouseListeners()
removeTouchListeners()
})
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
const session = () => props.sessions.get(rowProps.sessionId)
if (!session()) {
@@ -281,6 +185,19 @@ const SessionList: Component<SessionListProps> = (props) => {
>
<Copy class="w-3 h-3" />
</span>
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => {
event.stopPropagation()
openRenameDialog(rowProps.sessionId)
}}
role="button"
tabIndex={0}
aria-label="Rename session"
title="Rename session"
>
<Pencil class="w-3 h-3" />
</span>
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
@@ -348,14 +265,6 @@ const SessionList: Component<SessionListProps> = (props) => {
<div
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
>
<div
class="session-resize-handle"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
role="presentation"
aria-hidden="true"
/>
<Show when={props.showHeader !== false}>
<div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? (
@@ -418,8 +327,18 @@ const SessionList: Component<SessionListProps> = (props) => {
{props.footerContent ?? null}
</div>
</Show>
<SessionRenameDialog
open={Boolean(renameTarget())}
currentTitle={renameTarget()?.title ?? ""}
sessionLabel={renameTarget()?.label}
isSubmitting={isRenaming()}
onRename={handleRenameSubmit}
onClose={closeRenameDialog}
/>
</div>
)
}
export default SessionList

View File

@@ -0,0 +1,130 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createSignal } from "solid-js"
interface SessionRenameDialogProps {
open: boolean
currentTitle: string
sessionLabel?: string
isSubmitting?: boolean
onRename: (nextTitle: string) => Promise<void> | void
onClose: () => void
}
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
const [title, setTitle] = createSignal("")
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
let inputRef: HTMLInputElement | undefined
createEffect(() => {
if (!props.open) return
setTitle(props.currentTitle ?? "")
})
createEffect(() => {
if (!props.open) return
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return
window.requestAnimationFrame(() => {
inputRef?.focus()
inputRef?.select()
})
})
const isSubmitting = () => Boolean(props.isSubmitting)
const isRenameDisabled = () => isSubmitting() || !title().trim()
async function handleRename(event?: Event) {
event?.preventDefault()
if (isRenameDisabled()) return
await props.onRename(title().trim())
}
const description = () => {
if (props.sessionLabel && props.sessionLabel.trim()) {
return `Update the title for "${props.sessionLabel}".`
}
return "Set a new title for this session."
}
return (
<Dialog
open={props.open}
onOpenChange={(open) => {
if (!open && !isSubmitting()) {
props.onClose()
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
<Dialog.Title class="text-lg font-semibold text-primary">Rename Session</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
{description()}
</Dialog.Description>
<form class="mt-4 space-y-4" onSubmit={handleRename}>
<div class="space-y-2">
<label class="text-sm font-medium text-secondary" for={inputId}>
Session name
</label>
<input
id={inputId}
ref={(element) => {
inputRef = element
}}
type="text"
value={title()}
onInput={(event) => setTitle(event.currentTarget.value)}
placeholder="Enter a session name"
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="button-tertiary"
onClick={() => {
if (!isSubmitting()) {
props.onClose()
}
}}
disabled={isSubmitting()}
>
Cancel
</button>
<button
type="submit"
class="button-primary flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
disabled={isRenameDisabled()}
>
<Show
when={!isSubmitting()}
fallback={
<>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Renaming</span>
</>
}
>
Rename
</Show>
</button>
</div>
</form>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default SessionRenameDialog

View File

@@ -74,9 +74,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
if (scrollToBottomHandle && import.meta.env?.DEV) {
console.debug("[SessionView] handleSendMessage scroll", props.sessionId)
}
scheduleScrollToBottom()
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
}

View File

@@ -17,7 +17,8 @@ import type {
ToolRendererContext,
ToolScrollHelpers,
} from "./tool-call/types"
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./tool-call/utils"
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
@@ -117,21 +118,16 @@ function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
if (!normalizedPreferred) return []
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
if (candidateEntries.length === 0) return []
const prioritizedEntries = (() => {
if (!normalizedPreferred) return candidateEntries
const matched = candidateEntries.filter(([path]) => {
const normalized = normalizeDiagnosticPath(path)
if (normalized === normalizedPreferred) return true
if (normalized.endsWith(`/${normalizedPreferred}`)) return true
const normalizedBase = normalized.split("/").pop()
const preferredBase = normalizedPreferred.split("/").pop()
return normalizedBase && preferredBase ? normalizedBase === preferredBase : false
})
return matched.length > 0 ? matched : candidateEntries
})()
const prioritizedEntries = candidateEntries.filter(([path]) => {
const normalized = normalizeDiagnosticPath(path)
return normalized === normalizedPreferred
})
if (prioritizedEntries.length === 0) return []
const entries: DiagnosticEntry[] = []
for (const [pathKey, list] of prioritizedEntries) {
@@ -632,6 +628,17 @@ export default function ToolCall(props: ToolCallProps) {
const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const state = toolState()
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
if (shouldDeferMarkdown) {
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
{scrollHelpers.renderSentinel()}
</div>
)
}
const markdownPart: TextPart = { type: "text", text: options.content }
const cached = markdownCache.get<RenderCache>()
if (cached) {
@@ -699,6 +706,12 @@ export default function ToolCall(props: ToolCallProps) {
const renderToolTitle = () => {
const state = toolState()
const currentTool = toolName()
if (currentTool !== "task") {
return resolveTitleForTool({ toolName: currentTool, state })
}
if (!state) return getRendererAction()
if (state.status === "pending") return getRendererAction()
@@ -713,7 +726,7 @@ export default function ToolCall(props: ToolCallProps) {
return state.title
}
return getToolName(toolName())
return getToolName(currentTool)
}
const renderToolBody = () => {
@@ -907,33 +920,3 @@ export default function ToolCall(props: ToolCallProps) {
</div>
)
}
function getDefaultToolAction(toolName: string) {
switch (toolName) {
case "task":
return "Delegating..."
case "bash":
return "Writing command..."
case "edit":
return "Preparing edit..."
case "webfetch":
return "Fetching from the web..."
case "glob":
return "Finding files..."
case "grep":
return "Searching content..."
case "list":
return "Listing directory..."
case "read":
return "Reading file..."
case "write":
return "Preparing write..."
case "todowrite":
case "todoread":
return "Planning..."
case "patch":
return "Preparing patch..."
default:
return "Working..."
}
}

View File

@@ -1,25 +1,84 @@
import { For, createMemo } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRenderer } from "../types"
import { getRelativePath, getToolIcon, getToolName, readToolStatePayload } from "../utils"
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
import { getTodoTitle } from "./todo"
import { resolveTitleForTool } from "../tool-title"
interface TaskSummaryItem {
id: string
tool: string
input: Record<string, any>
metadata: Record<string, any>
state?: ToolState
status?: ToolState["status"]
title?: string
}
function describeTaskItem(item: TaskSummaryItem): string {
const input = item.input || {}
switch (item.tool) {
case "bash":
return typeof input.description === "string" ? input.description : input.command || "bash"
case "edit":
case "read":
case "write":
return `${item.tool} ${getRelativePath(typeof input.filePath === "string" ? input.filePath : "")}`.trim()
default:
return item.tool
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
return status
}
return undefined
}
function summarizeStatusIcon(status?: ToolState["status"]) {
switch (status) {
case "pending":
return "⏸"
case "running":
return "⏳"
case "completed":
return "✓"
case "error":
return "✗"
default:
return ""
}
}
function summarizeStatusLabel(status?: ToolState["status"]) {
switch (status) {
case "pending":
return "Pending"
case "running":
return "Running"
case "completed":
return "Completed"
case "error":
return "Error"
default:
return "Unknown"
}
}
function describeTaskTitle(input: Record<string, any>) {
const description = typeof input.description === "string" ? input.description : undefined
const subagent = typeof input.subagent_type === "string" ? input.subagent_type : undefined
const base = getToolName("task")
if (description && subagent) {
return `${base}[${subagent}] ${description}`
}
if (description) {
return `${base} ${description}`
}
return base
}
function describeToolTitle(item: TaskSummaryItem): string {
if (item.title && item.title.length > 0) {
return item.title
}
if (item.tool === "task") {
return describeTaskTitle({ ...item.metadata, ...item.input })
}
if (item.state) {
return resolveTitleForTool({ toolName: item.tool, state: item.state })
}
return getDefaultToolAction(item.tool)
}
export const taskRenderer: ToolRenderer = {
@@ -29,18 +88,9 @@ export const taskRenderer: ToolRenderer = {
const state = toolState()
if (!state) return undefined
const { input } = readToolStatePayload(state)
const description = input.description
const subagent = input.subagent_type
const base = getToolName("task")
if (description && subagent) {
return `${base}[${subagent}] ${description}`
}
if (description) {
return `${base} ${description}`
}
return base
return describeTaskTitle(input)
},
renderBody({ toolState, toolCall, messageVersion, partVersion, scrollHelpers }) {
renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) {
const items = createMemo(() => {
// Track the reactive change points so we only recompute when the part/message changes
messageVersion?.()
@@ -54,9 +104,13 @@ export const taskRenderer: ToolRenderer = {
return summary.map((entry, index) => {
const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown"
const input = typeof (entry as any)?.state?.input === "object" && entry.state?.input ? entry.state.input : {}
const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined
const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {}
const fallbackInput = typeof entry?.input === "object" && entry.input ? entry.input : {}
const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}`
return { id, tool, input }
const statusValue = normalizeStatus((entry?.status as string | undefined) ?? stateValue?.status)
const title = typeof entry?.title === "string" ? entry.title : undefined
return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title }
})
})
@@ -72,11 +126,23 @@ export const taskRenderer: ToolRenderer = {
<For each={items()}>
{(item) => {
const icon = getToolIcon(item.tool)
const description = describeTaskItem(item)
const description = describeToolTitle(item)
const toolLabel = getToolName(item.tool)
const status = normalizeStatus(item.status ?? item.state?.status)
const statusIcon = summarizeStatusIcon(status)
const statusLabel = summarizeStatusLabel(status)
const statusAttr = status ?? "pending"
return (
<div class="tool-call-task-item" data-task-id={item.id}>
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
<span class="tool-call-task-icon">{icon}</span>
<span class="tool-call-task-label">{toolLabel}</span>
<span class="tool-call-task-separator" aria-hidden="true"></span>
<span class="tool-call-task-text">{description}</span>
<Show when={statusIcon}>
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
{statusIcon}
</span>
</Show>
</div>
)
}}
@@ -87,4 +153,3 @@ export const taskRenderer: ToolRenderer = {
)
},
}

View File

@@ -1,11 +1,11 @@
import { For } from "solid-js"
import { For, Show } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRenderer } from "../types"
import { readToolStatePayload } from "../utils"
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
interface TodoViewItem {
export interface TodoViewItem {
id: string
content: string
status: TodoViewStatus
@@ -58,7 +58,56 @@ function getTodoStatusLabel(status: TodoViewStatus): string {
}
}
function getTodoTitle(state?: ToolState): string {
interface TodoListViewProps {
state?: ToolState
emptyLabel?: string
showStatusLabel?: boolean
}
export function TodoListView(props: TodoListViewProps) {
const todos = extractTodosFromState(props.state)
const counts = summarizeTodos(todos)
if (counts.total === 0) {
return <div class="tool-call-todo-empty">{props.emptyLabel ?? "No plan items yet."}</div>
}
return (
<div class="tool-call-todo-region">
<div class="tool-call-todos" role="list">
<For each={todos}>
{(todo) => {
const label = getTodoStatusLabel(todo.status)
return (
<div
class="tool-call-todo-item"
classList={{
"tool-call-todo-item-completed": todo.status === "completed",
"tool-call-todo-item-cancelled": todo.status === "cancelled",
"tool-call-todo-item-active": todo.status === "in_progress",
}}
role="listitem"
>
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
<div class="tool-call-todo-body">
<div class="tool-call-todo-heading">
<span class="tool-call-todo-text">{todo.content}</span>
<Show when={props.showStatusLabel !== false}>
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</Show>
</div>
</div>
</div>
)
}}
</For>
</div>
</div>
)
}
export function getTodoTitle(state?: ToolState): string {
if (!state) return "Plan"
const todos = extractTodosFromState(state)
@@ -80,42 +129,6 @@ export const todoRenderer: ToolRenderer = {
const state = toolState()
if (!state) return null
const todos = extractTodosFromState(state)
const counts = summarizeTodos(todos)
if (counts.total === 0) {
return <div class="tool-call-todo-empty">No plan items yet.</div>
}
return (
<div class="tool-call-todo-region">
<div class="tool-call-todos" role="list">
<For each={todos}>
{(todo) => {
const label = getTodoStatusLabel(todo.status)
return (
<div
class="tool-call-todo-item"
classList={{
"tool-call-todo-item-completed": todo.status === "completed",
"tool-call-todo-item-cancelled": todo.status === "cancelled",
"tool-call-todo-item-active": todo.status === "in_progress",
}}
role="listitem"
>
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
<div class="tool-call-todo-body">
<div class="tool-call-todo-heading">
<span class="tool-call-todo-text">{todo.content}</span>
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</div>
</div>
</div>
)
}}
</For>
</div>
</div>
)
return <TodoListView state={state} />
},
}

View File

@@ -0,0 +1,86 @@
import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
import { defaultRenderer } from "./renderers/default"
import { bashRenderer } from "./renderers/bash"
import { readRenderer } from "./renderers/read"
import { writeRenderer } from "./renderers/write"
import { editRenderer } from "./renderers/edit"
import { patchRenderer } from "./renderers/patch"
import { webfetchRenderer } from "./renderers/webfetch"
import { todoRenderer } from "./renderers/todo"
import { invalidRenderer } from "./renderers/invalid"
const TITLE_RENDERERS: Record<string, ToolRenderer> = {
bash: bashRenderer,
read: readRenderer,
write: writeRenderer,
edit: editRenderer,
patch: patchRenderer,
webfetch: webfetchRenderer,
todowrite: todoRenderer,
todoread: todoRenderer,
invalid: invalidRenderer,
}
interface TitleSnapshot {
toolName: string
state?: ToolState
}
function lookupRenderer(toolName: string): ToolRenderer {
return TITLE_RENDERERS[toolName] ?? defaultRenderer
}
function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
return {
id: "",
type: "tool",
tool: snapshot.toolName,
state: snapshot.state,
} as ToolCallPart
}
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
const toolStateAccessor = () => snapshot.state
const toolNameAccessor = () => snapshot.toolName
const toolCallAccessor = () => createStaticToolPart(snapshot)
const messageVersionAccessor = () => undefined
const partVersionAccessor = () => undefined
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
const renderDiff: ToolRendererContext["renderDiff"] = () => null
return {
toolCall: toolCallAccessor,
toolState: toolStateAccessor,
toolName: toolNameAccessor,
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown,
renderDiff,
scrollHelpers: undefined,
}
}
export function resolveTitleForTool(snapshot: TitleSnapshot): string {
const renderer = lookupRenderer(snapshot.toolName)
const context = createStaticContext(snapshot)
const state = snapshot.state
const defaultAction = renderer.getAction?.(context) ?? getDefaultToolAction(snapshot.toolName)
if (!state || state.status === "pending") {
return defaultAction
}
const stateTitle = typeof (state as { title?: string }).title === "string" ? (state as { title?: string }).title : undefined
if (stateTitle && stateTitle.length > 0) {
return stateTitle
}
const customTitle = renderer.getTitle?.(context)
if (customTitle) {
return customTitle
}
return getToolName(snapshot.toolName)
}

View File

@@ -192,3 +192,33 @@ export function readToolStatePayload(state?: ToolState): {
output: isToolStateCompleted(state) ? state.output : undefined,
}
}
export function getDefaultToolAction(toolName: string) {
switch (toolName) {
case "task":
return "Delegating..."
case "bash":
return "Writing command..."
case "edit":
return "Preparing edit..."
case "webfetch":
return "Fetching from the web..."
case "glob":
return "Finding files..."
case "grep":
return "Searching content..."
case "list":
return "Listing directory..."
case "read":
return "Reading file..."
case "write":
return "Preparing write..."
case "todowrite":
case "todoread":
return "Planning..."
case "patch":
return "Preparing patch..."
default:
return "Working..."
}
}

View File

@@ -3,6 +3,7 @@ import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, c
const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 32
const VISIBILITY_BUFFER_PX = 48
type ObserverRoot = Element | Document | null
@@ -48,6 +49,19 @@ function createSharedObserver(root: ObserverRoot, margin: number): SharedObserve
return { observer, listeners }
}
function shouldRenderEntry(entry: IntersectionObserverEntry) {
const rootBounds = entry.rootBounds
if (!rootBounds) {
return entry.isIntersecting
}
const distanceAbove = rootBounds.top - entry.boundingClientRect.bottom
const distanceBelow = entry.boundingClientRect.top - rootBounds.bottom
if (distanceAbove > VISIBILITY_BUFFER_PX || distanceBelow > VISIBILITY_BUFFER_PX) {
return false
}
return true
}
function subscribeToSharedObserver(
target: Element,
root: ObserverRoot,
@@ -167,6 +181,18 @@ export default function VirtualItem(props: VirtualItemProps) {
return
}
const normalized = nextHeight
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous))
if (shouldKeepPrevious) {
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
setHasMeasured(true)
sizeCache.set(props.cacheKey, previous)
setMeasuredHeight(previous)
return
}
if (normalized > 0) {
sizeCache.set(props.cacheKey, normalized)
setHasMeasured(true)
@@ -212,7 +238,8 @@ export default function VirtualItem(props: VirtualItemProps) {
}
const margin = props.threshold ?? DEFAULT_MARGIN_PX
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
queueVisibility(entry.isIntersecting)
const nextVisible = shouldRenderEntry(entry)
queueVisibility(nextVisible)
})
}
@@ -262,6 +289,7 @@ export default function VirtualItem(props: VirtualItemProps) {
})
createEffect(() => {
measurementsSuspended()
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
})

View File

@@ -0,0 +1,83 @@
import { Component, JSX, createContext, createEffect, createMemo, createSignal, useContext, type Accessor } from "solid-js"
import type { Instance } from "../../types/instance"
import { instances } from "../../stores/instances"
import { getInstanceMetadata } from "../../stores/instance-metadata"
import { loadInstanceMetadata, hasMetadataLoaded } from "../hooks/use-instance-metadata"
interface InstanceMetadataContextValue {
isLoading: Accessor<boolean>
instance: Accessor<Instance>
metadata: Accessor<Instance["metadata"] | undefined>
refreshMetadata: () => Promise<void>
}
const InstanceMetadataContext = createContext<InstanceMetadataContextValue | null>(null)
interface InstanceMetadataProviderProps {
instance: Instance
children: JSX.Element
}
export const InstanceMetadataProvider: Component<InstanceMetadataProviderProps> = (props) => {
const resolvedInstance = createMemo(() => instances().get(props.instance.id) ?? props.instance)
const [isLoading, setIsLoading] = createSignal(true)
const ensureMetadata = async (force = false) => {
const current = resolvedInstance()
if (!current) {
setIsLoading(false)
return
}
const cachedMetadata = getInstanceMetadata(current.id) ?? current.metadata
if (!force && hasMetadataLoaded(cachedMetadata)) {
setIsLoading(false)
return
}
setIsLoading(true)
await loadInstanceMetadata(current, { force })
setIsLoading(false)
}
createEffect(() => {
const current = resolvedInstance()
if (!current) {
setIsLoading(false)
return
}
const tracked = getInstanceMetadata(current.id) ?? current.metadata
if (!tracked || !hasMetadataLoaded(tracked)) {
void ensureMetadata()
return
}
setIsLoading(false)
})
const contextValue: InstanceMetadataContextValue = {
isLoading,
instance: resolvedInstance,
metadata: () => getInstanceMetadata(resolvedInstance().id) ?? resolvedInstance().metadata,
refreshMetadata: () => ensureMetadata(true),
}
return (
<InstanceMetadataContext.Provider value={contextValue}>
{props.children}
</InstanceMetadataContext.Provider>
)
}
export function useInstanceMetadataContext(): InstanceMetadataContextValue {
const ctx = useContext(InstanceMetadataContext)
if (!ctx) {
throw new Error("useInstanceMetadataContext must be used within InstanceMetadataProvider")
}
return ctx
}
export function useOptionalInstanceMetadataContext(): InstanceMetadataContextValue | null {
return useContext(InstanceMetadataContext)
}

View File

@@ -9,6 +9,7 @@ import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
import type { Instance } from "../../types/instance"
import { getLogger } from "../logger"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
const log = getLogger("actions")
@@ -55,38 +56,14 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
registerAgentShortcuts(
() => {
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
if (modelInput) {
modelInput.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true,
})
modelInput.dispatchEvent(event)
}, 10)
}
const instance = options.getActiveInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
},
() => {
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
if (agentTrigger) {
agentTrigger.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
})
agentTrigger.dispatchEvent(event)
}, 50)
}
const instance = options.getActiveInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
},
)

View File

@@ -18,9 +18,11 @@ import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { cleanupBlankSessions } from "../../stores/session-state"
import { getLogger } from "../logger"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
const log = getLogger("actions")
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
@@ -191,7 +193,10 @@ export function useCommands(options: UseCommandsOptions) {
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveSession(instanceId, ids[next])
if (ids[next]) {
setActiveSession(instanceId, ids[next])
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
}
},
})
@@ -212,7 +217,10 @@ export function useCommands(options: UseCommandsOptions) {
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveSession(instanceId, ids[prev])
if (ids[prev]) {
setActiveSession(instanceId, ids[prev])
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
}
},
})
@@ -345,21 +353,9 @@ export function useCommands(options: UseCommandsOptions) {
keywords: ["model", "llm", "ai"],
shortcut: { key: "M", meta: true, shift: true },
action: () => {
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
if (modelInput) {
modelInput.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true,
})
modelInput.dispatchEvent(event)
}, 10)
}
const instance = activeInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
},
})
@@ -371,21 +367,9 @@ export function useCommands(options: UseCommandsOptions) {
keywords: ["agent", "mode"],
shortcut: { key: "A", meta: true, shift: true },
action: () => {
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
if (agentTrigger) {
agentTrigger.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
})
agentTrigger.dispatchEvent(event)
}, 50)
}
const instance = activeInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
},
})

View File

@@ -0,0 +1,71 @@
import type { Instance, RawMcpStatus } from "../../types/instance"
import { fetchLspStatus } from "../../stores/instances"
import { getLogger } from "../../lib/logger"
import { getInstanceMetadata, mergeInstanceMetadata } from "../../stores/instance-metadata"
const log = getLogger("session")
const pendingMetadataRequests = new Set<string>()
function hasMetadataLoaded(metadata?: Instance["metadata"]): boolean {
if (!metadata) return false
return "project" in metadata && "mcpStatus" in metadata && "lspStatus" in metadata
}
export async function loadInstanceMetadata(instance: Instance, options?: { force?: boolean }): Promise<void> {
const client = instance.client
if (!client) {
log.warn("[metadata] Skipping fetch; client missing", { instanceId: instance.id })
return
}
const currentMetadata = getInstanceMetadata(instance.id) ?? instance.metadata
if (!options?.force && hasMetadataLoaded(currentMetadata)) {
return
}
if (pendingMetadataRequests.has(instance.id)) {
return
}
pendingMetadataRequests.add(instance.id)
try {
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
client.project.current(),
client.mcp.status(),
fetchLspStatus(instance.id),
])
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
const updates: Instance["metadata"] = { ...(currentMetadata ?? {}) }
if (projectResult.status === "fulfilled") {
updates.project = project ?? null
}
if (mcpResult.status === "fulfilled") {
updates.mcpStatus = mcpStatus ?? {}
}
if (lspResult.status === "fulfilled") {
updates.lspStatus = lspStatus ?? []
}
if (!updates?.version && instance.binaryVersion) {
updates.version = instance.binaryVersion
}
mergeInstanceMetadata(instance.id, updates)
} catch (error) {
log.error("Failed to load instance metadata", error)
} finally {
pendingMetadataRequests.delete(instance.id)
}
}
export { hasMetadataLoaded }

View File

@@ -0,0 +1,13 @@
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
export interface SessionSidebarRequestDetail {
instanceId: string
action: SessionSidebarRequestAction
}
export const SESSION_SIDEBAR_EVENT = "opencode:session-sidebar-request"
export function emitSessionSidebarRequest(detail: SessionSidebarRequestDetail) {
if (typeof window === "undefined") return
window.dispatchEvent(new CustomEvent<SessionSidebarRequestDetail>(SESSION_SIDEBAR_EVENT, { detail }))
}

View File

@@ -1,4 +1,6 @@
import { createContext, createEffect, createSignal, onMount, useContext, type JSX } from "solid-js"
import { createContext, createEffect, createMemo, createSignal, onMount, useContext, type JSX } from "solid-js"
import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/styles"
import CssBaseline from "@suid/material/CssBaseline"
import { useConfig } from "../stores/preferences"
interface ThemeContextValue {
@@ -10,6 +12,7 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue>()
function applyTheme(dark: boolean) {
if (typeof document === "undefined") return
if (dark) {
document.documentElement.setAttribute("data-theme", "dark")
return
@@ -18,8 +21,61 @@ function applyTheme(dark: boolean) {
document.documentElement.removeAttribute("data-theme")
}
interface ResolvedPaletteColors {
backgroundDefault: string
backgroundPaper: string
primary: string
primaryContrast: string
textPrimary: string
textSecondary: string
divider: string
}
const lightPaletteFallbacks: ResolvedPaletteColors = {
backgroundDefault: "#ffffff",
backgroundPaper: "#f5f5f5",
primary: "#0066ff",
primaryContrast: "#ffffff",
textPrimary: "#1a1a1a",
textSecondary: "#666666",
divider: "#e0e0e0",
}
const darkPaletteFallbacks: ResolvedPaletteColors = {
backgroundDefault: "#1a1a1a",
backgroundPaper: "#2a2a2a",
primary: "#0080ff",
primaryContrast: "#1a1a1a",
textPrimary: "#cfd4dc",
textSecondary: "#999999",
divider: "#3a3a3a",
}
const readCssVar = (token: string, fallback: string, rootStyle: CSSStyleDeclaration | null) => {
if (!rootStyle) return fallback
const value = rootStyle.getPropertyValue(token)
if (!value) return fallback
const trimmed = value.trim()
return trimmed || fallback
}
const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => {
const fallbackSet = dark ? darkPaletteFallbacks : lightPaletteFallbacks
const rootStyle = typeof window !== "undefined" ? getComputedStyle(document.documentElement) : null
return {
backgroundDefault: readCssVar("--surface-base", fallbackSet.backgroundDefault, rootStyle),
backgroundPaper: readCssVar("--surface-secondary", fallbackSet.backgroundPaper, rootStyle),
primary: readCssVar("--accent-primary", fallbackSet.primary, rootStyle),
primaryContrast: readCssVar("--text-inverted", fallbackSet.primaryContrast, rootStyle),
textPrimary: readCssVar("--text-primary", fallbackSet.textPrimary, rootStyle),
textSecondary: readCssVar("--text-secondary", fallbackSet.textSecondary, rootStyle),
divider: readCssVar("--border-base", fallbackSet.divider, rootStyle),
}
}
export function ThemeProvider(props: { children: JSX.Element }) {
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
const { themePreference, setThemePreference } = useConfig()
const [isDark, setIsDarkSignal] = createSignal(true)
@@ -39,14 +95,15 @@ export function ThemeProvider(props: { children: JSX.Element }) {
})
onMount(() => {
if (!mediaQuery) return
const handleSystemThemeChange = () => {
applyResolvedTheme()
}
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
mediaQuery.addEventListener("change", handleSystemThemeChange)
return () => {
systemPrefersDark.removeEventListener("change", handleSystemThemeChange)
mediaQuery.removeEventListener("change", handleSystemThemeChange)
}
})
@@ -58,7 +115,73 @@ export function ThemeProvider(props: { children: JSX.Element }) {
setTheme(true)
}
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
const muiTheme = createMemo(() => {
const paletteColors = resolvePaletteColors(isDark())
return createTheme({
palette: {
mode: isDark() ? "dark" : "light",
primary: {
main: paletteColors.primary,
contrastText: paletteColors.primaryContrast,
},
secondary: {
main: paletteColors.primary,
},
background: {
default: paletteColors.backgroundDefault,
paper: paletteColors.backgroundPaper,
},
text: {
primary: paletteColors.textPrimary,
secondary: paletteColors.textSecondary,
},
divider: paletteColors.divider,
},
typography: {
fontFamily: "var(--font-family-sans)",
},
shape: {
borderRadius: 8,
},
components: {
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: paletteColors.backgroundPaper,
color: paletteColors.textPrimary,
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: paletteColors.backgroundPaper,
color: paletteColors.textPrimary,
boxShadow: "none",
borderBottom: `1px solid ${paletteColors.divider}`,
zIndex: 10,
},
},
},
MuiToolbar: {
styleOverrides: {
root: {
minHeight: "56px",
},
},
},
} as any,
})
})
return (
<ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>
<MuiThemeProvider theme={muiTheme()}>
<CssBaseline />
{props.children}
</MuiThemeProvider>
</ThemeContext.Provider>
)
}
export function useTheme() {

View File

@@ -0,0 +1,35 @@
import { createSignal } from "solid-js"
import type { InstanceMetadata } from "../types/instance"
const [metadataMap, setMetadataMap] = createSignal<Map<string, InstanceMetadata | undefined>>(new Map())
function getInstanceMetadata(instanceId: string): InstanceMetadata | undefined {
return metadataMap().get(instanceId)
}
function setInstanceMetadata(instanceId: string, metadata: InstanceMetadata | undefined): void {
setMetadataMap((prev) => {
const next = new Map(prev)
if (metadata === undefined) {
next.delete(instanceId)
} else {
next.set(instanceId, metadata)
}
return next
})
}
function mergeInstanceMetadata(instanceId: string, updates: InstanceMetadata): void {
setMetadataMap((prev) => {
const next = new Map(prev)
const existing = next.get(instanceId) ?? {}
next.set(instanceId, { ...existing, ...updates })
return next
})
}
function clearInstanceMetadata(instanceId: string): void {
setInstanceMetadata(instanceId, undefined)
}
export { metadataMap, getInstanceMetadata, setInstanceMetadata, mergeInstanceMetadata, clearInstanceMetadata }

View File

@@ -20,6 +20,7 @@ import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForInstance } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
const log = getLogger("api")
@@ -290,6 +291,7 @@ function removeInstance(id: string) {
removeLogContainer(id)
clearCommands(id)
clearPermissionQueue(id)
clearInstanceMetadata(id)
if (activeInstanceId() === id) {
setActiveInstanceId(nextActiveId)
@@ -570,17 +572,7 @@ sseManager.onLspUpdated = async (instanceId) => {
if (!lspStatus) {
return
}
const instance = instances().get(instanceId)
if (!instance) {
log.warn("[LSP] Instance disappeared before metadata update", { instanceId })
return
}
updateInstance(instanceId, {
metadata: {
...(instance.metadata ?? {}),
lspStatus,
},
})
mergeInstanceMetadata(instanceId, { lspStatus })
} catch (error) {
log.error("Failed to refresh LSP status", error)
}

View File

@@ -6,6 +6,7 @@ import type { ClientPart, MessageInfo } from "../../types/message"
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
import type {
InstanceMessageState,
LatestTodoSnapshot,
MessageRecord,
MessageUpsertInput,
PartUpdateInput,
@@ -41,6 +42,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
},
usage: {},
scrollState: {},
latestTodos: {},
}
}
@@ -206,6 +208,7 @@ export interface InstanceMessageStore {
getSessionRevision: (sessionId: string) => number
getSessionMessageIds: (sessionId: string) => string[]
getMessage: (messageId: string) => MessageRecord | undefined
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
clearSession: (sessionId: string) => void
clearInstance: () => void
}
@@ -213,9 +216,55 @@ export interface InstanceMessageStore {
export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
const TODO_TOOL_NAME = "todowrite"
const messageInfoCache = new Map<string, MessageInfo>()
function isCompletedTodoPart(part: ClientPart | undefined): boolean {
if (!part || (part as any).type !== "tool") {
return false
}
const toolName = typeof (part as any).tool === "string" ? (part as any).tool : ""
if (toolName !== TODO_TOOL_NAME) {
return false
}
const toolState = (part as any).state
if (!toolState || typeof toolState !== "object") {
return false
}
return (toolState as { status?: string }).status === "completed"
}
function recordLatestTodoSnapshot(sessionId: string, snapshot: LatestTodoSnapshot) {
if (!sessionId) return
setState("latestTodos", sessionId, (existing) => {
if (existing && existing.timestamp > snapshot.timestamp) {
return existing
}
return snapshot
})
}
function maybeUpdateLatestTodoFromRecord(record: MessageRecord | undefined) {
if (!record || !Array.isArray(record.partIds) || record.partIds.length === 0) {
return
}
for (let index = record.partIds.length - 1; index >= 0; index -= 1) {
const partId = record.partIds[index]
const partRecord = record.parts[partId]
if (!partRecord) continue
if (isCompletedTodoPart(partRecord.data)) {
const timestamp = typeof record.updatedAt === "number" ? record.updatedAt : Date.now()
recordLatestTodoSnapshot(record.sessionId, { messageId: record.id, partId, timestamp })
break
}
}
}
function clearLatestTodoSnapshot(sessionId: string) {
setState("latestTodos", sessionId, undefined)
}
function bumpSessionRevision(sessionId: string) {
if (!sessionId) return
setState("sessionRevisions", sessionId, (value = 0) => value + 1)
@@ -365,6 +414,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
updatedAt: Date.now(),
}))
Object.values(normalizedRecords).forEach((record) => {
maybeUpdateLatestTodoFromRecord(record)
})
bumpSessionRevision(sessionId)
})
}
@@ -405,9 +458,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
const now = Date.now()
let nextRecord: MessageRecord | undefined
setState("messages", input.id, (previous) => {
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
return {
const record: MessageRecord = {
id: input.id,
sessionId: input.sessionId,
role: input.role,
@@ -419,8 +474,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
}
nextRecord = record
return record
})
if (nextRecord) {
maybeUpdateLatestTodoFromRecord(nextRecord)
}
insertMessageIntoSession(input.sessionId, input.id)
flushPendingParts(input.id)
bumpSessionRevision(input.sessionId)
@@ -471,6 +532,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}
}),
)
if (isCompletedTodoPart(cloned)) {
recordLatestTodoSnapshot(message.sessionId, {
messageId: input.messageId,
partId,
timestamp: Date.now(),
})
}
// Any part update can change the rendered height of the message
// list, so we treat it as a session revision for scroll purposes.
@@ -557,6 +626,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setState("pendingParts", options.newId, pending)
}
clearPendingPartsForMessage(options.oldId)
maybeUpdateLatestTodoFromRecord(cloned)
}
function setMessageInfo(messageId: string, info: MessageInfo) {
@@ -778,6 +848,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
})
clearLatestTodoSnapshot(sessionId)
hooks?.onSessionCleared?.(instanceId, sessionId)
}
@@ -812,10 +884,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setScrollSnapshot,
getScrollSnapshot,
getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getMessage: (messageId: string) => state.messages[messageId],
clearSession,
clearInstance,
}
}
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getMessage: (messageId: string) => state.messages[messageId],
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
clearSession,
clearInstance,
}
}

View File

@@ -88,6 +88,12 @@ export interface SessionUsageState {
latestMessageId?: string
}
export interface LatestTodoSnapshot {
messageId: string
partId: string
timestamp: number
}
export interface InstanceMessageState {
instanceId: string
sessions: Record<string, SessionRecord>
@@ -99,6 +105,7 @@ export interface InstanceMessageState {
permissions: InstancePermissionState
usage: Record<string, SessionUsageState>
scrollState: Record<string, ScrollSnapshot>
latestTodos: Record<string, LatestTodoSnapshot | undefined>
}
export interface SessionUpsertInput {

View File

@@ -178,8 +178,8 @@ async function sendMessage(
})
try {
log.info("session.prompt", { instanceId, sessionId, requestBody })
const response = await instance.client.session.prompt({
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
const response = await instance.client.session.promptAsync({
path: { id: sessionId },
body: requestBody,
})
@@ -334,9 +334,39 @@ async function updateSessionModel(
updateSessionInfo(instanceId, sessionId)
}
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
const trimmedTitle = nextTitle.trim()
if (!trimmedTitle) {
throw new Error("Session title is required")
}
await instance.client.session.update({
path: { id: sessionId },
body: { title: trimmedTitle },
})
withSession(instanceId, sessionId, (current) => {
current.title = trimmedTitle
const time = { ...(current.time ?? {}) }
time.updated = Date.now()
current.time = time
})
}
export {
abortSession,
executeCustomCommand,
renameSession,
runShellCommand,
sendMessage,
updateSessionAgent,

View File

@@ -41,6 +41,7 @@ import {
import {
abortSession,
executeCustomCommand,
renameSession,
runShellCommand,
sendMessage,
updateSessionAgent,
@@ -82,6 +83,7 @@ export {
createSession,
deleteSession,
executeCustomCommand,
renameSession,
runShellCommand,
fetchAgents,
fetchProviders,

View File

@@ -47,9 +47,10 @@
}
.selector-popover {
@apply rounded-md shadow-lg overflow-hidden z-50 min-w-[300px];
@apply rounded-md shadow-lg overflow-hidden min-w-[300px];
background-color: var(--surface-base);
border: 1px solid var(--border-base);
z-index: 2200;
}
.selector-search-container {

View File

@@ -5,16 +5,32 @@
width: 100%;
min-height: 0;
flex: 1 1 auto;
position: relative;
}
.message-layout--with-timeline {
grid-template-columns: minmax(0, 1fr) 64px;
}
.message-layout--with-timeline::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
right: 64px;
width: 1px;
background-color: var(--border-muted);
pointer-events: none;
}
@media (max-width: 720px) {
.message-layout--with-timeline {
grid-template-columns: minmax(0, 1fr) 40px;
}
.message-layout--with-timeline::after {
right: 40px;
}
}
.message-stream-shell {
@@ -22,19 +38,21 @@
display: flex;
flex-direction: column;
position: relative;
}
}
.message-stream-shell .message-stream {
.message-stream-shell .message-stream {
flex: 1 1 auto;
min-height: 0;
}
.message-timeline-sidebar {
}
.message-timeline-sidebar {
width: 64px;
min-height: 0;
display: flex;
flex-direction: column;
}
}
@media (max-width: 720px) {
.message-timeline-sidebar {

View File

@@ -6,29 +6,47 @@
}
.prompt-input-wrapper {
@apply flex items-stretch gap-2 p-2;
@apply grid items-stretch;
grid-template-columns: minmax(0, 1fr) 64px;
gap: 0;
padding: 0;
}
.prompt-input-actions {
@apply flex flex-col items-center justify-between;
align-self: stretch;
height: 100%;
padding: 0.25rem 0;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
}
.prompt-input-field {
.prompt-input-field-container {
position: relative;
width: 100%;
min-height: 56px;
flex: 1 1 auto;
height: 100%;
min-width: 0;
}
.prompt-input-field {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.prompt-input {
@apply flex-1 w-full min-h-[56px] max-h-[96px] pl-3 pr-10 pt-2.5 pb-4 border rounded-md text-sm resize-none outline-none transition-colors;
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
font-family: inherit;
background-color: var(--surface-base);
color: inherit;
border-color: var(--border-base);
line-height: var(--line-height-normal);
border-radius: 0;
padding-bottom: 0;
height: 100%;
min-height: 100%;
}
@@ -264,6 +282,12 @@
}
}
@media (max-width: 720px) {
.prompt-input-wrapper {
grid-template-columns: minmax(0, 1fr) 40px;
}
}
@media (max-width: 640px) {
.prompt-input {
min-height: 64px;
@@ -272,7 +296,7 @@
}
.prompt-input-wrapper {
gap: 0.75rem;
padding: 0.75rem;
gap: 0;
padding: 0;
}
}

View File

@@ -3,17 +3,77 @@
}
.tool-call-task-summary {
@apply my-2 flex flex-col gap-1.5;
display: flex;
flex-direction: column;
gap: 0.1rem;
margin: 0;
}
.tool-call-task-item {
font-size: var(--font-size-xs);
line-height: var(--line-height-normal);
padding-left: 8px;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.5rem 0.35rem 0.75rem;
border-left: 2px solid var(--border-base);
font-size: var(--font-size-sm);
font-family: var(--font-family-mono);
line-height: 1.35;
background-color: var(--surface-code);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.tool-call-task-item::before {
content: "∟ ";
.tool-call-task-item + .tool-call-task-item {
margin-top: 0.1rem;
}
.tool-call-task-item:hover {
background-color: var(--surface-hover);
}
.tool-call-task-item[data-task-status="completed"] {
border-left-color: var(--status-success);
}
.tool-call-task-item[data-task-status="running"] {
border-left-color: var(--status-warning);
}
.tool-call-task-item[data-task-status="pending"] {
border-left-color: var(--accent-primary);
}
.tool-call-task-item[data-task-status="error"] {
border-left-color: var(--status-error);
}
.tool-call-task-icon {
font-size: 0.9rem;
line-height: 1;
color: var(--text-muted);
}
.tool-call-task-label {
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
font-size: inherit;
}
.tool-call-task-separator {
color: var(--text-muted);
font-size: inherit;
}
.tool-call-task-text {
flex: 1;
min-width: 0;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-call-task-status {
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
font-size: 0.9rem;
}

View File

@@ -20,6 +20,7 @@
border-radius: 0;
padding: 10px 12px;
background-color: var(--surface-secondary);
min-height: 42px;
}
.tool-call-todo-item-completed {
@@ -82,9 +83,29 @@
}
.tool-call-todo-heading {
@apply flex items-start justify-between gap-3;
@apply flex items-start gap-3;
justify-content: space-between;
}
.tool-call-todo-status {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: 9999px;
padding: 2px 8px;
background-color: var(--surface-hover);
color: var(--text-muted);
white-space: nowrap;
}
.tool-call-todo-text {
font-size: var(--font-size-sm);
line-height: var(--line-height-tight);
color: var(--text-primary);
word-break: break-word;
}
.tool-call-todo-status {
font-size: 10px;
text-transform: uppercase;

View File

@@ -362,7 +362,7 @@
/* Panel component utilities */
.panel {
@apply rounded-lg shadow-sm border overflow-hidden;
@apply rounded-lg shadow-sm border overflow-hidden min-w-0;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
@@ -415,7 +415,8 @@
}
.panel-list {
@apply max-h-[400px] overflow-y-auto;
@apply max-h-[400px] overflow-y-auto w-full min-w-0;
overflow-x: hidden;
}
.panel-list--fill {
@@ -438,7 +439,7 @@
}
.panel-list-item-content {
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full min-w-0;
}
.panel-list-item-content:hover {
@@ -487,6 +488,7 @@
@apply flex flex-1 min-h-0 flex-col;
background-color: var(--surface-base);
color: inherit;
overflow: hidden;
}
/* Session list component */
@@ -534,17 +536,35 @@
}
.session-resize-handle {
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
z-index: 10;
}
.session-resize-handle--left {
right: 0;
}
.session-resize-handle--right {
left: 0;
}
.session-resize-handle:hover {
background-color: var(--accent-primary);
}
.session-resize-handle::before {
content: "";
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
@apply absolute top-0 h-full w-2;
}
.session-resize-handle--left::before {
right: 0;
transform: translateX(50%);
}
.session-resize-handle--right::before {
left: 0;
transform: translateX(-50%);
}
.session-list-header {

View File

@@ -1,6 +1,6 @@
/* Panel component shells */
.panel {
@apply rounded-lg shadow-sm border overflow-hidden;
@apply rounded-lg shadow-sm border overflow-hidden min-w-0;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
@@ -53,7 +53,8 @@
}
.panel-list {
@apply max-h-[400px] overflow-y-auto;
@apply max-h-[400px] overflow-y-auto w-full min-w-0;
overflow-x: hidden;
}
.panel-list--fill {
@@ -76,7 +77,7 @@
}
.panel-list-item-content {
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full min-w-0;
}
.panel-list-item-content:hover {

View File

@@ -3,6 +3,7 @@
@apply flex flex-1 min-h-0 flex-col;
background-color: var(--surface-base);
color: inherit;
overflow: hidden;
}
.session-list-container {
@@ -148,17 +149,35 @@ session-sidebar-controls .selector-trigger-primary {
}
.session-resize-handle {
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
z-index: 10;
}
.session-resize-handle--left {
right: 0;
}
.session-resize-handle--right {
left: 0;
}
.session-resize-handle:hover {
background-color: var(--accent-primary);
}
.session-resize-handle::before {
content: "";
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
@apply absolute top-0 h-full w-2;
}
.session-resize-handle--left::before {
right: 0;
transform: translateX(50%);
}
.session-resize-handle--right::before {
left: 0;
transform: translateX(-50%);
}
.session-list-header {

View File

@@ -22,12 +22,13 @@ export type RawMcpStatus = Record<string, {
}>
export interface InstanceMetadata {
project?: ProjectInfo
mcpStatus?: RawMcpStatus
lspStatus?: LspStatus[]
project?: ProjectInfo | null
mcpStatus?: RawMcpStatus | null
lspStatus?: LspStatus[] | null
version?: string
}
export interface Instance {
id: string
folder: string