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", "name": "codenomad-workspace",
"version": "0.3.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.3.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0" "google-auth-library": "^10.5.0"
@@ -1276,9 +1276,9 @@
} }
}, },
"node_modules/@opencode-ai/sdk": { "node_modules/@opencode-ai/sdk": {
"version": "1.0.133", "version": "1.0.138",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.133.tgz", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.138.tgz",
"integrity": "sha512-kM+VJJ09SU51aruQ78DSy+6CjNc4wMytvGBrZ1IIJ8etUIdGA59wrnIOSxBVs4u/Gb9pjjgsF8sWp59UdLWP9w==" "integrity": "sha512-9vXmpiAVVrhMZ3YNr7BGScyULFLyN0vnRx7iCDtN5qQDKxtsdQcXSQCz35XiVyD3A8lH5KOf5Zn0ByLYXuNeFQ=="
}, },
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
@@ -1296,6 +1296,16 @@
"node": ">=14" "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": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.5", "version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", "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" "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": { "node_modules/@swc/helpers": {
"version": "0.5.17", "version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
@@ -3102,6 +3215,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -8815,7 +8937,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.3.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server" "@neuralnomads/codenomad": "file:../server"
@@ -8843,7 +8965,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.3.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",
@@ -8882,19 +9004,22 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.3.0", "version": "0.4.0",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
} }
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.3.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "^1.0.133", "@opencode-ai/sdk": "^1.0.138",
"@solidjs/router": "^0.13.0", "@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", "debug": "^4.4.3",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0", "lucide-solid": "^0.300.0",

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev", "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/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.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", "name": "@codenomad/ui",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -12,8 +12,11 @@
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "^1.0.133", "@opencode-ai/sdk": "^1.0.138",
"@solidjs/router": "^0.13.0", "@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", "debug": "^4.4.3",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0", "lucide-solid": "^0.300.0",

View File

@@ -6,9 +6,11 @@ import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts" import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs" import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal" 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 { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown" import { initMarkdown } from "./lib/markdown"
import { useTheme } from "./lib/theme" import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
@@ -23,6 +25,7 @@ import {
showFolderSelection, showFolderSelection,
setShowFolderSelection, setShowFolderSelection,
} from "./stores/ui" } from "./stores/ui"
import { instances as instanceStore } from "./stores/instances"
import { useConfig } from "./stores/preferences" import { useConfig } from "./stores/preferences"
import { import {
createInstance, createInstance,
@@ -65,6 +68,13 @@ const App: Component = () => {
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null) const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = 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(() => { createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
@@ -74,6 +84,19 @@ const App: Component = () => {
initReleaseNotifications() 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 activeInstance = createMemo(() => getActiveInstance())
const activeSessionIdForInstance = createMemo(() => { const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance() const instance = activeInstance()
@@ -328,8 +351,10 @@ const App: Component = () => {
<For each={Array.from(instances().values())}> <For each={Array.from(instances().values())}>
{(instance) => { {(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection()
return ( return (
<div class="flex-1 min-h-0" style={{ display: isActiveInstance() ? "flex" : "none" }}> <div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
<InstanceMetadataProvider instance={instance}>
<InstanceShell <InstanceShell
instance={instance} instance={instance}
escapeInDebounce={escapeInDebounce()} escapeInDebounce={escapeInDebounce()}
@@ -339,9 +364,13 @@ const App: Component = () => {
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand} onExecuteCommand={executeCommand}
tabBarOffset={instanceTabBarHeight()}
/> />
</InstanceMetadataProvider>
</div> </div>
) )
}} }}
</For> </For>

View File

@@ -114,7 +114,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Trigger> </Select.Trigger>
<Select.Portal> <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.Listbox class="selector-listbox" />
</Select.Content> </Select.Content>
</Select.Portal> </Select.Portal>

View File

@@ -224,11 +224,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return ( return (
<> <>
<div <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)" style="background-color: var(--surface-secondary)"
> >
<div <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"} aria-busy={isLoading() ? "true" : "false"}
> >
<Show when={props.onOpenRemoteAccess}> <Show when={props.onOpenRemoteAccess}>

View File

@@ -1,134 +1,26 @@
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js" import { Component, For, Show, createMemo } from "solid-js"
import type { Instance, RawMcpStatus } from "../types/instance" import type { Instance } from "../types/instance"
import { fetchLspStatus, updateInstance } from "../stores/instances" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import { getLogger } from "../lib/logger" import InstanceServiceStatus from "./instance-service-status"
const log = getLogger("session")
interface InstanceInfoProps { interface InstanceInfoProps {
instance: Instance instance: Instance
compact?: boolean 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 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 currentInstance = () => instanceAccessor()
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version const metadata = () => metadataAccessor()
const mcpServers = () => { const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
const status = metadata()?.mcpStatus const environmentVariables = () => currentInstance().environmentVariables
return status ? parseMcpStatus(status) : [] const environmentEntries = createMemo(() => {
} const env = environmentVariables()
const lspServers = () => metadata()?.lspStatus ?? [] return env ? Object.entries(env) : []
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
})
}) })
return ( return (
@@ -140,7 +32,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</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"> <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>
</div> </div>
@@ -189,24 +81,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div> </div>
</Show> </Show>
<Show when={props.instance.binaryPath}> <Show when={currentInstance().binaryPath}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Binary Path Binary Path
</div> </div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary"> <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>
</div> </div>
</Show> </Show>
<Show when={props.instance.environmentVariables && Object.keys(props.instance.environmentVariables).length > 0}> <Show when={environmentEntries().length > 0}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5"> <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>
<div class="space-y-1"> <div class="space-y-1">
<For each={Object.entries(props.instance.environmentVariables!)}> <For each={environmentEntries()}>
{([key, value]) => ( {([key, value]) => (
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base"> <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}> <span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
@@ -222,79 +114,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div> </div>
</Show> </Show>
<Show when={!isLoadingMetadata() && lspServers().length > 0}> <InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
<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>
<Show when={isLoadingMetadata()}> <Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1"> <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="space-y-1 text-xs">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-secondary">Port:</span> <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>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-secondary">PID:</span> <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>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-secondary">Status:</span> <span class="text-secondary">Status:</span>
<span <span class={`status-badge ${currentInstance().status}`}>
class={`status-badge ${props.instance.status}`}
>
<div <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> </span>
</div> </div>
</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 { 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 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 InstanceInfo from "./instance-info"
import Kbd from "./kbd" import Kbd from "./kbd"
import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry" import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
import { isMac } from "../lib/keyboard-utils" import { isMac } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -24,6 +26,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
const [isDesktopLayout, setIsDesktopLayout] = createSignal( const [isDesktopLayout, setIsDesktopLayout] = createSignal(
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false, 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 parentSessions = () => getParentSessions(props.instance.id)
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(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) { 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 (showInstanceInfoOverlay()) {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
@@ -92,42 +115,56 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
if (sessions.length === 0) return if (sessions.length === 0) return
const listFocused = focusMode() === "sessions"
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
if (!listFocused) {
setFocusMode("sessions")
setSelectedIndex(0)
}
e.preventDefault() e.preventDefault()
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1) const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
setSelectedIndex(newIndex) setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex) 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() e.preventDefault()
const newIndex = Math.max(selectedIndex() - 1, 0) const newIndex = Math.max(selectedIndex() - 1, 0)
setSelectedIndex(newIndex) setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex) scrollToIndex(newIndex)
} else if (e.key === "PageDown") { return
}
if (!listFocused) {
return
}
if (e.key === "PageDown") {
e.preventDefault() e.preventDefault()
const pageSize = 5 const pageSize = 5
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1) const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
setSelectedIndex(newIndex) setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex) scrollToIndex(newIndex)
} else if (e.key === "PageUp") { } else if (e.key === "PageUp") {
e.preventDefault() e.preventDefault()
const pageSize = 5 const pageSize = 5
const newIndex = Math.max(selectedIndex() - pageSize, 0) const newIndex = Math.max(selectedIndex() - pageSize, 0)
setSelectedIndex(newIndex) setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex) scrollToIndex(newIndex)
} else if (e.key === "Home") { } else if (e.key === "Home") {
e.preventDefault() e.preventDefault()
setSelectedIndex(0) setSelectedIndex(0)
setFocusMode("sessions")
scrollToIndex(0) scrollToIndex(0)
} else if (e.key === "End") { } else if (e.key === "End") {
e.preventDefault() e.preventDefault()
const newIndex = sessions.length - 1 const newIndex = sessions.length - 1
setSelectedIndex(newIndex) setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex) scrollToIndex(newIndex)
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault() e.preventDefault()
@@ -138,6 +175,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
} }
} }
async function handleEnterKey() { async function handleEnterKey() {
const sessions = parentSessions() const sessions = parentSessions()
const index = selectedIndex() 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() { async function handleNewSession() {
if (isCreating()) return if (isCreating()) return
@@ -251,8 +314,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
return ( return (
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary"> <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 lg:flex-row gap-4 p-4 overflow-auto min-w-0">
<div class="flex-1 flex flex-col gap-4 min-h-0"> <div class="flex-1 flex flex-col gap-4 min-h-0 min-w-0">
<Show <Show
when={parentSessions().length > 0} when={parentSessions().length > 0}
fallback={ fallback={
@@ -336,7 +399,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <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={{ classList={{
"text-accent": isFocused(), "text-accent": isFocused(),
}} }}
@@ -355,6 +418,18 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<Show when={isFocused()}> <Show when={isFocused()}>
<div class="flex items-center gap-2 flex-shrink-0"> <div class="flex items-center gap-2 flex-shrink-0">
<kbd class="kbd flex-shrink-0"></kbd> <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 <button
type="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" 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>
<div class="hidden lg:block lg:w-80 flex-shrink-0"> <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} /> <InstanceInfo instance={props.instance} />
</div> </div>
</div> </div>
@@ -488,10 +563,17 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div> </div>
</div> </div>
</div> </div>
<SessionRenameDialog
open={Boolean(renameTarget())}
currentTitle={renameTarget()?.title ?? ""}
sessionLabel={renameTarget()?.label}
isSubmitting={isRenaming()}
onRename={handleRenameSubmit}
onClose={closeRenameDialog}
/>
</div> </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 { createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown" 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" import { getLogger } from "../lib/logger"
const log = getLogger("session") 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 { interface MarkdownProps {
part: TextPart part: TextPart
@@ -29,11 +34,25 @@ export function Markdown(props: MarkdownProps) {
const dark = Boolean(props.isDark) const dark = Boolean(props.isDark)
const themeKey = dark ? "dark" : "light" const themeKey = dark ? "dark" : "light"
const highlightEnabled = !props.disableHighlight 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 latestRequestedText = text
// Markdown initialization is now handled globally in App. const localCache = part.renderCache
// initMarkdown is idempotent but we avoid per-part calls here. 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) { if (!highlightEnabled) {
part.renderCache = undefined part.renderCache = undefined
@@ -42,39 +61,42 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text, { suppressHighlight: true }) const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
setHtml(rendered) setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered() notifyRendered()
} }
} catch (error) { } catch (error) {
log.error("Failed to render markdown:", error) log.error("Failed to render markdown:", error)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text) setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered() notifyRendered()
} }
} }
return return
} }
const cache = part.renderCache
if (cache && cache.text === text && cache.theme === themeKey) {
setHtml(cache.html)
notifyRendered()
return
}
try { try {
const rendered = await renderMarkdown(text) const rendered = await renderMarkdown(text)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
setHtml(rendered) setHtml(rendered)
part.renderCache = { text, html: rendered, theme: themeKey } part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered() notifyRendered()
} }
} catch (error) { } catch (error) {
log.error("Failed to render markdown:", error) log.error("Failed to render markdown:", error)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text) setHtml(text)
part.renderCache = { text, html: text, theme: themeKey } part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered() 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 VirtualItem from "./virtual-item"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -10,12 +10,10 @@ export function getMessageAnchorId(messageId: string) {
const VIRTUAL_ITEM_MARGIN_PX = 800 const VIRTUAL_ITEM_MARGIN_PX = 800
interface MessageBlockListProps { interface MessageBlockListProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
store: () => InstanceMessageStore store: () => InstanceMessageStore
messageIds: () => string[] messageIds: () => string[]
messageIndexMap: () => Map<string, number>
lastAssistantIndex: () => number lastAssistantIndex: () => number
showThinking: () => boolean showThinking: () => boolean
thinkingDefaultExpanded: () => boolean thinkingDefaultExpanded: () => boolean
@@ -27,35 +25,13 @@ interface MessageBlockListProps {
onContentRendered?: () => void onContentRendered?: () => void
setBottomSentinel: (element: HTMLDivElement | null) => void setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean suspendMeasurements?: () => boolean
onInitialRenderComplete?: () => void
} }
export default function MessageBlockList(props: MessageBlockListProps) { 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 ( return (
<> <>
<Index each={props.messageIds()}> <Index each={props.messageIds()}>
{(messageId) => { {(messageId, index) => (
return (
<VirtualItem <VirtualItem
id={getMessageAnchorId(messageId())} id={getMessageAnchorId(messageId())}
cacheKey={messageId()} cacheKey={messageId()}
@@ -64,14 +40,13 @@ export default function MessageBlockList(props: MessageBlockListProps) {
placeholderClass="message-stream-placeholder" placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading} virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements} suspendMeasurements={props.suspendMeasurements}
onMeasured={handleBlockRendered}
> >
<MessageBlock <MessageBlock
messageId={messageId()} messageId={messageId()}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={props.store} store={props.store}
messageIndexMap={props.messageIndexMap} messageIndex={index}
lastAssistantIndex={props.lastAssistantIndex} lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking} showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded} thinkingDefaultExpanded={props.thinkingDefaultExpanded}
@@ -81,8 +56,7 @@ export default function MessageBlockList(props: MessageBlockListProps) {
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</VirtualItem> </VirtualItem>
) )}
}}
</Index> </Index>
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} /> <div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</> </>

View File

@@ -204,7 +204,7 @@ interface MessageBlockProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
store: () => InstanceMessageStore store: () => InstanceMessageStore
messageIndexMap: () => Map<string, number> messageIndex: number
lastAssistantIndex: () => number lastAssistantIndex: () => number
showThinking: () => boolean showThinking: () => boolean
thinkingDefaultExpanded: () => boolean thinkingDefaultExpanded: () => boolean
@@ -223,7 +223,7 @@ export default function MessageBlock(props: MessageBlockProps) {
const current = record() const current = record()
if (!current) return null if (!current) return null
const index = props.messageIndexMap().get(current.id) ?? 0 const index = props.messageIndex
const lastAssistantIdx = props.lastAssistantIndex() const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx) const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const info = messageInfo() 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 type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
@@ -18,6 +18,7 @@ interface MessageItemProps {
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const [copied, setCopied] = createSignal(false)
const isUser = () => props.record.role === "user" const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -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()) { if (!isUser() && !hasContent()) {
return null return null
} }
@@ -218,8 +235,30 @@ interface MessageItemProps {
Fork Fork
</button> </button>
</Show> </Show>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</div> </div>
</Show> </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> <time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div> </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 MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -10,14 +10,7 @@ interface MessagePreviewProps {
} }
const MessagePreview: Component<MessagePreviewProps> = (props) => { const MessagePreview: Component<MessagePreviewProps> = (props) => {
const indexMap = createMemo(() => new Map([[props.messageId, 0]])) const lastAssistantIndex = () => 0
const lastAssistantIndex = createMemo(() => {
const record = props.store().getMessage(props.messageId)
if (record?.role === "assistant") {
return 0
}
return -1
})
return ( return (
<div class="message-preview message-stream"> <div class="message-preview message-stream">
@@ -26,7 +19,7 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={props.store} store={props.store}
messageIndexMap={indexMap} messageIndex={0}
lastAssistantIndex={lastAssistantIndex} lastAssistantIndex={lastAssistantIndex}
showThinking={() => false} showThinking={() => false}
thinkingDefaultExpanded={() => 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 Kbd from "./kbd"
import MessageBlockList, { getMessageAnchorId } from "./message-block-list" import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
import MessageListHeader from "./message-list-header"
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { getSessionInfo } from "../stores/sessions" import { getSessionInfo } from "../stores/sessions"
import { showCommandPalette } from "../stores/command-palette"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache" 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" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
const SCROLL_SCOPE = "session" 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 QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
function formatTokens(tokens: number): string {
return formatTokenTotal(tokens)
}
export interface MessageSectionProps { export interface MessageSectionProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
@@ -77,25 +69,12 @@ export default function MessageSection(props: MessageSectionProps) {
return `${showThinking}|${thinkingExpansion}|${showUsage}` return `${showThinking}|${thinkingExpansion}|${showUsage}`
}) })
const connectionStatus = () => sseManager.getStatus(props.instanceId)
const handleCommandPaletteClick = () => {
showCommandPalette(props.instanceId)
}
const handleTimelineSegmentClick = (segment: TimelineSegment) => { const handleTimelineSegmentClick = (segment: TimelineSegment) => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const anchor = document.getElementById(getMessageAnchorId(segment.messageId)) const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
anchor?.scrollIntoView({ block: "start", behavior: "smooth" }) 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 lastAssistantIndex = createMemo(() => {
const ids = messageIds() const ids = messageIds()
const resolvedStore = store() const resolvedStore = store()
@@ -108,23 +87,57 @@ export default function MessageSection(props: MessageSectionProps) {
return -1 return -1
}) })
const timelineSegments = createMemo<TimelineSegment[]>(() => { const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
const ids = messageIds() const hasTimelineSegments = () => timelineSegments().length > 0
const resolvedStore = store()
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[] = [] const segments: TimelineSegment[] = []
ids.forEach((messageId) => { ids.forEach((messageId) => {
const record = resolvedStore.getMessage(messageId) const record = resolvedStore.getMessage(messageId)
if (!record) return if (!record) return
seenTimelineMessageIds.add(messageId)
const built = buildTimelineSegments(props.instanceId, record) 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
}) })
setTimelineSegments(segments)
}
const hasTimelineSegments = () => timelineSegments().length > 0 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 [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
const changeToken = createMemo(() => String(sessionRevision())) const changeToken = createMemo(() => String(sessionRevision()))
const isActive = createMemo(() => props.isActive !== false)
const scrollCache = useScrollCache({ const scrollCache = useScrollCache({
@@ -164,8 +177,6 @@ export default function MessageSection(props: MessageSectionProps) {
let scrollToBottomDelayedFrame: number | null = null let scrollToBottomDelayedFrame: number | null = null
let pendingInitialScroll = true let pendingInitialScroll = true
const [initialRenderComplete, setInitialRenderComplete] = createSignal(false)
function markUserScrollIntent() { function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now() const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS 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 if (!containerRef) return
const sentinel = bottomSentinel() const sentinel = bottomSentinel()
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
if (!immediate) { const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true suppressAutoScrollOnce = true
} }
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior }) sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
@@ -260,6 +272,10 @@ export default function MessageSection(props: MessageSectionProps) {
} }
function requestScrollToBottom(immediate = true) { function requestScrollToBottom(immediate = true) {
if (!isActive()) {
pendingActiveScroll = true
return
}
if (!containerRef || !bottomSentinel()) { if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true pendingActiveScroll = true
return return
@@ -277,7 +293,7 @@ export default function MessageSection(props: MessageSectionProps) {
function resolvePendingActiveScroll() { function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return if (!pendingActiveScroll) return
if (!props.isActive) return if (!isActive()) return
requestScrollToBottom(true) requestScrollToBottom(true)
} }
@@ -292,8 +308,15 @@ export default function MessageSection(props: MessageSectionProps) {
function scheduleAnchorScroll(immediate = false) { function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return if (!autoScroll()) return
if (!isActive()) {
pendingActiveScroll = true
return
}
const sentinel = bottomSentinel() const sentinel = bottomSentinel()
if (!sentinel) return if (!sentinel) {
pendingActiveScroll = true
return
}
if (pendingAnchorScroll !== null) { if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll) cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null pendingAnchorScroll = null
@@ -377,10 +400,6 @@ export default function MessageSection(props: MessageSectionProps) {
scheduleAnchorScroll() scheduleAnchorScroll()
} }
function handleInitialRenderComplete() {
setInitialRenderComplete(true)
}
function handleScroll() { function handleScroll() {
if (!containerRef) return if (!containerRef) return
@@ -404,6 +423,7 @@ export default function MessageSection(props: MessageSectionProps) {
clearQuoteSelection() clearQuoteSelection()
scheduleScrollPersist() scheduleScrollPersist()
}) })
} }
@@ -415,10 +435,15 @@ export default function MessageSection(props: MessageSectionProps) {
let lastActiveState = false let lastActiveState = false
createEffect(() => { createEffect(() => {
const active = Boolean(props.isActive) const active = isActive()
if (active && !lastActiveState) { if (active) {
resolvePendingActiveScroll()
if (!lastActiveState && autoScroll()) {
requestScrollToBottom(true) requestScrollToBottom(true)
} }
} else if (autoScroll()) {
pendingActiveScroll = true
}
lastActiveState = active lastActiveState = active
}) })
@@ -426,12 +451,123 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading) const loading = Boolean(props.loading)
if (loading) { if (loading) {
pendingInitialScroll = true pendingInitialScroll = true
setInitialRenderComplete(false)
return return
} }
if (pendingInitialScroll && initialRenderComplete()) { if (!pendingInitialScroll) {
return
}
const container = scrollElement()
const sentinel = bottomSentinel()
if (!container || !sentinel || messageIds().length === 0) {
return
}
pendingInitialScroll = false pendingInitialScroll = false
requestScrollToBottom(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 ( return (
<div class="message-stream-container"> <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-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
<div class="message-stream-shell" ref={setShellElement}> <div class="message-stream-shell" ref={setShellElement}>
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}> <div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
@@ -659,7 +784,6 @@ export default function MessageSection(props: MessageSectionProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
store={store} store={store}
messageIds={messageIds} messageIds={messageIds}
messageIndexMap={messageIndexMap}
lastAssistantIndex={lastAssistantIndex} lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks} showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"} thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
@@ -670,8 +794,7 @@ export default function MessageSection(props: MessageSectionProps) {
onFork={props.onFork} onFork={props.onFork}
onContentRendered={handleContentRendered} onContentRendered={handleContentRendered}
setBottomSentinel={setBottomSentinel} setBottomSentinel={setBottomSentinel}
suspendMeasurements={() => props.isActive === false} suspendMeasurements={() => !isActive()}
onInitialRenderComplete={handleInitialRenderComplete}
/> />
@@ -688,7 +811,7 @@ export default function MessageSection(props: MessageSectionProps) {
<button <button
type="button" type="button"
class="message-scroll-button" class="message-scroll-button"
onClick={() => scrollToBottom()} onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label="Scroll to latest message" aria-label="Scroll to latest message"
> >
<span class="message-scroll-icon" aria-hidden="true"></span> <span class="message-scroll-icon" aria-hidden="true"></span>

View File

@@ -1086,6 +1086,7 @@ export default function PromptInput(props: PromptInputProps) {
</For> </For>
</div> </div>
</Show> </Show>
<div class="prompt-input-field-container">
<div class="prompt-input-field"> <div class="prompt-input-field">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -1167,6 +1168,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show> </Show>
</div> </div>
</div> </div>
</div>
<div class="prompt-input-actions"> <div class="prompt-input-actions">
<button <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 type { Session, SessionStatus } from "../types/session"
import { getSessionStatus } from "../stores/session-status" 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 KeyboardHint from "./keyboard-hint"
import Kbd from "./kbd" import Kbd from "./kbd"
import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { formatShortcut } from "../lib/keyboard-utils" import { formatShortcut } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { deleteSession, loading } from "../stores/sessions" import { deleteSession, loading, renameSession } from "../stores/sessions"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("session") const log = getLogger("session")
@@ -24,14 +25,8 @@ interface SessionListProps {
showFooter?: boolean showFooter?: boolean
headerContent?: JSX.Element headerContent?: JSX.Element
footerContent?: 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 { function formatSessionStatus(status: SessionStatus): string {
switch (status) { switch (status) {
case "working": case "working":
@@ -62,10 +57,8 @@ function arraysEqual(prev: readonly string[] | undefined, next: readonly string[
} }
const SessionList: Component<SessionListProps> = (props) => { const SessionList: Component<SessionListProps> = (props) => {
const [sidebarWidth, setSidebarWidth] = createSignal(DEFAULT_WIDTH) const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isResizing, setIsResizing] = createSignal(false) const [isRenaming, setIsRenaming] = createSignal(false)
const [startX, setStartX] = createSignal(0)
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
const infoShortcut = keyboardRegistry.get("switch-to-info") const infoShortcut = keyboardRegistry.get("switch-to-info")
const isSessionDeleting = (sessionId: string) => { const isSessionDeleting = (sessionId: string) => {
@@ -77,34 +70,6 @@ const SessionList: Component<SessionListProps> = (props) => {
props.onSelect(sessionId) 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) => { const copySessionId = async (event: MouseEvent, sessionId: string) => {
event.stopPropagation() event.stopPropagation()
@@ -133,94 +98,33 @@ const SessionList: Component<SessionListProps> = (props) => {
} }
} }
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
const removeMouseListeners = () => { setRenameTarget({ id: sessionId, title: session.title ?? "", label })
if (mouseMoveHandler) {
document.removeEventListener("mousemove", mouseMoveHandler)
mouseMoveHandler = null
} }
if (mouseUpHandler) {
document.removeEventListener("mouseup", mouseUpHandler) const closeRenameDialog = () => {
mouseUpHandler = null 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 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 SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
const session = () => props.sessions.get(rowProps.sessionId) const session = () => props.sessions.get(rowProps.sessionId)
@@ -281,6 +185,19 @@ const SessionList: Component<SessionListProps> = (props) => {
> >
<Copy class="w-3 h-3" /> <Copy class="w-3 h-3" />
</span> </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 <span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`} class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)} onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
@@ -348,14 +265,6 @@ const SessionList: Component<SessionListProps> = (props) => {
<div <div
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full" 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}> <Show when={props.showHeader !== false}>
<div class="session-list-header p-3 border-b border-base"> <div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? ( {props.headerContent ?? (
@@ -418,8 +327,18 @@ const SessionList: Component<SessionListProps> = (props) => {
{props.footerContent ?? null} {props.footerContent ?? null}
</div> </div>
</Show> </Show>
<SessionRenameDialog
open={Boolean(renameTarget())}
currentTitle={renameTarget()?.title ?? ""}
sessionLabel={renameTarget()?.label}
isSubmitting={isRenaming()}
onRename={handleRenameSubmit}
onClose={closeRenameDialog}
/>
</div> </div>
) )
} }
export default SessionList 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[]) { async function handleSendMessage(prompt: string, attachments: Attachment[]) {
if (scrollToBottomHandle && import.meta.env?.DEV) {
console.debug("[SessionView] handleSendMessage scroll", props.sessionId)
}
scheduleScrollToBottom() scheduleScrollToBottom()
await sendMessage(props.instanceId, props.sessionId, prompt, attachments) await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
} }

View File

@@ -17,7 +17,8 @@ import type {
ToolRendererContext, ToolRendererContext,
ToolScrollHelpers, ToolScrollHelpers,
} from "./tool-call/types" } 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" import { getLogger } from "../lib/logger"
const log = getLogger("session") 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 ].find((value) => typeof value === "string" && value.length > 0) as string | undefined
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
if (!normalizedPreferred) return []
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0) const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
if (candidateEntries.length === 0) return [] if (candidateEntries.length === 0) return []
const prioritizedEntries = (() => { const prioritizedEntries = candidateEntries.filter(([path]) => {
if (!normalizedPreferred) return candidateEntries
const matched = candidateEntries.filter(([path]) => {
const normalized = normalizeDiagnosticPath(path) const normalized = normalizeDiagnosticPath(path)
if (normalized === normalizedPreferred) return true return normalized === normalizedPreferred
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
})() if (prioritizedEntries.length === 0) return []
const entries: DiagnosticEntry[] = [] const entries: DiagnosticEntry[] = []
for (const [pathKey, list] of prioritizedEntries) { for (const [pathKey, list] of prioritizedEntries) {
@@ -632,6 +628,17 @@ export default function ToolCall(props: ToolCallProps) {
const disableHighlight = options.disableHighlight || false const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` 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 markdownPart: TextPart = { type: "text", text: options.content }
const cached = markdownCache.get<RenderCache>() const cached = markdownCache.get<RenderCache>()
if (cached) { if (cached) {
@@ -699,6 +706,12 @@ export default function ToolCall(props: ToolCallProps) {
const renderToolTitle = () => { const renderToolTitle = () => {
const state = toolState() const state = toolState()
const currentTool = toolName()
if (currentTool !== "task") {
return resolveTitleForTool({ toolName: currentTool, state })
}
if (!state) return getRendererAction() if (!state) return getRendererAction()
if (state.status === "pending") return getRendererAction() if (state.status === "pending") return getRendererAction()
@@ -713,7 +726,7 @@ export default function ToolCall(props: ToolCallProps) {
return state.title return state.title
} }
return getToolName(toolName()) return getToolName(currentTool)
} }
const renderToolBody = () => { const renderToolBody = () => {
@@ -907,33 +920,3 @@ export default function ToolCall(props: ToolCallProps) {
</div> </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 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 { interface TaskSummaryItem {
id: string id: string
tool: string tool: string
input: Record<string, any> input: Record<string, any>
metadata: Record<string, any>
state?: ToolState
status?: ToolState["status"]
title?: string
} }
function describeTaskItem(item: TaskSummaryItem): string { function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
const input = item.input || {} if (status === "pending" || status === "running" || status === "completed" || status === "error") {
switch (item.tool) { return status
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
} }
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 = { export const taskRenderer: ToolRenderer = {
@@ -29,18 +88,9 @@ export const taskRenderer: ToolRenderer = {
const state = toolState() const state = toolState()
if (!state) return undefined if (!state) return undefined
const { input } = readToolStatePayload(state) const { input } = readToolStatePayload(state)
const description = input.description return describeTaskTitle(input)
const subagent = input.subagent_type
const base = getToolName("task")
if (description && subagent) {
return `${base}[${subagent}] ${description}`
}
if (description) {
return `${base} ${description}`
}
return base
}, },
renderBody({ toolState, toolCall, messageVersion, partVersion, scrollHelpers }) { renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) {
const items = createMemo(() => { const items = createMemo(() => {
// Track the reactive change points so we only recompute when the part/message changes // Track the reactive change points so we only recompute when the part/message changes
messageVersion?.() messageVersion?.()
@@ -54,9 +104,13 @@ export const taskRenderer: ToolRenderer = {
return summary.map((entry, index) => { return summary.map((entry, index) => {
const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown" 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}` 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()}> <For each={items()}>
{(item) => { {(item) => {
const icon = getToolIcon(item.tool) 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 ( 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-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> <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> </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 { ToolState } from "@opencode-ai/sdk"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { readToolStatePayload } from "../utils" import { readToolStatePayload } from "../utils"
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
interface TodoViewItem { export interface TodoViewItem {
id: string id: string
content: string content: string
status: TodoViewStatus status: TodoViewStatus
@@ -58,33 +58,18 @@ function getTodoStatusLabel(status: TodoViewStatus): string {
} }
} }
function getTodoTitle(state?: ToolState): string { interface TodoListViewProps {
if (!state) return "Plan" state?: ToolState
emptyLabel?: string
const todos = extractTodosFromState(state) showStatusLabel?: boolean
if (state.status !== "completed" || todos.length === 0) return "Plan"
const counts = summarizeTodos(todos)
if (counts.pending === counts.total) return "Creating plan"
if (counts.completed === counts.total) return "Completing plan"
return "Updating plan"
} }
export const todoRenderer: ToolRenderer = { export function TodoListView(props: TodoListViewProps) {
tools: ["todowrite", "todoread"], const todos = extractTodosFromState(props.state)
getAction: () => "Planning...",
getTitle({ toolState }) {
return getTodoTitle(toolState())
},
renderBody({ toolState }) {
const state = toolState()
if (!state) return null
const todos = extractTodosFromState(state)
const counts = summarizeTodos(todos) const counts = summarizeTodos(todos)
if (counts.total === 0) { if (counts.total === 0) {
return <div class="tool-call-todo-empty">No plan items yet.</div> return <div class="tool-call-todo-empty">{props.emptyLabel ?? "No plan items yet."}</div>
} }
return ( return (
@@ -107,9 +92,12 @@ export const todoRenderer: ToolRenderer = {
<div class="tool-call-todo-body"> <div class="tool-call-todo-body">
<div class="tool-call-todo-heading"> <div class="tool-call-todo-heading">
<span class="tool-call-todo-text">{todo.content}</span> <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> <span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</Show>
</div> </div>
</div> </div>
</div> </div>
) )
}} }}
@@ -117,5 +105,30 @@ export const todoRenderer: ToolRenderer = {
</div> </div>
</div> </div>
) )
}
export function getTodoTitle(state?: ToolState): string {
if (!state) return "Plan"
const todos = extractTodosFromState(state)
if (state.status !== "completed" || todos.length === 0) return "Plan"
const counts = summarizeTodos(todos)
if (counts.pending === counts.total) return "Creating plan"
if (counts.completed === counts.total) return "Completing plan"
return "Updating plan"
}
export const todoRenderer: ToolRenderer = {
tools: ["todowrite", "todoread"],
getAction: () => "Planning...",
getTitle({ toolState }) {
return getTodoTitle(toolState())
},
renderBody({ toolState }) {
const state = toolState()
if (!state) return null
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, 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 sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600 const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 32 const MIN_PLACEHOLDER_HEIGHT = 32
const VISIBILITY_BUFFER_PX = 48
type ObserverRoot = Element | Document | null type ObserverRoot = Element | Document | null
@@ -48,6 +49,19 @@ function createSharedObserver(root: ObserverRoot, margin: number): SharedObserve
return { observer, listeners } 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( function subscribeToSharedObserver(
target: Element, target: Element,
root: ObserverRoot, root: ObserverRoot,
@@ -167,6 +181,18 @@ export default function VirtualItem(props: VirtualItemProps) {
return return
} }
const normalized = nextHeight 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) { if (normalized > 0) {
sizeCache.set(props.cacheKey, normalized) sizeCache.set(props.cacheKey, normalized)
setHasMeasured(true) setHasMeasured(true)
@@ -212,7 +238,8 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
const margin = props.threshold ?? DEFAULT_MARGIN_PX const margin = props.threshold ?? DEFAULT_MARGIN_PX
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => { 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(() => { createEffect(() => {
measurementsSuspended()
const root = props.scrollContainer ? props.scrollContainer() : null const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? 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 { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
import type { Instance } from "../../types/instance" import type { Instance } from "../../types/instance"
import { getLogger } from "../logger" import { getLogger } from "../logger"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
const log = getLogger("actions") const log = getLogger("actions")
@@ -55,38 +56,14 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
registerAgentShortcuts( registerAgentShortcuts(
() => { () => {
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement const instance = options.getActiveInstance()
if (modelInput) { if (!instance) return
modelInput.focus() emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true,
})
modelInput.dispatchEvent(event)
}, 10)
}
}, },
() => { () => {
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement const instance = options.getActiveInstance()
if (agentTrigger) { if (!instance) return
agentTrigger.focus() emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
})
agentTrigger.dispatchEvent(event)
}, 50)
}
}, },
) )

View File

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

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" import { useConfig } from "../stores/preferences"
interface ThemeContextValue { interface ThemeContextValue {
@@ -10,6 +12,7 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue>() const ThemeContext = createContext<ThemeContextValue>()
function applyTheme(dark: boolean) { function applyTheme(dark: boolean) {
if (typeof document === "undefined") return
if (dark) { if (dark) {
document.documentElement.setAttribute("data-theme", "dark") document.documentElement.setAttribute("data-theme", "dark")
return return
@@ -18,8 +21,61 @@ function applyTheme(dark: boolean) {
document.documentElement.removeAttribute("data-theme") 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 }) { 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 { themePreference, setThemePreference } = useConfig()
const [isDark, setIsDarkSignal] = createSignal(true) const [isDark, setIsDarkSignal] = createSignal(true)
@@ -39,14 +95,15 @@ export function ThemeProvider(props: { children: JSX.Element }) {
}) })
onMount(() => { onMount(() => {
if (!mediaQuery) return
const handleSystemThemeChange = () => { const handleSystemThemeChange = () => {
applyResolvedTheme() applyResolvedTheme()
} }
systemPrefersDark.addEventListener("change", handleSystemThemeChange) mediaQuery.addEventListener("change", handleSystemThemeChange)
return () => { return () => {
systemPrefersDark.removeEventListener("change", handleSystemThemeChange) mediaQuery.removeEventListener("change", handleSystemThemeChange)
} }
}) })
@@ -58,7 +115,73 @@ export function ThemeProvider(props: { children: JSX.Element }) {
setTheme(true) 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() { 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 { messageStoreBus } from "./message-v2/bus"
import { clearCacheForInstance } from "../lib/global-cache" import { clearCacheForInstance } from "../lib/global-cache"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
const log = getLogger("api") const log = getLogger("api")
@@ -290,6 +291,7 @@ function removeInstance(id: string) {
removeLogContainer(id) removeLogContainer(id)
clearCommands(id) clearCommands(id)
clearPermissionQueue(id) clearPermissionQueue(id)
clearInstanceMetadata(id)
if (activeInstanceId() === id) { if (activeInstanceId() === id) {
setActiveInstanceId(nextActiveId) setActiveInstanceId(nextActiveId)
@@ -570,17 +572,7 @@ sseManager.onLspUpdated = async (instanceId) => {
if (!lspStatus) { if (!lspStatus) {
return return
} }
const instance = instances().get(instanceId) mergeInstanceMetadata(instanceId, { lspStatus })
if (!instance) {
log.warn("[LSP] Instance disappeared before metadata update", { instanceId })
return
}
updateInstance(instanceId, {
metadata: {
...(instance.metadata ?? {}),
lspStatus,
},
})
} catch (error) { } catch (error) {
log.error("Failed to refresh LSP status", 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 { clearRecordDisplayCacheForMessages } from "./record-display-cache"
import type { import type {
InstanceMessageState, InstanceMessageState,
LatestTodoSnapshot,
MessageRecord, MessageRecord,
MessageUpsertInput, MessageUpsertInput,
PartUpdateInput, PartUpdateInput,
@@ -41,6 +42,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
}, },
usage: {}, usage: {},
scrollState: {}, scrollState: {},
latestTodos: {},
} }
} }
@@ -206,6 +208,7 @@ export interface InstanceMessageStore {
getSessionRevision: (sessionId: string) => number getSessionRevision: (sessionId: string) => number
getSessionMessageIds: (sessionId: string) => string[] getSessionMessageIds: (sessionId: string) => string[]
getMessage: (messageId: string) => MessageRecord | undefined getMessage: (messageId: string) => MessageRecord | undefined
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
clearSession: (sessionId: string) => void clearSession: (sessionId: string) => void
clearInstance: () => void clearInstance: () => void
} }
@@ -213,9 +216,55 @@ export interface InstanceMessageStore {
export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore { export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId)) const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
const TODO_TOOL_NAME = "todowrite"
const messageInfoCache = new Map<string, MessageInfo>() 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) { function bumpSessionRevision(sessionId: string) {
if (!sessionId) return if (!sessionId) return
setState("sessionRevisions", sessionId, (value = 0) => value + 1) setState("sessionRevisions", sessionId, (value = 0) => value + 1)
@@ -365,6 +414,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
updatedAt: Date.now(), updatedAt: Date.now(),
})) }))
Object.values(normalizedRecords).forEach((record) => {
maybeUpdateLatestTodoFromRecord(record)
})
bumpSessionRevision(sessionId) bumpSessionRevision(sessionId)
}) })
} }
@@ -405,9 +458,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const shouldBump = Boolean(input.bumpRevision || normalizedParts) const shouldBump = Boolean(input.bumpRevision || normalizedParts)
const now = Date.now() const now = Date.now()
let nextRecord: MessageRecord | undefined
setState("messages", input.id, (previous) => { setState("messages", input.id, (previous) => {
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0 const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
return { const record: MessageRecord = {
id: input.id, id: input.id,
sessionId: input.sessionId, sessionId: input.sessionId,
role: input.role, role: input.role,
@@ -419,8 +474,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
} }
nextRecord = record
return record
}) })
if (nextRecord) {
maybeUpdateLatestTodoFromRecord(nextRecord)
}
insertMessageIntoSession(input.sessionId, input.id) insertMessageIntoSession(input.sessionId, input.id)
flushPendingParts(input.id) flushPendingParts(input.id)
bumpSessionRevision(input.sessionId) bumpSessionRevision(input.sessionId)
@@ -472,6 +533,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 // Any part update can change the rendered height of the message
// list, so we treat it as a session revision for scroll purposes. // list, so we treat it as a session revision for scroll purposes.
bumpSessionRevision(message.sessionId) bumpSessionRevision(message.sessionId)
@@ -557,6 +626,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setState("pendingParts", options.newId, pending) setState("pendingParts", options.newId, pending)
} }
clearPendingPartsForMessage(options.oldId) clearPendingPartsForMessage(options.oldId)
maybeUpdateLatestTodoFromRecord(cloned)
} }
function setMessageInfo(messageId: string, info: MessageInfo) { function setMessageInfo(messageId: string, info: MessageInfo) {
@@ -779,6 +849,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId)) setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
}) })
clearLatestTodoSnapshot(sessionId)
hooks?.onSessionCleared?.(instanceId, sessionId) hooks?.onSessionCleared?.(instanceId, sessionId)
} }
@@ -814,8 +886,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
getSessionRevision: getSessionRevisionValue, getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getMessage: (messageId: string) => state.messages[messageId], getMessage: (messageId: string) => state.messages[messageId],
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
clearSession, clearSession,
clearInstance, clearInstance,
} }
} }

View File

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

View File

@@ -178,8 +178,8 @@ async function sendMessage(
}) })
try { try {
log.info("session.prompt", { instanceId, sessionId, requestBody }) log.info("session.promptAsync", { instanceId, sessionId, requestBody })
const response = await instance.client.session.prompt({ const response = await instance.client.session.promptAsync({
path: { id: sessionId }, path: { id: sessionId },
body: requestBody, body: requestBody,
}) })
@@ -334,9 +334,39 @@ async function updateSessionModel(
updateSessionInfo(instanceId, sessionId) 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 { export {
abortSession, abortSession,
executeCustomCommand, executeCustomCommand,
renameSession,
runShellCommand, runShellCommand,
sendMessage, sendMessage,
updateSessionAgent, updateSessionAgent,

View File

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

View File

@@ -47,9 +47,10 @@
} }
.selector-popover { .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); background-color: var(--surface-base);
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
z-index: 2200;
} }
.selector-search-container { .selector-search-container {

View File

@@ -5,16 +5,32 @@
width: 100%; width: 100%;
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 1 auto;
position: relative;
} }
.message-layout--with-timeline { .message-layout--with-timeline {
grid-template-columns: minmax(0, 1fr) 64px; 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) { @media (max-width: 720px) {
.message-layout--with-timeline { .message-layout--with-timeline {
grid-template-columns: minmax(0, 1fr) 40px; grid-template-columns: minmax(0, 1fr) 40px;
} }
.message-layout--with-timeline::after {
right: 40px;
}
} }
.message-stream-shell { .message-stream-shell {
@@ -22,19 +38,21 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
} }
.message-stream-shell .message-stream {
.message-stream-shell .message-stream {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
} }
.message-timeline-sidebar { .message-timeline-sidebar {
width: 64px; width: 64px;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.message-timeline-sidebar { .message-timeline-sidebar {

View File

@@ -6,29 +6,47 @@
} }
.prompt-input-wrapper { .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 { .prompt-input-actions {
@apply flex flex-col items-center justify-between; @apply flex flex-col items-center justify-between;
align-self: stretch; align-self: stretch;
height: 100%; height: 100%;
padding: 0.25rem 0; padding: 0.5rem 0.25rem;
gap: 0.5rem; gap: 0.5rem;
} }
.prompt-input-field { .prompt-input-field-container {
position: relative; position: relative;
width: 100%; 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 { .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; font-family: inherit;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
border-color: var(--border-base); border-color: var(--border-base);
line-height: var(--line-height-normal); 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) { @media (max-width: 640px) {
.prompt-input { .prompt-input {
min-height: 64px; min-height: 64px;
@@ -272,7 +296,7 @@
} }
.prompt-input-wrapper { .prompt-input-wrapper {
gap: 0.75rem; gap: 0;
padding: 0.75rem; padding: 0;
} }
} }

View File

@@ -3,17 +3,77 @@
} }
.tool-call-task-summary { .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 { .tool-call-task-item {
font-size: var(--font-size-xs); display: flex;
line-height: var(--line-height-normal); align-items: center;
padding-left: 8px; gap: 0.4rem;
padding: 0.35rem 0.5rem 0.35rem 0.75rem;
border-left: 2px solid var(--border-base); 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 { .tool-call-task-item + .tool-call-task-item {
content: "∟ "; 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); 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; border-radius: 0;
padding: 10px 12px; padding: 10px 12px;
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
min-height: 42px;
} }
.tool-call-todo-item-completed { .tool-call-todo-item-completed {
@@ -82,9 +83,29 @@
} }
.tool-call-todo-heading { .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 { .tool-call-todo-status {
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;

View File

@@ -362,7 +362,7 @@
/* Panel component utilities */ /* Panel component utilities */
.panel { .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); background-color: var(--surface-base);
border-color: var(--border-base); border-color: var(--border-base);
color: var(--text-primary); color: var(--text-primary);
@@ -415,7 +415,8 @@
} }
.panel-list { .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 { .panel-list--fill {
@@ -438,7 +439,7 @@
} }
.panel-list-item-content { .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 { .panel-list-item-content:hover {
@@ -487,6 +488,7 @@
@apply flex flex-1 min-h-0 flex-col; @apply flex flex-1 min-h-0 flex-col;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
overflow: hidden;
} }
/* Session list component */ /* Session list component */
@@ -534,17 +536,35 @@
} }
.session-resize-handle { .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; z-index: 10;
} }
.session-resize-handle--left {
right: 0;
}
.session-resize-handle--right {
left: 0;
}
.session-resize-handle:hover { .session-resize-handle:hover {
background-color: var(--accent-primary); background-color: var(--accent-primary);
} }
.session-resize-handle::before { .session-resize-handle::before {
content: ""; 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 { .session-list-header {

View File

@@ -1,6 +1,6 @@
/* Panel component shells */ /* Panel component shells */
.panel { .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); background-color: var(--surface-base);
border-color: var(--border-base); border-color: var(--border-base);
color: var(--text-primary); color: var(--text-primary);
@@ -53,7 +53,8 @@
} }
.panel-list { .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 { .panel-list--fill {
@@ -76,7 +77,7 @@
} }
.panel-list-item-content { .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 { .panel-list-item-content:hover {

View File

@@ -3,6 +3,7 @@
@apply flex flex-1 min-h-0 flex-col; @apply flex flex-1 min-h-0 flex-col;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
overflow: hidden;
} }
.session-list-container { .session-list-container {
@@ -148,17 +149,35 @@ session-sidebar-controls .selector-trigger-primary {
} }
.session-resize-handle { .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; z-index: 10;
} }
.session-resize-handle--left {
right: 0;
}
.session-resize-handle--right {
left: 0;
}
.session-resize-handle:hover { .session-resize-handle:hover {
background-color: var(--accent-primary); background-color: var(--accent-primary);
} }
.session-resize-handle::before { .session-resize-handle::before {
content: ""; 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 { .session-list-header {

View File

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