diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0c950871a335..ec8b082d4a9e 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -56,6 +56,7 @@ import { ServerConnection, ServerProvider, serverName, useServer } from "@/conte import { SettingsProvider, useSettings } from "@/context/settings" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" +import { TerminalProvider, TerminalRegistryProvider } from "@/context/terminal" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import LegacyLayout from "@/pages/layout" @@ -378,15 +379,17 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { ) } -// The draft page only renders the prompt composer, so it drops TerminalProvider. -// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. +// Drafts share their workspace with the terminal, files, and prompt context. A new chat can +// therefore open the same bottom terminal before its first prompt is submitted. function DraftProviders(props: ParentProps) { return ( - - - {props.children} - - + + + + {props.children} + + + ) } @@ -413,11 +416,13 @@ export function AppBaseProviders( }} > - - - {props.children} - - + + + + {props.children} + + + diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 25ef35dd4138..d97e3e31b361 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -4,6 +4,7 @@ import { ServerScope } from "@/utils/server-scope" let getWorkspaceTerminalCacheKey: typeof import("./terminal").getWorkspaceTerminalCacheKey let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] let migrateTerminalState: (value: unknown) => unknown +let createWorkspaceTerminalRegistry: typeof import("./terminal").createWorkspaceTerminalRegistry beforeAll(async () => { mock.module("@solidjs/router", () => ({ @@ -22,6 +23,7 @@ beforeAll(async () => { getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys migrateTerminalState = mod.migrateTerminalState + createWorkspaceTerminalRegistry = mod.createWorkspaceTerminalRegistry }) describe("getWorkspaceTerminalCacheKey", () => { @@ -89,3 +91,39 @@ describe("migrateTerminalState", () => { }) }) }) + +describe("createWorkspaceTerminalRegistry", () => { + test("reuses one terminal session across chats in the same workspace", () => { + const registry = createWorkspaceTerminalRegistry<{ id: string }>() + const disposed: string[] = [] + const first = registry.get("server\u0000repo-a", () => ({ + value: { id: "terminal-a" }, + dispose: () => disposed.push("terminal-a"), + })) + const second = registry.get("server\u0000repo-a", () => ({ + value: { id: "replacement" }, + dispose: () => disposed.push("replacement"), + })) + + expect(second).toBe(first) + expect(disposed).toEqual([]) + registry.dispose() + expect(disposed).toEqual(["terminal-a"]) + }) + + test("keeps workspaces separate and evicts the least recently used entry", () => { + const registry = createWorkspaceTerminalRegistry<{ id: string }>(2) + const disposed: string[] = [] + const create = (id: string) => ({ value: { id }, dispose: () => disposed.push(id) }) + + expect(registry.get("server\u0000repo-a", () => create("a")).id).toBe("a") + expect(registry.get("server\u0000repo-b", () => create("b")).id).toBe("b") + expect(registry.get("server\u0000repo-a", () => create("replacement")).id).toBe("a") + expect(registry.get("server\u0000repo-c", () => create("c")).id).toBe("c") + + expect(registry.peek("server\u0000repo-a")?.id).toBe("a") + expect(registry.peek("server\u0000repo-b")).toBeUndefined() + expect(registry.peek("server\u0000repo-c")?.id).toBe("c") + expect(disposed).toEqual(["b"]) + }) +}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index df575b71f7ce..571e5e256f42 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -96,12 +96,41 @@ export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: stri type TerminalSession = ReturnType -type TerminalCacheEntry = { - value: TerminalSession - dispose: VoidFunction +export function createWorkspaceTerminalRegistry(limit = MAX_TERMINAL_SESSIONS) { + const cache = new Map() + + return { + get(key: string, create: () => { value: T; dispose: VoidFunction }) { + const existing = cache.get(key) + if (existing) { + cache.delete(key) + cache.set(key, existing) + return existing.value + } + + const entry = create() + cache.set(key, entry) + while (cache.size > limit) { + const first = cache.keys().next().value + if (!first) break + cache.get(first)?.dispose() + cache.delete(first) + } + return entry.value + }, + peek(key: string) { + return cache.get(key)?.value + }, + dispose() { + for (const entry of cache.values()) entry.dispose() + cache.clear() + }, + } } -const caches = new Set>() +type TerminalRegistry = ReturnType> + +const registries = new Set() const trimTerminal = (pty: LocalPTY) => { if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty @@ -124,10 +153,7 @@ export function clearWorkspaceTerminals( scope: ServerScopeValue = ServerScope.local, ) { const key = getWorkspaceTerminalCacheKey(dir, scope) - for (const cache of caches) { - const entry = cache.get(key) - entry?.value.clear() - } + for (const registry of registries) registry.peek(key)?.clear() void removePersisted(terminalPersistTarget(scope, dir), platform) @@ -143,6 +169,20 @@ export function clearWorkspaceTerminals( } } +export const { use: useTerminalRegistry, provider: TerminalRegistryProvider } = createSimpleContext({ + name: "TerminalRegistry", + gate: false, + init: () => { + const registry = createWorkspaceTerminalRegistry() + registries.add(registry) + onCleanup(() => { + registries.delete(registry) + registry.dispose() + }) + return registry + }, +}) + function createWorkspaceTerminalSession( sdk: DirectorySDK, dir: string, @@ -460,51 +500,21 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont init: () => { const sdk = useSDK() const serverSDK = useServerSDK() + const registry = useTerminalRegistry() const params = useParams() - const cache = new Map() const scope = () => serverSDK().scope const directory = createMemo(() => base64Encode(sdk().directory)) - caches.add(cache) - onCleanup(() => caches.delete(cache)) - - const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } - cache.clear() - } - - onCleanup(disposeAll) - - const prune = () => { - while (cache.size > MAX_TERMINAL_SESSIONS) { - const first = cache.keys().next().value - if (!first) return - const entry = cache.get(first) - entry?.dispose() - cache.delete(first) - } - } - const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => { - // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. + // The app-level registry outlives chat routes, while each entry keeps the DirectorySDK from + // the workspace that created it. The server scope in the key prevents cross-server reuse. const key = getWorkspaceTerminalCacheKey(dir, serverScope) - const existing = cache.get(key) - if (existing) { - cache.delete(key) - cache.set(key, existing) - return existing.value - } - - const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID), - dispose, - })) - - cache.set(key, entry) - prune() - return entry.value + return registry.get(key, () => + createRoot((dispose) => ({ + value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID), + dispose, + })), + ) } const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))