From cc01538bb9d4e8dd81a3721dff2ea113c32fcfa7 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:59:54 +0000 Subject: [PATCH 1/7] fix(desktop): adopt chat sessions started outside the desktop as tabs (SUPER-3158) The desktop only adds tabs for missing terminal sessions, so a chat created by the CLI, SDK, an automation or a teammate never got a tab. On a cloud host the CLI's panes.newTab fails (no desktop attached) and the CLI hid that. - useAutoAdoptChatSessions lists the workspace's chats on layout ready, on chat:sessions-changed and on a 15s poll, and adds a background tab for each live chat no pane claims by session id or terminal id. Closed chats and pending cloud launches are skipped. - focusChatSession takes focus: false. - sessions create/resume/import say when no desktop tab was opened and return openedInDesktop in --json output. --- .../useAutoAdoptChatSessions/constants.ts | 2 + .../hooks/useAutoAdoptChatSessions/index.ts | 1 + .../useAutoAdoptChatSessions.ts | 72 ++++++++++++ .../chatSessionsToAdopt.test.ts | 106 ++++++++++++++++++ .../chatSessionsToAdopt.ts | 59 ++++++++++ .../utils/chatSessionsToAdopt/index.ts | 1 + .../v2-workspace/$workspaceId/page.tsx | 7 ++ .../focusChatSession/focusChatSession.test.ts | 12 ++ .../focusChatSession/focusChatSession.ts | 5 +- .../src/commands/sessions/create/command.ts | 5 +- .../src/commands/sessions/import/command.ts | 5 +- .../src/commands/sessions/resume/command.ts | 5 +- .../src/commands/sessions/sessions.test.ts | 15 +++ packages/cli/src/lib/sessions.ts | 6 + 14 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts new file mode 100644 index 00000000000..6e6766a8da4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts @@ -0,0 +1,2 @@ +export const CHAT_ADOPT_POLL_MS = 15_000; +export const CHAT_ADOPT_SESSIONS_LIMIT = 200; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/index.ts new file mode 100644 index 00000000000..a909456f496 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/index.ts @@ -0,0 +1 @@ +export { useAutoAdoptChatSessions } from "./useAutoAdoptChatSessions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts new file mode 100644 index 00000000000..c29d6653833 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts @@ -0,0 +1,72 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useWorkspaceClient } from "@superset/workspace-client"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; +import { useWorkspaceConnectionRefresh } from "renderer/hooks/host-service/useWorkspaceConnectionRefresh"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData } from "../../types"; +import { focusChatSession } from "../../utils/focusChatSession"; +import { useChatWiring } from "../usePaneRegistry/components/ChatSession/hooks/useSessionClient"; +import { CHAT_ADOPT_POLL_MS, CHAT_ADOPT_SESSIONS_LIMIT } from "./constants"; +import { chatSessionsToAdopt } from "./utils/chatSessionsToAdopt"; + +interface UseAutoAdoptChatSessionsArgs { + store: StoreApi>; + linkedStores: readonly StoreApi>[]; + workspaceId: string; + isLayoutReady: boolean; +} + +export function useAutoAdoptChatSessions({ + store, + linkedStores, + workspaceId, + isLayoutReady, +}: UseAutoAdoptChatSessionsArgs): void { + const { hostUrl } = useWorkspaceClient(); + const { transport } = useChatWiring(); + const queryKey = useMemo( + () => ["chat-v3", hostUrl, "autoAdopt", workspaceId], + [hostUrl, workspaceId], + ); + const sessionsQuery = useQuery({ + queryKey, + queryFn: () => + transport.listSessions({ + workspaceId, + limit: CHAT_ADOPT_SESSIONS_LIMIT, + }), + enabled: isLayoutReady, + refetchInterval: CHAT_ADOPT_POLL_MS, + refetchOnWindowFocus: false, + }); + const refresh = useWorkspaceConnectionRefresh( + workspaceId, + queryKey, + isLayoutReady, + ); + useWorkspaceEvent( + "chat:sessions-changed", + workspaceId, + refresh, + isLayoutReady, + ); + const sessions = sessionsQuery.data; + const isFetchingSessions = sessionsQuery.isFetching; + + useEffect(() => { + if (!isLayoutReady || !sessions || isFetchingSessions) return; + const state = store.getState(); + const toAdopt = chatSessionsToAdopt(sessions, [ + ...state.tabs, + ...linkedStores.flatMap((linked) => linked.getState().tabs), + ]); + if (toAdopt.length === 0) return; + const restoreActiveTabId = state.tabs.length > 0 ? state.activeTabId : null; + for (const session of toAdopt) { + focusChatSession({ store, linkedStores, session, focus: false }); + } + if (restoreActiveTabId) store.getState().setActiveTab(restoreActiveTabId); + }, [isLayoutReady, isFetchingSessions, sessions, store, linkedStores]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts new file mode 100644 index 00000000000..250e91643b3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "bun:test"; +import { createWorkspaceStore } from "@superset/panes"; +import { + type PaneViewerData, + PENDING_CLOUD_CHAT_PANE_KIND, +} from "../../../../types"; +import { + markChatSessionClosed, + reopenChatSession, +} from "../../../../utils/closedChatSessions"; +import { chatSessionsToAdopt } from "./chatSessionsToAdopt"; + +const listed = (sessionId: string, overrides = {}) => ({ + sessionId, + harness: "claude-acp", + harnessSessionId: `native-${sessionId}`, + title: `Chat ${sessionId}`, + updatedAt: 1, + live: true, + terminalId: `terminal-${sessionId}`, + ...overrides, +}); + +describe("chatSessionsToAdopt", () => { + it("adopts live chats no pane shows, oldest first", () => { + expect( + chatSessionsToAdopt( + [listed("new", { updatedAt: 2 }), listed("old", { updatedAt: 1 })], + [], + ), + ).toEqual([ + { + sessionId: "old", + presetId: "claude", + harnessSessionId: "native-old", + terminalId: "terminal-old", + title: "Chat old", + }, + { + sessionId: "new", + presetId: "claude", + harnessSessionId: "native-new", + terminalId: "terminal-new", + title: "Chat new", + }, + ]); + }); + + it("skips dead, unsupported, closed, and already shown chats", () => { + const store = createWorkspaceStore(); + store.getState().addTab({ + panes: [ + { + kind: "chat-v3", + data: { + terminalId: "pane-terminal", + sessionId: "shown", + agent: { id: "claude" }, + }, + }, + ], + }); + store.getState().addTab({ + panes: [ + { + kind: "chat-v3", + data: { + terminalId: "terminal-creating", + sessionId: null, + agent: { id: "claude" }, + }, + }, + ], + }); + store.getState().addTab({ + panes: [ + { + kind: PENDING_CLOUD_CHAT_PANE_KIND, + data: { + draftId: "draft-1", + agentId: "claude", + terminalId: "terminal-pending", + }, + }, + ], + }); + markChatSessionClosed("closed"); + try { + expect( + chatSessionsToAdopt( + [ + listed("dead", { live: false }), + listed("unknown", { harness: "unknown" }), + listed("closed"), + listed("shown"), + listed("creating"), + listed("pending"), + ], + store.getState().tabs, + ), + ).toEqual([]); + } finally { + reopenChatSession("closed"); + } + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts new file mode 100644 index 00000000000..617c2554832 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts @@ -0,0 +1,59 @@ +import { presetForAcpHarness } from "@superset/chat/core"; +import type { Tab } from "@superset/panes"; +import type { ChatPaneData, PaneViewerData } from "../../../../types"; +import { isChatSessionClosed } from "../../../../utils/closedChatSessions"; +import type { ChatSessionTarget } from "../../../../utils/focusChatSession"; + +interface ListedChatSession { + sessionId: string; + harness: string; + harnessSessionId: string | null; + title: string | null; + updatedAt: number; + live: boolean; + terminalId: string | null; +} + +/** + * Live chats in the workspace that no pane shows, oldest first. A pane + * claims a chat by its session id or by its terminal id, so a chat the + * desktop is still creating (or a pending cloud launch) is never adopted twice. + */ +export function chatSessionsToAdopt( + sessions: readonly ListedChatSession[], + tabs: readonly Tab[], +): ChatSessionTarget[] { + const claimedSessionIds = new Set(); + const claimedTerminalIds = new Set(); + for (const tab of tabs) { + for (const pane of Object.values(tab.panes)) { + const data = pane.data as Partial; + if (typeof data.terminalId === "string") + claimedTerminalIds.add(data.terminalId); + if (pane.kind === "chat-v3" && data.sessionId) + claimedSessionIds.add(data.sessionId); + } + } + const targets: Array = []; + for (const session of sessions) { + if (!session.live || !session.terminalId) continue; + if (claimedSessionIds.has(session.sessionId)) continue; + if (claimedTerminalIds.has(session.terminalId)) continue; + if (isChatSessionClosed(session.sessionId)) continue; + const presetId = presetForAcpHarness(session.harness); + if (!presetId) continue; + targets.push({ + sessionId: session.sessionId, + presetId, + terminalId: session.terminalId, + updatedAt: session.updatedAt, + ...(session.harnessSessionId + ? { harnessSessionId: session.harnessSessionId } + : {}), + ...(session.title ? { title: session.title } : {}), + }); + } + return targets + .sort((a, b) => a.updatedAt - b.updatedAt) + .map(({ updatedAt: _updatedAt, ...target }) => target); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/index.ts new file mode 100644 index 00000000000..4df5fd16372 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/index.ts @@ -0,0 +1 @@ +export { chatSessionsToAdopt } from "./chatSessionsToAdopt"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 89373dcfb3d..c0a334e666b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -61,6 +61,7 @@ import { import { WorkspaceToolbar } from "./components/WorkspaceToolbar"; import { useAgentSessionLauncher } from "./hooks/useAgentSessionLauncher"; import { useAutoAdoptBackgroundSessions } from "./hooks/useAutoAdoptBackgroundSessions"; +import { useAutoAdoptChatSessions } from "./hooks/useAutoAdoptChatSessions"; import { useCloseRightPaneAreaWhenEmpty } from "./hooks/useCloseRightPaneAreaWhenEmpty"; import { useConsumeAutomationRunLink } from "./hooks/useConsumeAutomationRunLink"; import { useConsumeOpenUrlRequest } from "./hooks/useConsumeOpenUrlRequest"; @@ -366,6 +367,12 @@ function V2WorkspaceContent() { workspaceId, isLayoutReady: isLayoutReady && isRightLayoutReady, }); + useAutoAdoptChatSessions({ + store, + linkedStores: linkedPaneStores, + workspaceId, + isLayoutReady: isLayoutReady && isRightLayoutReady, + }); useConsumeOpenUrlRequest({ viewer: viewerTarget, url: openUrl, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.test.ts index 0993ccd463b..f5e276a69e2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.test.ts @@ -69,4 +69,16 @@ describe("focusChatSession", () => { }), ).toBe("added"); }); + + it("finds an existing chat without moving focus to it", () => { + const store = newStore(); + expect(focusChatSession({ store, session, focus: false })).toBe("added"); + store.getState().addTab({ + panes: [{ kind: "terminal", data: { terminalId: "active" } }], + }); + const activeTabId = store.getState().activeTabId; + expect(focusChatSession({ store, session, focus: false })).toBe("focused"); + expect(store.getState().tabs).toHaveLength(2); + expect(store.getState().activeTabId).toBe(activeTabId); + }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.ts index 7a26b2745f0..ee99bcf7e73 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.ts @@ -16,10 +16,12 @@ export function focusChatSession({ store, linkedStores = [], session, + focus = true, }: { store: StoreApi>; linkedStores?: readonly StoreApi>[]; session: ChatSessionTarget; + focus?: boolean; }): "focused" | "added" { if (session.sessionId) { reopenChatSession(session.sessionId); @@ -39,12 +41,13 @@ export function focusChatSession({ ); }); if (pane) { - paneStore.getState().focusPane(pane.id); + if (focus) paneStore.getState().focusPane(pane.id); return "focused"; } } } store.getState().addTab({ + focus, panes: [ { kind: "chat-v3", diff --git a/packages/cli/src/commands/sessions/create/command.ts b/packages/cli/src/commands/sessions/create/command.ts index df7e2b8da10..0e5d6003f51 100644 --- a/packages/cli/src/commands/sessions/create/command.ts +++ b/packages/cli/src/commands/sessions/create/command.ts @@ -1,6 +1,7 @@ import { string } from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { + desktopTabNote, resolveSessionWorkspace, sessionWorkspaceOptions, } from "../../../lib/sessions"; @@ -53,8 +54,8 @@ export default command({ const opened = await openTab(result.sessionId); return { - data: result, - message: `Started ${result.label} chat session ${result.sessionId} in workspace ${options.workspace}${opened ? ", opened in a desktop tab" : ""}`, + data: { ...result, openedInDesktop: opened }, + message: `Started ${result.label} chat session ${result.sessionId} in workspace ${options.workspace}${desktopTabNote(opened)}`, }; }, }); diff --git a/packages/cli/src/commands/sessions/import/command.ts b/packages/cli/src/commands/sessions/import/command.ts index 90a284b4592..fbe322e3806 100644 --- a/packages/cli/src/commands/sessions/import/command.ts +++ b/packages/cli/src/commands/sessions/import/command.ts @@ -1,6 +1,7 @@ import { string } from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { + desktopTabNote, resolveSessionWorkspace, sessionWorkspaceOptions, } from "../../../lib/sessions"; @@ -30,8 +31,8 @@ export default command({ const opened = await openTab(result.sessionId); return { - data: result, - message: `Imported ${result.label} session ${options.nativeSession} as chat session ${result.sessionId} in workspace ${options.workspace}${opened ? ", opened in a desktop tab" : ""}`, + data: { ...result, openedInDesktop: opened }, + message: `Imported ${result.label} session ${options.nativeSession} as chat session ${result.sessionId} in workspace ${options.workspace}${desktopTabNote(opened)}`, }; }, }); diff --git a/packages/cli/src/commands/sessions/resume/command.ts b/packages/cli/src/commands/sessions/resume/command.ts index 7ccf5eb2a45..9b1304e6367 100644 --- a/packages/cli/src/commands/sessions/resume/command.ts +++ b/packages/cli/src/commands/sessions/resume/command.ts @@ -1,6 +1,7 @@ import { string } from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { + desktopTabNote, resolveSessionWorkspace, sessionWorkspaceOptions, } from "../../../lib/sessions"; @@ -21,8 +22,8 @@ export default command({ const opened = await openTab(result.sessionId); return { - data: result, - message: `Resumed ${result.label} chat session ${result.sessionId} in workspace ${options.workspace}${opened ? ", opened in a desktop tab" : ""}`, + data: { ...result, openedInDesktop: opened }, + message: `Resumed ${result.label} chat session ${result.sessionId} in workspace ${options.workspace}${desktopTabNote(opened)}`, }; }, }); diff --git a/packages/cli/src/commands/sessions/sessions.test.ts b/packages/cli/src/commands/sessions/sessions.test.ts index 1ba85c4c891..0f27a8ce137 100644 --- a/packages/cli/src/commands/sessions/sessions.test.ts +++ b/packages/cli/src/commands/sessions/sessions.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { withLocalHostService } from "../../lib/host/test-helpers"; +import createSessionCommand from "./create/command"; import listSessionsCommand from "./list/command"; import openSessionCommand from "./open/command"; import readSessionCommand from "./read/command"; @@ -40,6 +41,12 @@ withLocalHostService("org-1", { readInput = input; return { ok: true, envelopes: [], nextBefore: null }; }, + "sessions.create": () => ({ + kind: "chat", + sessionId: "chat-created", + terminalId: "term-2", + label: "claude", + }), "sessions.resume": (input) => ({ kind: "chat", sessionId: input.sessionId === SESSION_ID ? SESSION_ID : "chat-resumed", @@ -122,6 +129,14 @@ describe("sessions commands", () => { ).rejects.toThrow(/Session not found/); }); + test("create says when no desktop tab could be opened", async () => { + const result = (await runCommand(createSessionCommand, { + agent: "claude", + })) as { data: { openedInDesktop: boolean }; message: string }; + expect(result.data.openedInDesktop).toBe(false); + expect(result.message).toContain("No desktop tab was opened"); + }); + test("old host missing sessions procedures surfaces update host-service error", async () => { procedureError = new Error('No procedure found on path "sessions.list"'); await expect(runCommand(listSessionsCommand, {})).rejects.toThrow( diff --git a/packages/cli/src/lib/sessions.ts b/packages/cli/src/lib/sessions.ts index 799165ea33e..d18e3dc8923 100644 --- a/packages/cli/src/lib/sessions.ts +++ b/packages/cli/src/lib/sessions.ts @@ -56,6 +56,12 @@ function sessionsClient(client: HostServiceClient): SessionsClient { }; } +export function desktopTabNote(opened: boolean): string { + return opened + ? ", opened in a desktop tab" + : ". No desktop tab was opened (no desktop app is attached to this host); the Paradev desktop app adds one when it shows this workspace"; +} + export async function resolveSessionWorkspace( ctx: CliContext, options: { workspace: string; host?: string | null; local?: boolean | null }, From ebe917ddef00b15ef67edc324ba6a3a93d753521 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:55:30 +0000 Subject: [PATCH 2/7] fix(desktop): never adopt a chat session leaked on a closed tab's terminal Closing a chat tab now marks its terminal closed. Adoption skips sessions on a closed terminal, so a replacement session that leaked on that terminal (#8919) cannot come back as a tab, even when stopping it failed or it started after the close. Undo reopens the terminal with the session. --- .../chatSessionsToAdopt/chatSessionsToAdopt.test.ts | 4 ++++ .../utils/chatSessionsToAdopt/chatSessionsToAdopt.ts | 7 ++++++- .../useUndoableChatClose/useUndoableChatClose.tsx | 7 ++++++- .../utils/closedChatSessions/closedChatSessions.ts | 10 ++++++++++ .../$workspaceId/utils/closedChatSessions/index.ts | 2 ++ 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts index 250e91643b3..6181fb8429e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts @@ -6,6 +6,7 @@ import { } from "../../../../types"; import { markChatSessionClosed, + markChatTerminalClosed, reopenChatSession, } from "../../../../utils/closedChatSessions"; import { chatSessionsToAdopt } from "./chatSessionsToAdopt"; @@ -85,6 +86,7 @@ describe("chatSessionsToAdopt", () => { ], }); markChatSessionClosed("closed"); + const reopenTerminal = markChatTerminalClosed("terminal-stray"); try { expect( chatSessionsToAdopt( @@ -95,12 +97,14 @@ describe("chatSessionsToAdopt", () => { listed("shown"), listed("creating"), listed("pending"), + listed("stray"), ], store.getState().tabs, ), ).toEqual([]); } finally { reopenChatSession("closed"); + reopenTerminal(); } }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts index 617c2554832..a3edbf26319 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts @@ -1,7 +1,10 @@ import { presetForAcpHarness } from "@superset/chat/core"; import type { Tab } from "@superset/panes"; import type { ChatPaneData, PaneViewerData } from "../../../../types"; -import { isChatSessionClosed } from "../../../../utils/closedChatSessions"; +import { + isChatSessionClosed, + isChatTerminalClosed, +} from "../../../../utils/closedChatSessions"; import type { ChatSessionTarget } from "../../../../utils/focusChatSession"; interface ListedChatSession { @@ -18,6 +21,7 @@ interface ListedChatSession { * Live chats in the workspace that no pane shows, oldest first. A pane * claims a chat by its session id or by its terminal id, so a chat the * desktop is still creating (or a pending cloud launch) is never adopted twice. + * A closed pane keeps its terminal claimed, so a session leaked on it is never adopted. */ export function chatSessionsToAdopt( sessions: readonly ListedChatSession[], @@ -40,6 +44,7 @@ export function chatSessionsToAdopt( if (claimedSessionIds.has(session.sessionId)) continue; if (claimedTerminalIds.has(session.terminalId)) continue; if (isChatSessionClosed(session.sessionId)) continue; + if (isChatTerminalClosed(session.terminalId)) continue; const presetId = presetForAcpHarness(session.harness); if (!presetId) continue; targets.push({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx index 329307f6e3b..10fa486c930 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx @@ -8,6 +8,7 @@ import type { ChatPaneData, PaneViewerData } from "../../../../types"; import { isChatSessionClosed, markChatSessionClosed, + markChatTerminalClosed, } from "../../../../utils/closedChatSessions"; import { addPendingChatStop, @@ -171,6 +172,7 @@ export function useUndoableChatClose({ (pane) => { const data = pane.data as ChatPaneData; const { sessionId, terminalId } = data; + const reopenTerminal = markChatTerminalClosed(terminalId); void stopStrayChats(terminalId, sessionId); if (!sessionId || isChatSessionClosed(sessionId)) return; const index = previousTabs.current.findIndex( @@ -185,7 +187,10 @@ export function useUndoableChatClose({ tab && Object.keys(tab.panes).length === 1 ? { tab, index } : null, sessionId, title: pane.titleOverride ?? data.chatTitle ?? t({ message: "Chat" }), - reopen: reopenSession, + reopen: () => { + reopenSession(); + reopenTerminal(); + }, }); }, [batches, workspaceId, t, stopStrayChats], diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts index 80d3e5b9273..da5bb3612bc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts @@ -1,6 +1,7 @@ import { readPendingChatStopIds } from "../pendingChatStops"; const closed = new Set(readPendingChatStopIds()); +const closedTerminals = new Set(); export function markChatSessionClosed(sessionId: string): () => void { closed.add(sessionId); @@ -14,3 +15,12 @@ export function reopenChatSession(sessionId: string): void { export function isChatSessionClosed(sessionId: string): boolean { return closed.has(sessionId); } + +export function markChatTerminalClosed(terminalId: string): () => void { + closedTerminals.add(terminalId); + return () => closedTerminals.delete(terminalId); +} + +export function isChatTerminalClosed(terminalId: string): boolean { + return closedTerminals.has(terminalId); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts index 213e5ebf44e..159ef494cd1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts @@ -1,5 +1,7 @@ export { isChatSessionClosed, + isChatTerminalClosed, markChatSessionClosed, + markChatTerminalClosed, reopenChatSession, } from "./closedChatSessions"; From 09ac74d42bd66482616664c3f272b0cd3845f1b8 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:26:36 +0000 Subject: [PATCH 3/7] fix(desktop): close the gaps the chat-flow audit found in chat adoption - Fork into chat: the hidden loader session and the fork's terminal are ignored before they are created, so neither becomes an extra tab. - Mobile chats have no terminal id: adopt them under their session id, as the host's panes.newTab does. - Adoption adds the tab directly and matches only session or terminal id, so a resumed or imported chat is not hidden behind an old tab of the same agent session. - `superset panes close` on a chat marks it closed and queues its stop, so it is not adopted back. - Pending stops keep the closed terminal; adoption skips both, so a second window or a reload inside the undo window does not bring a closed chat back. - Closing a launching cloud chat ignores its promised terminal. - A chat whose stop failed is shown again instead of running without a tab. - Only chats active in the last 24 h are adopted. - Adopted tabs land in one store write with the user's tab still selected, so a reload keeps that selection. - The CLI note no longer claims no desktop is attached for every failure. --- .../useAutoAdoptChatSessions/constants.ts | 1 + .../useAutoAdoptChatSessions.ts | 43 +++++-- .../chatSessionsToAdopt.test.ts | 111 +++++++++++++----- .../chatSessionsToAdopt.ts | 40 +++++-- .../components/AcpChatPane/AcpChatPane.tsx | 2 + .../hooks/useSurfaceFork/useSurfaceFork.ts | 3 + .../useUndoableChatClose.tsx | 13 +- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 5 + .../closedChatSessions/closedChatSessions.ts | 13 +- .../utils/closedChatSessions/index.ts | 4 +- .../focusChatSession/focusChatSession.test.ts | 22 ++-- .../focusChatSession/focusChatSession.ts | 18 ++- .../utils/focusChatSession/index.ts | 6 +- .../utils/pendingChatStops/index.ts | 1 + .../pendingChatStops/pendingChatStops.ts | 17 ++- .../usePaneLayoutRequests.ts | 31 ++++- packages/cli/src/lib/sessions.ts | 2 +- 17 files changed, 251 insertions(+), 81 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts index 6e6766a8da4..785e112a266 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts @@ -1,2 +1,3 @@ export const CHAT_ADOPT_POLL_MS = 15_000; export const CHAT_ADOPT_SESSIONS_LIMIT = 200; +export const CHAT_ADOPT_MAX_IDLE_MS = 24 * 60 * 60 * 1000; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts index c29d6653833..a8410311a67 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts @@ -1,4 +1,4 @@ -import type { WorkspaceStore } from "@superset/panes"; +import { createWorkspaceStore, type WorkspaceStore } from "@superset/panes"; import { useWorkspaceClient } from "@superset/workspace-client"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; @@ -6,7 +6,7 @@ import { useWorkspaceConnectionRefresh } from "renderer/hooks/host-service/useWo import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import type { StoreApi } from "zustand/vanilla"; import type { PaneViewerData } from "../../types"; -import { focusChatSession } from "../../utils/focusChatSession"; +import { addChatTab } from "../../utils/focusChatSession"; import { useChatWiring } from "../usePaneRegistry/components/ChatSession/hooks/useSessionClient"; import { CHAT_ADOPT_POLL_MS, CHAT_ADOPT_SESSIONS_LIMIT } from "./constants"; import { chatSessionsToAdopt } from "./utils/chatSessionsToAdopt"; @@ -57,16 +57,37 @@ export function useAutoAdoptChatSessions({ useEffect(() => { if (!isLayoutReady || !sessions || isFetchingSessions) return; - const state = store.getState(); - const toAdopt = chatSessionsToAdopt(sessions, [ - ...state.tabs, - ...linkedStores.flatMap((linked) => linked.getState().tabs), - ]); + const { version, tabs, activeTabId } = store.getState(); + const toAdopt = chatSessionsToAdopt({ + sessions, + tabs: [ + ...tabs, + ...linkedStores.flatMap((linked) => linked.getState().tabs), + ], + workspaceId, + now: Date.now(), + }); if (toAdopt.length === 0) return; - const restoreActiveTabId = state.tabs.length > 0 ? state.activeTabId : null; + // One write with the user's tab still selected: `addTab` selects each new + // tab, and a selection change alone is not saved. + const next = createWorkspaceStore({ + initialState: { version, tabs, activeTabId }, + }); for (const session of toAdopt) { - focusChatSession({ store, linkedStores, session, focus: false }); + addChatTab({ store: next, session, focus: false }); } - if (restoreActiveTabId) store.getState().setActiveTab(restoreActiveTabId); - }, [isLayoutReady, isFetchingSessions, sessions, store, linkedStores]); + if (activeTabId) next.getState().setActiveTab(activeTabId); + store.getState().replaceState({ + version, + tabs: next.getState().tabs, + activeTabId: next.getState().activeTabId, + }); + }, [ + isLayoutReady, + isFetchingSessions, + sessions, + store, + linkedStores, + workspaceId, + ]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts index 6181fb8429e..a0436161df0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts @@ -1,34 +1,62 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import { createWorkspaceStore } from "@superset/panes"; import { type PaneViewerData, PENDING_CLOUD_CHAT_PANE_KIND, } from "../../../../types"; import { + ignoreChatTerminal, markChatSessionClosed, - markChatTerminalClosed, - reopenChatSession, } from "../../../../utils/closedChatSessions"; +import { + addPendingChatStop, + removePendingChatStop, +} from "../../../../utils/pendingChatStops"; +import { CHAT_ADOPT_MAX_IDLE_MS } from "../../constants"; import { chatSessionsToAdopt } from "./chatSessionsToAdopt"; +const NOW = 10 * CHAT_ADOPT_MAX_IDLE_MS; +const WORKSPACE_ID = "workspace-1"; + const listed = (sessionId: string, overrides = {}) => ({ sessionId, harness: "claude-acp", harnessSessionId: `native-${sessionId}`, title: `Chat ${sessionId}`, - updatedAt: 1, + updatedAt: NOW - 1000, live: true, terminalId: `terminal-${sessionId}`, ...overrides, }); +const adoptedIds = ( + sessions: ReturnType[], + tabs = createWorkspaceStore().getState().tabs, +) => + chatSessionsToAdopt({ + sessions, + tabs, + workspaceId: WORKSPACE_ID, + now: NOW, + }).map((target) => target.sessionId); + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + describe("chatSessionsToAdopt", () => { it("adopts live chats no pane shows, oldest first", () => { expect( - chatSessionsToAdopt( - [listed("new", { updatedAt: 2 }), listed("old", { updatedAt: 1 })], - [], - ), + chatSessionsToAdopt({ + sessions: [ + listed("new", { updatedAt: NOW - 1 }), + listed("old", { updatedAt: NOW - 2 }), + ], + tabs: [], + workspaceId: WORKSPACE_ID, + now: NOW, + }), ).toEqual([ { sessionId: "old", @@ -47,7 +75,17 @@ describe("chatSessionsToAdopt", () => { ]); }); - it("skips dead, unsupported, closed, and already shown chats", () => { + it("adopts a chat started without a terminal under its session id", () => { + const [target] = chatSessionsToAdopt({ + sessions: [listed("mobile", { terminalId: null })], + tabs: [], + workspaceId: WORKSPACE_ID, + now: NOW, + }); + expect(target?.terminalId).toBe("mobile"); + }); + + it("skips dead, idle, unsupported, closed, and already shown chats", () => { const store = createWorkspaceStore(); store.getState().addTab({ panes: [ @@ -85,26 +123,39 @@ describe("chatSessionsToAdopt", () => { }, ], }); - markChatSessionClosed("closed"); - const reopenTerminal = markChatTerminalClosed("terminal-stray"); - try { - expect( - chatSessionsToAdopt( - [ - listed("dead", { live: false }), - listed("unknown", { harness: "unknown" }), - listed("closed"), - listed("shown"), - listed("creating"), - listed("pending"), - listed("stray"), - ], - store.getState().tabs, - ), - ).toEqual([]); - } finally { - reopenChatSession("closed"); - reopenTerminal(); - } + cleanups.push(markChatSessionClosed("closed")); + expect( + adoptedIds( + [ + listed("dead", { live: false }), + listed("idle", { updatedAt: NOW - CHAT_ADOPT_MAX_IDLE_MS - 1 }), + listed("unknown", { harness: "unknown" }), + listed("closed"), + listed("shown"), + listed("creating"), + listed("pending"), + ], + store.getState().tabs, + ), + ).toEqual([]); + }); + + it("skips chats on an ignored terminal, such as a closed tab's or a fork loader's", () => { + cleanups.push(ignoreChatTerminal("terminal-stray")); + expect(adoptedIds([listed("stray"), listed("other")])).toEqual(["other"]); + }); + + it("skips chats another window is stopping, by session or terminal", () => { + addPendingChatStop(WORKSPACE_ID, "stopping"); + addPendingChatStop(WORKSPACE_ID, "replaced", "terminal-leaked"); + addPendingChatStop("other-workspace", "elsewhere"); + cleanups.push( + () => removePendingChatStop("stopping"), + () => removePendingChatStop("replaced"), + () => removePendingChatStop("elsewhere"), + ); + expect( + adoptedIds([listed("stopping"), listed("leaked"), listed("elsewhere")]), + ).toEqual(["elsewhere"]); }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts index a3edbf26319..1c1a2061761 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts @@ -3,9 +3,14 @@ import type { Tab } from "@superset/panes"; import type { ChatPaneData, PaneViewerData } from "../../../../types"; import { isChatSessionClosed, - isChatTerminalClosed, + isChatTerminalIgnored, } from "../../../../utils/closedChatSessions"; import type { ChatSessionTarget } from "../../../../utils/focusChatSession"; +import { + readPendingChatStops, + readPendingChatStopTerminalIds, +} from "../../../../utils/pendingChatStops"; +import { CHAT_ADOPT_MAX_IDLE_MS } from "../../constants"; interface ListedChatSession { sessionId: string; @@ -21,14 +26,23 @@ interface ListedChatSession { * Live chats in the workspace that no pane shows, oldest first. A pane * claims a chat by its session id or by its terminal id, so a chat the * desktop is still creating (or a pending cloud launch) is never adopted twice. - * A closed pane keeps its terminal claimed, so a session leaked on it is never adopted. + * A chat with no terminal uses its session id, as the host's `panes.newTab` does. */ -export function chatSessionsToAdopt( - sessions: readonly ListedChatSession[], - tabs: readonly Tab[], -): ChatSessionTarget[] { - const claimedSessionIds = new Set(); - const claimedTerminalIds = new Set(); +export function chatSessionsToAdopt({ + sessions, + tabs, + workspaceId, + now, +}: { + sessions: readonly ListedChatSession[]; + tabs: readonly Tab[]; + workspaceId: string; + now: number; +}): ChatSessionTarget[] { + const claimedSessionIds = new Set(readPendingChatStops(workspaceId)); + const claimedTerminalIds = new Set( + readPendingChatStopTerminalIds(workspaceId), + ); for (const tab of tabs) { for (const pane of Object.values(tab.panes)) { const data = pane.data as Partial; @@ -40,17 +54,19 @@ export function chatSessionsToAdopt( } const targets: Array = []; for (const session of sessions) { - if (!session.live || !session.terminalId) continue; + const terminalId = session.terminalId ?? session.sessionId; + if (!session.live) continue; + if (now - session.updatedAt > CHAT_ADOPT_MAX_IDLE_MS) continue; if (claimedSessionIds.has(session.sessionId)) continue; - if (claimedTerminalIds.has(session.terminalId)) continue; + if (claimedTerminalIds.has(terminalId)) continue; if (isChatSessionClosed(session.sessionId)) continue; - if (isChatTerminalClosed(session.terminalId)) continue; + if (isChatTerminalIgnored(terminalId)) continue; const presetId = presetForAcpHarness(session.harness); if (!presetId) continue; targets.push({ sessionId: session.sessionId, presetId, - terminalId: session.terminalId, + terminalId, updatedAt: session.updatedAt, ...(session.harnessSessionId ? { harnessSessionId: session.harnessSessionId } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx index d5e08621307..b895066292a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx @@ -19,6 +19,7 @@ import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; import { isUnrestrictedMode } from "renderer/routes/_authenticated/utils/isUnrestrictedMode"; import { useChatTurnSnapshotsEnabled } from "renderer/stores/chat-turn-snapshots"; import type { OpenFile } from "../../../../../../types"; +import { ignoreChatTerminal } from "../../../../../../utils/closedChatSessions"; import { saveLastChatAgent } from "../../../../../../utils/lastChatAgent"; import { SessionView } from "../../../ChatSession/components/SessionView"; import { useSessionClient } from "../../../ChatSession/hooks/useSessionClient"; @@ -272,6 +273,7 @@ export function AcpChatPane({ const forkHere = useCallback(() => { if (!sessionId) return; const terminalId = crypto.randomUUID(); + ignoreChatTerminal(terminalId); void wiring.transport .forkSession({ commandId: crypto.randomUUID(), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useSurfaceFork/useSurfaceFork.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useSurfaceFork/useSurfaceFork.ts index 587dfebb0ac..8458be9a993 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useSurfaceFork/useSurfaceFork.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useSurfaceFork/useSurfaceFork.ts @@ -5,6 +5,7 @@ import type { CreatePaneInput, RendererContext } from "@superset/panes"; import { toast } from "@superset/ui/sonner"; import { useCallback } from "react"; import type { ChatPaneData, PaneViewerData } from "../../../../types"; +import { ignoreChatTerminal } from "../../../../utils/closedChatSessions"; import type { CreateNewAgentSession } from "../../../useAgentSessionLauncher"; import { useChatWiring } from "../../components/ChatSession/hooks/useSessionClient"; @@ -60,6 +61,7 @@ export function useSurfaceFork( // loaded into a hidden one. It needs its own terminal id: the CLI's would // rebind that terminal's agent. const loaderTerminalId = crypto.randomUUID(); + ignoreChatTerminal(loaderTerminalId); const loader = await transport.createSession({ commandId: crypto.randomUUID(), workspaceId, @@ -131,6 +133,7 @@ export function useSurfaceFork( } const terminalId = crypto.randomUUID(); + ignoreChatTerminal(terminalId); const toastId = toast.loading(t({ message: "Forking into a chat…" })); try { const forked = await forkIntoChat( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx index 10fa486c930..4bda7471515 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx @@ -6,9 +6,9 @@ import { useUndoStack } from "renderer/stores/undo-stack"; import type { StoreApi } from "zustand/vanilla"; import type { ChatPaneData, PaneViewerData } from "../../../../types"; import { + ignoreChatTerminal, isChatSessionClosed, markChatSessionClosed, - markChatTerminalClosed, } from "../../../../utils/closedChatSessions"; import { addPendingChatStop, @@ -29,6 +29,7 @@ interface ClosedChat { sessionId: string; title: string; reopen: () => void; + showAgain: () => void; } export function useUndoableChatClose({ @@ -100,12 +101,13 @@ export function useUndoableChatClose({ ); const [batches] = useState(() => { - const stopSession = (sessionId: string) => { + const stopSession = (sessionId: string, showAgain?: () => void) => { if (!isChatSessionClosed(sessionId)) return; void latest.current .stopChat(sessionId) .then((stopped) => { if (!stopped) { + showAgain?.(); toast.error( latest.current.t({ message: "Couldn't stop the chat" }), ); @@ -153,7 +155,7 @@ export function useUndoableChatClose({ }, stop: (chat) => { const { terminalId } = chat.pane.data as ChatPaneData; - stopSession(chat.sessionId); + stopSession(chat.sessionId, chat.showAgain); useChatSubagentStore.getState().forget(terminalId); }, }); @@ -172,14 +174,14 @@ export function useUndoableChatClose({ (pane) => { const data = pane.data as ChatPaneData; const { sessionId, terminalId } = data; - const reopenTerminal = markChatTerminalClosed(terminalId); + const reopenTerminal = ignoreChatTerminal(terminalId); void stopStrayChats(terminalId, sessionId); if (!sessionId || isChatSessionClosed(sessionId)) return; const index = previousTabs.current.findIndex( (tab) => tab.panes[pane.id] !== undefined, ); const tab = previousTabs.current[index]; - addPendingChatStop(workspaceId, sessionId); + addPendingChatStop(workspaceId, sessionId, terminalId); const reopenSession = markChatSessionClosed(sessionId); batches.closed.add({ pane, @@ -191,6 +193,7 @@ export function useUndoableChatClose({ reopenSession(); reopenTerminal(); }, + showAgain: reopenTerminal, }); }, [batches, workspaceId, t, stopStrayChats], diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index f077d0b4302..015b4983973 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -74,6 +74,7 @@ import { type SubagentPaneData, type TerminalPaneData, } from "../../types"; +import { ignoreChatTerminal } from "../../utils/closedChatSessions"; import { findTerminalPaneLocation, focusOrAddTerminalPane, @@ -900,6 +901,10 @@ export function usePaneRegistry({ }); }, getTitle: () => t({ message: "Chat" }), + onAfterClose: (pane) => { + const { terminalId } = pane.data as PendingCloudChatPaneData; + if (terminalId) ignoreChatTerminal(terminalId); + }, insetsAside: true, renderPane: (ctx: RendererContext) => ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts index da5bb3612bc..637dbf80cfe 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts @@ -1,7 +1,7 @@ import { readPendingChatStopIds } from "../pendingChatStops"; const closed = new Set(readPendingChatStopIds()); -const closedTerminals = new Set(); +const ignoredTerminals = new Set(); export function markChatSessionClosed(sessionId: string): () => void { closed.add(sessionId); @@ -16,11 +16,12 @@ export function isChatSessionClosed(sessionId: string): boolean { return closed.has(sessionId); } -export function markChatTerminalClosed(terminalId: string): () => void { - closedTerminals.add(terminalId); - return () => closedTerminals.delete(terminalId); +/** Sessions on this terminal never get a tab of their own: its tab was closed, or no tab is meant to show it. */ +export function ignoreChatTerminal(terminalId: string): () => void { + ignoredTerminals.add(terminalId); + return () => ignoredTerminals.delete(terminalId); } -export function isChatTerminalClosed(terminalId: string): boolean { - return closedTerminals.has(terminalId); +export function isChatTerminalIgnored(terminalId: string): boolean { + return ignoredTerminals.has(terminalId); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts index 159ef494cd1..de0bf897ed7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts @@ -1,7 +1,7 @@ export { + ignoreChatTerminal, isChatSessionClosed, - isChatTerminalClosed, + isChatTerminalIgnored, markChatSessionClosed, - markChatTerminalClosed, reopenChatSession, } from "./closedChatSessions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.test.ts index f5e276a69e2..c170f51ae0e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.test.ts @@ -6,7 +6,7 @@ import { markChatSessionClosed, } from "../closedChatSessions"; import { addPendingChatStop, readPendingChatStops } from "../pendingChatStops"; -import { focusChatSession } from "./focusChatSession"; +import { addChatTab, focusChatSession } from "./focusChatSession"; const session = { sessionId: "chat-1", @@ -70,15 +70,23 @@ describe("focusChatSession", () => { ).toBe("added"); }); - it("finds an existing chat without moving focus to it", () => { + it("adds a chat tab even when a pane shows the same agent session", () => { const store = newStore(); - expect(focusChatSession({ store, session, focus: false })).toBe("added"); + focusChatSession({ store, session }); store.getState().addTab({ panes: [{ kind: "terminal", data: { terminalId: "active" } }], }); - const activeTabId = store.getState().activeTabId; - expect(focusChatSession({ store, session, focus: false })).toBe("focused"); - expect(store.getState().tabs).toHaveLength(2); - expect(store.getState().activeTabId).toBe(activeTabId); + addChatTab({ + store, + session: { ...session, sessionId: "chat-resumed", terminalId: "t-2" }, + focus: false, + }); + const tabs = store.getState().tabs; + expect(tabs).toHaveLength(3); + expect(Object.values(tabs[2]?.panes ?? {})[0]?.data).toMatchObject({ + sessionId: "chat-resumed", + terminalId: "t-2", + agent: { id: "claude", sessionId: "native-1" }, + }); }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.ts index ee99bcf7e73..c3cd37ca9e5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/focusChatSession.ts @@ -16,12 +16,10 @@ export function focusChatSession({ store, linkedStores = [], session, - focus = true, }: { store: StoreApi>; linkedStores?: readonly StoreApi>[]; session: ChatSessionTarget; - focus?: boolean; }): "focused" | "added" { if (session.sessionId) { reopenChatSession(session.sessionId); @@ -41,11 +39,24 @@ export function focusChatSession({ ); }); if (pane) { - if (focus) paneStore.getState().focusPane(pane.id); + paneStore.getState().focusPane(pane.id); return "focused"; } } } + addChatTab({ store, session }); + return "added"; +} + +export function addChatTab({ + store, + session, + focus = true, +}: { + store: StoreApi>; + session: ChatSessionTarget; + focus?: boolean; +}): void { store.getState().addTab({ focus, panes: [ @@ -65,5 +76,4 @@ export function focusChatSession({ }, ], }); - return "added"; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/index.ts index c4ad34f6d05..31997320d2d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/focusChatSession/index.ts @@ -1 +1,5 @@ -export { type ChatSessionTarget, focusChatSession } from "./focusChatSession"; +export { + addChatTab, + type ChatSessionTarget, + focusChatSession, +} from "./focusChatSession"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops/index.ts index 660c278964b..f764268f1fe 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops/index.ts @@ -2,5 +2,6 @@ export { addPendingChatStop, readPendingChatStopIds, readPendingChatStops, + readPendingChatStopTerminalIds, removePendingChatStop, } from "./pendingChatStops"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops/pendingChatStops.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops/pendingChatStops.ts index 3d2fb72d562..9d81061cab9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops/pendingChatStops.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops/pendingChatStops.ts @@ -4,6 +4,7 @@ const MAX_AGE_MS = 24 * 60 * 60 * 1000; interface PendingChatStop { workspaceId: string; sessionId: string; + terminalId?: string; closedAt: number; } @@ -40,13 +41,27 @@ export function readPendingChatStops(workspaceId: string): string[] { .map((entry) => entry.sessionId); } +export function readPendingChatStopTerminalIds(workspaceId: string): string[] { + return read().flatMap((entry) => + entry.workspaceId === workspaceId && entry.terminalId + ? [entry.terminalId] + : [], + ); +} + export function addPendingChatStop( workspaceId: string, sessionId: string, + terminalId?: string, ): void { write([ ...read().filter((entry) => entry.sessionId !== sessionId), - { workspaceId, sessionId, closedAt: Date.now() }, + { + workspaceId, + sessionId, + ...(terminalId ? { terminalId } : {}), + closedAt: Date.now(), + }, ]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts index fd047567beb..15866d86d12 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalPaneLayoutRequests/hooks/usePaneLayoutRequests/usePaneLayoutRequests.ts @@ -7,7 +7,15 @@ import type { import { useEffect } from "react"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import { electronTrpcClient } from "renderer/lib/trpc-client"; -import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import type { + ChatPaneData, + PaneViewerData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { + ignoreChatTerminal, + markChatSessionClosed, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions"; +import { addPendingChatStop } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/pendingChatStops"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; import { useRightPaneFullScreen } from "renderer/stores/right-pane-full-screen"; @@ -59,6 +67,26 @@ function backgroundClosedTerminal( } } +/** Keeps a chat closed with `superset panes close` from being adopted back into a new tab. */ +function forgetClosedChat( + areas: PaneAreas, + workspaceId: string, + op: PaneLayoutOp, +): void { + if (op.type !== "close") return; + for (const tab of [...areas.main.tabs, ...areas.sidebar.tabs]) { + const pane = tab.panes[op.paneId]; + if (pane?.kind !== "chat-v3") continue; + const { sessionId, terminalId } = pane.data as ChatPaneData; + ignoreChatTerminal(terminalId); + if (sessionId && !op.keepTerminal) { + markChatSessionClosed(sessionId); + addPendingChatStop(workspaceId, sessionId, terminalId); + } + return; + } +} + function readArea( row: { paneLayout?: unknown; rightPaneLayout?: unknown } | undefined, workspaceId: string, @@ -115,6 +143,7 @@ function runPaneLayoutOp( }; const outcome = applyPaneLayoutOp(previous, op); backgroundClosedTerminal(previous, workspaceId, op); + forgetClosedChat(previous, workspaceId, op); if (op.type !== "list") { // Sidebar first: a non-terminal pane landing in the main area is moved to diff --git a/packages/cli/src/lib/sessions.ts b/packages/cli/src/lib/sessions.ts index d18e3dc8923..0d698d4ee88 100644 --- a/packages/cli/src/lib/sessions.ts +++ b/packages/cli/src/lib/sessions.ts @@ -59,7 +59,7 @@ function sessionsClient(client: HostServiceClient): SessionsClient { export function desktopTabNote(opened: boolean): string { return opened ? ", opened in a desktop tab" - : ". No desktop tab was opened (no desktop app is attached to this host); the Paradev desktop app adds one when it shows this workspace"; + : ". No desktop tab was opened yet; the Paradev desktop app adds one when it shows this workspace"; } export async function resolveSessionWorkspace( From 866151d33fccb97ed1af8d976c0eab8404507f1e Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:26:36 +0000 Subject: [PATCH 4/7] fix(chat): drop a chat's tabs everywhere when a person closes it, and stop chats closed with `panes close` A chat closed on one device stayed as a tab on the others, and opening that tab restarted it. `superset panes close` on a chat hid the tab but left the chat running. - closeSession takes an optional `userClosed`; chat.db records it as `closed_by_user` (migration 0002, generated). Internal closes (restart, account switch, strays, fork loaders) leave it unset and never clear it. - Desktop tab close, `superset sessions close`, `superset panes close` and the mobile close actions pass it. - The desktop removes tabs whose chat a person closed elsewhere and never auto-restarts such a chat, unless it was opened again in this window (history, a link, Undo). The hook is now useSyncChatSessionTabs. - Host `panes.close` stops the live chats on the closed pane's terminal unless `keepTerminal` is set, the same as it already does for terminals. Additive for released clients: the input field is optional and a missing `closedByUser` behaves as before. --- .../hooks/useAutoAdoptChatSessions/index.ts | 1 - .../chatSessionTarget.test.ts | 1 + .../components/AcpChatPane/AcpChatPane.tsx | 14 +- .../utils/historyEntries.test.ts | 1 + .../useUndoableChatClose.tsx | 2 +- .../constants.ts | 0 .../hooks/useSyncChatSessionTabs/index.ts | 1 + .../useSyncChatSessionTabs.ts} | 16 +- .../chatPanesClosedElsewhere.test.ts | 76 ++++++++ .../chatPanesClosedElsewhere.ts | 31 ++++ .../utils/chatPanesClosedElsewhere/index.ts | 1 + .../chatSessionsToAdopt.test.ts | 0 .../chatSessionsToAdopt.ts | 0 .../utils/chatSessionsToAdopt/index.ts | 0 .../v2-workspace/$workspaceId/page.tsx | 4 +- .../closedChatSessions/closedChatSessions.ts | 8 + .../utils/closedChatSessions/index.ts | 1 + .../[id]/WorkspaceScreen/WorkspaceScreen.tsx | 5 +- .../workspace/[id]/sessions/SessionsSheet.tsx | 1 + .../src/commands/commands/commands.test.ts | 19 ++ .../src/commands/commands/commands.ts | 4 +- .../drizzle/0002_session_closed_by_user.sql | 1 + .../src/db/drizzle/meta/0002_snapshot.json | 170 ++++++++++++++++++ .../src/db/drizzle/meta/_journal.json | 7 + packages/chat-runtime/src/db/schema/schema.ts | 3 + .../src/projection/projection.test.ts | 17 +- .../chat-runtime/src/projection/projection.ts | 11 +- .../agentSessionClient/agentSessionClient.ts | 1 + packages/chat/src/protocol/commands.ts | 2 + .../src/trpc/router/panes/panes.ts | 25 ++- .../src/trpc/router/sessions/sessions.ts | 5 +- 31 files changed, 409 insertions(+), 19 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/{useAutoAdoptChatSessions => useSyncChatSessionTabs}/constants.ts (100%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/{useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts => useSyncChatSessionTabs/useSyncChatSessionTabs.ts} (85%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/chatPanesClosedElsewhere.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/chatPanesClosedElsewhere.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/{useAutoAdoptChatSessions => useSyncChatSessionTabs}/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/{useAutoAdoptChatSessions => useSyncChatSessionTabs}/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/{useAutoAdoptChatSessions => useSyncChatSessionTabs}/utils/chatSessionsToAdopt/index.ts (100%) create mode 100644 packages/chat-runtime/src/db/drizzle/0002_session_closed_by_user.sql create mode 100644 packages/chat-runtime/src/db/drizzle/meta/0002_snapshot.json diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/index.ts deleted file mode 100644 index a909456f496..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAutoAdoptChatSessions } from "./useAutoAdoptChatSessions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/chatSessionTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/chatSessionTarget.test.ts index fc1ee5954c7..8f8157af194 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/chatSessionTarget.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/chatSessionTarget.test.ts @@ -14,6 +14,7 @@ const session: NonNullable = { queuedCount: 0, updatedAt: 1, closedAt: null, + closedByUser: false, }; const result: SessionResult = { session, cursor: null, live: true }; const liveSession: Awaited>[number] = diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx index b895066292a..c4e1f966717 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx @@ -19,7 +19,10 @@ import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; import { isUnrestrictedMode } from "renderer/routes/_authenticated/utils/isUnrestrictedMode"; import { useChatTurnSnapshotsEnabled } from "renderer/stores/chat-turn-snapshots"; import type { OpenFile } from "../../../../../../types"; -import { ignoreChatTerminal } from "../../../../../../utils/closedChatSessions"; +import { + ignoreChatTerminal, + isChatSessionReopened, +} from "../../../../../../utils/closedChatSessions"; import { saveLastChatAgent } from "../../../../../../utils/lastChatAgent"; import { SessionView } from "../../../ChatSession/components/SessionView"; import { useSessionClient } from "../../../ChatSession/hooks/useSessionClient"; @@ -465,8 +468,15 @@ export function AcpChatPane({ !binding.chatSessionId && binding.endedAt === undefined, ); + const closedElsewhere = + stored?.session?.closedByUser === true && + !!sessionId && + !isChatSessionReopened(sessionId); const recoverable = Boolean( - (sessionStopped || sessionDead) && harness && !continuedInTerminal, + (sessionStopped || sessionDead) && + harness && + !continuedInTerminal && + !closedElsewhere, ); const recoveries = useRef(0); if (stored?.live && !sessionDead) recoveries.current = 0; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatHistoryPicker/utils/historyEntries.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatHistoryPicker/utils/historyEntries.test.ts index dee5ed2a00f..ac7657d2cd7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatHistoryPicker/utils/historyEntries.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatHistoryPicker/utils/historyEntries.test.ts @@ -15,6 +15,7 @@ function saved(overrides: Partial = {}): SavedSession { queuedCount: 0, updatedAt: 1000, closedAt: null, + closedByUser: false, live: false, terminalId: null, ...overrides, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx index 4bda7471515..b3789c3f3df 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useUndoableChatClose/useUndoableChatClose.tsx @@ -47,7 +47,7 @@ export function useUndoableChatClose({ async (sessionId: string) => { const reopen = markChatSessionClosed(sessionId); try { - await wiring.transport.closeSession({ sessionId }); + await wiring.transport.closeSession({ sessionId, userClosed: true }); return true; } catch (error) { console.warn("[acp-chat] could not stop the chat session", error); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/constants.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/constants.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/constants.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/index.ts new file mode 100644 index 00000000000..32c73486203 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/index.ts @@ -0,0 +1 @@ +export { useSyncChatSessionTabs } from "./useSyncChatSessionTabs"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts similarity index 85% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts index a8410311a67..e3543bf8caf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts @@ -9,21 +9,22 @@ import type { PaneViewerData } from "../../types"; import { addChatTab } from "../../utils/focusChatSession"; import { useChatWiring } from "../usePaneRegistry/components/ChatSession/hooks/useSessionClient"; import { CHAT_ADOPT_POLL_MS, CHAT_ADOPT_SESSIONS_LIMIT } from "./constants"; +import { chatPanesClosedElsewhere } from "./utils/chatPanesClosedElsewhere"; import { chatSessionsToAdopt } from "./utils/chatSessionsToAdopt"; -interface UseAutoAdoptChatSessionsArgs { +interface UseSyncChatSessionTabsArgs { store: StoreApi>; linkedStores: readonly StoreApi>[]; workspaceId: string; isLayoutReady: boolean; } -export function useAutoAdoptChatSessions({ +export function useSyncChatSessionTabs({ store, linkedStores, workspaceId, isLayoutReady, -}: UseAutoAdoptChatSessionsArgs): void { +}: UseSyncChatSessionTabsArgs): void { const { hostUrl } = useWorkspaceClient(); const { transport } = useChatWiring(); const queryKey = useMemo( @@ -57,6 +58,15 @@ export function useAutoAdoptChatSessions({ useEffect(() => { if (!isLayoutReady || !sessions || isFetchingSessions) return; + for (const paneStore of [store, ...linkedStores]) { + const panes = chatPanesClosedElsewhere( + sessions, + paneStore.getState().tabs, + ); + for (const { tabId, paneId } of panes) { + paneStore.getState().closePane({ tabId, paneId, intent: "remove" }); + } + } const { version, tabs, activeTabId } = store.getState(); const toAdopt = chatSessionsToAdopt({ sessions, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/chatPanesClosedElsewhere.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/chatPanesClosedElsewhere.test.ts new file mode 100644 index 00000000000..5c6d7c06f5e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/chatPanesClosedElsewhere.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { createWorkspaceStore } from "@superset/panes"; +import type { PaneViewerData } from "../../../../types"; +import { reopenChatSession } from "../../../../utils/closedChatSessions"; +import { chatPanesClosedElsewhere } from "./chatPanesClosedElsewhere"; + +function storeWithChats(...sessionIds: string[]) { + const store = createWorkspaceStore(); + for (const sessionId of sessionIds) { + store.getState().addTab({ + panes: [ + { + kind: "chat-v3", + data: { + terminalId: `terminal-${sessionId}`, + sessionId, + agent: { id: "claude" }, + }, + }, + ], + }); + } + return store; +} + +const listed = (sessionId: string, overrides = {}) => ({ + sessionId, + live: false, + closedByUser: true, + ...overrides, +}); + +describe("chatPanesClosedElsewhere", () => { + it("finds chat panes whose chat a person closed and that stopped", () => { + const store = storeWithChats("closed-on-device-a", "kept-on-device-a"); + const [closedTab] = store.getState().tabs; + const found = chatPanesClosedElsewhere( + [ + listed("closed-on-device-a"), + listed("kept-on-device-a", { closedByUser: false }), + ], + store.getState().tabs, + ); + expect(found).toEqual([ + { + tabId: closedTab?.id ?? "", + paneId: Object.keys(closedTab?.panes ?? {})[0] ?? "", + }, + ]); + }); + + it("keeps chats that restarted internally, are still live, or come from an older host", () => { + const store = storeWithChats("restarting", "live", "old-host"); + expect( + chatPanesClosedElsewhere( + [ + listed("restarting", { closedByUser: false }), + listed("live", { live: true }), + { sessionId: "old-host", live: false }, + ], + store.getState().tabs, + ), + ).toEqual([]); + }); + + it("keeps a chat opened again in this window after it was closed", () => { + const store = storeWithChats("reopened-from-history"); + reopenChatSession("reopened-from-history"); + expect( + chatPanesClosedElsewhere( + [listed("reopened-from-history")], + store.getState().tabs, + ), + ).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/chatPanesClosedElsewhere.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/chatPanesClosedElsewhere.ts new file mode 100644 index 00000000000..f6ca8f4a0db --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/chatPanesClosedElsewhere.ts @@ -0,0 +1,31 @@ +import type { Tab } from "@superset/panes"; +import type { ChatPaneData, PaneViewerData } from "../../../../types"; +import { isChatSessionReopened } from "../../../../utils/closedChatSessions"; + +interface ListedChatSession { + sessionId: string; + live: boolean; + closedByUser?: boolean; +} + +/** Chat panes whose chat a person closed somewhere else: on another device, in the CLI or on mobile. */ +export function chatPanesClosedElsewhere( + sessions: readonly ListedChatSession[], + tabs: readonly Tab[], +): Array<{ tabId: string; paneId: string }> { + const closedElsewhere = new Set( + sessions + .filter((session) => session.closedByUser === true && !session.live) + .map((session) => session.sessionId), + ); + if (closedElsewhere.size === 0) return []; + return tabs.flatMap((tab) => + Object.values(tab.panes).flatMap((pane) => { + if (pane.kind !== "chat-v3") return []; + const { sessionId } = pane.data as ChatPaneData; + if (!sessionId || !closedElsewhere.has(sessionId)) return []; + if (isChatSessionReopened(sessionId)) return []; + return [{ tabId: tab.id, paneId: pane.id }]; + }), + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/index.ts new file mode 100644 index 00000000000..6959e0a3eb8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatPanesClosedElsewhere/index.ts @@ -0,0 +1 @@ +export { chatPanesClosedElsewhere } from "./chatPanesClosedElsewhere"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/chatSessionsToAdopt/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index c0a334e666b..b476e033f3f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -61,7 +61,6 @@ import { import { WorkspaceToolbar } from "./components/WorkspaceToolbar"; import { useAgentSessionLauncher } from "./hooks/useAgentSessionLauncher"; import { useAutoAdoptBackgroundSessions } from "./hooks/useAutoAdoptBackgroundSessions"; -import { useAutoAdoptChatSessions } from "./hooks/useAutoAdoptChatSessions"; import { useCloseRightPaneAreaWhenEmpty } from "./hooks/useCloseRightPaneAreaWhenEmpty"; import { useConsumeAutomationRunLink } from "./hooks/useConsumeAutomationRunLink"; import { useConsumeOpenUrlRequest } from "./hooks/useConsumeOpenUrlRequest"; @@ -82,6 +81,7 @@ import { useRightPaneAreaLifecycle } from "./hooks/useRightPaneAreaLifecycle"; import { useRunPendingChatHandoff } from "./hooks/useRunPendingChatHandoff"; import { useRunWorkspaceCreationPresets } from "./hooks/useRunWorkspaceCreationPresets"; import { useShellInteractionPassthrough } from "./hooks/useShellInteractionPassthrough"; +import { useSyncChatSessionTabs } from "./hooks/useSyncChatSessionTabs"; import { useTabCloseGuard } from "./hooks/useTabCloseGuard"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2TerminalLauncher } from "./hooks/useV2TerminalLauncher"; @@ -367,7 +367,7 @@ function V2WorkspaceContent() { workspaceId, isLayoutReady: isLayoutReady && isRightLayoutReady, }); - useAutoAdoptChatSessions({ + useSyncChatSessionTabs({ store, linkedStores: linkedPaneStores, workspaceId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts index 637dbf80cfe..56104d99fa1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts @@ -1,21 +1,29 @@ import { readPendingChatStopIds } from "../pendingChatStops"; const closed = new Set(readPendingChatStopIds()); +const reopened = new Set(); const ignoredTerminals = new Set(); export function markChatSessionClosed(sessionId: string): () => void { closed.add(sessionId); + reopened.delete(sessionId); return () => reopenChatSession(sessionId); } export function reopenChatSession(sessionId: string): void { closed.delete(sessionId); + reopened.add(sessionId); } export function isChatSessionClosed(sessionId: string): boolean { return closed.has(sessionId); } +/** Opened on purpose in this window (history, a link, Undo), so a close elsewhere does not take it away. */ +export function isChatSessionReopened(sessionId: string): boolean { + return reopened.has(sessionId); +} + /** Sessions on this terminal never get a tab of their own: its tab was closed, or no tab is meant to show it. */ export function ignoreChatTerminal(terminalId: string): () => void { ignoredTerminals.add(terminalId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts index de0bf897ed7..3f19082e20a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/index.ts @@ -1,6 +1,7 @@ export { ignoreChatTerminal, isChatSessionClosed, + isChatSessionReopened, isChatTerminalIgnored, markChatSessionClosed, reopenChatSession, diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx index 33e5622f4c1..654518739e8 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx @@ -586,7 +586,10 @@ export function WorkspaceScreen() { const row = rows.find((candidate) => candidate.terminalId === terminalId); const closing = row?.kind === "chat" - ? getChatTransport(hostUrl).closeSession({ sessionId: terminalId }) + ? getChatTransport(hostUrl).closeSession({ + sessionId: terminalId, + userClosed: true, + }) : getHostServiceClientByUrl(hostUrl).terminal.killSession.mutate({ terminalId, workspaceId: id, diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/sessions/SessionsSheet.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/sessions/SessionsSheet.tsx index 77639eaf3bf..bbda5e609c2 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/sessions/SessionsSheet.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/sessions/SessionsSheet.tsx @@ -91,6 +91,7 @@ export function SessionsSheet() { row.kind === "chat" ? getChatTransport(hostUrl).closeSession({ sessionId: row.terminalId, + userClosed: true, }) : getHostServiceClientByUrl( hostUrl, diff --git a/packages/chat-runtime/src/commands/commands/commands.test.ts b/packages/chat-runtime/src/commands/commands/commands.test.ts index 4215b49b09f..5ab33d7058b 100644 --- a/packages/chat-runtime/src/commands/commands/commands.test.ts +++ b/packages/chat-runtime/src/commands/commands/commands.test.ts @@ -67,6 +67,25 @@ describe("chat commands", () => { await runtime.dispose(); }); + test("closeSession records whether a person closed the chat", async () => { + const { runtime } = newRuntime(); + const byUser = createSession(runtime); + const internal = createSession(runtime); + await runtime.commands.closeSession({ + sessionId: byUser.sessionId, + userClosed: true, + }); + await runtime.commands.closeSession({ sessionId: internal.sessionId }); + const listed = runtime.commands.listSessions({ scopeId: "workspace-1" }); + expect( + listed.find((row) => row.sessionId === byUser.sessionId), + ).toMatchObject({ live: false, closedByUser: true }); + expect( + listed.find((row) => row.sessionId === internal.sessionId), + ).toMatchObject({ live: false, closedByUser: false }); + await runtime.dispose(); + }); + test("closing a session drops its cached subagent tree", async () => { const forget = spyOn(SubagentItems.prototype, "forget"); try { diff --git a/packages/chat-runtime/src/commands/commands/commands.ts b/packages/chat-runtime/src/commands/commands/commands.ts index 2509f6c2d99..be15d6d49cb 100644 --- a/packages/chat-runtime/src/commands/commands/commands.ts +++ b/packages/chat-runtime/src/commands/commands/commands.ts @@ -725,7 +725,9 @@ export function createCommands(options: CommandsOptions): ChatCommands { async closeSession(input) { const parsed: CloseSessionInput = closeSessionInputSchema.parse(input); const wasLive = options.live.get(parsed.sessionId) !== null; - options.sessions.markClosed(parsed.sessionId); + options.sessions.markClosed(parsed.sessionId, { + byUser: parsed.userClosed === true, + }); replays.delete(parsed.sessionId); subagentItems.forget(parsed.sessionId); try { diff --git a/packages/chat-runtime/src/db/drizzle/0002_session_closed_by_user.sql b/packages/chat-runtime/src/db/drizzle/0002_session_closed_by_user.sql new file mode 100644 index 00000000000..66cc81f9b82 --- /dev/null +++ b/packages/chat-runtime/src/db/drizzle/0002_session_closed_by_user.sql @@ -0,0 +1 @@ +ALTER TABLE `chat_sessions_local` ADD `closed_by_user` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/chat-runtime/src/db/drizzle/meta/0002_snapshot.json b/packages/chat-runtime/src/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000000..a63d83f5877 --- /dev/null +++ b/packages/chat-runtime/src/db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,170 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cb6b8987-5445-4211-9144-3ebf1cae9501", + "prevId": "b8a18ee1-85df-4b1f-aa8b-8cbdba827af3", + "tables": { + "chat_journal": { + "name": "chat_journal", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_json": { + "name": "event_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_journal_session_id_epoch_seq_pk": { + "columns": [ + "session_id", + "epoch", + "seq" + ], + "name": "chat_journal_session_id_epoch_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sessions_local": { + "name": "chat_sessions_local", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_count": { + "name": "queued_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "closed_by_user": { + "name": "closed_by_user", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "chat_sessions_local_scope_id_idx": { + "name": "chat_sessions_local_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/chat-runtime/src/db/drizzle/meta/_journal.json b/packages/chat-runtime/src/db/drizzle/meta/_journal.json index fc889db8e55..874a4a03849 100644 --- a/packages/chat-runtime/src/db/drizzle/meta/_journal.json +++ b/packages/chat-runtime/src/db/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1793301081019, "tag": "0001_session_closed_at", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1793907873926, + "tag": "0002_session_closed_by_user", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/chat-runtime/src/db/schema/schema.ts b/packages/chat-runtime/src/db/schema/schema.ts index 0491f477125..3c0ca231623 100644 --- a/packages/chat-runtime/src/db/schema/schema.ts +++ b/packages/chat-runtime/src/db/schema/schema.ts @@ -36,6 +36,9 @@ export const chatSessionsLocal = sqliteTable( queuedCount: integer("queued_count").notNull().default(0), updatedAt: integer("updated_at").notNull(), closedAt: integer("closed_at").default(0), + closedByUser: integer("closed_by_user", { mode: "boolean" }) + .notNull() + .default(false), }, (table) => [index("chat_sessions_local_scope_id_idx").on(table.scopeId)], ); diff --git a/packages/chat-runtime/src/projection/projection.test.ts b/packages/chat-runtime/src/projection/projection.test.ts index b6bbd9a006e..6aa27bb7d70 100644 --- a/packages/chat-runtime/src/projection/projection.test.ts +++ b/packages/chat-runtime/src/projection/projection.test.ts @@ -73,13 +73,26 @@ describe("ChatSessionStore", () => { test("a new session is open, and reopening a closed one opens it again", () => { expect(runtime.sessions.get("s1")?.closedAt).toBeNull(); - runtime.sessions.markClosed("s1", 5); + runtime.sessions.markClosed("s1", { closedAt: 5, byUser: true }); runtime.journal.open({ sessionId: "s1", scopeId: "w1", harness: "claude-code", }); - expect(runtime.sessions.get("s1")?.closedAt).toBeNull(); + expect(runtime.sessions.get("s1")).toMatchObject({ + closedAt: null, + closedByUser: false, + }); + }); + + test("a close by a person stays recorded when the chat is closed again internally", () => { + expect(runtime.sessions.get("s1")?.closedByUser).toBe(false); + runtime.sessions.markClosed("s1", { closedAt: 5, byUser: true }); + runtime.sessions.markClosed("s1", { closedAt: 6 }); + expect(runtime.sessions.get("s1")).toMatchObject({ + closedAt: 6, + closedByUser: true, + }); }); test("returns null for an unknown session", () => { diff --git a/packages/chat-runtime/src/projection/projection.ts b/packages/chat-runtime/src/projection/projection.ts index 94060c52acb..c7f7e3bd1a1 100644 --- a/packages/chat-runtime/src/projection/projection.ts +++ b/packages/chat-runtime/src/projection/projection.ts @@ -59,6 +59,7 @@ export function resetSessionForEpoch( queuedCount: 0, updatedAt, closedAt: null, + closedByUser: false, }) .where(eq(chatSessionsLocal.sessionId, sessionId)) .run(); @@ -85,9 +86,10 @@ export function markSessionClosed( db: ChatDb, sessionId: string, closedAt: number, + byUser = false, ): void { db.update(chatSessionsLocal) - .set({ closedAt }) + .set({ closedAt, ...(byUser ? { closedByUser: true } : {}) }) .where(eq(chatSessionsLocal.sessionId, sessionId)) .run(); } @@ -110,8 +112,11 @@ export class ChatSessionStore { return readSessionRow(this.db, sessionId); } - markClosed(sessionId: string, closedAt = Date.now()): void { - markSessionClosed(this.db, sessionId, closedAt); + markClosed( + sessionId: string, + { byUser = false, closedAt = Date.now() } = {}, + ): void { + markSessionClosed(this.db, sessionId, closedAt, byUser); } list(): ChatSessionRow[] { diff --git a/packages/chat/src/client/agentSessionClient/agentSessionClient.ts b/packages/chat/src/client/agentSessionClient/agentSessionClient.ts index d7797018c8f..e2702bf9304 100644 --- a/packages/chat/src/client/agentSessionClient/agentSessionClient.ts +++ b/packages/chat/src/client/agentSessionClient/agentSessionClient.ts @@ -149,6 +149,7 @@ export function createAgentSessionClient( queuedCount: 0, updatedAt: head.updatedAt ?? now(), closedAt: null, + closedByUser: false, }, cursor: head.cursor, live: true, diff --git a/packages/chat/src/protocol/commands.ts b/packages/chat/src/protocol/commands.ts index 20503e42bbc..5a2cc65b6f7 100644 --- a/packages/chat/src/protocol/commands.ts +++ b/packages/chat/src/protocol/commands.ts @@ -173,6 +173,8 @@ export type RewindSessionInput = z.infer; */ export const closeSessionInputSchema = z.object({ sessionId: z.string().min(1), + /** A person closed the chat, not a restart or cleanup: other clients drop its tabs instead of restarting it. */ + userClosed: z.boolean().optional(), }); export type CloseSessionInput = z.infer; diff --git a/packages/host-service/src/trpc/router/panes/panes.ts b/packages/host-service/src/trpc/router/panes/panes.ts index ebb0f47ec38..2280b2953ab 100644 --- a/packages/host-service/src/trpc/router/panes/panes.ts +++ b/packages/host-service/src/trpc/router/panes/panes.ts @@ -81,6 +81,26 @@ function chatTabTarget( }; } +async function closeChatsOnTerminal( + ctx: HostServiceContext, + workspaceId: string, + terminalId: string, +) { + const chat = ctx.runtime.chat?.(); + if (!chat) return; + const onTerminal = chat.commands + .listSessions({ scopeId: workspaceId, limit: 200 }) + .filter((session) => session.live && session.terminalId === terminalId); + await Promise.all( + onTerminal.map((session) => + chat.commands.closeSession({ + sessionId: session.sessionId, + userClosed: true, + }), + ), + ); +} + function requireWorkspaceTerminal( ctx: HostServiceContext, workspaceId: string, @@ -195,13 +215,14 @@ export const panesRouter = router({ paneId: input.paneId, keepTerminal: input.keepTerminal, }); - // A live terminal left without a pane is re-adopted into a new tab - // by the desktop, so closing ends it unless it was backgrounded. + // A live terminal or chat left without a pane is re-adopted into a new + // tab by the desktop, so closing ends it unless it was backgrounded. if (terminalId && !input.keepTerminal) { await createTerminalCaller(ctx).killSession({ workspaceId: input.workspaceId, terminalId, }); + await closeChatsOnTerminal(ctx, input.workspaceId, terminalId); } return { ...result, terminalId: terminalId ?? null }; }), diff --git a/packages/host-service/src/trpc/router/sessions/sessions.ts b/packages/host-service/src/trpc/router/sessions/sessions.ts index 2f8288a62ea..a60e4e7a77d 100644 --- a/packages/host-service/src/trpc/router/sessions/sessions.ts +++ b/packages/host-service/src/trpc/router/sessions/sessions.ts @@ -112,7 +112,10 @@ export const sessionsRouter = router({ input.workspaceId, input.sessionId, ); - return chat.commands.closeSession({ sessionId: input.sessionId }); + return chat.commands.closeSession({ + sessionId: input.sessionId, + userClosed: true, + }); }), resume: protectedProcedure .input(sessionInput.extend({ commandId: z.string().uuid().optional() })) From 4fe563e8b37214d129c8a41ede870e2422abf9ab Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:32:29 +0000 Subject: [PATCH 5/7] fix(desktop): open on the chat, and keep long-idle chats adoptable - A workspace opened with no tabs of the user's selects the newest adopted chat on the first check, instead of a setup terminal opened a moment earlier. Chats that arrive later still open in the background. - Drop the 24 h idle limit: a long-running agent's chat gets a tab however long it waited. To avoid a tab per idle automation chat, only the 10 most recently active live chats in a workspace are adopted. --- .../hooks/useSyncChatSessionTabs/constants.ts | 2 +- .../useSyncChatSessionTabs.ts | 20 ++++++++++++++--- .../chatSessionsToAdopt.test.ts | 22 +++++++++++++------ .../chatSessionsToAdopt.ts | 22 ++++++++++++------- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/constants.ts index 785e112a266..4d417de81a4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/constants.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/constants.ts @@ -1,3 +1,3 @@ export const CHAT_ADOPT_POLL_MS = 15_000; export const CHAT_ADOPT_SESSIONS_LIMIT = 200; -export const CHAT_ADOPT_MAX_IDLE_MS = 24 * 60 * 60 * 1000; +export const CHAT_ADOPT_MAX_LIVE_CHATS = 10; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts index e3543bf8caf..e8c32b4e259 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts @@ -1,7 +1,7 @@ import { createWorkspaceStore, type WorkspaceStore } from "@superset/panes"; import { useWorkspaceClient } from "@superset/workspace-client"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useWorkspaceConnectionRefresh } from "renderer/hooks/host-service/useWorkspaceConnectionRefresh"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import type { StoreApi } from "zustand/vanilla"; @@ -55,6 +55,13 @@ export function useSyncChatSessionTabs({ ); const sessions = sessionsQuery.data; const isFetchingSessions = sessionsQuery.isFetching; + const openedEmpty = useRef(null); + useEffect(() => { + if (!isLayoutReady || openedEmpty.current !== null) return; + openedEmpty.current = [store, ...linkedStores].every( + (paneStore) => paneStore.getState().tabs.length === 0, + ); + }, [isLayoutReady, store, linkedStores]); useEffect(() => { if (!isLayoutReady || !sessions || isFetchingSessions) return; @@ -75,8 +82,15 @@ export function useSyncChatSessionTabs({ ...linkedStores.flatMap((linked) => linked.getState().tabs), ], workspaceId, - now: Date.now(), }); + // A workspace opened with no tabs of the user's lands on the newest chat, + // not on a setup terminal that was opened for it a moment earlier. + const selectChat = + openedEmpty.current === true && + tabs.every((tab) => + Object.values(tab.panes).every((pane) => pane.kind === "terminal"), + ); + openedEmpty.current = false; if (toAdopt.length === 0) return; // One write with the user's tab still selected: `addTab` selects each new // tab, and a selection change alone is not saved. @@ -86,7 +100,7 @@ export function useSyncChatSessionTabs({ for (const session of toAdopt) { addChatTab({ store: next, session, focus: false }); } - if (activeTabId) next.getState().setActiveTab(activeTabId); + if (activeTabId && !selectChat) next.getState().setActiveTab(activeTabId); store.getState().replaceState({ version, tabs: next.getState().tabs, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts index a0436161df0..1f27e14f1c2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts @@ -12,10 +12,10 @@ import { addPendingChatStop, removePendingChatStop, } from "../../../../utils/pendingChatStops"; -import { CHAT_ADOPT_MAX_IDLE_MS } from "../../constants"; +import { CHAT_ADOPT_MAX_LIVE_CHATS } from "../../constants"; import { chatSessionsToAdopt } from "./chatSessionsToAdopt"; -const NOW = 10 * CHAT_ADOPT_MAX_IDLE_MS; +const NOW = 1_000_000; const WORKSPACE_ID = "workspace-1"; const listed = (sessionId: string, overrides = {}) => ({ @@ -37,7 +37,6 @@ const adoptedIds = ( sessions, tabs, workspaceId: WORKSPACE_ID, - now: NOW, }).map((target) => target.sessionId); const cleanups: Array<() => void> = []; @@ -55,7 +54,6 @@ describe("chatSessionsToAdopt", () => { ], tabs: [], workspaceId: WORKSPACE_ID, - now: NOW, }), ).toEqual([ { @@ -80,12 +78,11 @@ describe("chatSessionsToAdopt", () => { sessions: [listed("mobile", { terminalId: null })], tabs: [], workspaceId: WORKSPACE_ID, - now: NOW, }); expect(target?.terminalId).toBe("mobile"); }); - it("skips dead, idle, unsupported, closed, and already shown chats", () => { + it("skips dead, unsupported, closed, and already shown chats", () => { const store = createWorkspaceStore(); store.getState().addTab({ panes: [ @@ -128,7 +125,6 @@ describe("chatSessionsToAdopt", () => { adoptedIds( [ listed("dead", { live: false }), - listed("idle", { updatedAt: NOW - CHAT_ADOPT_MAX_IDLE_MS - 1 }), listed("unknown", { harness: "unknown" }), listed("closed"), listed("shown"), @@ -140,6 +136,18 @@ describe("chatSessionsToAdopt", () => { ).toEqual([]); }); + it("adopts a chat however long it was idle, but only among the most recent live chats", () => { + const sessions = Array.from( + { length: CHAT_ADOPT_MAX_LIVE_CHATS + 2 }, + (_, index) => listed(`chat-${index}`, { updatedAt: index }), + ); + const adopted = adoptedIds(sessions); + expect(adopted).toHaveLength(CHAT_ADOPT_MAX_LIVE_CHATS); + expect(adopted).not.toContain("chat-0"); + expect(adopted).not.toContain("chat-1"); + expect(adopted.at(-1)).toBe(`chat-${CHAT_ADOPT_MAX_LIVE_CHATS + 1}`); + }); + it("skips chats on an ignored terminal, such as a closed tab's or a fork loader's", () => { cleanups.push(ignoreChatTerminal("terminal-stray")); expect(adoptedIds([listed("stray"), listed("other")])).toEqual(["other"]); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts index 1c1a2061761..695de017458 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts @@ -10,7 +10,7 @@ import { readPendingChatStops, readPendingChatStopTerminalIds, } from "../../../../utils/pendingChatStops"; -import { CHAT_ADOPT_MAX_IDLE_MS } from "../../constants"; +import { CHAT_ADOPT_MAX_LIVE_CHATS } from "../../constants"; interface ListedChatSession { sessionId: string; @@ -27,17 +27,17 @@ interface ListedChatSession { * claims a chat by its session id or by its terminal id, so a chat the * desktop is still creating (or a pending cloud launch) is never adopted twice. * A chat with no terminal uses its session id, as the host's `panes.newTab` does. + * Only the most recently active live chats are candidates, so a workspace with + * many idle chats does not open a tab for each of them. */ export function chatSessionsToAdopt({ sessions, tabs, workspaceId, - now, }: { sessions: readonly ListedChatSession[]; tabs: readonly Tab[]; workspaceId: string; - now: number; }): ChatSessionTarget[] { const claimedSessionIds = new Set(readPendingChatStops(workspaceId)); const claimedTerminalIds = new Set( @@ -52,15 +52,21 @@ export function chatSessionsToAdopt({ claimedSessionIds.add(data.sessionId); } } + const recent = sessions + .filter( + (session) => + session.live && + presetForAcpHarness(session.harness) && + !isChatSessionClosed(session.sessionId) && + !isChatTerminalIgnored(session.terminalId ?? session.sessionId), + ) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, CHAT_ADOPT_MAX_LIVE_CHATS); const targets: Array = []; - for (const session of sessions) { + for (const session of recent) { const terminalId = session.terminalId ?? session.sessionId; - if (!session.live) continue; - if (now - session.updatedAt > CHAT_ADOPT_MAX_IDLE_MS) continue; if (claimedSessionIds.has(session.sessionId)) continue; if (claimedTerminalIds.has(terminalId)) continue; - if (isChatSessionClosed(session.sessionId)) continue; - if (isChatTerminalIgnored(terminalId)) continue; const presetId = presetForAcpHarness(session.harness); if (!presetId) continue; targets.push({ From 7137c082d7de7291d31240fc49e496efdaa522e7 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:45:28 +0000 Subject: [PATCH 6/7] fix(cli): API keys reach remote hosts, clear attachment limits, cloud --attachment The smaller items from SUPER-3158: - With an API key, commands that reach a host through the relay failed with "Session expired": the relay takes a user JWT, not a key. The CLI now trades the key for a JWT at /api/auth/token once per run, as `pages storage` already did. `--host cloud` on a host command (projects list, agents list) now says each cloud workspace runs its own host; `snapshot --host cloud` takes `--workspace` and reads that workspace's host. - An 80 MB `--attachment` failed with "JSON Parse error": the inline upload is capped at 25 MB and the gate answered the larger body with HTML. Files are now checked before upload. A cloud workspace takes larger files (up to 200 MB) through Paradev's storage and attachments.importFromCloud; a host on someone's machine refuses them with the limit, by design. Files up to 25 MB use the inline path as before. - `workspaces create --attachment` works for a cloud workspace: the files are uploaded to storage and handed to the agent as attachmentFileIds, as the desktop composer does. --- apps/docs/content/docs/cli/cli-reference.mdx | 13 +- .../cli/src/commands/agents/create/command.ts | 6 +- .../cli/src/commands/pages/storage/command.ts | 13 +- .../src/commands/sessions/create/command.ts | 6 +- packages/cli/src/commands/snapshot/command.ts | 4 +- .../src/commands/workspaces/create/command.ts | 8 +- .../create/createCloudWorkspace.test.ts | 46 +++++ .../workspaces/create/createCloudWorkspace.ts | 17 +- packages/cli/src/lib/api-key-jwt.test.ts | 49 +++++ packages/cli/src/lib/api-key-jwt.ts | 35 ++++ .../lib/host-target/resolveHostTarget.test.ts | 39 ++++ .../src/lib/host-target/resolveHostTarget.ts | 16 +- packages/cli/src/lib/resolve-host-client.ts | 23 ++- .../cli/src/lib/upload-attachments.test.ts | 165 +++++++++++++++++ packages/cli/src/lib/upload-attachments.ts | 171 +++++++++++++++--- 15 files changed, 558 insertions(+), 53 deletions(-) create mode 100644 packages/cli/src/lib/api-key-jwt.test.ts create mode 100644 packages/cli/src/lib/api-key-jwt.ts create mode 100644 packages/cli/src/lib/host-target/resolveHostTarget.test.ts create mode 100644 packages/cli/src/lib/upload-attachments.test.ts diff --git a/apps/docs/content/docs/cli/cli-reference.mdx b/apps/docs/content/docs/cli/cli-reference.mdx index 90abe7858f4..788ddf5ee9b 100644 --- a/apps/docs/content/docs/cli/cli-reference.mdx +++ b/apps/docs/content/docs/cli/cli-reference.mdx @@ -18,7 +18,7 @@ Run `superset --help` for the top-level command list, or ## Choosing a host -Commands with `--host ` and `--local` options target a host. `--local` is this machine. For workspace, agent, and terminal commands, `--host ` takes a host machineId or `cloud`. With neither option, they target the cloud if your account has cloud workspaces, otherwise this machine. If the cloud check fails, they target this machine. Remote hosts go through the relay. `snapshot`, `panes`, `browser`, and `notifications` default to this machine. +Commands with `--host ` and `--local` options target a host. `--local` is this machine. For workspace, agent, and terminal commands, `--host ` takes a host machineId or `cloud`. With neither option, they target the cloud if your account has cloud workspaces, otherwise this machine. If the cloud check fails, they target this machine. Remote hosts go through the relay. `snapshot`, `panes`, `browser`, and `notifications` default to this machine. Each cloud workspace runs its own host, so commands about one host, such as `projects list` and `agents list`, take a machineId rather than `cloud`; `snapshot --host cloud` needs `--workspace`. ## Global options @@ -808,7 +808,7 @@ project id when the host does not know the project's name. { flag: "--model ", description: "Model for the spawned agent. Supported values depend on the agent; omit to use its default." }, { flag: "--effort ", description: "Reasoning effort for the spawned agent. Omit to use the agent default." }, { flag: "--command ", description: "Shell command to run in the new workspace after creation." }, - { flag: "--attachment ", description: "Local file to upload for the spawned agent. Repeatable." }, + { flag: "--attachment ", description: "Local file to upload for the spawned agent. Repeatable. Up to 25 MB a file on a host, 200 MB in the cloud." }, { flag: "--tag ", description: "Workspace tag. Repeatable. Each tag groups the workspace into a sidebar folder of the same name." }, ]} output={`{ workspace: Workspace; alreadyExists: boolean; url: string; webUrl: string }`} @@ -836,8 +836,9 @@ Without `--host` or `--local`, the command creates a [cloud workspace](/cloud-wo if your account has cloud workspaces. A cloud workspace takes no project: it starts from an environment, and Paradev creates a new working branch from `--branch`. `--project`, `--session`, `--checkout`, `--pr`, `--task`, `--base-branch`, `--skip-branch-prefix`, -`--tag`, `--attachment`, and `--command` are rejected for a cloud workspace. The command -returns while the workspace is still provisioning. +`--tag`, and `--command` are rejected for a cloud workspace. `--attachment` needs `--agent`: +the files go to the agent through Paradev's storage, up to 200 MB each and 10 at a time. +The command returns while the workspace is still provisioning. ```bash superset workspaces create \ @@ -1332,7 +1333,7 @@ superset agents list --host host_… { flag: "--model ", description: "Model for this launch. Supported values depend on the agent; omit to use its default." }, { flag: "--effort ", description: "Reasoning effort for this launch. Omit to use the agent default." }, { flag: "--attachment-id ", description: "Pre-uploaded attachment UUID; pass repeatedly for multiple attachments." }, - { flag: "--attachment ", description: "Local file path to upload as an attachment. Repeatable." }, + { flag: "--attachment ", description: "Local file path to upload as an attachment. Repeatable. Up to 25 MB a file for a workspace on a host, 200 MB for a cloud workspace." }, ]} output={`{ kind: "terminal" | "chat"; @@ -1644,7 +1645,7 @@ Close (dispose) a terminal running in a workspace. This ends the session. ", description: "Host to read (default: this machine)." }, + { flag: "--host ", description: "Host to read (default: this machine), or `cloud` with `--workspace` for that cloud workspace's own host." }, { flag: "--workspace ", description: "Only this workspace." }, ]} > diff --git a/packages/cli/src/commands/agents/create/command.ts b/packages/cli/src/commands/agents/create/command.ts index 74a80179331..3313055118f 100644 --- a/packages/cli/src/commands/agents/create/command.ts +++ b/packages/cli/src/commands/agents/create/command.ts @@ -109,7 +109,11 @@ export default command({ : (options.prompt ?? ""); const uploadedIds = options.attachment - ? await uploadAttachments(target.client, options.attachment) + ? await uploadAttachments({ + target, + api: ctx.api, + paths: options.attachment, + }) : []; const attachmentIds = [...(options.attachmentId ?? []), ...uploadedIds]; diff --git a/packages/cli/src/commands/pages/storage/command.ts b/packages/cli/src/commands/pages/storage/command.ts index 91374493b4e..a495be7a854 100644 --- a/packages/cli/src/commands/pages/storage/command.ts +++ b/packages/cli/src/commands/pages/storage/command.ts @@ -13,8 +13,8 @@ import { parsePageStorageScope, } from "@superset/shared/page-storage"; import { pageStorageRecordsPath } from "@superset/shared/page-storage-hub"; +import { jwtForApiKey } from "../../../lib/api-key-jwt"; import { command } from "../../../lib/command"; -import { getApiUrl } from "../../../lib/config"; import { env } from "../../../lib/env"; import { resolvePageId } from "../pageId"; @@ -29,16 +29,7 @@ export async function userJwt( "Run: superset auth login, or pass --api-key", ); } - const response = await fetchImpl(`${getApiUrl()}/api/auth/token`, { - headers: { "x-api-key": bearer }, - }); - if (!response.ok) { - throw new CLIError( - `Could not get a token for this API key (${response.status})`, - "Check the key with: superset auth whoami", - ); - } - return ((await response.json()) as { token: string }).token; + return jwtForApiKey(bearer, fetchImpl); } export async function readStorage({ diff --git a/packages/cli/src/commands/sessions/create/command.ts b/packages/cli/src/commands/sessions/create/command.ts index 0e5d6003f51..bccbb63e30f 100644 --- a/packages/cli/src/commands/sessions/create/command.ts +++ b/packages/cli/src/commands/sessions/create/command.ts @@ -39,7 +39,11 @@ export default command({ ); const uploadedIds = options.attachment - ? await uploadAttachments(target.client, options.attachment) + ? await uploadAttachments({ + target, + api: ctx.api, + paths: options.attachment, + }) : []; const attachmentIds = [...(options.attachmentId ?? []), ...uploadedIds]; diff --git a/packages/cli/src/commands/snapshot/command.ts b/packages/cli/src/commands/snapshot/command.ts index 1e724cc12dd..a8d191e6acb 100644 --- a/packages/cli/src/commands/snapshot/command.ts +++ b/packages/cli/src/commands/snapshot/command.ts @@ -12,7 +12,9 @@ export default command({ description: "One view of a host: every workspace, its terminals, the agent in each and its state, and the pane layout", options: { - host: string().desc("Host to read (default: this machine)"), + host: string().desc( + "Host to read (default: this machine), or `cloud` with --workspace", + ), workspace: string().desc("Only this workspace"), }, run: async ({ ctx, options }) => { diff --git a/packages/cli/src/commands/workspaces/create/command.ts b/packages/cli/src/commands/workspaces/create/command.ts index cdfd0657072..7d8b4af4117 100644 --- a/packages/cli/src/commands/workspaces/create/command.ts +++ b/packages/cli/src/commands/workspaces/create/command.ts @@ -62,7 +62,7 @@ export default command({ attachment: string() .variadic() .desc( - "Local file path to upload as an attachment to the host. Repeatable. Only used when --agent is set", + "Local file path to give the agent as an attachment. Repeatable. Only used when --agent is set", ), tag: string() .variadic() @@ -215,7 +215,11 @@ export default command({ } const attachmentIds = options.attachment - ? await uploadAttachments(target.client, options.attachment) + ? await uploadAttachments({ + target, + api: ctx.api, + paths: options.attachment, + }) : []; const agents = diff --git a/packages/cli/src/commands/workspaces/create/createCloudWorkspace.test.ts b/packages/cli/src/commands/workspaces/create/createCloudWorkspace.test.ts index a0655464f8e..79ef8c79a8b 100644 --- a/packages/cli/src/commands/workspaces/create/createCloudWorkspace.test.ts +++ b/packages/cli/src/commands/workspaces/create/createCloudWorkspace.test.ts @@ -1,4 +1,7 @@ import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { ApiClient } from "../../../lib/api-client"; import { workspaceLinks } from "../../../lib/open-url"; import { createCloudWorkspace } from "./createCloudWorkspace"; @@ -75,3 +78,46 @@ test("returns the cloud workspace's desktop and web links", async () => { }); expect(result.data).toMatchObject({ id: "ws-1", ...workspaceLinks("ws-1") }); }); + +test("uploads --attachment files and hands their ids to the agent", async () => { + const { api, calls } = recordingApi(); + const dir = mkdtempSync(join(tmpdir(), "cloud-create-")); + const path = join(dir, "brief.md"); + writeFileSync(path, "# brief"); + const withUploads = { + ...api, + attachment: { + createUpload: { + mutate: async () => ({ + fileId: "file-1", + upload: { url: "https://storage.test/put", headers: {} }, + }), + }, + }, + } as unknown as ApiClient; + const fetchImpl = (async () => + new Response(null, { status: 200 })) as unknown as typeof fetch; + await createCloudWorkspace({ + api: withUploads, + organizationId: "org", + options: { agent: "claude", prompt: "Read the brief", attachment: [path] }, + fetchImpl, + }); + rmSync(dir, { recursive: true, force: true }); + expect(calls.createInput).toMatchObject({ + agent: "claude", + attachmentFileIds: ["file-1"], + }); +}); + +test("rejects --attachment without --agent before contacting cloud", async () => { + const { api, calls } = recordingApi(); + await expect( + createCloudWorkspace({ + api, + organizationId: "org", + options: { attachment: ["brief.md"] }, + }), + ).rejects.toThrow("--attachment requires --agent"); + expect(calls.createInput).toBeUndefined(); +}); diff --git a/packages/cli/src/commands/workspaces/create/createCloudWorkspace.ts b/packages/cli/src/commands/workspaces/create/createCloudWorkspace.ts index ab188496208..b70befc6d6b 100644 --- a/packages/cli/src/commands/workspaces/create/createCloudWorkspace.ts +++ b/packages/cli/src/commands/workspaces/create/createCloudWorkspace.ts @@ -4,6 +4,7 @@ import { TRPCClientError } from "@trpc/client"; import type { ApiClient } from "../../../lib/api-client"; import { resolveCloudEnvironment } from "../../../lib/cloud-workspaces"; import { workspaceLinks } from "../../../lib/open-url"; +import { uploadToCloudStorage } from "../../../lib/upload-attachments"; /** What `ws create` parsed; the host-only flags are here only to be refused. */ export interface CloudCreateOptions { @@ -35,8 +36,9 @@ export async function createCloudWorkspace(args: { api: ApiClient; organizationId: string; options: CloudCreateOptions; + fetchImpl?: typeof fetch; }): Promise<{ data: unknown; message: string }> { - const { api, organizationId, options } = args; + const { api, organizationId, options, fetchImpl } = args; // A cloud workspace's checkout is its environment's: no project, worktree // or host. Those flags mean the caller wanted a host, so say how. @@ -48,10 +50,6 @@ export async function createCloudWorkspace(args: { ["--task", options.task], ["--skip-branch-prefix", options.skipBranchPrefix || undefined], ["--tag", options.tag?.length ? options.tag : undefined], - [ - "--attachment", - options.attachment?.length ? options.attachment : undefined, - ], ["--command", options.command], ] as const) { if (value !== undefined) { @@ -74,6 +72,10 @@ export async function createCloudWorkspace(args: { ["--prompt", options.prompt], ["--model", options.model], ["--effort", options.effort], + [ + "--attachment", + options.attachment?.length ? options.attachment : undefined, + ], ] as const) { if (value !== undefined && !options.agent) { throw new CLIError( @@ -97,6 +99,10 @@ export async function createCloudWorkspace(args: { options.environment, ); + const attachmentFileIds = options.attachment?.length + ? await uploadToCloudStorage({ api, paths: options.attachment, fetchImpl }) + : []; + const row = await api.cloudWorkspace.create .mutate({ organizationId, @@ -108,6 +114,7 @@ export async function createCloudWorkspace(args: { ...(options.model ? { model: options.model } : {}), ...(options.effort ? { effort: options.effort } : {}), ...(options.machineSize ? { machineSize: options.machineSize } : {}), + ...(attachmentFileIds.length > 0 ? { attachmentFileIds } : {}), }) .catch(rethrowWithLocationHint); diff --git a/packages/cli/src/lib/api-key-jwt.test.ts b/packages/cli/src/lib/api-key-jwt.test.ts new file mode 100644 index 00000000000..8c644ffd8af --- /dev/null +++ b/packages/cli/src/lib/api-key-jwt.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { jwtForApiKey } from "./api-key-jwt"; + +function tokenEndpoint(status = 200) { + const calls: Array<{ url: string; apiKey: string | null }> = []; + const fetchImpl = (async (url: string, init?: RequestInit) => { + calls.push({ + url, + apiKey: new Headers(init?.headers).get("x-api-key"), + }); + return new Response(JSON.stringify({ token: "minted.jwt.token" }), { + status, + }); + }) as unknown as typeof fetch; + return { calls, fetchImpl }; +} + +describe("jwtForApiKey", () => { + it("passes a JWT or any other credential through untouched", async () => { + const { calls, fetchImpl } = tokenEndpoint(); + expect(await jwtForApiKey("a.b.c", fetchImpl)).toBe("a.b.c"); + expect(await jwtForApiKey("", fetchImpl)).toBe(""); + expect(calls).toHaveLength(0); + }); + + it("trades an API key for a JWT once per run", async () => { + const { calls, fetchImpl } = tokenEndpoint(); + expect(await jwtForApiKey("sk_live_once", fetchImpl)).toBe( + "minted.jwt.token", + ); + expect(await jwtForApiKey("sk_live_once", fetchImpl)).toBe( + "minted.jwt.token", + ); + expect(calls).toHaveLength(1); + expect(calls[0]?.url.endsWith("/api/auth/token")).toBe(true); + expect(calls[0]?.apiKey).toBe("sk_live_once"); + }); + + it("says the key was refused, and tries again on the next call", async () => { + const refused = tokenEndpoint(401); + await expect( + jwtForApiKey("sk_live_refused", refused.fetchImpl), + ).rejects.toThrow("Could not get a token for this API key (401)"); + const { fetchImpl } = tokenEndpoint(); + expect(await jwtForApiKey("sk_live_refused", fetchImpl)).toBe( + "minted.jwt.token", + ); + }); +}); diff --git a/packages/cli/src/lib/api-key-jwt.ts b/packages/cli/src/lib/api-key-jwt.ts new file mode 100644 index 00000000000..55a7adac06c --- /dev/null +++ b/packages/cli/src/lib/api-key-jwt.ts @@ -0,0 +1,35 @@ +import { CLIError } from "@superset/cli-framework"; +import { getApiUrl } from "./config"; + +const minted = new Map>(); + +/** + * The relay and the Workers verify a user JWT; an `sk_live_` API key is traded + * for one at the API, once per run. Any other credential is returned as is. + */ +export function jwtForApiKey( + bearer: string, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!bearer.startsWith("sk_live_")) return Promise.resolve(bearer); + let jwt = minted.get(bearer); + if (!jwt) { + jwt = mint(bearer, fetchImpl); + minted.set(bearer, jwt); + jwt.catch(() => minted.delete(bearer)); + } + return jwt; +} + +async function mint(apiKey: string, fetchImpl: typeof fetch): Promise { + const response = await fetchImpl(`${getApiUrl()}/api/auth/token`, { + headers: { "x-api-key": apiKey }, + }); + if (!response.ok) { + throw new CLIError( + `Could not get a token for this API key (${response.status})`, + "Check the key with: superset auth whoami", + ); + } + return ((await response.json()) as { token: string }).token; +} diff --git a/packages/cli/src/lib/host-target/resolveHostTarget.test.ts b/packages/cli/src/lib/host-target/resolveHostTarget.test.ts new file mode 100644 index 00000000000..5ac177757b3 --- /dev/null +++ b/packages/cli/src/lib/host-target/resolveHostTarget.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import type { ApiClient } from "../api-client"; +import { resolveHostTarget } from "./resolveHostTarget"; + +const api = { + host: { + relayEndpoint: { query: async () => ({ url: "https://relay.test" }) }, + }, +} as unknown as ApiClient; + +const mintJwt = (async () => + new Response( + JSON.stringify({ token: "minted.jwt.token" }), + )) as unknown as typeof fetch; + +describe("resolveHostTarget", () => { + it("reaches a remote host with a JWT traded for the API key", async () => { + const target = await resolveHostTarget({ + requestedHostId: "other-machine", + organizationId: "org-1", + userJwt: "sk_live_remote", + api, + fetchImpl: mintJwt, + }); + expect(target.kind).toBe("remote"); + expect(target.ws.token).toBe("minted.jwt.token"); + }); + + it("explains that the cloud is not one host", async () => { + await expect( + resolveHostTarget({ + requestedHostId: "cloud", + organizationId: "org-1", + userJwt: "a.b.c", + api, + }), + ).rejects.toThrow("`--host cloud` is not one host"); + }); +}); diff --git a/packages/cli/src/lib/host-target/resolveHostTarget.ts b/packages/cli/src/lib/host-target/resolveHostTarget.ts index 6d1d397ec4c..3fb1806add4 100644 --- a/packages/cli/src/lib/host-target/resolveHostTarget.ts +++ b/packages/cli/src/lib/host-target/resolveHostTarget.ts @@ -3,11 +3,13 @@ import type { AppRouter as HostServiceRouter } from "@superset/host-service/trpc import { getHostId } from "@superset/shared/host-info"; import { buildHostRoutingKey, + CLOUD_HOST_ID, SUPERSET_USER_ID_HEADER, } from "@superset/shared/host-routing"; import { createTRPCClient, httpBatchLink } from "@trpc/client"; import SuperJSON from "superjson"; import type { ApiClient } from "../api-client"; +import { jwtForApiKey } from "../api-key-jwt"; import { isProcessAlive, readManifest } from "../host/manifest"; import { getRelayUrl } from "../host/relay-url"; import { readJwtSubject } from "./readJwtSubject"; @@ -43,6 +45,8 @@ export interface ResolveHostTargetOptions { userJwt: string; /** Resolves the relay a remote host is on; unused for local targets. */ api: ApiClient; + /** Trades an API key for the JWT the relay takes. */ + fetchImpl?: typeof fetch; } export async function resolveHostTarget( @@ -91,8 +95,16 @@ export async function resolveHostTarget( }; } + if (targetHostId.toLowerCase() === CLOUD_HOST_ID) { + throw new CLIError( + "`--host cloud` is not one host: each cloud workspace runs its own", + "Name the workspace: `superset snapshot --host cloud --workspace `, or a workspace command such as `superset sessions list --workspace `", + ); + } + const routingKey = buildHostRoutingKey(options.organizationId, targetHostId); const relayUrl = await getRelayUrl(options.api); + const userJwt = await jwtForApiKey(options.userJwt, options.fetchImpl); return { kind: "remote", hostId: targetHostId, @@ -102,7 +114,7 @@ export async function resolveHostTarget( url: `${relayUrl}/hosts/${routingKey}/trpc`, transformer: SuperJSON, headers: { - Authorization: `Bearer ${options.userJwt}`, + Authorization: `Bearer ${userJwt}`, "x-superset-client-machine-id": localHostId, }, }), @@ -110,7 +122,7 @@ export async function resolveHostTarget( }), ws: { baseWsUrl: `${relayUrl.replace(/^http/, "ws")}/hosts/${routingKey}`, - token: options.userJwt, + token: userJwt, }, }; } diff --git a/packages/cli/src/lib/resolve-host-client.ts b/packages/cli/src/lib/resolve-host-client.ts index 5585188a9b3..ad13ca8b340 100644 --- a/packages/cli/src/lib/resolve-host-client.ts +++ b/packages/cli/src/lib/resolve-host-client.ts @@ -1,17 +1,34 @@ import { CLIError } from "@superset/cli-framework"; import { getHostId } from "@superset/shared/host-info"; +import { CLOUD_HOST_ID } from "@superset/shared/host-routing"; import type { CliContext } from "./command"; -import { type HostServiceClient, resolveHostTarget } from "./host-target"; +import { + type HostServiceClient, + resolveCloudWorkspaceTarget, + resolveHostTarget, +} from "./host-target"; -/** A client for `--host`, or for this machine's host when it is not given. */ +/** + * A client for `--host`, or for this machine's host when it is not given. + * `--host cloud` takes the workspace's own host, since each cloud workspace + * runs one. + */ export async function resolveHostClient( ctx: CliContext, - options: { host?: string | null }, + options: { host?: string | null; workspace?: string | null }, ): Promise { const organizationId = ctx.config.organizationId; if (!organizationId) { throw new CLIError("No active organization", "Run: superset auth login"); } + if (options.host?.toLowerCase() === CLOUD_HOST_ID && options.workspace) { + const { target } = await resolveCloudWorkspaceTarget({ + api: ctx.api, + organizationId, + workspaceId: options.workspace, + }); + return target.client; + } const target = await resolveHostTarget({ requestedHostId: options.host ?? getHostId(), organizationId, diff --git a/packages/cli/src/lib/upload-attachments.test.ts b/packages/cli/src/lib/upload-attachments.test.ts new file mode 100644 index 00000000000..08bbfd8cc6f --- /dev/null +++ b/packages/cli/src/lib/upload-attachments.test.ts @@ -0,0 +1,165 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { + closeSync, + ftruncateSync, + mkdtempSync, + openSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENTS, + MAX_INLINE_ATTACHMENT_BYTES, +} from "@superset/shared/attachment-limits"; +import type { ApiClient } from "./api-client"; +import type { HostServiceClient } from "./host-target"; +import { uploadAttachments, uploadToCloudStorage } from "./upload-attachments"; + +const dir = mkdtempSync(join(tmpdir(), "upload-attachments-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +function fileOfSize(name: string, sizeBytes: number): string { + const path = join(dir, name); + const fd = openSync(path, "w"); + ftruncateSync(fd, sizeBytes); + closeSync(fd); + return path; +} + +const small = join(dir, "notes.md"); +writeFileSync(small, "# notes"); +const large = fileOfSize("dump.zip", MAX_INLINE_ATTACHMENT_BYTES + 1); + +function recorder() { + const calls: string[] = []; + const client = { + attachments: { + upload: { + mutate: async (input: { originalFilename: string }) => { + calls.push(`inline:${input.originalFilename}`); + return { attachmentId: `inline-${input.originalFilename}` }; + }, + }, + importFromCloud: { + mutate: async ({ fileIds }: { fileIds: string[] }) => { + calls.push(`import:${fileIds.join(",")}`); + return fileIds.map((fileId) => ({ + attachmentId: `imported-${fileId}`, + })); + }, + }, + }, + } as unknown as HostServiceClient; + const api = { + attachment: { + createUpload: { + mutate: async (input: { name: string; sizeBytes: number }) => { + calls.push(`createUpload:${input.name}:${input.sizeBytes}`); + return { + fileId: `file-${input.name}`, + upload: { url: "https://storage.test/put", headers: {} }, + }; + }, + }, + }, + } as unknown as ApiClient; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + calls.push(`put:${init?.method}`); + return new Response(null, { status: 200 }); + }) as unknown as typeof fetch; + return { calls, client, api, fetchImpl }; +} + +describe("uploadAttachments", () => { + it("refuses a file over the inline limit for a host before sending anything", async () => { + const { calls, client, api, fetchImpl } = recorder(); + await expect( + uploadAttachments({ + target: { kind: "remote", client }, + api, + paths: [small, large], + fetchImpl, + }), + ).rejects.toThrow("a workspace on a host takes at most 25 MB a file"); + expect(calls).toEqual([]); + }); + + it("sends small files inline and larger ones through storage to a cloud workspace, in order", async () => { + const { calls, client, api, fetchImpl } = recorder(); + const ids = await uploadAttachments({ + target: { kind: "cloud", client }, + api, + paths: [large, small], + fetchImpl, + }); + expect(ids).toEqual(["imported-file-dump.zip", "inline-notes.md"]); + expect(calls).toEqual([ + `createUpload:dump.zip:${MAX_INLINE_ATTACHMENT_BYTES + 1}`, + "put:PUT", + "import:file-dump.zip", + "inline:notes.md", + ]); + }); + + it("says when a cloud workspace's host is too old for large files", async () => { + const { client, api, fetchImpl } = recorder(); + const oldHost = { + attachments: { + ...client.attachments, + importFromCloud: { + mutate: async () => { + throw new Error( + 'No procedure found on path "attachments.importFromCloud"', + ); + }, + }, + }, + } as unknown as HostServiceClient; + await expect( + uploadAttachments({ + target: { kind: "cloud", client: oldHost }, + api, + paths: [large], + fetchImpl, + }), + ).rejects.toThrow("This workspace's host is too old for files over 25 MB"); + }); + + it("refuses a file over the storage limit for a cloud workspace", async () => { + const { calls, client, api, fetchImpl } = recorder(); + const huge = fileOfSize("huge.zip", MAX_ATTACHMENT_BYTES + 1); + await expect( + uploadAttachments({ + target: { kind: "cloud", client }, + api, + paths: [huge], + fetchImpl, + }), + ).rejects.toThrow("the limit is 200 MB a file"); + expect(calls).toEqual([]); + }); +}); + +describe("uploadToCloudStorage", () => { + it("uploads each file and returns its file id", async () => { + const { calls, api, fetchImpl } = recorder(); + expect( + await uploadToCloudStorage({ api, paths: [small], fetchImpl }), + ).toEqual(["file-notes.md"]); + expect(calls).toEqual(["createUpload:notes.md:7", "put:PUT"]); + }); + + it("refuses more files than one send carries", async () => { + const { api, fetchImpl } = recorder(); + await expect( + uploadToCloudStorage({ + api, + paths: Array.from({ length: MAX_ATTACHMENTS + 1 }, () => small), + fetchImpl, + }), + ).rejects.toThrow(`At most ${MAX_ATTACHMENTS} attachments`); + }); +}); diff --git a/packages/cli/src/lib/upload-attachments.ts b/packages/cli/src/lib/upload-attachments.ts index 82778a2142f..261cbc17a76 100644 --- a/packages/cli/src/lib/upload-attachments.ts +++ b/packages/cli/src/lib/upload-attachments.ts @@ -1,31 +1,160 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; import { basename } from "node:path"; import { CLIError } from "@superset/cli-framework"; +import { + MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENTS, + MAX_INLINE_ATTACHMENT_BYTES, +} from "@superset/shared/attachment-limits"; import mimeTypes from "mime-types"; -import type { HostServiceClient } from "./host-target"; +import type { ApiClient } from "./api-client"; +import type { ResolvedHostTarget } from "./host-target"; -export async function uploadAttachments( - client: HostServiceClient, - paths: string[], -): Promise { - if (paths.length === 0) return []; - const ids: string[] = []; - for (const path of paths) { - const filename = basename(path); - const mediaType = mimeTypes.lookup(filename); - if (!mediaType) { +interface LocalFile { + path: string; + filename: string; + mediaType: string; + sizeBytes: number; +} + +function readLocalFile(path: string): LocalFile { + const filename = basename(path); + const mediaType = mimeTypes.lookup(filename); + if (!mediaType) { + throw new CLIError( + `Could not determine media type for attachment: ${path}`, + "Use a recognizable file extension (e.g. .png, .pdf, .md)", + ); + } + let sizeBytes: number; + try { + sizeBytes = statSync(path).size; + } catch { + throw new CLIError(`Attachment not found: ${path}`); + } + return { path, filename, mediaType, sizeBytes }; +} + +function megabytes(bytes: number): string { + return `${Math.ceil(bytes / 1024 / 1024)} MB`; +} + +/** + * Sends files to a workspace's host. Up to 25 MB a file goes inline, which + * every host takes. A cloud workspace takes larger files through Paradev's + * storage; a host on someone's machine does not, by design. + */ +export async function uploadAttachments({ + target, + api, + paths, + fetchImpl = fetch, +}: { + target: Pick; + api: ApiClient; + paths: string[]; + fetchImpl?: typeof fetch; +}): Promise { + const files = paths.map(readLocalFile); + for (const file of files) { + if (file.sizeBytes <= MAX_INLINE_ATTACHMENT_BYTES) continue; + if (target.kind !== "cloud") { throw new CLIError( - `Could not determine media type for attachment: ${path}`, - "Use a recognizable file extension (e.g. .png, .pdf, .md)", + `Attachment ${file.filename} is ${megabytes(file.sizeBytes)}; a workspace on a host takes at most ${megabytes(MAX_INLINE_ATTACHMENT_BYTES)} a file`, + "Files for a host go straight to it, not through Paradev's storage. Send a smaller file, or copy it into the workspace", ); } - const bytes = readFileSync(path); - const result = await client.attachments.upload.mutate({ - data: { kind: "base64", data: bytes.toString("base64") }, - mediaType, - originalFilename: filename, - }); - ids.push(result.attachmentId); + assertCloudSize(file); + } + const ids: string[] = []; + for (const file of files) { + if (file.sizeBytes <= MAX_INLINE_ATTACHMENT_BYTES) { + const result = await target.client.attachments.upload.mutate({ + data: { + kind: "base64", + data: readFileSync(file.path).toString("base64"), + }, + mediaType: file.mediaType, + originalFilename: file.filename, + }); + ids.push(result.attachmentId); + continue; + } + const fileId = await uploadToStorage(api, file, fetchImpl); + const [imported] = await target.client.attachments.importFromCloud + .mutate({ fileIds: [fileId] }) + .catch((error: unknown) => { + if ( + error instanceof Error && + /No procedure found on path "?attachments\.importFromCloud/.test( + error.message, + ) + ) { + throw new CLIError( + `This workspace's host is too old for files over ${megabytes(MAX_INLINE_ATTACHMENT_BYTES)}`, + "Restart the workspace to update its host, or send a smaller file", + ); + } + throw error; + }); + if (!imported) { + throw new CLIError(`The workspace could not import ${file.filename}`); + } + ids.push(imported.attachmentId); } return ids; } + +/** Uploads files to Paradev's storage for a cloud workspace and returns their file ids. */ +export async function uploadToCloudStorage({ + api, + paths, + fetchImpl = fetch, +}: { + api: ApiClient; + paths: string[]; + fetchImpl?: typeof fetch; +}): Promise { + if (paths.length > MAX_ATTACHMENTS) { + throw new CLIError( + `At most ${MAX_ATTACHMENTS} attachments can be sent at once`, + ); + } + const files = paths.map(readLocalFile); + for (const file of files) assertCloudSize(file); + const fileIds: string[] = []; + for (const file of files) { + fileIds.push(await uploadToStorage(api, file, fetchImpl)); + } + return fileIds; +} + +function assertCloudSize(file: LocalFile): void { + if (file.sizeBytes <= MAX_ATTACHMENT_BYTES) return; + throw new CLIError( + `Attachment ${file.filename} is ${megabytes(file.sizeBytes)}; the limit is ${megabytes(MAX_ATTACHMENT_BYTES)} a file`, + ); +} + +async function uploadToStorage( + api: ApiClient, + file: LocalFile, + fetchImpl: typeof fetch, +): Promise { + const { fileId, upload } = await api.attachment.createUpload.mutate({ + name: file.filename, + contentType: file.mediaType, + sizeBytes: file.sizeBytes, + }); + const response = await fetchImpl(upload.url, { + method: "PUT", + headers: upload.headers, + body: readFileSync(file.path), + }); + if (!response.ok) { + throw new CLIError( + `Could not upload ${file.filename} (${response.status})`, + ); + } + return fileId; +} From b2a13637cf21c23c029036a620f0e3971a45a46c Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:41:49 +0000 Subject: [PATCH 7/7] fix: stop a terminal-less chat on `panes close`, and cap the closed-chat sets From the review of #9399: - `panes.close` matched live chats by `terminalId` only. A chat started without a terminal (mobile) lists `terminalId: null` while its pane uses the session id, so it was never stopped and the desktop then ignored it. Match `terminalId ?? sessionId`, as `newTab` and adoption do. Moved to close-chats-on-terminal.ts with tests. - The desktop's closed, reopened and ignored-terminal sets kept growing; each now keeps the newest 500. --- .../closedChatSessions.test.ts | 20 +++++++ .../closedChatSessions/closedChatSessions.ts | 17 +++++- .../panes/close-chats-on-terminal.test.ts | 55 +++++++++++++++++++ .../router/panes/close-chats-on-terminal.ts | 26 +++++++++ .../src/trpc/router/panes/panes.ts | 21 +------ 5 files changed, 116 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.test.ts create mode 100644 packages/host-service/src/trpc/router/panes/close-chats-on-terminal.test.ts create mode 100644 packages/host-service/src/trpc/router/panes/close-chats-on-terminal.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.test.ts new file mode 100644 index 00000000000..33d8f9c39fa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import { + ignoreChatTerminal, + isChatSessionClosed, + isChatTerminalIgnored, + markChatSessionClosed, +} from "./closedChatSessions"; + +describe("closedChatSessions", () => { + it("forgets the oldest entries past a cap, keeping the newest", () => { + for (let index = 0; index < 600; index++) { + markChatSessionClosed(`capped-session-${index}`); + ignoreChatTerminal(`capped-terminal-${index}`); + } + expect(isChatSessionClosed("capped-session-0")).toBe(false); + expect(isChatSessionClosed("capped-session-599")).toBe(true); + expect(isChatTerminalIgnored("capped-terminal-0")).toBe(false); + expect(isChatTerminalIgnored("capped-terminal-599")).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts index 56104d99fa1..e07028512b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/closedChatSessions/closedChatSessions.ts @@ -1,18 +1,29 @@ import { readPendingChatStopIds } from "../pendingChatStops"; +const MAX_REMEMBERED = 500; + const closed = new Set(readPendingChatStopIds()); const reopened = new Set(); const ignoredTerminals = new Set(); +/** Oldest entries go first: by then their chats were stopped long ago. */ +function remember(set: Set, value: string): void { + set.delete(value); + set.add(value); + if (set.size <= MAX_REMEMBERED) return; + const oldest = set.values().next().value; + if (oldest !== undefined) set.delete(oldest); +} + export function markChatSessionClosed(sessionId: string): () => void { - closed.add(sessionId); + remember(closed, sessionId); reopened.delete(sessionId); return () => reopenChatSession(sessionId); } export function reopenChatSession(sessionId: string): void { closed.delete(sessionId); - reopened.add(sessionId); + remember(reopened, sessionId); } export function isChatSessionClosed(sessionId: string): boolean { @@ -26,7 +37,7 @@ export function isChatSessionReopened(sessionId: string): boolean { /** Sessions on this terminal never get a tab of their own: its tab was closed, or no tab is meant to show it. */ export function ignoreChatTerminal(terminalId: string): () => void { - ignoredTerminals.add(terminalId); + remember(ignoredTerminals, terminalId); return () => ignoredTerminals.delete(terminalId); } diff --git a/packages/host-service/src/trpc/router/panes/close-chats-on-terminal.test.ts b/packages/host-service/src/trpc/router/panes/close-chats-on-terminal.test.ts new file mode 100644 index 00000000000..2d4f480c8af --- /dev/null +++ b/packages/host-service/src/trpc/router/panes/close-chats-on-terminal.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { closeChatsOnTerminal } from "./close-chats-on-terminal"; + +function runtimeWith( + sessions: Array<{ + sessionId: string; + terminalId: string | null; + live: boolean; + }>, +) { + const closed: Array<{ sessionId: string; userClosed?: boolean }> = []; + const ctx = { + runtime: { + chat: () => ({ + commands: { + listSessions: () => sessions, + closeSession: async (input: { + sessionId: string; + userClosed?: boolean; + }) => { + closed.push(input); + }, + }, + }), + }, + } as unknown as Parameters[0]; + return { ctx, closed }; +} + +describe("closeChatsOnTerminal", () => { + test("stops the live chats on the pane's terminal as closed by a person", async () => { + const { ctx, closed } = runtimeWith([ + { sessionId: "on-pane", terminalId: "t1", live: true }, + { sessionId: "stopped", terminalId: "t1", live: false }, + { sessionId: "elsewhere", terminalId: "t2", live: true }, + ]); + await closeChatsOnTerminal(ctx, "ws", "t1"); + expect(closed).toEqual([{ sessionId: "on-pane", userClosed: true }]); + }); + + test("matches a chat with no terminal by its session id, as its pane shows it", async () => { + const { ctx, closed } = runtimeWith([ + { sessionId: "from-mobile", terminalId: null, live: true }, + ]); + await closeChatsOnTerminal(ctx, "ws", "from-mobile"); + expect(closed).toEqual([{ sessionId: "from-mobile", userClosed: true }]); + }); + + test("does nothing on a host without chat", async () => { + const ctx = { runtime: {} } as unknown as Parameters< + typeof closeChatsOnTerminal + >[0]; + await closeChatsOnTerminal(ctx, "ws", "t1"); + }); +}); diff --git a/packages/host-service/src/trpc/router/panes/close-chats-on-terminal.ts b/packages/host-service/src/trpc/router/panes/close-chats-on-terminal.ts new file mode 100644 index 00000000000..50c82584ab4 --- /dev/null +++ b/packages/host-service/src/trpc/router/panes/close-chats-on-terminal.ts @@ -0,0 +1,26 @@ +import type { HostServiceContext } from "../../../types"; + +/** A chat with no terminal of its own is shown under its session id, as `newTab` does. */ +export async function closeChatsOnTerminal( + ctx: Pick, + workspaceId: string, + terminalId: string, +): Promise { + const chat = ctx.runtime.chat?.(); + if (!chat) return; + const onTerminal = chat.commands + .listSessions({ scopeId: workspaceId, limit: 200 }) + .filter( + (session) => + session.live && + (session.terminalId ?? session.sessionId) === terminalId, + ); + await Promise.all( + onTerminal.map((session) => + chat.commands.closeSession({ + sessionId: session.sessionId, + userClosed: true, + }), + ), + ); +} diff --git a/packages/host-service/src/trpc/router/panes/panes.ts b/packages/host-service/src/trpc/router/panes/panes.ts index 2280b2953ab..56ea7dd984f 100644 --- a/packages/host-service/src/trpc/router/panes/panes.ts +++ b/packages/host-service/src/trpc/router/panes/panes.ts @@ -17,6 +17,7 @@ import { createCallerFactory, protectedProcedure, router } from "../../index"; import { requireChatSession } from "../sessions/session-service"; import { terminalRouter } from "../terminal"; import { addPaneDetails } from "./add-pane-details"; +import { closeChatsOnTerminal } from "./close-chats-on-terminal"; import { resolveOpenPath } from "./resolve-open-path"; const createTerminalCaller = createCallerFactory(terminalRouter); @@ -81,26 +82,6 @@ function chatTabTarget( }; } -async function closeChatsOnTerminal( - ctx: HostServiceContext, - workspaceId: string, - terminalId: string, -) { - const chat = ctx.runtime.chat?.(); - if (!chat) return; - const onTerminal = chat.commands - .listSessions({ scopeId: workspaceId, limit: 200 }) - .filter((session) => session.live && session.terminalId === terminalId); - await Promise.all( - onTerminal.map((session) => - chat.commands.closeSession({ - sessionId: session.sessionId, - userClosed: true, - }), - ), - ); -} - function requireWorkspaceTerminal( ctx: HostServiceContext, workspaceId: string,