diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 9dd11c67ed..202ad89cc5 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { useKeyboard, useRenderer } from "@opentui/solid" import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" @@ -156,26 +156,30 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp } } - createEffect(() => { - if (!enabled()) return - if (route.data.type !== "session" || route.data.sessionID === "dummy") return - const sessionID = root(route.data.sessionID) - cancelledTabs.delete(sessionID) - history = recordSessionTabHistory(history, sessionID) - const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined - const tabs = openSessionTab(state().tabs, { - sessionID, - title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback), - }) - if (tabs === state().tabs) return - update((draft) => { - if (cancelledTabs.has(sessionID)) return - draft.tabs = openSessionTab(draft.tabs, { - sessionID, - title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback), - }) - }) - }) + // Shared storage updates must not re-admit a tab unless this client changes route or scope. + createEffect( + on( + [ + () => (enabled() && route.data.type === "session" ? route.data.sessionID : undefined), + () => config.tabs.scope, + ], + ([routed]) => { + if (!routed || routed === "dummy") return + const sessionID = root(routed) + cancelledTabs.delete(sessionID) + history = recordSessionTabHistory(history, sessionID) + if (state().tabs.some((tab) => tab.sessionID === sessionID)) return + const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined + update((draft) => { + if (cancelledTabs.has(sessionID)) return + draft.tabs = openSessionTab(draft.tabs, { + sessionID, + title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback), + }) + }) + }, + ), + ) // Viewed state is server-global, so acknowledgement runs even with tabs disabled: other // clients rely on this client reporting what its user has seen. diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 63fe46f027..7541a06aa5 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -505,6 +505,34 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session } }) +test("closing a tab is not undone by another TUI viewing the same session", async () => { + await using temporary = await tmpdir() + const clients: Awaited>[] = [] + + try { + const first = await renderSessionTabs("shared", { state: temporary.path }) + clients.push(first) + const second = await renderSessionTabs("shared", { state: temporary.path }) + clients.push(second) + await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared")) + await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "shared")) + first.tabs.close() + await wait(() => first.route.data.type === "home") + await wait(() => !second.tabs.tabs().some((tab) => tab.sessionID === "shared")) + await Promise.all([first.flush(), second.flush()]) + + const stored = await Bun.file(path.join(temporary.path, "test", "tui", "tabs.json")).json() + expect(stored.cwd[directory].tabs).toEqual([]) + + second.route.navigate({ type: "home" }) + await wait(() => second.route.data.type === "home") + second.route.navigate({ type: "session", sessionID: "shared" }) + await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared")) + } finally { + await Promise.allSettled(clients.map((client) => client.destroy())) + } +}) + test("user prompt admissions pulse an already-busy background tab", async () => { const setup = await renderSessionTabs("background") const admitted = (sessionID: string, inboxID: string): OpenCodeEvent => ({