diff --git a/package-lock.json b/package-lock.json index 8ef8dab7..a21fa89e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "7zip-bin": "^5.2.0", "google-auth-library": "^10.5.0" @@ -1276,9 +1276,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.0.133", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.133.tgz", - "integrity": "sha512-kM+VJJ09SU51aruQ78DSy+6CjNc4wMytvGBrZ1IIJ8etUIdGA59wrnIOSxBVs4u/Gb9pjjgsF8sWp59UdLWP9w==" + "version": "1.0.138", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.138.tgz", + "integrity": "sha512-9vXmpiAVVrhMZ3YNr7BGScyULFLyN0vnRx7iCDtN5qQDKxtsdQcXSQCz35XiVyD3A8lH5KOf5Zn0ByLYXuNeFQ==" }, "node_modules/@pinojs/redact": { "version": "0.4.0", @@ -1296,6 +1296,16 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", @@ -1531,6 +1541,109 @@ "solid-js": "^1.8.6" } }, + "node_modules/@suid/base": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@suid/base/-/base-0.11.0.tgz", + "integrity": "sha512-jNe+LlXuxfkSZo8/MP9koqYYWswucDWSCwc7ViqUhQ0Y/V7sP2RiQ/Bnms+ePSMBZsk5k1b9fAjvj7DtNbbHXw==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.11.8", + "@suid/css": "0.4.1", + "@suid/system": "0.14.0", + "@suid/types": "0.8.0", + "@suid/utils": "0.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/css": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@suid/css/-/css-0.4.1.tgz", + "integrity": "sha512-Hsi4O3dBOm7rrlqKoWfNoTeRFAXm/7TPaeEmyxNx+wFaT3eROjMVdhadAIiagFT+PsHrq/6fDauUI5TkL+5Zvg==", + "license": "MIT" + }, + "node_modules/@suid/icons-material": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@suid/icons-material/-/icons-material-0.9.0.tgz", + "integrity": "sha512-2idgaT/JARd12dwDfocZBQizaiZVgR0ujRsVc61OlAuPZbeH+3TrSxUJkE3Z7+TPftw9+6p0A24GhJjJLvi6RQ==", + "license": "MIT", + "dependencies": { + "@suid/material": "0.19.0" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/material": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@suid/material/-/material-0.19.0.tgz", + "integrity": "sha512-vfudxYpHdur5CWTjd3eBb7q1b6A9X/pDWTEf2twc0gXVTcErS9VtY/VPBLa65AzO2SPJsdjAE+BCdVZiXASBbA==", + "license": "MIT", + "dependencies": { + "@suid/base": "0.11.0", + "@suid/css": "0.4.1", + "@suid/system": "0.14.0", + "@suid/types": "0.8.0", + "@suid/utils": "0.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/styled-engine": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@suid/styled-engine/-/styled-engine-0.9.0.tgz", + "integrity": "sha512-IfNHjQ3Im63mFIjFl/doiwdn5qbwgcwi/vUXnX7dmIUC/Cw1f3LPhzVT9V8Z3eqyvvFToy53O+BsuLy2e/WmDw==", + "license": "MIT", + "dependencies": { + "@suid/css": "0.4.1", + "@suid/utils": "0.11.0" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/system": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@suid/system/-/system-0.14.0.tgz", + "integrity": "sha512-aRVilPP53hHkqyAyQp2pasT/u8aQCcELwU4kFDnt3b+rj4fsPQRlhMumlX5mZ5aijIboH1CngU6TDG6Z9Mr3UA==", + "license": "MIT", + "dependencies": { + "@suid/css": "0.4.1", + "@suid/styled-engine": "0.9.0", + "@suid/types": "0.8.0", + "@suid/utils": "0.11.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/types": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@suid/types/-/types-0.8.0.tgz", + "integrity": "sha512-/Z2abkbypMjF6ygSpnjqnWohcmPqvgw8Xpx1wPPHeh+LajBP2imNT6uEa5dBqNEkJY8O3wEUCVqErAad/rmn5Q==", + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/utils": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@suid/utils/-/utils-0.11.0.tgz", + "integrity": "sha512-dk+6YJkex9kcU2qQHCOk8J0/zkOKKbng0SsjC0LBLyBrf2OC3OtDQq7o22pH3m/8CU/0M6uyM7tnyzZA4eWF3Q==", + "license": "MIT", + "dependencies": { + "@suid/types": "0.8.0" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -3102,6 +3215,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8815,7 +8937,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@codenomad/ui": "file:../ui", "@neuralnomads/codenomad": "file:../server" @@ -8843,7 +8965,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", @@ -8882,19 +9004,22 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.3.0", + "version": "0.4.0", "devDependencies": { "@tauri-apps/cli": "^2.9.4" } }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "^1.0.133", + "@opencode-ai/sdk": "^1.0.138", "@solidjs/router": "^0.13.0", + "@suid/icons-material": "^0.9.0", + "@suid/material": "^0.19.0", + "@suid/system": "^0.14.0", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", diff --git a/package.json b/package.json index 59e673ba..9194aec0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "CodeNomad monorepo workspace", "workspaces": { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 908cc8f8..0a72cf76 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.3.0", + "version": "0.4.0", "description": "CodeNomad - AI coding assistant", "author": { "name": "Neural Nomads", diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index b558b01c..ca16fa3d 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@fastify/cors": "^8.5.0", "commander": "^12.1.0", diff --git a/packages/server/package.json b/packages/server/package.json index f8f160fe..c6892ee6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.3.0", + "version": "0.4.0", "description": "CodeNomad Server", "author": { "name": "Neural Nomads", diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 4bb74b4f..1d16d5e9 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.3.0", + "version": "0.4.0", "private": true, "scripts": { "dev": "npx --yes @tauri-apps/cli@^2.9.4 dev", diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 4220f801..6b43574d 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -622,6 +622,18 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { candidates.push(Some(resources.join("resources/server/dist/index.js"))); candidates.push(Some(resources.join("resources/server/dist/server/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/server/index.js"))); + + let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; + for root in linux_resource_roots { + candidates.push(Some(root.join("server/dist/bin.js"))); + candidates.push(Some(root.join("server/dist/index.js"))); + candidates.push(Some(root.join("server/dist/server/bin.js"))); + candidates.push(Some(root.join("server/dist/server/index.js"))); + candidates.push(Some(root.join("resources/server/dist/bin.js"))); + candidates.push(Some(root.join("resources/server/dist/index.js"))); + candidates.push(Some(root.join("resources/server/dist/server/bin.js"))); + candidates.push(Some(root.join("resources/server/dist/server/index.js"))); + } } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 94dd5d36..098a9c26 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { @@ -12,8 +12,11 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "^1.0.133", + "@opencode-ai/sdk": "^1.0.138", "@solidjs/router": "^0.13.0", + "@suid/icons-material": "^0.9.0", + "@suid/material": "^0.19.0", + "@suid/system": "^0.14.0", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 770bb40b..cc1b8762 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -6,9 +6,11 @@ import FolderSelectionView from "./components/folder-selection-view" import { showConfirmDialog } from "./stores/alerts" import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" -import InstanceShell from "./components/instance/instance-shell" +import InstanceShell from "./components/instance/instance-shell2" import { RemoteAccessOverlay } from "./components/remote-access-overlay" +import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { initMarkdown } from "./lib/markdown" + import { useTheme } from "./lib/theme" import { useCommands } from "./lib/hooks/use-commands" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" @@ -23,6 +25,7 @@ import { showFolderSelection, setShowFolderSelection, } from "./stores/ui" +import { instances as instanceStore } from "./stores/instances" import { useConfig } from "./stores/preferences" import { createInstance, @@ -65,6 +68,13 @@ const App: Component = () => { const [launchErrorBinary, setLaunchErrorBinary] = createSignal(null) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) + const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) + + const updateInstanceTabBarHeight = () => { + if (typeof document === "undefined") return + const element = document.querySelector(".tab-bar-instance") + setInstanceTabBarHeight(element?.offsetHeight ?? 0) + } createEffect(() => { void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) @@ -74,6 +84,19 @@ const App: Component = () => { initReleaseNotifications() }) + createEffect(() => { + instances() + hasInstances() + requestAnimationFrame(() => updateInstanceTabBarHeight()) + }) + + onMount(() => { + updateInstanceTabBarHeight() + const handleResize = () => updateInstanceTabBarHeight() + window.addEventListener("resize", handleResize) + onCleanup(() => window.removeEventListener("resize", handleResize)) + }) + const activeInstance = createMemo(() => getActiveInstance()) const activeSessionIdForInstance = createMemo(() => { const instance = activeInstance() @@ -328,20 +351,26 @@ const App: Component = () => { {(instance) => { const isActiveInstance = () => activeInstanceId() === instance.id - return ( -
- handleCloseSession(instance.id, sessionId)} - onNewSession={() => handleNewSession(instance.id)} - handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} - handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} - onExecuteCommand={executeCommand} - /> -
- ) + const isVisible = () => isActiveInstance() && !showFolderSelection() + return ( +
+ + handleCloseSession(instance.id, sessionId)} + onNewSession={() => handleNewSession(instance.id)} + handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} + handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} + onExecuteCommand={executeCommand} + tabBarOffset={instanceTabBarHeight()} + /> + + +
+ ) + }}
diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index 80c5ddfa..25112669 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -114,7 +114,7 @@ export default function AgentSelector(props: AgentSelectorProps) { - + diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 51e469c3..c495457a 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -224,11 +224,11 @@ const FolderSelectionView: Component = (props) => { return ( <>
diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index e3af7c11..15db4ac7 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -1,134 +1,26 @@ -import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js" -import type { Instance, RawMcpStatus } from "../types/instance" -import { fetchLspStatus, updateInstance } from "../stores/instances" -import { getLogger } from "../lib/logger" - -const log = getLogger("session") +import { Component, For, Show, createMemo } from "solid-js" +import type { Instance } from "../types/instance" +import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" +import InstanceServiceStatus from "./instance-service-status" interface InstanceInfoProps { instance: Instance compact?: boolean } -type ParsedMcpStatus = { - name: string - status: "running" | "stopped" | "error" - error?: string -} - -function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] { - if (!status || typeof status !== "object") return [] - - const result: ParsedMcpStatus[] = [] - - for (const [name, value] of Object.entries(status)) { - if (!value || typeof value !== "object") continue - const rawStatus = (value as { status?: string }).status - if (!rawStatus) continue - - let mappedStatus: ParsedMcpStatus["status"] - if (rawStatus === "connected") { - mappedStatus = "running" - } else if (rawStatus === "failed") { - mappedStatus = "error" - } else { - mappedStatus = "stopped" - } - - result.push({ - name, - status: mappedStatus, - error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined, - }) - } - - return result -} - -const pendingMetadataRequests = new Set() - const InstanceInfo: Component = (props) => { - const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true) + const metadataContext = useOptionalInstanceMetadataContext() + const isLoadingMetadata = metadataContext?.isLoading ?? (() => false) + const instanceAccessor = metadataContext?.instance ?? (() => props.instance) + const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata) - const metadata = () => props.instance.metadata - const binaryVersion = () => props.instance.binaryVersion || metadata()?.version - const mcpServers = () => { - const status = metadata()?.mcpStatus - return status ? parseMcpStatus(status) : [] - } - const lspServers = () => metadata()?.lspStatus ?? [] - - createEffect(() => { - const instance = props.instance - const instanceId = instance.id - const client = instance.client - const hasMetadata = Boolean(instance.metadata) - - if (!client) { - setIsLoadingMetadata(false) - pendingMetadataRequests.delete(instanceId) - return - } - - if (hasMetadata) { - setIsLoadingMetadata(false) - pendingMetadataRequests.delete(instanceId) - return - } - - if (pendingMetadataRequests.has(instanceId)) { - setIsLoadingMetadata(true) - return - } - - let cancelled = false - pendingMetadataRequests.add(instanceId) - setIsLoadingMetadata(true) - - void (async () => { - try { - const [projectResult, mcpResult, lspResult] = await Promise.allSettled([ - client.project.current(), - client.mcp.status(), - fetchLspStatus(instanceId), - ]) - - if (cancelled) { - return - } - - const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined - const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined - const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined - - const nextMetadata = { - ...(instance.metadata ?? {}), - ...(project ? { project } : {}), - ...(mcpStatus ? { mcpStatus } : {}), - ...(lspStatus ? { lspStatus } : {}), - } - - if (!nextMetadata.version && instance.binaryVersion) { - nextMetadata.version = instance.binaryVersion - } - - updateInstance(instanceId, { metadata: nextMetadata }) - - } catch (error) { - if (!cancelled) { - log.error("Failed to load instance metadata", error) - } - } finally { - pendingMetadataRequests.delete(instanceId) - if (!cancelled) { - setIsLoadingMetadata(false) - } - } - })() - - onCleanup(() => { - cancelled = true - }) + const currentInstance = () => instanceAccessor() + const metadata = () => metadataAccessor() + const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version + const environmentVariables = () => currentInstance().environmentVariables + const environmentEntries = createMemo(() => { + const env = environmentVariables() + return env ? Object.entries(env) : [] }) return ( @@ -140,7 +32,7 @@ const InstanceInfo: Component = (props) => {
Folder
- {props.instance.folder} + {currentInstance().folder}
@@ -189,24 +81,24 @@ const InstanceInfo: Component = (props) => {
- +
Binary Path
- {props.instance.binaryPath} + {currentInstance().binaryPath}
- 0}> + 0}>
- Environment Variables ({Object.keys(props.instance.environmentVariables!).length}) + Environment Variables ({environmentEntries().length})
- + {([key, value]) => (
@@ -222,79 +114,7 @@ const InstanceInfo: Component = (props) => {
- 0}> -
-
- LSP Servers -
-
- - {(server) => ( -
-
-
- {server.name ?? server.id} - - {server.root} - -
-
-
- {server.status === "connected" ? "Connected" : "Error"} -
-
-
- )} - -
-
- - - 0}> -
-
- MCP Servers -
-
- - {(server) => ( -
-
- {server.name} -
-
- - { - server.status === "running" - ? "Connected" - : server.status === "error" - ? "Error" - : "Disabled" - } - -
-
- - {(error) => ( -
- {error()} -
- )} -
-
- )} - -
-
- +
@@ -317,21 +137,19 @@ const InstanceInfo: Component = (props) => {
Port: - {props.instance.port} + {currentInstance().port}
PID: - {props.instance.pid} + {currentInstance().pid}
Status: - +
- {props.instance.status} + {currentInstance().status}
diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx new file mode 100644 index 00000000..dc610416 --- /dev/null +++ b/packages/ui/src/components/instance-service-status.tsx @@ -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 = (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(() => 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>({}) + + 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) => ( +

+ {message} +

+ ) + + const renderLspSection = () => ( +
+ +
+ LSP Servers +
+
+ 0} + fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")} + > +
+ + {(server) => ( +
+
+
+ {server.name ?? server.id} + + {server.root} + +
+
+
+ {server.status === "connected" ? "Connected" : "Error"} +
+
+
+ )} + +
+ +
+ ) + + const renderMcpSection = () => ( +
+ +
+ MCP Servers +
+
+ 0} + fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")} + > +
+ + {(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 ( +
+
+ {server.name} +
+
+ + + + + + +
+
+
+ { + if (switchDisabled()) return + void toggleMcpServer(server.name, Boolean(checked)) + }} + /> +
+
+ +
+ + {(error) => ( +
+ {error()} +
+ )} +
+
+ ) + }} + +
+ +
+ ) + + return ( +
+ {renderLspSection()} + {renderMcpSection()} +
+ ) +} + +export default InstanceServiceStatus diff --git a/packages/ui/src/components/instance-welcome-view.tsx b/packages/ui/src/components/instance-welcome-view.tsx index 214481c3..15ca0365 100644 --- a/packages/ui/src/components/instance-welcome-view.tsx +++ b/packages/ui/src/components/instance-welcome-view.tsx @@ -1,12 +1,14 @@ import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js" -import { Loader2, Trash2 } from "lucide-solid" +import { Loader2, Pencil, Trash2 } from "lucide-solid" import type { Instance } from "../types/instance" -import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading } from "../stores/sessions" +import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading, renameSession } from "../stores/sessions" import InstanceInfo from "./instance-info" import Kbd from "./kbd" +import SessionRenameDialog from "./session-rename-dialog" import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry" import { isMac } from "../lib/keyboard-utils" +import { showToastNotification } from "../lib/notifications" import { getLogger } from "../lib/logger" const log = getLogger("actions") @@ -24,6 +26,8 @@ const InstanceWelcomeView: Component = (props) => { const [isDesktopLayout, setIsDesktopLayout] = createSignal( typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false, ) + const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) + const [isRenaming, setIsRenaming] = createSignal(false) const parentSessions = () => getParentSessions(props.instance.id) const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id))) @@ -74,6 +78,25 @@ const InstanceWelcomeView: Component = (props) => { } function handleKeyDown(e: KeyboardEvent) { + let activeElement: HTMLElement | null = null + if (typeof document !== "undefined") { + activeElement = document.activeElement as HTMLElement | null + } + const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']") + const isEditingField = + activeElement && + (["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || + activeElement.isContentEditable || + Boolean(insideModal)) + + if (isEditingField) { + if (insideModal && e.key === "Escape" && renameTarget()) { + e.preventDefault() + closeRenameDialog() + } + return + } + if (showInstanceInfoOverlay()) { if (e.key === "Escape") { e.preventDefault() @@ -81,53 +104,67 @@ const InstanceWelcomeView: Component = (props) => { } return } - + const sessions = parentSessions() - + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") { e.preventDefault() handleNewSession() return } - + if (sessions.length === 0) return - + + const listFocused = focusMode() === "sessions" + if (e.key === "ArrowDown") { + if (!listFocused) { + setFocusMode("sessions") + setSelectedIndex(0) + } e.preventDefault() const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1) setSelectedIndex(newIndex) - setFocusMode("sessions") scrollToIndex(newIndex) - } else if (e.key === "ArrowUp") { + return + } + + if (e.key === "ArrowUp") { + if (!listFocused) { + setFocusMode("sessions") + setSelectedIndex(Math.max(parentSessions().length - 1, 0)) + } e.preventDefault() const newIndex = Math.max(selectedIndex() - 1, 0) setSelectedIndex(newIndex) - setFocusMode("sessions") scrollToIndex(newIndex) - } else if (e.key === "PageDown") { + return + } + + if (!listFocused) { + return + } + + if (e.key === "PageDown") { e.preventDefault() const pageSize = 5 const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1) setSelectedIndex(newIndex) - setFocusMode("sessions") scrollToIndex(newIndex) } else if (e.key === "PageUp") { e.preventDefault() const pageSize = 5 const newIndex = Math.max(selectedIndex() - pageSize, 0) setSelectedIndex(newIndex) - setFocusMode("sessions") scrollToIndex(newIndex) } else if (e.key === "Home") { e.preventDefault() setSelectedIndex(0) - setFocusMode("sessions") scrollToIndex(0) } else if (e.key === "End") { e.preventDefault() const newIndex = sessions.length - 1 setSelectedIndex(newIndex) - setFocusMode("sessions") scrollToIndex(newIndex) } else if (e.key === "Enter") { e.preventDefault() @@ -138,6 +175,7 @@ const InstanceWelcomeView: Component = (props) => { } } + async function handleEnterKey() { const sessions = parentSessions() const index = selectedIndex() @@ -234,6 +272,31 @@ const InstanceWelcomeView: Component = (props) => { } } + function openRenameDialogForSession(sessionId: string, title: string) { + const label = title && title.trim() ? title : sessionId + setRenameTarget({ id: sessionId, title: title ?? "", label }) + } + + function closeRenameDialog() { + setRenameTarget(null) + } + + async function handleRenameSubmit(nextTitle: string) { + const target = renameTarget() + if (!target) return + + setIsRenaming(true) + try { + await renameSession(props.instance.id, target.id, nextTitle) + setRenameTarget(null) + } catch (error) { + log.error("Failed to rename session:", error) + showToastNotification({ message: "Unable to rename session", variant: "error" }) + } finally { + setIsRenaming(false) + } + } + async function handleNewSession() { if (isCreating()) return @@ -251,8 +314,8 @@ const InstanceWelcomeView: Component = (props) => { return (
-
-
+
+
0} fallback={ @@ -336,7 +399,7 @@ const InstanceWelcomeView: Component = (props) => {
= (props) => {
+
+ +
) } - - export default InstanceWelcomeView - +export default InstanceWelcomeView diff --git a/packages/ui/src/components/instance/instance-shell.tsx b/packages/ui/src/components/instance/instance-shell.tsx deleted file mode 100644 index 80949998..00000000 --- a/packages/ui/src/components/instance/instance-shell.tsx +++ /dev/null @@ -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 - onCloseSession: (sessionId: string) => Promise | void - onNewSession: () => Promise | void - handleSidebarAgentChange: (sessionId: string, agent: string) => Promise - handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise - onExecuteCommand: (command: Command) => void -} - -const DEFAULT_SESSION_SIDEBAR_WIDTH = 350 -const MOBILE_SIDEBAR_BREAKPOINT = 1024 -const SESSION_CACHE_LIMIT = 2 - -const InstanceShell: Component = (props) => { - const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) - const [isCompactLayout, setIsCompactLayout] = createSignal(false) - const [isSidebarOpen, setIsSidebarOpen] = createSignal(true) - const [cachedSessionIds, setCachedSessionIds] = createSignal([]) - const [pendingEvictions, setPendingEvictions] = createSignal([]) - 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[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 ( - <> - 0} fallback={}> -
-
- { - 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={ -
-
- Sessions - - - -
-
- {keyboardShortcuts().length ? ( - - ) : null} -
-
- } - onWidthChange={setSessionSidebarWidth} - /> - -
- - {(activeSession) => ( - <> - -
- props.handleSidebarAgentChange(activeSession().id, agent)} - /> - - - - props.handleSidebarModelChange(activeSession().id, model)} - /> - -
- - )} -
-
- -
- - - - 0 && activeSessionIdForInstance()} - fallback={ -
-
-

No session selected

-

Select a session to view messages

-
-
- } - > - - {(sessionId) => { - const isActive = () => activeSessionIdForInstance() === sessionId - return ( -
- setIsSidebarOpen(true)} - forceCompactStatusLayout={shouldShowSidebarToggle()} - isActive={isActive()} - /> -
- ) - }} -
-
- } - > - - -
- - -
- - - hideCommandPalette(props.instance.id)} - commands={instancePaletteCommands()} - onExecute={props.onExecuteCommand} - /> - - ) -} - -export default InstanceShell diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx new file mode 100644 index 00000000..7f345f8a --- /dev/null +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -0,0 +1,1308 @@ +import { + For, + Show, + batch, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, + type Accessor, + type Component, +} from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import { Accordion } from "@kobalte/core" +import { ChevronDown } from "lucide-solid" +import AppBar from "@suid/material/AppBar" +import Box from "@suid/material/Box" +import Divider from "@suid/material/Divider" +import Drawer from "@suid/material/Drawer" +import IconButton from "@suid/material/IconButton" +import Toolbar from "@suid/material/Toolbar" +import Typography from "@suid/material/Typography" +import useMediaQuery from "@suid/material/useMediaQuery" +import CloseIcon from "@suid/icons-material/Close" +import MenuIcon from "@suid/icons-material/Menu" +import MenuOpenIcon from "@suid/icons-material/MenuOpen" +import PushPinIcon from "@suid/icons-material/PushPin" +import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" +import type { Instance } from "../../types/instance" +import type { Command } from "../../lib/commands" +import { + activeParentSessionId, + activeSessionId as activeSessionMap, + getSessionFamily, + getSessionInfo, + 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, showCommandPalette } 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 InstanceServiceStatus from "../instance-service-status" +import AgentSelector from "../agent-selector" +import ModelSelector from "../model-selector" +import CommandPalette from "../command-palette" +import Kbd from "../kbd" +import { TodoListView } from "../tool-call/renderers/todo" +import ContextUsagePanel from "../session/context-usage-panel" +import SessionView from "../session/session-view" +import { formatTokenTotal } from "../../lib/formatters" +import { sseManager } from "../../lib/sse-manager" +import { getLogger } from "../../lib/logger" +import { + SESSION_SIDEBAR_EVENT, + type SessionSidebarRequestAction, + type SessionSidebarRequestDetail, +} from "../../lib/session-sidebar-events" + +const log = getLogger("session") + +interface InstanceShellProps { + instance: Instance + escapeInDebounce: boolean + paletteCommands: Accessor + onCloseSession: (sessionId: string) => Promise | void + onNewSession: () => Promise | void + handleSidebarAgentChange: (sessionId: string, agent: string) => Promise + handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise + onExecuteCommand: (command: Command) => void + tabBarOffset: number +} + +const DEFAULT_SESSION_SIDEBAR_WIDTH = 280 +const MIN_SESSION_SIDEBAR_WIDTH = 220 +const MAX_SESSION_SIDEBAR_WIDTH = 360 +const RIGHT_DRAWER_WIDTH = 260 +const MIN_RIGHT_DRAWER_WIDTH = 200 +const MAX_RIGHT_DRAWER_WIDTH = 380 +const SESSION_CACHE_LIMIT = 2 +const APP_BAR_HEIGHT = 56 +const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8" +const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" +const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" +const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1" + + + + +type LayoutMode = "desktop" | "tablet" | "phone" + +const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) +const clampRightWidth = (value: number) => Math.min(MAX_RIGHT_DRAWER_WIDTH, Math.max(MIN_RIGHT_DRAWER_WIDTH, value)) +const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY) +function readStoredPinState(side: "left" | "right", defaultValue: boolean) { + if (typeof window === "undefined") return defaultValue + const stored = window.localStorage.getItem(getPinStorageKey(side)) + if (stored === "true") return true + if (stored === "false") return false + return defaultValue +} +function persistPinState(side: "left" | "right", value: boolean) { + if (typeof window === "undefined") return + window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false") +} + +const InstanceShell2: Component = (props) => { + const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) + const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH) + const [leftPinned, setLeftPinned] = createSignal(true) + const [leftOpen, setLeftOpen] = createSignal(true) + const [rightPinned, setRightPinned] = createSignal(true) + const [rightOpen, setRightOpen] = createSignal(true) + const [cachedSessionIds, setCachedSessionIds] = createSignal([]) + const [pendingEvictions, setPendingEvictions] = createSignal([]) + const [drawerHost, setDrawerHost] = createSignal(null) + const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0) + const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0) + const [leftDrawerContentEl, setLeftDrawerContentEl] = createSignal(null) + const [rightDrawerContentEl, setRightDrawerContentEl] = createSignal(null) + const [leftToggleButtonEl, setLeftToggleButtonEl] = createSignal(null) + const [rightToggleButtonEl, setRightToggleButtonEl] = createSignal(null) + const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) + const [resizeStartX, setResizeStartX] = createSignal(0) + const [resizeStartWidth, setResizeStartWidth] = createSignal(0) + const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal(["lsp", "mcp"]) + + const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) + + const desktopQuery = useMediaQuery("(min-width: 1280px)") + + const tabletQuery = useMediaQuery("(min-width: 768px)") + + const layoutMode = createMemo(() => { + if (desktopQuery()) return "desktop" + if (tabletQuery()) return "tablet" + return "phone" + }) + + const isPhoneLayout = createMemo(() => layoutMode() === "phone") + const leftPinningSupported = createMemo(() => layoutMode() === "desktop") + const rightPinningSupported = createMemo(() => layoutMode() !== "phone") + + const persistPinIfSupported = (side: "left" | "right", value: boolean) => { + if (side === "left" && !leftPinningSupported()) return + if (side === "right" && !rightPinningSupported()) return + persistPinState(side, value) + } + + createEffect(() => { + switch (layoutMode()) { + case "desktop": { + const leftSaved = readStoredPinState("left", true) + const rightSaved = readStoredPinState("right", true) + setLeftPinned(leftSaved) + setLeftOpen(leftSaved) + setRightPinned(rightSaved) + setRightOpen(rightSaved) + break + } + case "tablet": { + const rightSaved = readStoredPinState("right", true) + setLeftPinned(false) + setLeftOpen(false) + setRightPinned(rightSaved) + setRightOpen(rightSaved) + break + } + default: + setLeftPinned(false) + setLeftOpen(false) + setRightPinned(false) + setRightOpen(false) + break + } + }) + + const measureDrawerHost = () => { + if (typeof window === "undefined") return + const host = drawerHost() + if (!host) return + const rect = host.getBoundingClientRect() + const toolbar = host.querySelector(".session-toolbar") + const toolbarHeight = toolbar?.offsetHeight ?? APP_BAR_HEIGHT + setFloatingDrawerTop(rect.top + toolbarHeight) + setFloatingDrawerHeight(Math.max(0, rect.height - toolbarHeight)) + } + + onMount(() => { + if (typeof window === "undefined") return + + const savedLeft = window.localStorage.getItem(LEFT_DRAWER_STORAGE_KEY) + if (savedLeft) { + const parsed = Number.parseInt(savedLeft, 10) + if (Number.isFinite(parsed)) { + setSessionSidebarWidth(clampWidth(parsed)) + } + } + + const savedRight = window.localStorage.getItem(RIGHT_DRAWER_STORAGE_KEY) + if (savedRight) { + const parsed = Number.parseInt(savedRight, 10) + if (Number.isFinite(parsed)) { + setRightDrawerWidth(clampRightWidth(parsed)) + } + } + + const handleResize = () => { + const width = clampWidth(window.innerWidth * 0.3) + setSessionSidebarWidth((current) => clampWidth(current || width)) + measureDrawerHost() + } + + handleResize() + window.addEventListener("resize", handleResize) + onCleanup(() => window.removeEventListener("resize", handleResize)) + }) + + onMount(() => { + if (typeof window === "undefined") return + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if (!detail || detail.instanceId !== props.instance.id) return + handleSidebarRequest(detail.action) + } + window.addEventListener(SESSION_SIDEBAR_EVENT, handler) + onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler)) + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString()) + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString()) + }) + + createEffect(() => { + props.tabBarOffset + requestAnimationFrame(() => measureDrawerHost()) + }) + + const activeSessions = createMemo(() => { + const parentId = activeParentSessionId().get(props.instance.id) + if (!parentId) return new Map[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 activeSessionUsage = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId) return null + const store = messageStore() + return store?.getSessionUsage(sessionId) ?? null + }) + + const activeSessionInfoDetails = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId) return null + return getSessionInfo(props.instance.id, sessionId) ?? null + }) + + const tokenStats = createMemo(() => { + const usage = activeSessionUsage() + const info = activeSessionInfoDetails() + return { + used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0, + avail: info?.contextAvailableTokens ?? null, + } + }) + + const latestTodoSnapshot = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + const store = messageStore() + if (!store) return null + const snapshot = store.state.latestTodos[sessionId] + return snapshot ?? null + }) + + const latestTodoState = createMemo(() => { + const snapshot = latestTodoSnapshot() + if (!snapshot) return null + const store = messageStore() + if (!store) return null + const message = store.getMessage(snapshot.messageId) + if (!message) return null + const partRecord = message.parts?.[snapshot.partId] + const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState } + if (!part || part.type !== "tool" || part.tool !== "todowrite") return null + const state = part.state + if (!state || state.status !== "completed") return null + return state + }) + + const connectionStatus = () => sseManager.getStatus(props.instance.id) + const connectionStatusClass = () => { + const status = connectionStatus() + if (status === "connecting") return "connecting" + if (status === "connected") return "connected" + return "disconnected" + } + + const handleCommandPaletteClick = () => { + showCommandPalette(props.instance.id) + } + + 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), + ), + ) + + interface PendingSidebarAction { + action: SessionSidebarRequestAction + id: number + } + + let sidebarActionId = 0 + const [pendingSidebarAction, setPendingSidebarAction] = createSignal(null) + + const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: options.key, + code: options.code, + keyCode: options.keyCode, + which: options.keyCode, + bubbles: true, + cancelable: true, + }), + ) + } + + const focusAgentSelectorControl = () => { + const agentTrigger = leftDrawerContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null + if (!agentTrigger) return false + agentTrigger.focus() + setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10) + return true + } + + const focusModelSelectorControl = () => { + const input = leftDrawerContentEl()?.querySelector("[data-model-selector]") + if (!input) return false + input.focus() + setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10) + return true + } + + createEffect(() => { + const pending = pendingSidebarAction() + if (!pending) return + const action = pending.action + const contentReady = Boolean(leftDrawerContentEl()) + if (!contentReady) { + return + } + if (action === "show-session-list") { + setPendingSidebarAction(null) + return + } + const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl() + if (handled) { + setPendingSidebarAction(null) + } + }) + + const handleSidebarRequest = (action: SessionSidebarRequestAction) => { + setPendingSidebarAction({ action, id: sidebarActionId++ }) + if (!leftPinned() && !leftOpen()) { + setLeftOpen(true) + measureDrawerHost() + } + } + + const closeFloatingDrawersIfAny = () => { + let handled = false + if (!leftPinned() && leftOpen()) { + setLeftOpen(false) + blurIfInside(leftDrawerContentEl()) + focusTarget(leftToggleButtonEl()) + handled = true + } + if (!rightPinned() && rightOpen()) { + setRightOpen(false) + blurIfInside(rightDrawerContentEl()) + focusTarget(rightToggleButtonEl()) + handled = true + } + return handled + } + + onMount(() => { + if (typeof window === "undefined") return + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + if (!closeFloatingDrawersIfAny()) return + event.preventDefault() + event.stopPropagation() + } + window.addEventListener("keydown", handleEscape, true) + onCleanup(() => window.removeEventListener("keydown", handleEscape, true)) + }) + + 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) + + 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 + }) + }) + + const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen()) + + const drawerContainer = () => { + const host = drawerHost() + if (host) return host + if (typeof document !== "undefined") { + return document.body + } + return undefined + } + + const fallbackDrawerTop = () => APP_BAR_HEIGHT + props.tabBarOffset + const floatingTop = () => { + const measured = floatingDrawerTop() + if (measured > 0) return measured + return fallbackDrawerTop() + } + const floatingTopPx = () => `${floatingTop()}px` + const floatingHeight = () => { + const measured = floatingDrawerHeight() + if (measured > 0) return `${measured}px` + return `calc(100% - ${floatingTop()}px)` + } + + const scheduleDrawerMeasure = () => { + if (typeof window === "undefined") { + measureDrawerHost() + return + } + requestAnimationFrame(() => measureDrawerHost()) + } + + const applyDrawerWidth = (side: "left" | "right", width: number) => { + if (side === "left") { + setSessionSidebarWidth(width) + } else { + setRightDrawerWidth(width) + } + scheduleDrawerMeasure() + } + + const handleDrawerPointerMove = (clientX: number) => { + const side = activeResizeSide() + if (!side) return + const startWidth = resizeStartWidth() + const clamp = side === "left" ? clampWidth : clampRightWidth + const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX + const nextWidth = clamp(startWidth + delta) + applyDrawerWidth(side, nextWidth) + } + + function stopDrawerResize() { + setActiveResizeSide(null) + document.removeEventListener("mousemove", drawerMouseMove) + document.removeEventListener("mouseup", drawerMouseUp) + document.removeEventListener("touchmove", drawerTouchMove) + document.removeEventListener("touchend", drawerTouchEnd) + } + + function drawerMouseMove(event: MouseEvent) { + event.preventDefault() + handleDrawerPointerMove(event.clientX) + } + + function drawerMouseUp() { + stopDrawerResize() + } + + function drawerTouchMove(event: TouchEvent) { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + handleDrawerPointerMove(touch.clientX) + } + + function drawerTouchEnd() { + stopDrawerResize() + } + + const startDrawerResize = (side: "left" | "right", clientX: number) => { + setActiveResizeSide(side) + setResizeStartX(clientX) + setResizeStartWidth(side === "left" ? sessionSidebarWidth() : rightDrawerWidth()) + document.addEventListener("mousemove", drawerMouseMove) + document.addEventListener("mouseup", drawerMouseUp) + document.addEventListener("touchmove", drawerTouchMove, { passive: false }) + document.addEventListener("touchend", drawerTouchEnd) + } + + const handleDrawerResizeMouseDown = (side: "left" | "right") => (event: MouseEvent) => { + event.preventDefault() + startDrawerResize(side, event.clientX) + } + + const handleDrawerResizeTouchStart = (side: "left" | "right") => (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + startDrawerResize(side, touch.clientX) + } + + onCleanup(() => { + stopDrawerResize() + }) + + type DrawerViewState = "pinned" | "floating-open" | "floating-closed" + + + const leftDrawerState = createMemo(() => { + if (leftPinned()) return "pinned" + return leftOpen() ? "floating-open" : "floating-closed" + }) + + const rightDrawerState = createMemo(() => { + if (rightPinned()) return "pinned" + return rightOpen() ? "floating-open" : "floating-closed" + }) + + const leftAppBarButtonLabel = () => { + const state = leftDrawerState() + if (state === "pinned") return "Left drawer pinned" + if (state === "floating-closed") return "Open left drawer" + return "Close left drawer" + } + + const rightAppBarButtonLabel = () => { + const state = rightDrawerState() + if (state === "pinned") return "Right drawer pinned" + if (state === "floating-closed") return "Open right drawer" + return "Close right drawer" + } + + const leftAppBarButtonIcon = () => { + const state = leftDrawerState() + if (state === "floating-closed") return + return + } + + const rightAppBarButtonIcon = () => { + const state = rightDrawerState() + if (state === "floating-closed") return + return + } + + + + + const pinLeftDrawer = () => { + blurIfInside(leftDrawerContentEl()) + batch(() => { + setLeftPinned(true) + setLeftOpen(true) + }) + persistPinIfSupported("left", true) + measureDrawerHost() + } + + const unpinLeftDrawer = () => { + blurIfInside(leftDrawerContentEl()) + batch(() => { + setLeftPinned(false) + setLeftOpen(true) + }) + persistPinIfSupported("left", false) + measureDrawerHost() + } + + const pinRightDrawer = () => { + blurIfInside(rightDrawerContentEl()) + batch(() => { + setRightPinned(true) + setRightOpen(true) + }) + persistPinIfSupported("right", true) + measureDrawerHost() + } + + const unpinRightDrawer = () => { + blurIfInside(rightDrawerContentEl()) + batch(() => { + setRightPinned(false) + setRightOpen(true) + }) + persistPinIfSupported("right", false) + measureDrawerHost() + } + + const handleLeftAppBarButtonClick = () => { + const state = leftDrawerState() + if (state === "pinned") return + if (state === "floating-closed") { + setLeftOpen(true) + measureDrawerHost() + return + } + blurIfInside(leftDrawerContentEl()) + setLeftOpen(false) + focusTarget(leftToggleButtonEl()) + measureDrawerHost() + } + + const handleRightAppBarButtonClick = () => { + const state = rightDrawerState() + if (state === "pinned") return + if (state === "floating-closed") { + setRightOpen(true) + measureDrawerHost() + return + } + blurIfInside(rightDrawerContentEl()) + setRightOpen(false) + focusTarget(rightToggleButtonEl()) + measureDrawerHost() + } + + + const focusTarget = (element: HTMLElement | null) => { + if (!element) return + requestAnimationFrame(() => { + element.focus() + }) + } + + const blurIfInside = (element: HTMLElement | null) => { + if (typeof document === "undefined" || !element) return + const active = document.activeElement as HTMLElement | null + if (active && element.contains(active)) { + active.blur() + } + } + + const closeLeftDrawer = () => { + if (leftDrawerState() === "pinned") return + blurIfInside(leftDrawerContentEl()) + setLeftOpen(false) + focusTarget(leftToggleButtonEl()) + } + const closeRightDrawer = () => { + if (rightDrawerState() === "pinned") return + blurIfInside(rightDrawerContentEl()) + setRightOpen(false) + focusTarget(rightToggleButtonEl()) + } + + const formattedUsedTokens = () => formatTokenTotal(tokenStats().used) + + + const formattedAvailableTokens = () => { + const avail = tokenStats().avail + if (typeof avail === "number") { + return formatTokenTotal(avail) + } + return "--" + } + + const LeftDrawerContent = () => ( +
+
+
+ Sessions +
+ + + +
+
+
+ + (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} + > + {leftPinned() ? : } + + +
+ +
+ +
+ { + 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={false} + showFooter={false} + /> + + + + {(activeSession) => ( + <> + +
+ props.handleSidebarAgentChange(activeSession().id, agent)} + /> + + + + props.handleSidebarModelChange(activeSession().id, model)} + /> +
+ + )} +
+
+
+ ) + + const RightDrawerContent = () => { + const renderPlanSectionContent = () => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") { + return

Select a session to view plan.

+ } + const todoState = latestTodoState() + if (!todoState) { + return

Nothing planned yet.

+ } + return + } + + const sections = [ + { + id: "lsp", + label: "LSP Servers", + render: () => ( + + ), + }, + { + id: "mcp", + label: "MCP Servers", + render: () => ( + + ), + }, + { + id: "plan", + label: "Plan", + render: renderPlanSectionContent, + }, + ] + + createEffect(() => { + const currentExpanded = new Set(rightPanelExpandedItems()) + if (sections.every((section) => currentExpanded.has(section.id))) return + setRightPanelExpandedItems(sections.map((section) => section.id)) + }) + + const handleAccordionChange = (values: string[]) => { + setRightPanelExpandedItems(values) + } + + const isSectionExpanded = (id: string) => rightPanelExpandedItems().includes(id) + + return ( +
+
+ + Status Panel + +
+ + (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} + > + {rightPinned() ? : } + + +
+
+
+ + + {(section) => ( + + + + {section.label} + + + + + {section.render()} + + + )} + + +
+
+ ) + } + + const renderLeftPanel = () => { + if (leftPinned()) { + return ( + + diff --git a/packages/ui/src/components/message-preview.tsx b/packages/ui/src/components/message-preview.tsx index 2a2c0da7..8b2aee4d 100644 --- a/packages/ui/src/components/message-preview.tsx +++ b/packages/ui/src/components/message-preview.tsx @@ -1,4 +1,4 @@ -import { createMemo, type Component } from "solid-js" +import type { Component } from "solid-js" import MessageBlock from "./message-block" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" @@ -10,14 +10,7 @@ interface MessagePreviewProps { } const MessagePreview: Component = (props) => { - const indexMap = createMemo(() => new Map([[props.messageId, 0]])) - const lastAssistantIndex = createMemo(() => { - const record = props.store().getMessage(props.messageId) - if (record?.role === "assistant") { - return 0 - } - return -1 - }) + const lastAssistantIndex = () => 0 return (
@@ -26,7 +19,7 @@ const MessagePreview: Component = (props) => { instanceId={props.instanceId} sessionId={props.sessionId} store={props.store} - messageIndexMap={indexMap} + messageIndex={0} lastAssistantIndex={lastAssistantIndex} showThinking={() => false} thinkingDefaultExpanded={() => false} diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 0f578e76..6ee0f933 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -1,15 +1,11 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import Kbd from "./kbd" import MessageBlockList, { getMessageAnchorId } from "./message-block-list" -import MessageListHeader from "./message-list-header" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" -import { showCommandPalette } from "../stores/command-palette" import { messageStoreBus } from "../stores/message-v2/bus" import { useScrollCache } from "../lib/hooks/use-scroll-cache" -import { sseManager } from "../lib/sse-manager" -import { formatTokenTotal } from "../lib/formatters" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" const SCROLL_SCOPE = "session" @@ -19,10 +15,6 @@ const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown" const QUOTE_SELECTION_MAX_LENGTH = 2000 const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href -function formatTokens(tokens: number): string { - return formatTokenTotal(tokens) -} - export interface MessageSectionProps { instanceId: string sessionId: string @@ -77,25 +69,12 @@ export default function MessageSection(props: MessageSectionProps) { return `${showThinking}|${thinkingExpansion}|${showUsage}` }) - const connectionStatus = () => sseManager.getStatus(props.instanceId) - const handleCommandPaletteClick = () => { - showCommandPalette(props.instanceId) - } - const handleTimelineSegmentClick = (segment: TimelineSegment) => { if (typeof document === "undefined") return const anchor = document.getElementById(getMessageAnchorId(segment.messageId)) anchor?.scrollIntoView({ block: "start", behavior: "smooth" }) } - const messageIndexMap = createMemo(() => { - - const map = new Map() - const ids = messageIds() - ids.forEach((id, index) => map.set(id, index)) - return map - }) - const lastAssistantIndex = createMemo(() => { const ids = messageIds() const resolvedStore = store() @@ -108,23 +87,57 @@ export default function MessageSection(props: MessageSectionProps) { return -1 }) - const timelineSegments = createMemo(() => { - const ids = messageIds() - const resolvedStore = store() + const [timelineSegments, setTimelineSegments] = createSignal([]) + const hasTimelineSegments = () => timelineSegments().length > 0 + + const seenTimelineMessageIds = new Set() + const seenTimelineSegmentKeys = new Set() + + function makeTimelineKey(segment: TimelineSegment) { + return `${segment.messageId}:${segment.id}:${segment.type}` + } + + function seedTimeline() { + seenTimelineMessageIds.clear() + seenTimelineSegmentKeys.clear() + const ids = untrack(messageIds) + const resolvedStore = untrack(store) const segments: TimelineSegment[] = [] ids.forEach((messageId) => { const record = resolvedStore.getMessage(messageId) if (!record) return + seenTimelineMessageIds.add(messageId) const built = buildTimelineSegments(props.instanceId, record) - segments.push(...built) + built.forEach((segment) => { + const key = makeTimelineKey(segment) + if (seenTimelineSegmentKeys.has(key)) return + seenTimelineSegmentKeys.add(key) + segments.push(segment) + }) }) - return segments - }) - - const hasTimelineSegments = () => timelineSegments().length > 0 + setTimelineSegments(segments) + } + + function appendTimelineForMessage(messageId: string) { + const record = untrack(() => store().getMessage(messageId)) + if (!record) return + const built = buildTimelineSegments(props.instanceId, record) + if (built.length === 0) return + const newSegments: TimelineSegment[] = [] + built.forEach((segment) => { + const key = makeTimelineKey(segment) + if (seenTimelineSegmentKeys.has(key)) return + seenTimelineSegmentKeys.add(key) + newSegments.push(segment) + }) + if (newSegments.length > 0) { + setTimelineSegments((prev) => [...prev, ...newSegments]) + } + } const [activeMessageId, setActiveMessageId] = createSignal(null) const changeToken = createMemo(() => String(sessionRevision())) + const isActive = createMemo(() => props.isActive !== false) const scrollCache = useScrollCache({ @@ -164,8 +177,6 @@ export default function MessageSection(props: MessageSectionProps) { let scrollToBottomDelayedFrame: number | null = null let pendingInitialScroll = true - const [initialRenderComplete, setInitialRenderComplete] = createSignal(false) - function markUserScrollIntent() { const now = typeof performance !== "undefined" ? performance.now() : Date.now() userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS @@ -236,11 +247,12 @@ export default function MessageSection(props: MessageSectionProps) { }) } - function scrollToBottom(immediate = false) { + function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) { if (!containerRef) return const sentinel = bottomSentinel() const behavior = immediate ? "auto" : "smooth" - if (!immediate) { + const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate + if (suppressAutoAnchor) { suppressAutoScrollOnce = true } sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior }) @@ -260,6 +272,10 @@ export default function MessageSection(props: MessageSectionProps) { } function requestScrollToBottom(immediate = true) { + if (!isActive()) { + pendingActiveScroll = true + return + } if (!containerRef || !bottomSentinel()) { pendingActiveScroll = true return @@ -277,7 +293,7 @@ export default function MessageSection(props: MessageSectionProps) { function resolvePendingActiveScroll() { if (!pendingActiveScroll) return - if (!props.isActive) return + if (!isActive()) return requestScrollToBottom(true) } @@ -292,8 +308,15 @@ export default function MessageSection(props: MessageSectionProps) { function scheduleAnchorScroll(immediate = false) { if (!autoScroll()) return + if (!isActive()) { + pendingActiveScroll = true + return + } const sentinel = bottomSentinel() - if (!sentinel) return + if (!sentinel) { + pendingActiveScroll = true + return + } if (pendingAnchorScroll !== null) { cancelAnimationFrame(pendingAnchorScroll) pendingAnchorScroll = null @@ -377,10 +400,6 @@ export default function MessageSection(props: MessageSectionProps) { scheduleAnchorScroll() } - function handleInitialRenderComplete() { - setInitialRenderComplete(true) - } - function handleScroll() { if (!containerRef) return @@ -404,6 +423,7 @@ export default function MessageSection(props: MessageSectionProps) { clearQuoteSelection() scheduleScrollPersist() }) + } @@ -415,9 +435,14 @@ export default function MessageSection(props: MessageSectionProps) { let lastActiveState = false createEffect(() => { - const active = Boolean(props.isActive) - if (active && !lastActiveState) { - requestScrollToBottom(true) + const active = isActive() + if (active) { + resolvePendingActiveScroll() + if (!lastActiveState && autoScroll()) { + requestScrollToBottom(true) + } + } else if (autoScroll()) { + pendingActiveScroll = true } lastActiveState = active }) @@ -426,12 +451,123 @@ export default function MessageSection(props: MessageSectionProps) { const loading = Boolean(props.loading) if (loading) { pendingInitialScroll = true - setInitialRenderComplete(false) return } - if (pendingInitialScroll && initialRenderComplete()) { - pendingInitialScroll = false - requestScrollToBottom(false) + if (!pendingInitialScroll) { + return + } + const container = scrollElement() + const sentinel = bottomSentinel() + if (!container || !sentinel || messageIds().length === 0) { + return + } + pendingInitialScroll = false + requestScrollToBottom(true) + }) + + let previousTimelineIds: string[] = [] + let previousLastTimelineMessageId: string | null = null + let previousLastTimelinePartCount = 0 + + createEffect(() => { + const loading = Boolean(props.loading) + const ids = messageIds() + + if (loading) { + previousTimelineIds = [] + previousLastTimelineMessageId = null + previousLastTimelinePartCount = 0 + setTimelineSegments([]) + seenTimelineMessageIds.clear() + seenTimelineSegmentKeys.clear() + return + } + + if (previousTimelineIds.length === 0 && ids.length > 0) { + seedTimeline() + previousTimelineIds = ids.slice() + return + } + + if (ids.length < previousTimelineIds.length) { + seedTimeline() + previousTimelineIds = ids.slice() + return + } + + if (ids.length === previousTimelineIds.length) { + let changedIndex = -1 + let changeCount = 0 + for (let index = 0; index < ids.length; index++) { + if (ids[index] !== previousTimelineIds[index]) { + changedIndex = index + changeCount += 1 + if (changeCount > 1) break + } + } + if (changeCount === 1 && changedIndex >= 0) { + const oldId = previousTimelineIds[changedIndex] + const newId = ids[changedIndex] + if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) { + seenTimelineMessageIds.delete(oldId) + seenTimelineMessageIds.add(newId) + setTimelineSegments((prev) => { + const next = prev.map((segment) => { + if (segment.messageId !== oldId) return segment + const updatedId = segment.id.replace(oldId, newId) + return { ...segment, messageId: newId, id: updatedId } + }) + seenTimelineSegmentKeys.clear() + next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment))) + return next + }) + previousTimelineIds = ids.slice() + return + } + } + } + + const newIds: string[] = [] + ids.forEach((id) => { + if (!seenTimelineMessageIds.has(id)) { + newIds.push(id) + } + }) + + if (newIds.length > 0) { + newIds.forEach((id) => { + seenTimelineMessageIds.add(id) + appendTimelineForMessage(id) + }) + } + + previousTimelineIds = ids.slice() + }) + + createEffect(() => { + if (props.loading) return + const ids = messageIds() + if (ids.length === 0) return + const lastId = ids[ids.length - 1] + if (!lastId) return + const record = store().getMessage(lastId) + if (!record) return + const partCount = record.partIds.length + if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) { + return + } + previousLastTimelineMessageId = lastId + previousLastTimelinePartCount = partCount + const built = buildTimelineSegments(props.instanceId, record) + const newSegments: TimelineSegment[] = [] + built.forEach((segment) => { + const key = makeTimelineKey(segment) + if (seenTimelineSegmentKeys.has(key)) return + seenTimelineSegmentKeys.add(key) + newSegments.push(segment) + }) + if (newSegments.length > 0) { + setTimelineSegments((prev) => [...prev, ...newSegments]) } }) @@ -609,17 +745,6 @@ export default function MessageSection(props: MessageSectionProps) { return (
- -
@@ -659,7 +784,6 @@ export default function MessageSection(props: MessageSectionProps) { sessionId={props.sessionId} store={store} messageIds={messageIds} - messageIndexMap={messageIndexMap} lastAssistantIndex={lastAssistantIndex} showThinking={() => preferences().showThinkingBlocks} thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"} @@ -670,8 +794,7 @@ export default function MessageSection(props: MessageSectionProps) { onFork={props.onFork} onContentRendered={handleContentRendered} setBottomSentinel={setBottomSentinel} - suspendMeasurements={() => props.isActive === false} - onInitialRenderComplete={handleInitialRenderComplete} + suspendMeasurements={() => !isActive()} /> @@ -688,7 +811,7 @@ export default function MessageSection(props: MessageSectionProps) {
-
-