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 aac6ed4be47..21010aae69f 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 @@ -21,6 +21,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, + isChatSessionReopened, +} from "../../../../../../utils/closedChatSessions"; import { saveLastChatAgent } from "../../../../../../utils/lastChatAgent"; import { forgetSavedChatMode, @@ -297,6 +301,7 @@ export function AcpChatPane({ const forkHere = useCallback(() => { if (!sessionId) return; const terminalId = crypto.randomUUID(); + ignoreChatTerminal(terminalId); void wiring.transport .forkSession({ commandId: crypto.randomUUID(), @@ -485,8 +490,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/useSurfaceFork/useSurfaceFork.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/useSurfaceFork/useSurfaceFork.ts index a6bceba46ea..b37974e3ba2 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 @@ -8,6 +8,7 @@ import { useWorkspaceClient } from "@superset/workspace-client"; import { useCallback } from "react"; import { useHostServiceInfo } from "renderer/hooks/host-service/useHostServiceInfo"; import type { ChatPaneData, PaneViewerData } from "../../../../types"; +import { ignoreChatTerminal } from "../../../../utils/closedChatSessions"; import type { CreateNewAgentSession } from "../../../useAgentSessionLauncher"; import { useChatWiring } from "../../components/ChatSession/hooks/useSessionClient"; import { readSavedChatMode } from "../../utils/savedChatMode"; @@ -66,6 +67,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 defaultModeId = acpStartModes(presetId)?.defaultModeId; const modeId = readSavedChatMode(presetId) ?? @@ -145,6 +147,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 329307f6e3b..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 @@ -6,6 +6,7 @@ import { useUndoStack } from "renderer/stores/undo-stack"; import type { StoreApi } from "zustand/vanilla"; import type { ChatPaneData, PaneViewerData } from "../../../../types"; import { + ignoreChatTerminal, isChatSessionClosed, markChatSessionClosed, } from "../../../../utils/closedChatSessions"; @@ -28,6 +29,7 @@ interface ClosedChat { sessionId: string; title: string; reopen: () => void; + showAgain: () => void; } export function useUndoableChatClose({ @@ -45,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); @@ -99,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" }), ); @@ -152,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); }, }); @@ -171,13 +174,14 @@ export function useUndoableChatClose({ (pane) => { const data = pane.data as ChatPaneData; const { sessionId, terminalId } = data; + 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, @@ -185,7 +189,11 @@ 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(); + }, + 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/hooks/useSyncChatSessionTabs/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/constants.ts new file mode 100644 index 00000000000..4d417de81a4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/constants.ts @@ -0,0 +1,3 @@ +export const CHAT_ADOPT_POLL_MS = 15_000; +export const CHAT_ADOPT_SESSIONS_LIMIT = 200; +export const CHAT_ADOPT_MAX_LIVE_CHATS = 10; 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/useSyncChatSessionTabs/useSyncChatSessionTabs.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts new file mode 100644 index 00000000000..e8c32b4e259 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/useSyncChatSessionTabs.ts @@ -0,0 +1,117 @@ +import { createWorkspaceStore, type WorkspaceStore } from "@superset/panes"; +import { useWorkspaceClient } from "@superset/workspace-client"; +import { useQuery } from "@tanstack/react-query"; +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"; +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 UseSyncChatSessionTabsArgs { + store: StoreApi>; + linkedStores: readonly StoreApi>[]; + workspaceId: string; + isLayoutReady: boolean; +} + +export function useSyncChatSessionTabs({ + store, + linkedStores, + workspaceId, + isLayoutReady, +}: UseSyncChatSessionTabsArgs): 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; + 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; + 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, + tabs: [ + ...tabs, + ...linkedStores.flatMap((linked) => linked.getState().tabs), + ], + workspaceId, + }); + // 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. + const next = createWorkspaceStore({ + initialState: { version, tabs, activeTabId }, + }); + for (const session of toAdopt) { + addChatTab({ store: next, session, focus: false }); + } + if (activeTabId && !selectChat) 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/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/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts new file mode 100644 index 00000000000..1f27e14f1c2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.test.ts @@ -0,0 +1,169 @@ +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, +} from "../../../../utils/closedChatSessions"; +import { + addPendingChatStop, + removePendingChatStop, +} from "../../../../utils/pendingChatStops"; +import { CHAT_ADOPT_MAX_LIVE_CHATS } from "../../constants"; +import { chatSessionsToAdopt } from "./chatSessionsToAdopt"; + +const NOW = 1_000_000; +const WORKSPACE_ID = "workspace-1"; + +const listed = (sessionId: string, overrides = {}) => ({ + sessionId, + harness: "claude-acp", + harnessSessionId: `native-${sessionId}`, + title: `Chat ${sessionId}`, + updatedAt: NOW - 1000, + live: true, + terminalId: `terminal-${sessionId}`, + ...overrides, +}); + +const adoptedIds = ( + sessions: ReturnType[], + tabs = createWorkspaceStore().getState().tabs, +) => + chatSessionsToAdopt({ + sessions, + tabs, + workspaceId: WORKSPACE_ID, + }).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({ + sessions: [ + listed("new", { updatedAt: NOW - 1 }), + listed("old", { updatedAt: NOW - 2 }), + ], + tabs: [], + workspaceId: WORKSPACE_ID, + }), + ).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("adopts a chat started without a terminal under its session id", () => { + const [target] = chatSessionsToAdopt({ + sessions: [listed("mobile", { terminalId: null })], + tabs: [], + workspaceId: WORKSPACE_ID, + }); + expect(target?.terminalId).toBe("mobile"); + }); + + 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", + }, + }, + ], + }); + cleanups.push(markChatSessionClosed("closed")); + expect( + adoptedIds( + [ + listed("dead", { live: false }), + listed("unknown", { harness: "unknown" }), + listed("closed"), + listed("shown"), + listed("creating"), + listed("pending"), + ], + store.getState().tabs, + ), + ).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"]); + }); + + 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/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts new file mode 100644 index 00000000000..695de017458 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/utils/chatSessionsToAdopt/chatSessionsToAdopt.ts @@ -0,0 +1,86 @@ +import { presetForAcpHarness } from "@superset/chat/core"; +import type { Tab } from "@superset/panes"; +import type { ChatPaneData, PaneViewerData } from "../../../../types"; +import { + isChatSessionClosed, + isChatTerminalIgnored, +} from "../../../../utils/closedChatSessions"; +import type { ChatSessionTarget } from "../../../../utils/focusChatSession"; +import { + readPendingChatStops, + readPendingChatStopTerminalIds, +} from "../../../../utils/pendingChatStops"; +import { CHAT_ADOPT_MAX_LIVE_CHATS } from "../../constants"; + +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. + * 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, +}: { + sessions: readonly ListedChatSession[]; + tabs: readonly Tab[]; + workspaceId: string; +}): 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; + if (typeof data.terminalId === "string") + claimedTerminalIds.add(data.terminalId); + if (pane.kind === "chat-v3" && data.sessionId) + 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 recent) { + const terminalId = session.terminalId ?? session.sessionId; + if (claimedSessionIds.has(session.sessionId)) continue; + if (claimedTerminalIds.has(terminalId)) continue; + const presetId = presetForAcpHarness(session.harness); + if (!presetId) continue; + targets.push({ + sessionId: session.sessionId, + presetId, + 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/useSyncChatSessionTabs/utils/chatSessionsToAdopt/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSyncChatSessionTabs/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/useSyncChatSessionTabs/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..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 @@ -81,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"; @@ -366,6 +367,12 @@ function V2WorkspaceContent() { workspaceId, isLayoutReady: isLayoutReady && isRightLayoutReady, }); + useSyncChatSessionTabs({ + 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/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 80d3e5b9273..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,16 +1,46 @@ 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); + remember(reopened, 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 { + remember(ignoredTerminals, terminalId); + return () => ignoredTerminals.delete(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 213e5ebf44e..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,5 +1,8 @@ export { + ignoreChatTerminal, isChatSessionClosed, + isChatSessionReopened, + isChatTerminalIgnored, markChatSessionClosed, 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 0993ccd463b..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", @@ -69,4 +69,24 @@ describe("focusChatSession", () => { }), ).toBe("added"); }); + + it("adds a chat tab even when a pane shows the same agent session", () => { + const store = newStore(); + focusChatSession({ store, session }); + store.getState().addTab({ + panes: [{ kind: "terminal", data: { terminalId: "active" } }], + }); + 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 7a26b2745f0..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 @@ -44,7 +44,21 @@ export function focusChatSession({ } } } + addChatTab({ store, session }); + return "added"; +} + +export function addChatTab({ + store, + session, + focus = true, +}: { + store: StoreApi>; + session: ChatSessionTarget; + focus?: boolean; +}): void { store.getState().addTab({ + focus, panes: [ { kind: "chat-v3", @@ -62,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/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/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 c2a211a4420..414912111b8 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 7b6b1bee97c..c666e50717f 100644 --- a/packages/chat-runtime/src/commands/commands/commands.ts +++ b/packages/chat-runtime/src/commands/commands/commands.ts @@ -809,7 +809,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); for (const category of SELECTION_CATEGORIES) { diff --git a/packages/chat-runtime/src/db/drizzle/0003_session_closed_by_user.sql b/packages/chat-runtime/src/db/drizzle/0003_session_closed_by_user.sql new file mode 100644 index 00000000000..66cc81f9b82 --- /dev/null +++ b/packages/chat-runtime/src/db/drizzle/0003_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/0003_snapshot.json b/packages/chat-runtime/src/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000000..cb93e1fa142 --- /dev/null +++ b/packages/chat-runtime/src/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,216 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7eb04644-4327-454e-aad6-076e0449f99a", + "prevId": "f2149b87-59bc-4d8f-94e2-fed53dcb6cca", + "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_selections": { + "name": "chat_selections", + "columns": { + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_selections_harness_category_pk": { + "columns": [ + "harness", + "category" + ], + "name": "chat_selections_harness_category_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 f4145e973b5..ed531ca7d43 100644 --- a/packages/chat-runtime/src/db/drizzle/meta/_journal.json +++ b/packages/chat-runtime/src/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1793923174628, "tag": "0002_chat_selections", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1793929979089, + "tag": "0003_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 7b3ec56049d..6607c060b13 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 989a840a239..2b3c2e318a1 100644 --- a/packages/chat/src/protocol/commands.ts +++ b/packages/chat/src/protocol/commands.ts @@ -179,6 +179,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/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 df7e2b8da10..bccbb63e30f 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"; @@ -38,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]; @@ -53,8 +58,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/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 index 362fbb3fd21..9940469368b 100644 --- a/packages/cli/src/lib/host-target/resolveHostTarget.test.ts +++ b/packages/cli/src/lib/host-target/resolveHostTarget.test.ts @@ -45,4 +45,33 @@ describe("resolveHostTarget", () => { expect(seen.at(-1)?.get("authorization")).toBe("Bearer user-jwt"); }); + + test("an API key is traded for a JWT before it reaches the relay", async () => { + const target = await resolveHostTarget({ + requestedHostId: "some-other-host", + organizationId: "1c0587c9-a74c-423c-ae40-b97296ad9b27", + userJwt: "sk_live_remote", + api, + fetchImpl: (async () => + Response.json({ + token: "minted.jwt.token", + })) as unknown as typeof fetch, + }); + + await target.client.workspace.list.query(); + + expect(seen.at(-1)?.get("authorization")).toBe("Bearer minted.jwt.token"); + expect(target.ws.token).toBe("minted.jwt.token"); + }); + + test("explains that the cloud is not one host", async () => { + await expect( + resolveHostTarget({ + requestedHostId: "cloud", + organizationId: "1c0587c9-a74c-423c-ae40-b97296ad9b27", + 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 07cae58246e..587aa065c72 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, @@ -103,9 +115,7 @@ export async function resolveHostTarget( transformer: SuperJSON, // A cloud workspace has no bearer; its firewall adds its credential. headers: { - ...(options.userJwt - ? { Authorization: `Bearer ${options.userJwt}` } - : {}), + ...(userJwt ? { Authorization: `Bearer ${userJwt}` } : {}), "x-superset-client-machine-id": localHostId, }, }), @@ -113,7 +123,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/sessions.ts b/packages/cli/src/lib/sessions.ts index 799165ea33e..0d698d4ee88 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 yet; 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 }, 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; +} 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 ebb0f47ec38..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); @@ -195,13 +196,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() }))