Repository navigation
fix: show chats started outside the desktop as tabs, plus CLI API-key and attachment fixes (SUPER-3158) #9399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cc01538
ebe917d
09ac74d
866151d
4fe563e
7137c08
b2a1363
28ec18e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Comment on lines
+493
to
+501
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ); | ||
| const recoveries = useRef(0); | ||
| if (stored?.live && !sessionDead) recoveries.current = 0; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,21 +174,26 @@ export function useUndoableChatClose({ | |
| (pane) => { | ||
| const data = pane.data as ChatPaneData; | ||
| const { sessionId, terminalId } = data; | ||
| const reopenTerminal = ignoreChatTerminal(terminalId); | ||
| void stopStrayChats(terminalId, sessionId); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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, | ||
| slot: | ||
| 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], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Comment on lines
+904
to
+906
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }, | ||
| insetsAside: true, | ||
| renderPane: (ctx: RendererContext<PaneViewerData>) => ( | ||
| <PendingCloudChatPane ctx={ctx} workspaceId={workspaceId} /> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { useSyncChatSessionTabs } from "./useSyncChatSessionTabs"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import { createWorkspaceStore, type WorkspaceStore } from "@superset/panes"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This hook has no test, though its selection and restore logic is order-dependent. Add a hook test covering first-load selection of the newest chat, restoring the user's active tab when a chat arrives later, and no adoption while Prompt for AI agents |
||
| 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<WorkspaceStore<PaneViewerData>>; | ||
| linkedStores: readonly StoreApi<WorkspaceStore<PaneViewerData>>[]; | ||
| 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, | ||
| }), | ||
|
Comment on lines
+37
to
+40
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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<boolean | null>(null); | ||
| useEffect(() => { | ||
| if (!isLayoutReady || openedEmpty.current !== null) return; | ||
| openedEmpty.current = [store, ...linkedStores].every( | ||
| (paneStore) => paneStore.getState().tabs.length === 0, | ||
| ); | ||
|
Comment on lines
+59
to
+63
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }, [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<PaneViewerData>({ | ||
| 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, | ||
| ]); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PaneViewerData>(); | ||
| 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"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This test name says the chat was closed before being reopened, but the body never closes it. Call Prompt for AI agents |
||
| expect( | ||
| chatPanesClosedElsewhere( | ||
| [listed("reopened-from-history")], | ||
| store.getState().tabs, | ||
| ), | ||
| ).toEqual([]); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: This
closedElsewherepredicate repeats the rule inchatPanesClosedElsewhere: closed by user and not reopened in this window. Move the check into one shared helper so the pane and the tab sync cannot drift apart.Prompt for AI agents