From a7519805a8255dd2ea73dcbed166060dad435d0c Mon Sep 17 00:00:00 2001 From: Abe Isleem Date: Tue, 8 Sep 2026 18:49:48 +0200 Subject: [PATCH 1/3] fix(tui): keep saved tabs separate by server --- packages/tui/src/context/client.tsx | 3 + packages/tui/src/context/server.ts | 10 + packages/tui/src/context/session-tabs.tsx | 26 +- .../tui/src/context/session-terminals.tsx | 36 ++- .../tui/test/context/session-tabs.test.tsx | 228 ++++++++++++++++-- .../test/context/session-terminals.test.tsx | 213 ++++++++++++++++ services/www/src/docs/content/cli/index.mdx | 14 ++ 7 files changed, 491 insertions(+), 39 deletions(-) create mode 100644 packages/tui/src/context/server.ts create mode 100644 packages/tui/test/context/session-terminals.test.tsx diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index cedd8b92118e..5c553ae3ebb9 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup } from "solid-js" import { createSimpleContext } from "./helper" import { useLog } from "./log" +import { serverIdentity } from "./server" type ManagedService = { reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; url?: string }> @@ -43,6 +44,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( }) return { + // Freeze startup identity even if managed-service reconnect changes the transport URL. + server: serverIdentity(props.url ?? "http://localhost", service !== undefined), get api() { return api }, diff --git a/packages/tui/src/context/server.ts b/packages/tui/src/context/server.ts new file mode 100644 index 000000000000..840b09177116 --- /dev/null +++ b/packages/tui/src/context/server.ts @@ -0,0 +1,10 @@ +export function serverIdentity(url: string, managed = false) { + if (managed) return "local" + const value = new URL(url) + value.username = "" + value.password = "" + value.search = "" + value.hash = "" + value.pathname = value.pathname.replace(/\/+$/, "") || "/" + return value.toString() +} diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 0f4f477a5a1a..274f4b24e39f 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -36,8 +36,10 @@ type TabsState = { } type PersistedState = { - global: TabsState - cwd: Record + servers?: Record }> + // The managed local server continues using the legacy fields. + global?: TabsState + cwd?: Record } type ScrollAnchor = { @@ -69,10 +71,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp // Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of // mutating in place, which per-row animations and drag state depend on. const [store, updateStore] = storage.store("tabs", { - initial: { - global: empty(), - cwd: {}, - }, + initial: { servers: {} }, key: "sessionID", }) const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({}) @@ -102,8 +101,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }) function state() { - if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback - return store.global + const server = client.server === "local" ? store : store.servers?.[client.server] + if (!server) return fallback + if (config.tabs.scope === "cwd") return server.cwd?.[paths.cwd] ?? fallback + return server.global ?? fallback } const previewID = () => preview[config.tabs.scope] @@ -111,7 +112,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function update(mutation: (draft: TabsState) => void) { const scope = config.tabs.scope - void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch( + void updateStore((draft) => { + const server = + client.server === "local" + ? draft + : ((draft.servers ??= {})[client.server] ??= { global: empty(), cwd: {} }) + server.cwd ??= {} + mutation(scope === "cwd" ? (server.cwd[paths.cwd] ??= empty()) : (server.global ??= empty())) + }).catch( // Failed writes lose only tab layout, but silence would hide tabs resetting every launch. (error) => console.error("Failed to persist session tabs", error), ) diff --git a/packages/tui/src/context/session-terminals.tsx b/packages/tui/src/context/session-terminals.tsx index bd81afa2005d..d2bee7042241 100644 --- a/packages/tui/src/context/session-terminals.tsx +++ b/packages/tui/src/context/session-terminals.tsx @@ -8,7 +8,9 @@ import { useEvent } from "./event" import { useStorage } from "./storage" type SessionTerminalsState = { - sessions: Record + servers?: Record }> + // The managed local server continues using the legacy fields. + sessions?: Record } export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = createSimpleContext({ @@ -21,21 +23,26 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = const [focus, setFocus] = createSignal() const storage = useStorage() const [store, update] = storage.store("session-terminal-selection", { - initial: { sessions: {} }, - }) - const [terminals, updateTerminals] = storage.memory>("session-terminals", { - initial: {}, + initial: { servers: {} }, }) + const [terminals, updateTerminals] = storage.memory>( + `session-terminals:${client.server}`, + { + initial: {}, + }, + ) + const selected = () => (client.server === "local" ? store.sessions : store.servers?.[client.server]?.sessions) const refresh = async (sessionID: string) => { if (!terminals[sessionID]) updateTerminals((draft) => (draft[sessionID] = [])) const result = await client.api.experimental.persistentPty.list({ sessionID }) updateTerminals((draft) => (draft[sessionID] = result)) - const selected = store.sessions[sessionID] - if (!selected || result.some((terminal) => terminal.id === selected)) return + const current = selected()?.[sessionID] + if (!current || result.some((terminal) => terminal.id === current)) return await update((draft) => { - if (draft.sessions[sessionID] !== selected) return - draft.sessions[sessionID] = null + const sessions = client.server === "local" ? draft.sessions : draft.servers?.[client.server]?.sessions + if (!sessions || sessions[sessionID] !== current) return + sessions[sessionID] = null }) } @@ -43,7 +50,14 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = if (ptyID !== null && !terminals[sessionID]?.some((terminal) => terminal.id === ptyID)) return setFocus(ptyID ?? undefined) await update((draft) => { - draft.sessions[sessionID] = ptyID + if (client.server === "local") { + draft.sessions ??= {} + draft.sessions[sessionID] = ptyID + return + } + draft.servers ??= {} + const server = (draft.servers[client.server] ??= { sessions: {} }) + server.sessions[sessionID] = ptyID }) } @@ -70,7 +84,7 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = get(sessionID: string) { return { terminals: terminals[sessionID] ?? [], - selectedTerminalID: store.sessions[sessionID] ?? null, + selectedTerminalID: selected()?.[sessionID] ?? null, } }, refresh, diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index a259c5b782d2..a562a2bd7563 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -13,6 +13,7 @@ import { TuiAppProvider } from "../../src/context/runtime" import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs" import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model" import { StorageProvider, useStorage } from "../../src/context/storage" +import { serverIdentity } from "../../src/context/server" import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client" import { TestTuiContexts } from "../fixture/tui-environment" import { tmpdir } from "../fixture/fixture" @@ -43,6 +44,9 @@ async function renderSessionTabs( tabsEnabled?: boolean viewFailures?: number experimental?: Record + server?: string + managed?: boolean + scope?: "global" | "cwd" }, ) { const temporary = options?.state ? undefined : await tmpdir() @@ -53,11 +57,15 @@ async function renderSessionTabs( await Bun.write( file, JSON.stringify({ - global: { tabs: [], unread: { ses_legacy: "error" } }, - cwd: { - [directory]: { - tabs: options.persisted.map((sessionID) => ({ sessionID })), - unread: { ses_legacy: "activity" }, + servers: { + [serverIdentity(options.server ?? "http://localhost")]: { + global: { tabs: [], unread: { ses_legacy: "error" } }, + cwd: { + [directory]: { + tabs: options.persisted.map((sessionID) => ({ sessionID })), + unread: { ses_legacy: "activity" }, + }, + }, }, }, }), @@ -135,7 +143,7 @@ async function renderSessionTabs( let storage!: ReturnType let config!: ReturnType let configuration = { - tabs: { enabled: options?.tabsEnabled ?? true }, + tabs: { enabled: options?.tabsEnabled ?? true, scope: options?.scope ?? "cwd" }, experimental: options?.experimental, session: { new_location: options?.newLocation ?? "launch" }, } @@ -168,7 +176,18 @@ async function renderSessionTabs( - + ({ api: createApi(calls.fetch) }), + restart: async () => {}, + } + : undefined + } + > @@ -270,6 +289,7 @@ test("loads location metadata when an open session moves", async () => { const setup = await renderSessionTabs("first") try { + await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first")) await wait(() => setup.locations.includes(directory) && setup.vcsLocations.includes(directory)) setup.emit({ id: "evt_moved", @@ -426,15 +446,179 @@ test("stores preview tab membership without persisting preview identity", async await setup.flush() const stored = await Bun.file(path.join(setup.state, "test", "tui", "tabs.json")).json() - expect(stored.cwd[directory].tabs).toHaveLength(1) - expect(stored.cwd[directory].tabs[0].sessionID).toBe("preview") - expect(stored.cwd[directory].tabs[0]).not.toHaveProperty("preview") + const state = stored.servers[serverIdentity("http://localhost")] + expect(state.cwd[directory].tabs).toHaveLength(1) + expect(state.cwd[directory].tabs[0].sessionID).toBe("preview") + expect(state.cwd[directory].tabs[0]).not.toHaveProperty("preview") expect(await Bun.file(path.join(setup.state, "test", "tui", "session-tab-preview.json")).exists()).toBe(false) } finally { await setup.destroy() } }) +test("normalizes credential-free endpoint identities without conflating ports or paths", () => { + expect(serverIdentity("HTTPS://user:password@EXAMPLE.com:443/base///?token=secret#fragment")).toBe( + "https://example.com/base", + ) + expect(serverIdentity("http://example.com:80/")).toBe(serverIdentity("http://example.com")) + expect(serverIdentity("http://example.com:8080")).not.toBe(serverIdentity("http://example.com:8081")) + expect(serverIdentity("https://example.com/one")).not.toBe(serverIdentity("https://example.com/two")) +}) + +test("keeps explicit loopback endpoints from adopting or changing legacy local tabs", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "tabs.json") + const legacy = { global: { tabs: [{ sessionID: "shared" }], unread: {} }, cwd: {} } + mkdirSync(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify(legacy)) + const setup = await renderSessionTabs("shared", { + state: temporary.path, + home: true, + server: "http://127.0.0.1:4096", + }) + try { + expect(setup.tabs.tabs()).toEqual([]) + await setup.flush() + expect(await Bun.file(file).json()).toEqual(legacy) + setup.route.navigate({ type: "session", sessionID: "shared" }) + await wait(() => setup.tabs.tabs().length === 1) + await setup.flush() + expect(await Bun.file(file).json()).toMatchObject(legacy) + } finally { + await setup.destroy() + } +}) + +test.each(["global", "cwd"] as const)( + "keeps legacy %s tabs readable and writable by managed local only", + async (scope) => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "tabs.json") + const tabs = { tabs: [{ sessionID: "shared" }], unread: {} } + mkdirSync(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify({ global: tabs, cwd: { [directory]: tabs } })) + const setup = await renderSessionTabs("shared", { + state: temporary.path, + home: true, + managed: true, + server: "http://127.0.0.1:54321", + scope, + }) + try { + await wait(() => setup.tabs.tabs().length === 1) + expect(setup.tabs.tabs()[0].sessionID).toBe("shared") + setup.tabs.close("shared") + await setup.flush() + const stored = await Bun.file(file).json() + expect((scope === "global" ? stored.global : stored.cwd[directory]).tabs).toEqual([]) + expect(stored.servers ?? {}).toEqual({}) + } finally { + await setup.destroy() + } + const restored = await renderSessionTabs("shared", { + state: temporary.path, + home: true, + managed: true, + server: "http://127.0.0.1:54322", + scope, + }) + try { + expect(restored.tabs.tabs()).toEqual([]) + } finally { + await restored.destroy() + } + }, +) + +test("uses stable managed local identity without treating explicit loopback as local", () => { + expect(serverIdentity("http://127.0.0.1:54321", true)).toBe("local") + expect(serverIdentity("http://localhost:54322", true)).toBe("local") + expect(serverIdentity("http://127.0.0.1:54321")).not.toBe("local") +}) + +test.each(["global", "cwd"] as const)( + "isolates concurrent server writes and restores %s tabs on remount", + async (scope) => { + await using temporary = await tmpdir() + const first = await renderSessionTabs("shared", { + state: temporary.path, + server: "https://first.example", + scope, + home: true, + }) + const second = await renderSessionTabs("shared", { + state: temporary.path, + server: "https://second.example", + scope, + home: true, + }) + try { + first.route.navigate({ type: "session", sessionID: "shared" }) + second.route.navigate({ type: "session", sessionID: "shared" }) + await wait(() => first.tabs.tabs().length === 1 && second.tabs.tabs().length === 1) + first.tabs.promote("first-only") + second.tabs.promote("second-only") + first.route.navigate({ type: "session", sessionID: "first-only" }) + second.route.navigate({ type: "session", sessionID: "second-only" }) + await wait(() => first.tabs.tabs().length === 2 && second.tabs.tabs().length === 2) + await Promise.all([first.flush(), second.flush()]) + } finally { + await first.destroy() + await second.destroy() + } + for (const name of ["first", "second"]) { + const restored = await renderSessionTabs("shared", { + state: temporary.path, + server: `https://${name}.example`, + scope, + home: true, + }) + try { + expect(restored.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["shared", `${name}-only`]) + expect(restored.tabs.current()).toBeUndefined() + restored.tabs.reopen() + expect(restored.tabs.current()).toBeUndefined() + } finally { + await restored.destroy() + } + } + }, +) + +test("keeps identical session IDs distinct across servers", async () => { + await using temporary = await tmpdir() + const first = await renderSessionTabs("shared", { + state: temporary.path, + server: "HTTPS://FIRST.example:443/", + persisted: ["shared"], + }) + try { + await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared")) + first.tabs.promote("only-first") + first.route.navigate({ type: "session", sessionID: "only-first" }) + await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "only-first")) + await first.flush() + } finally { + await first.destroy() + } + + const second = await renderSessionTabs("shared", { state: temporary.path, server: "https://second.example" }) + try { + await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "shared")) + expect(second.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["shared"]) + await second.flush() + const stored = await Bun.file(path.join(temporary.path, "test", "tui", "tabs.json")).json() + expect( + stored.servers["https://first.example/"].cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID), + ).toEqual(["shared", "only-first"]) + expect( + stored.servers["https://second.example/"].cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID), + ).toEqual(["shared"]) + } finally { + await second.destroy() + } +}) + test("unrelated user admissions do not pre-promote an unopened local session", async () => { const setup = await renderSessionTabs("remote", { home: true }) @@ -557,13 +741,16 @@ test("stores session tabs for the current working directory by default", async ( await wait(async () => { if (!(await Bun.file(file).exists())) return false const stored = await Bun.file(file).json() - return stored.cwd[directory]?.tabs.some((tab: { sessionID: string }) => tab.sessionID === "first") + return stored.servers[serverIdentity("http://localhost")].cwd[directory]?.tabs.some( + (tab: { sessionID: string }) => tab.sessionID === "first", + ) }) const stored = await Bun.file(file).json() - expect(stored.global).toEqual({ tabs: [], unread: {} }) - expect(Object.keys(stored.cwd)).toEqual([directory]) - expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"]) - expect(stored.cwd[directory].unread).toEqual({}) + const state = stored.servers[serverIdentity("http://localhost")] + expect(state.global).toEqual({ tabs: [], unread: {} }) + expect(Object.keys(state.cwd)).toEqual([directory]) + expect(state.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"]) + expect(state.cwd[directory].unread).toEqual({}) } finally { await setup.destroy() } @@ -673,7 +860,7 @@ test("empties legacy persisted unread records for rollback compatibility", async // Normalize rewrites the active scope; legacy values must not survive, but older clients require the field. await wait(async () => { const stored = await Bun.file(file).json() - return Object.keys(stored.cwd[directory].unread).length === 0 + return Object.keys(stored.servers[serverIdentity("http://localhost")].cwd[directory].unread).length === 0 }) } finally { await setup.destroy() @@ -862,7 +1049,10 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session await titled.data.session.sync("shared") await wait(async () => { if (!(await Bun.file(file).exists())) return false - return (await Bun.file(file).json()).cwd[directory]?.tabs[0]?.title === "Generated title" + return ( + (await Bun.file(file).json()).servers[serverIdentity("http://localhost")].cwd[directory]?.tabs[0]?.title === + "Generated title" + ) }) const observed = ["Generated title"] const pending = new Set>() @@ -871,7 +1061,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session const read = Bun.file(file) .json() .then((value) => { - const title = value.cwd[directory]?.tabs[0]?.title + const title = value.servers[serverIdentity("http://localhost")].cwd[directory]?.tabs[0]?.title if (title && observed.at(-1) !== title) observed.push(title) }) .catch(() => undefined) @@ -916,7 +1106,7 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn await second.flush() const stored = await Bun.file(path.join(temporary.path, "test", "tui", "tabs.json")).json() - expect(stored.cwd[directory].tabs).toEqual([]) + expect(stored.servers[serverIdentity("http://localhost")].cwd[directory].tabs).toEqual([]) second.route.navigate({ type: "home" }) await wait(() => second.route.data.type === "home", 2_000, "second client to navigate home") diff --git a/packages/tui/test/context/session-terminals.test.tsx b/packages/tui/test/context/session-terminals.test.tsx new file mode 100644 index 000000000000..a6388c61b89f --- /dev/null +++ b/packages/tui/test/context/session-terminals.test.tsx @@ -0,0 +1,213 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { PersistentPtyInfo } from "@opencode/client" +import { mkdirSync } from "fs" +import path from "path" +import { ConfigProvider } from "../../src/config" +import { ClientProvider } from "../../src/context/client" +import { DataProvider } from "../../src/context/data" +import { SessionTerminalsProvider, useSessionTerminals } from "../../src/context/session-terminals" +import { StorageProvider, useStorage } from "../../src/context/storage" +import { TuiAppProvider } from "../../src/context/runtime" +import { createApi, createFetch, directory, json } from "../fixture/tui-client" +import { TestTuiContexts } from "../fixture/tui-environment" +import { tmpdir } from "../fixture/fixture" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +test("keeps terminal selections for identical session and PTY IDs distinct across servers", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") + mkdirSync(path.dirname(file), { recursive: true }) + await Bun.write( + file, + JSON.stringify({ + servers: { + "https://first.example/": { sessions: { shared: "pty_shared" } }, + "https://second.example/": { sessions: { shared: "pty_other" } }, + }, + }), + ) + + expect(await selected(temporary.path, "HTTPS://FIRST.example:443/")).toBe("pty_shared") + expect(await selected(temporary.path, "https://second.example")).toBe("pty_other") +}) + +test("explicit endpoints cannot adopt or change legacy local selections", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") + mkdirSync(path.dirname(file), { recursive: true }) + const legacy = { sessions: { shared: "pty_shared", untouched: "pty_legacy" } } + await Bun.write(file, JSON.stringify(legacy)) + await using app = await mounted(temporary.path, ["https://first.example", "https://second.example"]) + for (const server of app.servers) { + expect(server.terminals.get("shared").selectedTerminalID).toBeNull() + await server.terminals.refresh("shared") + } + await app.storage.flush() + expect(await Bun.file(file).json()).toEqual(legacy) + + await app.servers[0].terminals.selectTerminal("shared", "pty_shared") + expect(await Bun.file(file).json()).toEqual({ + ...legacy, + servers: { "https://first.example/": { sessions: { shared: "pty_shared" } } }, + }) + expect(app.servers[1].terminals.get("shared").selectedTerminalID).toBeNull() + expect(app.servers[0].terminals.get("untouched").selectedTerminalID).toBeNull() +}) + +test("managed local reads and writes legacy selections across endpoint changes", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") + mkdirSync(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify({ sessions: { shared: "pty_shared", untouched: "pty_legacy" } })) + { + await using local = await mounted(temporary.path, ["http://127.0.0.1:54321"], true) + await using remote = await mounted(temporary.path, ["http://127.0.0.1:54321"]) + expect(local.servers[0].terminals.get("shared").selectedTerminalID).toBe("pty_shared") + expect(remote.servers[0].terminals.get("shared").selectedTerminalID).toBeNull() + await local.servers[0].terminals.selectTerminal("shared", null) + expect((await Bun.file(file).json()).sessions).toEqual({ shared: null, untouched: "pty_legacy" }) + await local.servers[0].terminals.refresh("shared") + await local.servers[0].terminals.selectTerminal("shared", "pty_shared") + } + await using restored = await mounted(temporary.path, ["http://127.0.0.1:54322"], true) + expect(restored.servers[0].terminals.get("shared").selectedTerminalID).toBe("pty_shared") + restored.servers[0].items.length = 0 + await restored.servers[0].terminals.refresh("shared") + expect((await Bun.file(file).json()).sessions).toEqual({ shared: null, untouched: "pty_legacy" }) +}) + +test("isolates terminal lists and writes selection, null, and missing-terminal clearing through to disk", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") + await using app = await mounted(temporary.path, ["https://first.example", "https://second.example"]) + const first = app.servers[0] + const second = app.servers[1] + await first.terminals.refresh("shared") + expect(second.terminals.get("shared").terminals).toEqual([]) + await second.terminals.refresh("shared") + expect(first.terminals.get("shared").terminals[0].title).toBe("https://first.example") + expect(second.terminals.get("shared").terminals[0].title).toBe("https://second.example") + + await first.terminals.selectTerminal("shared", "pty_shared") + expect(first.terminals.get("shared").selectedTerminalID).toBe("pty_shared") + expect(second.terminals.get("shared").selectedTerminalID).toBeNull() + expect((await Bun.file(file).json()).servers).toEqual({ + "https://first.example/": { sessions: { shared: "pty_shared" } }, + }) + await second.terminals.selectTerminal("shared", "pty_shared") + await first.terminals.selectTerminal("shared", null) + expect((await Bun.file(file).json()).servers).toEqual({ + "https://first.example/": { sessions: { shared: null } }, + "https://second.example/": { sessions: { shared: "pty_shared" } }, + }) + await first.terminals.selectTerminal("shared", "pty_shared") + first.items.length = 0 + await first.terminals.refresh("shared") + expect(first.terminals.get("shared").selectedTerminalID).toBeNull() + expect(second.terminals.get("shared").selectedTerminalID).toBe("pty_shared") + expect(second.terminals.get("shared").terminals).toHaveLength(1) + expect((await Bun.file(file).json()).servers).toEqual({ + "https://first.example/": { sessions: { shared: null } }, + "https://second.example/": { sessions: { shared: "pty_shared" } }, + }) +}) + +test("merges concurrent writes from independent storage providers and restores them after remount", async () => { + await using temporary = await tmpdir() + { + await using first = await mounted(temporary.path, ["https://first.example"]) + await using second = await mounted(temporary.path, ["https://second.example"]) + await Promise.all([first.servers[0].terminals.refresh("shared"), second.servers[0].terminals.refresh("shared")]) + await Promise.all([ + first.servers[0].terminals.selectTerminal("shared", "pty_shared"), + second.servers[0].terminals.selectTerminal("shared", "pty_shared"), + ]) + expect(await Bun.file(path.join(temporary.path, "test", "tui", "session-terminal-selection.json")).json()).toEqual({ + servers: { + "https://first.example/": { sessions: { shared: "pty_shared" } }, + "https://second.example/": { sessions: { shared: "pty_shared" } }, + }, + }) + } + await using restored = await mounted(temporary.path, ["HTTPS://FIRST.example:443/", "https://second.example"]) + for (const server of restored.servers) { + expect(server.terminals.get("shared").selectedTerminalID).toBe("pty_shared") + expect(server.terminals.get("shared").terminals).toEqual([]) + } +}) + +async function selected(state: string, server: string) { + await using app = await mounted(state, [server]) + return app.servers[0].terminals.get("shared").selectedTerminalID +} + +async function mounted(state: string, urls: string[], managed = false) { + const servers = urls.map((url) => { + const items: PersistentPtyInfo[] = [ + { + id: "pty_shared", + title: url, + command: "sh", + args: [], + cwd: directory, + status: "running", + pid: 123, + sessionID: "shared", + foregroundProcess: null, + size: { cols: 80, rows: 24 }, + output: { head: 0, tail: 0 }, + }, + ] + const calls = createFetch(async (request) => { + if (request.pathname === "/api/experimental/session/shared/terminal") return json({ data: items }) + return undefined + }) + return { url, items, api: createApi(calls.fetch), terminals: undefined! as ReturnType } + }) + let storage!: ReturnType + function Probe(props: { server: (typeof servers)[number] }) { + props.server.terminals = useSessionTerminals() + storage = useStorage() + return + } + const app = await testRender(() => ( + + + + + {servers.map((server) => ( + ({ api: server.api }), + restart: async () => {}, + } + : undefined + } + > + + + + + + + ))} + + + + + )) + return { + servers, + storage, + async [Symbol.asyncDispose]() { + app.renderer.destroy() + await storage.flush() + }, + } +} diff --git a/services/www/src/docs/content/cli/index.mdx b/services/www/src/docs/content/cli/index.mdx index 1d523d72d9d7..55bd69450334 100644 --- a/services/www/src/docs/content/cli/index.mdx +++ b/services/www/src/docs/content/cli/index.mdx @@ -56,3 +56,17 @@ opencode2 --server http://localhost:4096 ``` See [Troubleshooting](/troubleshooting) for shared service diagnostics and the [API reference](/api) for server endpoints. + +## Saved tabs and terminal selection + +The TUI saves open session tabs and selected terminal panes separately for each server. Both global +and current-directory tab scopes stay within that server's saved state. + +- URL identity ignores credentials, query strings, fragments, default ports, and trailing slashes. Distinct ports and paths + keep separate state; hostname aliases such as `localhost` and `127.0.0.1` also remain separate. +- The managed local server uses a stable local identity, even when its URL changes. Older saved tabs and terminal + selections continue to work there using the existing local storage fields. +- Explicit `--server` endpoints (including loopback URLs) and standalone servers use URL-based identities, separate from + managed local state. Reconnecting keeps the current TUI's startup identity. A standalone launch at a different URL has + separate saved state. +- Navigation history, closed-tab reopening history, and the active session remain in-memory state. From 040b55c04f315155d815cc5e16ee400762e571a2 Mon Sep 17 00:00:00 2001 From: Abe Isleem Date: Wed, 9 Sep 2026 09:05:15 +0200 Subject: [PATCH 2/3] docs(cli): remove saved tab state addition --- services/www/src/docs/content/cli/index.mdx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/services/www/src/docs/content/cli/index.mdx b/services/www/src/docs/content/cli/index.mdx index 55bd69450334..1d523d72d9d7 100644 --- a/services/www/src/docs/content/cli/index.mdx +++ b/services/www/src/docs/content/cli/index.mdx @@ -56,17 +56,3 @@ opencode2 --server http://localhost:4096 ``` See [Troubleshooting](/troubleshooting) for shared service diagnostics and the [API reference](/api) for server endpoints. - -## Saved tabs and terminal selection - -The TUI saves open session tabs and selected terminal panes separately for each server. Both global -and current-directory tab scopes stay within that server's saved state. - -- URL identity ignores credentials, query strings, fragments, default ports, and trailing slashes. Distinct ports and paths - keep separate state; hostname aliases such as `localhost` and `127.0.0.1` also remain separate. -- The managed local server uses a stable local identity, even when its URL changes. Older saved tabs and terminal - selections continue to work there using the existing local storage fields. -- Explicit `--server` endpoints (including loopback URLs) and standalone servers use URL-based identities, separate from - managed local state. Reconnecting keeps the current TUI's startup identity. A standalone launch at a different URL has - separate saved state. -- Navigation history, closed-tab reopening history, and the active session remain in-memory state. From 571124eea57ac9c00314a105656af6e2ac065738 Mon Sep 17 00:00:00 2001 From: Abe Isleem Date: Wed, 9 Sep 2026 09:46:15 +0200 Subject: [PATCH 3/3] refactor(tui): simplify server state isolation --- packages/tui/src/context/client.tsx | 12 +++++++- packages/tui/src/context/server.ts | 10 ------- .../tui/test/context/session-tabs.test.tsx | 3 +- .../test/context/session-terminals.test.tsx | 29 ------------------- 4 files changed, 12 insertions(+), 42 deletions(-) delete mode 100644 packages/tui/src/context/server.ts diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index 5c553ae3ebb9..b12fcf8b5231 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -4,7 +4,6 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup } from "solid-js" import { createSimpleContext } from "./helper" import { useLog } from "./log" -import { serverIdentity } from "./server" type ManagedService = { reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; url?: string }> @@ -62,3 +61,14 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( } }, }) + +export function serverIdentity(url: string, managed = false) { + if (managed) return "local" + const value = new URL(url) + value.username = "" + value.password = "" + value.search = "" + value.hash = "" + value.pathname = value.pathname.replace(/\/+$/, "") || "/" + return value.toString() +} diff --git a/packages/tui/src/context/server.ts b/packages/tui/src/context/server.ts deleted file mode 100644 index 840b09177116..000000000000 --- a/packages/tui/src/context/server.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function serverIdentity(url: string, managed = false) { - if (managed) return "local" - const value = new URL(url) - value.username = "" - value.password = "" - value.search = "" - value.hash = "" - value.pathname = value.pathname.replace(/\/+$/, "") || "/" - return value.toString() -} diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index a562a2bd7563..17800cad8830 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -5,7 +5,7 @@ import { testRender } from "@opentui/solid" import { mkdirSync, watch } from "fs" import path from "path" import { ConfigProvider, useConfig } from "../../src/config" -import { ClientProvider, useClient } from "../../src/context/client" +import { ClientProvider, serverIdentity, useClient } from "../../src/context/client" import { DataProvider, useData } from "../../src/context/data" import { LocationProvider } from "../../src/context/location" import { RouteProvider, useRoute } from "../../src/context/route" @@ -13,7 +13,6 @@ import { TuiAppProvider } from "../../src/context/runtime" import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs" import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model" import { StorageProvider, useStorage } from "../../src/context/storage" -import { serverIdentity } from "../../src/context/server" import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client" import { TestTuiContexts } from "../fixture/tui-environment" import { tmpdir } from "../fixture/fixture" diff --git a/packages/tui/test/context/session-terminals.test.tsx b/packages/tui/test/context/session-terminals.test.tsx index a6388c61b89f..85359f7679e2 100644 --- a/packages/tui/test/context/session-terminals.test.tsx +++ b/packages/tui/test/context/session-terminals.test.tsx @@ -15,24 +15,6 @@ import { TestTuiContexts } from "../fixture/tui-environment" import { tmpdir } from "../fixture/fixture" import { createTuiResolvedConfig } from "../fixture/tui-runtime" -test("keeps terminal selections for identical session and PTY IDs distinct across servers", async () => { - await using temporary = await tmpdir() - const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") - mkdirSync(path.dirname(file), { recursive: true }) - await Bun.write( - file, - JSON.stringify({ - servers: { - "https://first.example/": { sessions: { shared: "pty_shared" } }, - "https://second.example/": { sessions: { shared: "pty_other" } }, - }, - }), - ) - - expect(await selected(temporary.path, "HTTPS://FIRST.example:443/")).toBe("pty_shared") - expect(await selected(temporary.path, "https://second.example")).toBe("pty_other") -}) - test("explicit endpoints cannot adopt or change legacy local selections", async () => { await using temporary = await tmpdir() const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") @@ -124,12 +106,6 @@ test("merges concurrent writes from independent storage providers and restores t first.servers[0].terminals.selectTerminal("shared", "pty_shared"), second.servers[0].terminals.selectTerminal("shared", "pty_shared"), ]) - expect(await Bun.file(path.join(temporary.path, "test", "tui", "session-terminal-selection.json")).json()).toEqual({ - servers: { - "https://first.example/": { sessions: { shared: "pty_shared" } }, - "https://second.example/": { sessions: { shared: "pty_shared" } }, - }, - }) } await using restored = await mounted(temporary.path, ["HTTPS://FIRST.example:443/", "https://second.example"]) for (const server of restored.servers) { @@ -138,11 +114,6 @@ test("merges concurrent writes from independent storage providers and restores t } }) -async function selected(state: string, server: string) { - await using app = await mounted(state, [server]) - return app.servers[0].terminals.get("shared").selectedTerminalID -} - async function mounted(state: string, urls: string[], managed = false) { const servers = urls.map((url) => { const items: PersistentPtyInfo[] = [