diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 7e233440f6d..6d7aa24fc2f 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -480,7 +480,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }), listPullRequests: publicProcedure - .input(z.object({ projectId: z.string() })) + .input( + z.object({ + projectId: z.string(), + includeClosed: z.boolean().optional(), + }), + ) .query(async ({ input }) => { const project = localDb .select() @@ -496,7 +501,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { "pr", "list", "--state", - "open", + input.includeClosed ? "all" : "open", "--limit", "30", "--json", @@ -517,6 +522,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { z.object({ projectId: z.string(), query: z.string(), + includeClosed: z.boolean().optional(), }), ) .query(async ({ input }) => { @@ -534,7 +540,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { "pr", "list", "--state", - "all", + input.includeClosed ? "all" : "open", "--search", input.query, "--limit", @@ -553,7 +559,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }), listIssues: publicProcedure - .input(z.object({ projectId: z.string() })) + .input( + z.object({ + projectId: z.string(), + includeClosed: z.boolean().optional(), + }), + ) .query(async ({ input }) => { const project = localDb .select() @@ -569,7 +580,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { "issue", "list", "--state", - "open", + input.includeClosed ? "all" : "open", "--limit", "30", "--json", diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index 9d403a4dbdf..7e5a0eee5a0 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -51,6 +51,7 @@ interface ChatInputFooterProps { pendingQuestion?: { questionId: string; question: string; + description?: string; options?: { label: string; description?: string }[]; } | null; isQuestionSubmitting?: boolean; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx index 9064246551b..4af96469d6b 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx @@ -9,6 +9,7 @@ interface QuestionInputOverlayProps { question: { questionId: string; question: string; + description?: string; options?: QuestionOption[]; }; isSubmitting: boolean; @@ -63,9 +64,16 @@ export function QuestionInputOverlay({
{/* Question — pinned header */}
-

- {question.question} -

+
+

+ {question.question} +

+ {question.description && ( +

+ {question.description} +

+ )} +
); @@ -134,6 +150,7 @@ export const DiffFileEntry = memo(function DiffFileEntry({ viewed={viewed} onToggleViewed={handleToggleViewed} onOpenFile={handleOpenFile} + onOpenInExternalEditor={handleOpenInExternalEditor} /> ) : null}
@@ -148,7 +165,8 @@ interface DeferredDiffPlaceholderProps { onToggleCollapsed: () => void; viewed: boolean; onToggleViewed: () => void; - onOpenFile?: () => void; + onOpenFile?: (openInNewTab?: boolean) => void; + onOpenInExternalEditor?: () => void; } function DeferredDiffPlaceholder({ @@ -160,6 +178,7 @@ function DeferredDiffPlaceholder({ viewed, onToggleViewed, onOpenFile, + onOpenInExternalEditor, }: DeferredDiffPlaceholderProps) { const isDeleted = reason === "deleted"; const fullHeight = isDeleted @@ -185,6 +204,7 @@ function DeferredDiffPlaceholder({ viewed={viewed} onToggleViewed={onToggleViewed} onOpenFile={onOpenFile} + onOpenInExternalEditor={onOpenInExternalEditor} /> {!collapsed && (
void; viewed: boolean; onToggleViewed: () => void; - onOpenFile?: () => void; + onOpenFile?: (openInNewTab?: boolean) => void; + onOpenInExternalEditor?: () => void; onCopyContents?: () => void; onDiscard?: () => void; } @@ -34,6 +43,7 @@ export function DiffFileHeader({ viewed, onToggleViewed, onOpenFile, + onOpenInExternalEditor, onCopyContents, onDiscard, }: DiffFileHeaderProps) { @@ -58,8 +68,16 @@ export function DiffFileHeader({ - Open {path} + Open in file viewer. {CLICK_HINT_TOOLTIP}
+ + + + + + + + {onOpenInExternalEditor + ? "Open in editor" + : "Open in editor unavailable"} + + +
void; viewed: boolean; onToggleViewed: () => void; - onOpenFile?: () => void; + onOpenFile?: (openInNewTab?: boolean) => void; + onOpenInExternalEditor?: () => void; } export const WorkspaceDiff = memo(function WorkspaceDiff({ @@ -45,6 +46,7 @@ export const WorkspaceDiff = memo(function WorkspaceDiff({ viewed, onToggleViewed, onOpenFile, + onOpenInExternalEditor, }: WorkspaceDiffProps) { const activeTheme = useResolvedTheme(); const { data: fontSettings } = electronTrpc.settings.getFontSettings.useQuery( @@ -155,6 +157,7 @@ export const WorkspaceDiff = memo(function WorkspaceDiff({ viewed={viewed} onToggleViewed={onToggleViewed} onOpenFile={onOpenFile} + onOpenInExternalEditor={onOpenInExternalEditor} onCopyContents={handleCopyContents} onDiscard={handleDiscard} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 3acb1e15b85..6b8ce9b3f55 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -74,6 +74,7 @@ export function TerminalPane({ () => paneData.terminalId ?? crypto.randomUUID(), [paneData.terminalId], ); + const terminalInstanceId = ctx.pane.id; const containerRef = useRef(null); const activeTheme = useTheme(); const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -100,6 +101,12 @@ export function TerminalPane({ const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); const ensureSessionRef = useRef(ensureSession); ensureSessionRef.current = ensureSession; + const workspaceTrpcUtils = workspaceTrpc.useUtils(); + const invalidateTerminalSessionsRef = useRef( + workspaceTrpcUtils.terminal.listSessions.invalidate, + ); + invalidateTerminalSessionsRef.current = + workspaceTrpcUtils.terminal.listSessions.invalidate; // useCallback so useSyncExternalStore doesn't re-subscribe every render — // otherwise every keystroke-triggered re-render unsubscribes and @@ -107,8 +114,12 @@ export function TerminalPane({ // docs ("If you don't memoize the subscribe function…"). const subscribe = useCallback( (callback: () => void) => - terminalRuntimeRegistry.onStateChange(terminalId, callback), - [terminalId], + terminalRuntimeRegistry.onStateChange( + terminalId, + callback, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], ); const highlightedSessionId = useBrowserAutomationStore((state) => state.connectModal.isOpen ? state.connectModal.selectedSessionId : null, @@ -117,8 +128,11 @@ export function TerminalPane({ highlightedSessionId === `terminal:${ctx.pane.id}`; const getSnapshot = useCallback( (): ConnectionState => - terminalRuntimeRegistry.getConnectionState(terminalId), - [terminalId], + terminalRuntimeRegistry.getConnectionState( + terminalId, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], ); const connectionState = useSyncExternalStore(subscribe, getSnapshot); @@ -137,9 +151,15 @@ export function TerminalPane({ const container = containerRef.current; if (!container) return; - terminalRuntimeRegistry.mount(terminalId, container, appearanceRef.current); + terminalRuntimeRegistry.mount( + terminalId, + container, + appearanceRef.current, + terminalInstanceId, + ); let cancelled = false; + const sessionWorkspaceId = workspaceIdRef.current; // Always connect after ensureSession settles, even on error: if the // session actually exists on the server (e.g. we raced another client), @@ -149,34 +169,53 @@ export function TerminalPane({ ensureSessionRef.current .mutateAsync({ terminalId, - workspaceId: workspaceIdRef.current, + workspaceId: sessionWorkspaceId, themeType: initialThemeTypeRef.current, }) + .then((result) => { + if (result.status === "active") { + void invalidateTerminalSessionsRef.current({ + workspaceId: sessionWorkspaceId, + }); + } + }) .catch((err) => { console.error("[TerminalPane] ensureSession failed:", err); }) .finally(() => { if (cancelled) return; - terminalRuntimeRegistry.connect(terminalId, websocketUrlRef.current); + terminalRuntimeRegistry.connect( + terminalId, + websocketUrlRef.current, + terminalInstanceId, + ); }); return () => { cancelled = true; - terminalRuntimeRegistry.detach(terminalId); + terminalRuntimeRegistry.detach(terminalId, terminalInstanceId); }; - }, [terminalId]); + }, [terminalId, terminalInstanceId]); // WS URL can change while the terminal stays mounted (token refresh, host // URL re-resolution on provider remount). Reconnect only if the transport // is already live — on initial mount the transport is "disconnected" and // we let the ensureSession path above open it. useEffect(() => { - terminalRuntimeRegistry.reconnect(terminalId, websocketUrl); - }, [terminalId, websocketUrl]); + terminalRuntimeRegistry.reconnect( + terminalId, + websocketUrl, + terminalInstanceId, + ); + }, [terminalId, terminalInstanceId, websocketUrl]); useEffect(() => { - terminalRuntimeRegistry.updateAppearance(terminalId, appearance); - }, [terminalId, appearance]); + terminalRuntimeRegistry.updateAppearance( + terminalId, + appearance, + terminalInstanceId, + ); + }, [terminalId, terminalInstanceId, appearance]); // --- Link handlers --- // All filesystem operations go through the host service. @@ -187,81 +226,84 @@ export function TerminalPane({ statPathRef.current = statPathMutation.mutateAsync; useEffect(() => { - terminalRuntimeRegistry.setLinkHandlers(terminalId, { - stat: async (path) => { - try { - const result = await statPathRef.current({ - workspaceId, - path, - }); - if (!result) return null; - return { - isDirectory: result.isDirectory, - resolvedPath: result.resolvedPath, - }; - } catch { - return null; - } - }, - onFileLinkClick: (event, link) => { - // Folders are not settings-controlled: ⌘ reveals in sidebar, - // ⌘⇧ falls through to the external editor path, plain = hint. - if (link.isDirectory) { - if (!event.metaKey && !event.ctrlKey) { + terminalRuntimeRegistry.setLinkHandlers( + terminalId, + { + stat: async (path) => { + try { + const result = await statPathRef.current({ + workspaceId, + path, + }); + if (!result) return null; + return { + isDirectory: result.isDirectory, + resolvedPath: result.resolvedPath, + }; + } catch { + return null; + } + }, + onFileLinkClick: (event, link) => { + // Folders are not settings-controlled: ⌘ reveals in sidebar, + // ⌘⇧ falls through to the external editor path, plain = hint. + if (link.isDirectory) { + if (!event.metaKey && !event.ctrlKey) { + showHint(event.clientX, event.clientY); + return; + } + event.preventDefault(); + if (event.shiftKey) { + openInExternalEditor(link.resolvedPath); + } else { + onRevealPath(link.resolvedPath, { isDirectory: true }); + } + return; + } + + const action = getFileAction(event); + if (action === null) { showHint(event.clientX, event.clientY); return; } event.preventDefault(); - if (event.shiftKey) { - openInExternalEditor(link.resolvedPath); + if (action === "external") { + openInExternalEditor(link.resolvedPath, { + line: link.row, + column: link.col, + }); } else { - onRevealPath(link.resolvedPath, { isDirectory: true }); + onOpenFile(link.resolvedPath); } - return; - } - - const action = getFileAction(event); - if (action === null) { - showHint(event.clientX, event.clientY); - return; - } - event.preventDefault(); - if (action === "external") { - openInExternalEditor(link.resolvedPath, { - line: link.row, - column: link.col, - }); - } else { - onOpenFile(link.resolvedPath); - } - }, - onUrlClick: (event, url) => { - const action = getUrlAction(event); - if (action === null) { - showHint(event.clientX, event.clientY); - return; - } - event.preventDefault(); - if (action === "external") { - electronTrpcClient.external.openUrl.mutate(url).catch((error) => { - console.error("[v2 Terminal] Failed to open URL:", url, error); - }); - } else { - ctx.store.getState().openPane({ - pane: { - kind: "browser", - // FORK NOTE: fork BrowserPaneData requires `mode`; default to - // "generic" for terminal-link-opened URLs. - data: { url, mode: "generic" } satisfies BrowserPaneData, - }, - }); - } + }, + onUrlClick: (event, url) => { + const action = getUrlAction(event); + if (action === null) { + showHint(event.clientX, event.clientY); + return; + } + event.preventDefault(); + if (action === "external") { + electronTrpcClient.external.openUrl.mutate(url).catch((error) => { + console.error("[v2 Terminal] Failed to open URL:", url, error); + }); + } else { + ctx.store.getState().openPane({ + pane: { + kind: "browser", + data: { url, mode: "generic" } satisfies BrowserPaneData, + }, + }); + } + }, + onLinkHover, + onLinkLeave, }, - onLinkHover, - onLinkLeave, - }); + terminalInstanceId, + ); }, [ terminalId, + terminalInstanceId, workspaceId, ctx.store, onOpenFile, @@ -277,7 +319,7 @@ export function TerminalPane({ useHotkey( "CLEAR_TERMINAL", () => { - terminalRuntimeRegistry.clear(terminalId); + terminalRuntimeRegistry.clear(terminalId, terminalInstanceId); }, { enabled: ctx.isActive }, ); @@ -285,7 +327,7 @@ export function TerminalPane({ useHotkey( "SCROLL_TO_BOTTOM", () => { - terminalRuntimeRegistry.scrollToBottom(terminalId); + terminalRuntimeRegistry.scrollToBottom(terminalId, terminalInstanceId); }, { enabled: ctx.isActive }, ); @@ -298,14 +340,15 @@ export function TerminalPane({ // connectionState in deps ensures terminal ref re-derives after connect/disconnect // biome-ignore lint/correctness/useExhaustiveDependencies: connectionState is intentionally included to trigger re-derive const terminal = useMemo( - () => terminalRuntimeRegistry.getTerminal(terminalId), - [terminalId, connectionState], + () => terminalRuntimeRegistry.getTerminal(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId, connectionState], ); // biome-ignore lint/correctness/useExhaustiveDependencies: connectionState is intentionally included to trigger re-derive const searchAddon = useMemo( - () => terminalRuntimeRegistry.getSearchAddon(terminalId), - [terminalId, connectionState], + () => + terminalRuntimeRegistry.getSearchAddon(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId, connectionState], ); const [isDropActive, setIsDropActive] = useState(false); @@ -363,8 +406,10 @@ export function TerminalPane({ } const text = resolveDroppedText(event.dataTransfer); if (!text) return; - terminalRuntimeRegistry.getTerminal(terminalId)?.focus(); - terminalRuntimeRegistry.paste(terminalId, text); + terminalRuntimeRegistry + .getTerminal(terminalId, terminalInstanceId) + ?.focus(); + terminalRuntimeRegistry.paste(terminalId, text, terminalInstanceId); }; return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx new file mode 100644 index 00000000000..afe1237fe18 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx @@ -0,0 +1,60 @@ +import type { RendererContext } from "@superset/panes"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { Archive } from "lucide-react"; +import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; + +interface TerminalHeaderExtrasProps { + context: RendererContext; +} + +export function TerminalHeaderExtras({ context }: TerminalHeaderExtrasProps) { + if (context.pane.kind !== "terminal") return null; + + const data = context.pane.data as TerminalPaneData; + + const handleMoveToBackground = () => { + // Check whether other panes sharing the same terminalId still exist. + // If so, only close this pane without marking the background intent — + // the stale intent would otherwise cause the remaining pane's eventual + // close to skip dispose/killSession (silent PTY leak). + const state = context.store.getState(); + const duplicateExists = state.tabs.some((tab) => + Object.values(tab.panes).some( + (pane) => + pane.id !== context.pane.id && + pane.kind === "terminal" && + (pane.data as TerminalPaneData).terminalId === data.terminalId, + ), + ); + + if (!duplicateExists) { + markTerminalForBackground(data.terminalId); + } + void context.actions.close(); + }; + + return ( + + + + + + Move terminal to background + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts new file mode 100644 index 00000000000..7f654de91f5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts @@ -0,0 +1 @@ +export { TerminalHeaderExtras } from "./TerminalHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx new file mode 100644 index 00000000000..6e0d97c0c20 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -0,0 +1,307 @@ +import type { RendererContext } from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { + Check, + ChevronDown, + LoaderCircle, + Plus, + TerminalSquare, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; + +interface TerminalSessionDropdownProps { + context: RendererContext; + workspaceId: string; +} + +interface VisibleTerminalSession { + terminalId: string; + workspaceId: string; + createdAt?: number; + exited: boolean; + exitCode: number; + attached: boolean; + pending?: boolean; +} + +interface TerminalPaneLocation { + tabId: string; + paneId: string; + titleOverride?: string; +} + +function formatCreatedAt(createdAt: number | undefined): string { + if (!createdAt) return "Creating"; + + return getRelativeTime(createdAt, { format: "compact" }); +} + +function getTerminalPaneLocations( + context: RendererContext, +): Map { + const locations = new Map(); + for (const tab of context.store.getState().tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.id === context.pane.id || pane.kind !== "terminal") continue; + const data = pane.data as Partial; + if (data.terminalId) { + const terminalLocations = locations.get(data.terminalId) ?? []; + terminalLocations.push({ + tabId: tab.id, + paneId: pane.id, + titleOverride: pane.titleOverride, + }); + locations.set(data.terminalId, terminalLocations); + } + } + } + return locations; +} + +export function TerminalSessionDropdown({ + context, + workspaceId, +}: TerminalSessionDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const data = context.pane.data as TerminalPaneData; + const { terminalId } = data; + const utils = workspaceTrpc.useUtils(); + const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); + const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( + { workspaceId }, + { + refetchInterval: isOpen ? 2_000 : false, + refetchOnWindowFocus: true, + }, + ); + + const sessions = useMemo(() => { + const liveSessions = sessionsQuery.data?.sessions ?? []; + if (liveSessions.some((session) => session.terminalId === terminalId)) { + return liveSessions; + } + return [ + { + terminalId, + workspaceId, + exited: false, + exitCode: 0, + attached: false, + pending: true, + }, + ...liveSessions, + ]; + }, [sessionsQuery.data?.sessions, terminalId, workspaceId]); + const renderTerminalPaneLocations = getTerminalPaneLocations(context); + + const handleSelectSession = (nextTerminalId: string) => { + if (nextTerminalId === terminalId) { + setIsOpen(false); + return; + } + + const state = context.store.getState(); + const terminalPaneLocations = getTerminalPaneLocations(context); + const existingLocation = terminalPaneLocations.get(nextTerminalId)?.[0]; + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } + + state.setPaneData({ + paneId: context.pane.id, + data: { terminalId: nextTerminalId } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: existingLocation?.titleOverride, + }); + setIsOpen(false); + }; + + const closePanesForTerminal = (targetTerminalId: string) => { + const terminalPaneLocations = getTerminalPaneLocations(context); + for (const location of terminalPaneLocations.get(targetTerminalId) ?? []) { + context.store.getState().closePane({ + tabId: location.tabId, + paneId: location.paneId, + }); + } + + if (targetTerminalId === terminalId) { + void context.actions.close(); + } + }; + + const removeTerminalSession = async (targetTerminalId: string) => { + await killTerminalSession.mutateAsync({ + terminalId: targetTerminalId, + workspaceId, + }); + closePanesForTerminal(targetTerminalId); + await utils.terminal.listSessions.invalidate({ workspaceId }); + }; + + const handleRemoveTerminal = (targetTerminalId: string) => { + alert({ + title: "Remove terminal session?", + description: + "This will terminate the underlying process. Use Move terminal to background to keep it running without a pane.", + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Remove Terminal", + variant: "destructive", + onClick: () => { + toast.promise(removeTerminalSession(targetTerminalId), { + loading: "Removing terminal...", + success: "Terminal removed", + error: "Failed to remove terminal", + }); + }, + }, + ], + }); + }; + + const handleNewTerminal = () => { + const state = context.store.getState(); + const terminalPaneLocations = getTerminalPaneLocations(context); + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } + state.setPaneData({ + paneId: context.pane.id, + data: { + terminalId: crypto.randomUUID(), + } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: undefined, + }); + void utils.terminal.listSessions.invalidate({ workspaceId }); + setIsOpen(false); + }; + + const triggerTitle = context.pane.titleOverride ?? "Terminal"; + const currentSession = sessions.find( + (session) => session.terminalId === terminalId, + ); + const currentCreatedAtLabel = formatCreatedAt(currentSession?.createdAt); + + return ( + + + + + + + Terminal Sessions + + +
+ {sessions.length > 0 ? ( + sessions.map((session) => { + const isCurrent = session.terminalId === terminalId; + const location = renderTerminalPaneLocations.get( + session.terminalId, + )?.[0]; + const createdAtLabel = formatCreatedAt(session.createdAt); + const status = isCurrent + ? "Current" + : session.pending + ? "Starting" + : session.attached + ? "Attached" + : "Detached"; + const title = isCurrent + ? triggerTitle + : (location?.titleOverride ?? "Terminal"); + + return ( + { + handleSelectSession(session.terminalId); + }} + > + + {isCurrent && } + + + {title} + + + {createdAtLabel} + + + {status} + + + + ); + }) + ) : ( +
+ No live sessions +
+ )} +
+ + + + New Terminal + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts new file mode 100644 index 00000000000..320b21eb495 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts @@ -0,0 +1 @@ +export { TerminalSessionDropdown } from "./TerminalSessionDropdown"; 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 b7a94443b63..7dda40dd18c 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 @@ -4,8 +4,10 @@ import type { RendererContext, } from "@superset/panes"; import { alert } from "@superset/ui/atoms/Alert"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { workspaceTrpc } from "@superset/workspace-client"; import { Circle, GitCompareArrows, @@ -22,6 +24,7 @@ import { LuClipboard, LuClipboardCopy, LuEraser, + LuPower, } from "react-icons/lu"; import { TbScan } from "react-icons/tb"; import { useHotkeyDisplay } from "renderer/hotkeys"; @@ -47,6 +50,8 @@ import { DiffPane } from "./components/DiffPane"; import { FilePane } from "./components/FilePane"; import { FilePaneHeaderExtras } from "./components/FilePane/components/FilePaneHeaderExtras"; import { TerminalPane } from "./components/TerminalPane"; +import { TerminalHeaderExtras } from "./components/TerminalPane/components/TerminalHeaderExtras"; +import { TerminalSessionDropdown } from "./components/TerminalPane/components/TerminalSessionDropdown"; function getFileName(filePath: string): string { // FORK NOTE: v2 desktop runs on Windows too, so split on both separators. @@ -203,6 +208,21 @@ export function usePaneRegistry( ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; + const workspaceTrpcUtils = workspaceTrpc.useUtils(); + const { mutate: killTerminalSession, isPending: isKillingTerminalSession } = + workspaceTrpc.terminal.killSession.useMutation({ + onSuccess: () => { + toast.success("Terminal session killed"); + void workspaceTrpcUtils.terminal.listSessions.invalidate({ + workspaceId, + }); + }, + onError: (error) => { + toast.error("Failed to kill terminal session", { + description: error.message, + }); + }, + }); return useMemo>( () => ({ @@ -284,7 +304,11 @@ export function usePaneRegistry( getIcon: () => , getTitle: () => "Changes", renderPane: (ctx: RendererContext) => ( - + ), renderHeaderExtras: () => , contextMenuActions: (_ctx, defaults) => @@ -295,6 +319,12 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", + renderTitle: (ctx: RendererContext) => ( + + ), + renderHeaderExtras: (ctx: RendererContext) => ( + + ), renderPane: (ctx: RendererContext) => ( { const { terminalId } = ctx.pane.data as TerminalPaneData; - return !terminalRuntimeRegistry.getSelection(terminalId); + return !terminalRuntimeRegistry.getSelection( + terminalId, + ctx.pane.id, + ); }, onSelect: (ctx) => { const { terminalId } = ctx.pane.data as TerminalPaneData; - const text = terminalRuntimeRegistry.getSelection(terminalId); + const text = terminalRuntimeRegistry.getSelection( + terminalId, + ctx.pane.id, + ); if (text) navigator.clipboard.writeText(text); }, }, @@ -329,7 +365,13 @@ export function usePaneRegistry( const { terminalId } = ctx.pane.data as TerminalPaneData; try { const text = await navigator.clipboard.readText(); - if (text) terminalRuntimeRegistry.paste(terminalId, text); + if (text) { + terminalRuntimeRegistry.paste( + terminalId, + text, + ctx.pane.id, + ); + } } catch { // Clipboard access denied } @@ -344,7 +386,7 @@ export function usePaneRegistry( clearShortcut !== "Unassigned" ? clearShortcut : undefined, onSelect: (ctx) => { const { terminalId } = ctx.pane.data as TerminalPaneData; - terminalRuntimeRegistry.clear(terminalId); + terminalRuntimeRegistry.clear(terminalId, ctx.pane.id); }, }, { @@ -357,18 +399,51 @@ export function usePaneRegistry( : undefined, onSelect: (ctx) => { const { terminalId } = ctx.pane.data as TerminalPaneData; - terminalRuntimeRegistry.scrollToBottom(terminalId); + terminalRuntimeRegistry.scrollToBottom(terminalId, ctx.pane.id); }, }, { key: "sep-terminal-defaults", type: "separator" }, ]; - // Update close label const modifiedDefaults = defaults.map((d) => d.key === "close-pane" ? { ...d, label: "Close Terminal" } : d, ); - return [...terminalActions, ...modifiedDefaults]; + const killAction: ContextMenuActionConfig = { + key: "kill-terminal-session", + label: "Kill Terminal Session", + icon: , + variant: "destructive", + disabled: isKillingTerminalSession, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + alert({ + title: "Kill terminal session?", + description: + "This will terminate the underlying process. Move the terminal to background to keep it running without a pane.", + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Kill Session", + variant: "destructive", + onClick: () => { + killTerminalSession({ + terminalId, + workspaceId, + }); + }, + }, + ], + }); + }, + }; + + return [ + ...terminalActions, + ...modifiedDefaults, + { key: "sep-terminal-kill", type: "separator" }, + killAction, + ]; }, }, browser: { @@ -468,6 +543,8 @@ export function usePaneRegistry( workspaceId, clearShortcut, scrollToBottomShortcut, + killTerminalSession, + isKillingTerminalSession, onOpenFile, onRevealPath, ], 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 ff9ee2dde49..1fa0f09db63 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 @@ -316,7 +316,13 @@ function WorkspaceContent({ // right-sidebar-open width. const openSidebarFilePane = useCallback( (filePath: string, openInNewTab?: boolean) => { - recordRecentlyViewed(filePath); + // Defensively resolve to absolute path: git diff yields relative paths, + // but FilePane requires an absolute path for ensureWithinRoot checks. + const absoluteFilePath = + worktreePath && !filePath.startsWith("/") + ? toAbsoluteWorkspacePath(worktreePath, filePath) + : filePath; + recordRecentlyViewed(absoluteFilePath); const state = store.getState(); if (openInNewTab) { state.addTab({ @@ -324,7 +330,7 @@ function WorkspaceContent({ { kind: "file", data: { - filePath, + filePath: absoluteFilePath, mode: "editor", } as FilePaneData, }, @@ -338,7 +344,7 @@ function WorkspaceContent({ : null; if ( active?.pane.kind === "file" && - (active.pane.data as FilePaneData).filePath === filePath + (active.pane.data as FilePaneData).filePath === absoluteFilePath ) { state.setPanePinned({ paneId: active.pane.id, pinned: true }); return; @@ -355,7 +361,7 @@ function WorkspaceContent({ pane: { kind: "file", data: { - filePath, + filePath: absoluteFilePath, mode: "editor", } as FilePaneData, }, @@ -382,7 +388,7 @@ function WorkspaceContent({ splitPercentage: 100 - rightSidebarOpenViewWidth, }); }, - [rightSidebarOpenViewWidth, store, recordRecentlyViewed], + [rightSidebarOpenViewWidth, store, recordRecentlyViewed, worktreePath], ); const revealPath = useCallback( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx index 5152309b17e..48c40175136 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -113,6 +113,12 @@ export function V2WorkspacesList({ pinned, others }: V2WorkspacesListProps) { const hasAnyMatches = pinnedCount > 0 || othersCount > 0; const hasActiveFilters = searchQuery.trim() !== "" || deviceFilter !== "all"; + // FORK NOTE: upstream #3714 (99db5be26) は SortableHeader を使った + // columnHeader 行を導入したが、fork の V2WorkspacesList は sortField / + // sortDirection / handleSort を保持していないためそのまま取り込めない。 + // constants.ts の grid 列変更と V2WorkspaceRow の sidebar 操作位置変更は + // 取り込み済みなので、列ヘッダーは別 PR で fork 側に hook を足してから + // 戻す。empty 判定は fork の `!hasAnyMatches` (フィルタ込み) を維持。 if (!hasAnyMatches) { return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx index 566c3bc3709..df7d08f6a78 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx @@ -66,9 +66,13 @@ export function V2WorkspaceRow({ const handleRemoveFromSidebar = useCallback( (event: React.MouseEvent) => { event.stopPropagation(); + if (isCurrentRoute) { + event.preventDefault(); + return; + } removeWorkspaceFromSidebar(workspace.id); }, - [removeWorkspaceFromSidebar, workspace.id], + [isCurrentRoute, removeWorkspaceFromSidebar, workspace.id], ); const creatorLabel = workspace.isCreatedByCurrentUser @@ -131,16 +135,47 @@ export function V2WorkspaceRow({ isCurrentRoute && "bg-accent/40", )} > - +
{workspace.isInSidebar ? ( - - ) : null} - + + + + + + {isCurrentRoute + ? "Can't remove the current workspace" + : "Remove from sidebar"} + + + ) : ( + + + + + Add to sidebar + + )} +
{timeLabel} · {creatorLabel} - -
- {workspace.isInSidebar ? ( - - - - - - {isCurrentRoute - ? "Can't remove the current workspace" - : "Remove from sidebar"} - - - ) : ( - - - - - Add to sidebar - - )} -
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts index 738705ecda4..ed4b034fa9a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts @@ -1,5 +1,5 @@ // Shared grid template used by the column header row and every workspace row -// so the Sidebar / Name / Host / Branch / Created / Action columns align -// across the whole view. Columns hide progressively on narrower viewports. +// so the Sidebar action / Name / Host / Branch / Created columns align across +// the whole view. Columns hide progressively on narrower viewports. export const V2_WORKSPACES_ROW_GRID = - "grid grid-cols-[1.25rem_minmax(0,1fr)_2.5rem] gap-4 md:grid-cols-[1.25rem_minmax(0,1fr)_12rem_2.5rem] lg:grid-cols-[1.25rem_minmax(0,1fr)_12rem_14rem_2.5rem] xl:grid-cols-[1.25rem_minmax(0,1fr)_12rem_14rem_11rem_2.5rem] items-center"; + "grid grid-cols-[2.5rem_minmax(0,1fr)] gap-4 md:grid-cols-[2.5rem_minmax(0,1fr)_12rem] lg:grid-cols-[2.5rem_minmax(0,1fr)_12rem_14rem] xl:grid-cols-[2.5rem_minmax(0,1fr)_12rem_14rem_11rem] items-center"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx index 7ae6c85560a..68c7b8613c5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -1,3 +1,4 @@ +import { Checkbox } from "@superset/ui/checkbox"; import { Command, CommandEmpty, @@ -10,7 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useQuery } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { useState } from "react"; +import { useId, useState } from "react"; import { env } from "renderer/env.renderer"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -50,6 +51,8 @@ export function GitHubIssueLinkCommand({ }: GitHubIssueLinkCommandProps) { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); const debouncedQuery = useDebouncedValue(searchQuery, 300); const { activeHostUrl } = useLocalHostService(); @@ -69,6 +72,7 @@ export function GitHubIssueLinkCommand({ projectId, hostUrl, debouncedTrimmed, + showClosed, ], queryFn: async () => { if (!hostUrl || !projectId) return { issues: [] }; @@ -77,6 +81,7 @@ export function GitHubIssueLinkCommand({ projectId, query: debouncedTrimmed || undefined, limit: MAX_RESULTS, + includeClosed: showClosed, }); }, enabled: !!projectId && !!hostUrl && open, @@ -133,6 +138,19 @@ export function GitHubIssueLinkCommand({ value={searchQuery} onValueChange={setSearchQuery} /> +
+ setShowClosed(checked === true)} + /> + +
{searchResults.length === 0 && ( @@ -143,8 +161,12 @@ export function GitHubIssueLinkCommand({ : repoMismatch ? `Issue URL must match ${repoMismatch}.` : debouncedTrimmed - ? "No issues found." - : "No issues found."} + ? showClosed + ? "No issues found." + : "No open issues found." + : showClosed + ? "No issues found." + : "No open issues found."} )} {searchResults.length > 0 && ( @@ -152,7 +174,9 @@ export function GitHubIssueLinkCommand({ heading={ debouncedTrimmed ? `${searchResults.length} result${searchResults.length === 1 ? "" : "s"}` - : "Recent issues" + : showClosed + ? "Recent issues" + : "Open issues" } > {searchResults.map((issue) => ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx index 889f71dfbc7..4bfabc827e1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx @@ -1,3 +1,4 @@ +import { Checkbox } from "@superset/ui/checkbox"; import { Command, CommandEmpty, @@ -10,7 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useQuery } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { useState } from "react"; +import { useId, useState } from "react"; import { env } from "renderer/env.renderer"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -51,6 +52,8 @@ export function PRLinkCommand({ }: PRLinkCommandProps) { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); const debouncedQuery = useDebouncedValue(searchQuery, 300); const { activeHostUrl } = useLocalHostService(); @@ -70,6 +73,7 @@ export function PRLinkCommand({ projectId, hostUrl, debouncedTrimmed, + showClosed, ], queryFn: async () => { if (!hostUrl || !projectId) return { pullRequests: [] }; @@ -78,6 +82,7 @@ export function PRLinkCommand({ projectId, query: debouncedTrimmed || undefined, limit: 30, + includeClosed: showClosed, }); }, enabled: !!projectId && !!hostUrl && open, @@ -129,6 +134,19 @@ export function PRLinkCommand({ value={searchQuery} onValueChange={setSearchQuery} /> +
+ setShowClosed(checked === true)} + /> + +
{pullRequests.length === 0 && ( @@ -139,8 +157,12 @@ export function PRLinkCommand({ : repoMismatch ? `PR URL must match ${repoMismatch}.` : debouncedTrimmed - ? "No pull requests found." - : "No pull requests found."} + ? showClosed + ? "No pull requests found." + : "No open pull requests found." + : showClosed + ? "No pull requests found." + : "No open pull requests."} )} {pullRequests.length > 0 && ( @@ -148,7 +170,9 @@ export function PRLinkCommand({ heading={ debouncedTrimmed ? `${pullRequests.length} result${pullRequests.length === 1 ? "" : "s"}` - : "Recent PRs" + : showClosed + ? "Recent PRs" + : "Open PRs" } > {pullRequests.map((pr) => ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts index 92c2a17f489..2f5768fa2af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts @@ -3,6 +3,12 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useRef } from "react"; import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "../../../utils/paneLifecycleRows"; /** * Grace period for cross-workspace pane moves / renames before destroying. @@ -10,20 +16,21 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect */ const DESTROY_DELAY_MS = 500; -function extractBrowserPaneIds(rows: { paneLayout: unknown }[]): Set { - const ids = new Set(); - for (const row of rows) { - const layout = row.paneLayout as WorkspaceState | undefined; - if (!layout?.tabs) continue; - for (const tab of layout.tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind === "browser") { - ids.add(pane.id); - } - } - } - } - return ids; +interface PendingBrowserDestruction { + workspaceId: string; + timer: ReturnType | null; +} + +function getBrowserPaneId( + pane: WorkspaceState["tabs"][number]["panes"][string], +): string | null { + return pane.kind === "browser" ? pane.id : null; +} + +function extractBrowserLocations( + rows: PaneLifecycleRow[], +): Map { + return extractPaneLocations(rows, getBrowserPaneId); } /** @@ -40,8 +47,8 @@ function extractBrowserPaneIds(rows: { paneLayout: unknown }[]): Set { */ export function useGlobalBrowserLifecycle() { const collections = useCollections(); - const prevBrowserIdsRef = useRef>(new Set()); - const pendingDestruction = useRef>>( + const prevBrowserLocationsRef = useRef>(new Map()); + const pendingDestruction = useRef>( new Map(), ); @@ -54,47 +61,79 @@ export function useGlobalBrowserLifecycle() { ); useEffect(() => { - const currentBrowserIds = extractBrowserPaneIds(allWorkspaceRows); - const prevBrowserIds = prevBrowserIdsRef.current; + const rows = allWorkspaceRows as PaneLifecycleRow[]; + const currentBrowserLocations = extractBrowserLocations(rows); + const currentWorkspaceIds = extractWorkspaceIds(rows); + const prevBrowserLocations = prevBrowserLocationsRef.current; // Cancel any pending destruction for ids that reappeared (e.g. pane // moved between workspaces, user undo, or the transient replaceState // churn we were fighting in the first place). - for (const browserId of currentBrowserIds) { - const timer = pendingDestruction.current.get(browserId); - if (timer) { - clearTimeout(timer); + for (const browserId of currentBrowserLocations.keys()) { + const pending = pendingDestruction.current.get(browserId); + if (pending?.timer) { + clearTimeout(pending.timer); + } + pendingDestruction.current.delete(browserId); + } + + // If a pane was authoritatively removed but the owner row disappeared + // before the grace timer fired, keep waiting until that row is present + // again. That avoids destroying webviews during sleep/wake while still + // cleaning up when the post-removal layout comes back. + for (const [browserId, pending] of pendingDestruction.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { pendingDestruction.current.delete(browserId); + browserRuntimeRegistry.destroy(browserId); } } - for (const browserId of prevBrowserIds) { - if (currentBrowserIds.has(browserId)) continue; + const removedLocations = getRemovedPaneLocations({ + previousLocations: prevBrowserLocations, + currentLocations: currentBrowserLocations, + currentWorkspaceIds, + }); + + for (const { id: browserId, workspaceId } of removedLocations) { if (pendingDestruction.current.has(browserId)) continue; const timer = setTimeout(() => { - pendingDestruction.current.delete(browserId); - const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ); - const freshIds = extractBrowserPaneIds(freshRows); + ) as PaneLifecycleRow[]; + const freshLocations = extractBrowserLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); - if (!freshIds.has(browserId)) { + if (freshLocations.has(browserId)) { + pendingDestruction.current.delete(browserId); + return; + } + + if (freshWorkspaceIds.has(workspaceId)) { + pendingDestruction.current.delete(browserId); browserRuntimeRegistry.destroy(browserId); + return; + } + + const pending = pendingDestruction.current.get(browserId); + if (pending) { + pending.timer = null; } }, DESTROY_DELAY_MS); - pendingDestruction.current.set(browserId, timer); + pendingDestruction.current.set(browserId, { workspaceId, timer }); } - prevBrowserIdsRef.current = currentBrowserIds; + prevBrowserLocationsRef.current = currentBrowserLocations; }, [allWorkspaceRows, collections]); useEffect(() => { return () => { - for (const timer of pendingDestruction.current.values()) { - clearTimeout(timer); + for (const pending of pendingDestruction.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } } pendingDestruction.current.clear(); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts index 172eb65ac26..ee39bff4278 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -1,41 +1,124 @@ import type { WorkspaceState } from "@superset/panes"; +import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { env } from "renderer/env.renderer"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { consumeTerminalBackgroundIntent } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "../../../utils/paneLifecycleRows"; -/** Grace period for cross-workspace pane moves before disposing. */ -const DISPOSE_DELAY_MS = 500; +/** Grace period for cross-workspace pane moves before terminal cleanup. */ +const RELEASE_DELAY_MS = 500; interface TerminalPaneData { terminalId: string; } -function extractTerminalIds(rows: { paneLayout: unknown }[]): Set { - const ids = new Set(); - for (const row of rows) { - const layout = row.paneLayout as WorkspaceState | undefined; - if (!layout?.tabs) continue; - for (const tab of layout.tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind === "terminal") { - const data = pane.data as TerminalPaneData; - if (data.terminalId) { - ids.add(data.terminalId); - } - } - } - } +interface PendingTerminalCleanup { + workspaceId: string; + timer: ReturnType | null; +} + +interface PendingTerminalInstanceRelease { + terminalId: string; + workspaceId: string; + timer: ReturnType | null; +} + +function getTerminalId( + pane: WorkspaceState["tabs"][number]["panes"][string], +): string | null { + if (pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const data = pane.data as Partial; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +function getTerminalInstanceKey( + pane: WorkspaceState["tabs"][number]["panes"][string], +): string | null { + const terminalId = getTerminalId(pane); + return terminalId ? `${terminalId}\u0000${pane.id}` : null; +} + +function parseTerminalInstanceKey( + key: string, +): { terminalId: string; instanceId: string } | null { + const separatorIndex = key.indexOf("\u0000"); + if (separatorIndex === -1) return null; + return { + terminalId: key.slice(0, separatorIndex), + instanceId: key.slice(separatorIndex + 1), + }; +} + +function extractTerminalLocations( + rows: PaneLifecycleRow[], +): Map { + return extractPaneLocations(rows, getTerminalId); +} + +function extractTerminalInstanceLocations( + rows: PaneLifecycleRow[], +): Map { + return extractPaneLocations(rows, getTerminalInstanceKey); +} + +function cleanupRemovedTerminal({ + terminalId, + workspaceId, + hostUrlByWorkspaceId, +}: { + terminalId: string; + workspaceId: string; + hostUrlByWorkspaceId: Map; +}) { + if (consumeTerminalBackgroundIntent(terminalId)) { + terminalRuntimeRegistry.release(terminalId); + return; + } + + terminalRuntimeRegistry.dispose(terminalId); + const hostUrl = hostUrlByWorkspaceId.get(workspaceId); + if (!hostUrl) { + console.warn( + "[GlobalTerminalLifecycle] Missing host URL while killing removed terminal", + { terminalId, workspaceId }, + ); + return; } - return ids; + + getHostServiceClientByUrl(hostUrl) + .terminal.killSession.mutate({ terminalId, workspaceId }) + .catch((error) => { + console.warn( + "[GlobalTerminalLifecycle] Failed to kill removed terminal", + { terminalId, workspaceId, error }, + ); + }); } export function useGlobalTerminalLifecycle() { const collections = useCollections(); - const prevTerminalIdsRef = useRef>(new Set()); - const pendingDisposals = useRef>>( + const { machineId, activeHostUrl } = useLocalHostService(); + const prevTerminalLocationsRef = useRef>(new Map()); + const prevTerminalInstanceLocationsRef = useRef>( new Map(), ); + const pendingCleanups = useRef>( + new Map(), + ); + const pendingInstanceReleases = useRef< + Map + >(new Map()); const { data: allWorkspaceRows = [] } = useLiveQuery( (query) => @@ -45,47 +128,198 @@ export function useGlobalTerminalLifecycle() { [collections], ); + const { data: workspacesWithHosts = [] } = useLiveQuery( + (query) => + query + .from({ v2Workspaces: collections.v2Workspaces }) + .leftJoin({ hosts: collections.v2Hosts }, ({ v2Workspaces, hosts }) => + eq(v2Workspaces.hostId, hosts.id), + ) + .select(({ v2Workspaces, hosts }) => ({ + workspaceId: v2Workspaces.id, + hostId: v2Workspaces.hostId, + hostMachineId: hosts?.machineId ?? null, + })), + [collections], + ); + + const hostUrlByWorkspaceId = useMemo(() => { + const urls = new Map(); + for (const workspace of workspacesWithHosts) { + if (workspace.hostMachineId === machineId) { + if (activeHostUrl) { + urls.set(workspace.workspaceId, activeHostUrl); + } + continue; + } + + if (workspace.hostId) { + urls.set( + workspace.workspaceId, + `${env.RELAY_URL}/hosts/${workspace.hostId}`, + ); + } + } + return urls; + }, [activeHostUrl, machineId, workspacesWithHosts]); + const hostUrlByWorkspaceIdRef = useRef(hostUrlByWorkspaceId); + hostUrlByWorkspaceIdRef.current = hostUrlByWorkspaceId; + useEffect(() => { - const currentTerminalIds = extractTerminalIds(allWorkspaceRows); - const prevTerminalIds = prevTerminalIdsRef.current; - - for (const terminalId of currentTerminalIds) { - const timer = pendingDisposals.current.get(terminalId); - if (timer) { - clearTimeout(timer); - pendingDisposals.current.delete(terminalId); + const rows = allWorkspaceRows as PaneLifecycleRow[]; + const currentTerminalLocations = extractTerminalLocations(rows); + const currentTerminalInstanceLocations = + extractTerminalInstanceLocations(rows); + const currentWorkspaceIds = extractWorkspaceIds(rows); + const prevTerminalLocations = prevTerminalLocationsRef.current; + const prevTerminalInstanceLocations = + prevTerminalInstanceLocationsRef.current; + + for (const terminalId of currentTerminalLocations.keys()) { + const pending = pendingCleanups.current.get(terminalId); + if (pending?.timer) { + clearTimeout(pending.timer); } + pendingCleanups.current.delete(terminalId); } - for (const terminalId of prevTerminalIds) { - if (currentTerminalIds.has(terminalId)) continue; - if (pendingDisposals.current.has(terminalId)) continue; + for (const instanceKey of currentTerminalInstanceLocations.keys()) { + const pending = pendingInstanceReleases.current.get(instanceKey); + if (pending?.timer) { + clearTimeout(pending.timer); + } + pendingInstanceReleases.current.delete(instanceKey); + } + + // If a pane was authoritatively removed but the owner row disappeared + // before the grace timer fired, keep waiting until that row is present + // again. That avoids releasing active renderer state during sleep/wake + // while still cleaning up when the post-removal layout comes back. + for (const [terminalId, pending] of pendingCleanups.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { + pendingCleanups.current.delete(terminalId); + cleanupRemovedTerminal({ + terminalId, + workspaceId: pending.workspaceId, + hostUrlByWorkspaceId: hostUrlByWorkspaceIdRef.current, + }); + } + } + + for (const [instanceKey, pending] of pendingInstanceReleases.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { + pendingInstanceReleases.current.delete(instanceKey); + const parsed = parseTerminalInstanceKey(instanceKey); + if (parsed) { + terminalRuntimeRegistry.release(parsed.terminalId, parsed.instanceId); + } + } + } + + const removedInstanceLocations = getRemovedPaneLocations({ + previousLocations: prevTerminalInstanceLocations, + currentLocations: currentTerminalInstanceLocations, + currentWorkspaceIds, + }); + + for (const { id: instanceKey, workspaceId } of removedInstanceLocations) { + if (pendingInstanceReleases.current.has(instanceKey)) continue; + + const parsed = parseTerminalInstanceKey(instanceKey); + if (!parsed) continue; const timer = setTimeout(() => { - pendingDisposals.current.delete(terminalId); + const freshRows = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) as PaneLifecycleRow[]; + const freshInstanceLocations = + extractTerminalInstanceLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); + + if (freshInstanceLocations.has(instanceKey)) { + pendingInstanceReleases.current.delete(instanceKey); + return; + } + if (freshWorkspaceIds.has(workspaceId)) { + pendingInstanceReleases.current.delete(instanceKey); + terminalRuntimeRegistry.release(parsed.terminalId, parsed.instanceId); + return; + } + + const pending = pendingInstanceReleases.current.get(instanceKey); + if (pending) { + pending.timer = null; + } + }, RELEASE_DELAY_MS); + + pendingInstanceReleases.current.set(instanceKey, { + terminalId: parsed.terminalId, + workspaceId, + timer, + }); + } + + const removedLocations = getRemovedPaneLocations({ + previousLocations: prevTerminalLocations, + currentLocations: currentTerminalLocations, + currentWorkspaceIds, + }); + + for (const { id: terminalId, workspaceId } of removedLocations) { + if (pendingCleanups.current.has(terminalId)) continue; + + const timer = setTimeout(() => { const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ); - const freshIds = extractTerminalIds(freshRows); + ) as PaneLifecycleRow[]; + const freshLocations = extractTerminalLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); + + if (freshLocations.has(terminalId)) { + pendingCleanups.current.delete(terminalId); + return; + } + + if (freshWorkspaceIds.has(workspaceId)) { + pendingCleanups.current.delete(terminalId); + cleanupRemovedTerminal({ + terminalId, + workspaceId, + hostUrlByWorkspaceId: hostUrlByWorkspaceIdRef.current, + }); + return; + } - if (!freshIds.has(terminalId)) { - terminalRuntimeRegistry.dispose(terminalId); + const pending = pendingCleanups.current.get(terminalId); + if (pending) { + pending.timer = null; } - }, DISPOSE_DELAY_MS); + }, RELEASE_DELAY_MS); - pendingDisposals.current.set(terminalId, timer); + pendingCleanups.current.set(terminalId, { workspaceId, timer }); } - prevTerminalIdsRef.current = currentTerminalIds; + prevTerminalLocationsRef.current = currentTerminalLocations; + prevTerminalInstanceLocationsRef.current = currentTerminalInstanceLocations; }, [allWorkspaceRows, collections]); useEffect(() => { return () => { - for (const timer of pendingDisposals.current.values()) { - clearTimeout(timer); + for (const pending of pendingCleanups.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } + } + pendingCleanups.current.clear(); + for (const pending of pendingInstanceReleases.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } } - pendingDisposals.current.clear(); + pendingInstanceReleases.current.clear(); }; }, []); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts new file mode 100644 index 00000000000..bd0002dcfd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts @@ -0,0 +1,8 @@ +export { + extractPaneIds, + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, + type RemovedPaneLocation, +} from "./paneLifecycleRows"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts new file mode 100644 index 00000000000..2f87536559a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { + extractPaneIds, + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "./paneLifecycleRows"; + +function row( + workspaceId: string, + panes: Record, +): PaneLifecycleRow { + return { + workspaceId, + paneLayout: { + tabs: [ + { + id: `${workspaceId}-tab`, + title: "Tab", + panes, + layout: null, + activePaneId: null, + }, + ], + activeTabId: `${workspaceId}-tab`, + }, + }; +} + +function terminalPane(id: string) { + return { + id: `pane-${id}`, + kind: "terminal", + data: { terminalId: id }, + }; +} + +function terminalIdForPane(pane: { + kind: string; + data: unknown; +}): string | null { + if (pane.kind !== "terminal") return null; + const data = pane.data as { terminalId?: unknown }; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +describe("paneLifecycleRows", () => { + test("extracts workspace IDs and tracked pane locations", () => { + const rows = [ + row("workspace-a", { + "pane-term-1": terminalPane("term-1"), + "pane-file-1": { id: "pane-file-1", kind: "file", data: {} }, + }), + row("workspace-b", { + "pane-term-2": terminalPane("term-2"), + }), + ]; + + expect([...extractWorkspaceIds(rows)]).toEqual([ + "workspace-a", + "workspace-b", + ]); + expect([ + ...extractPaneLocations(rows, terminalIdForPane).entries(), + ]).toEqual([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + expect([...extractPaneIds(rows, terminalIdForPane)]).toEqual([ + "term-1", + "term-2", + ]); + }); + + test("marks a pane removed only when its owner workspace row is present", () => { + const previousLocations = new Map([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + const currentLocations = new Map([["term-2", "workspace-b"]]); + const currentWorkspaceIds = new Set(["workspace-a", "workspace-b"]); + + expect( + getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, + }), + ).toEqual([{ id: "term-1", workspaceId: "workspace-a" }]); + }); + + test("ignores panes whose owner workspace row disappeared", () => { + const previousLocations = new Map([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + const currentLocations = new Map([["term-2", "workspace-b"]]); + const currentWorkspaceIds = new Set(["workspace-b"]); + + expect( + getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, + }), + ).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts new file mode 100644 index 00000000000..e5873152b24 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts @@ -0,0 +1,93 @@ +import type { Pane, WorkspaceState } from "@superset/panes"; + +export interface PaneLifecycleRow { + workspaceId: unknown; + paneLayout: unknown; +} + +export interface RemovedPaneLocation { + id: string; + workspaceId: string; +} + +export function extractWorkspaceIds(rows: PaneLifecycleRow[]): Set { + const workspaceIds = new Set(); + for (const row of rows) { + if (typeof row.workspaceId === "string") { + workspaceIds.add(row.workspaceId); + } + } + return workspaceIds; +} + +export function extractPaneLocations( + rows: PaneLifecycleRow[], + getTrackedPaneId: (pane: Pane) => string | null, +): Map { + const locations = new Map(); + + for (const row of rows) { + if (typeof row.workspaceId !== "string") continue; + + const layout = row.paneLayout as WorkspaceState | undefined; + if (!layout?.tabs) continue; + + for (const tab of layout.tabs) { + for (const pane of Object.values(tab.panes)) { + const trackedPaneId = getTrackedPaneId(pane); + if (trackedPaneId) { + locations.set(trackedPaneId, row.workspaceId); + } + } + } + } + + return locations; +} + +export function extractPaneIds( + rows: PaneLifecycleRow[], + getTrackedPaneId: (pane: Pane) => string | null, +): Set { + const ids = new Set(); + + for (const row of rows) { + const layout = row.paneLayout as WorkspaceState | undefined; + if (!layout?.tabs) continue; + + for (const tab of layout.tabs) { + for (const pane of Object.values(tab.panes)) { + const trackedPaneId = getTrackedPaneId(pane); + if (trackedPaneId) { + ids.add(trackedPaneId); + } + } + } + } + + return ids; +} + +export function getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, +}: { + previousLocations: Map; + currentLocations: Map; + currentWorkspaceIds: Set; +}): RemovedPaneLocation[] { + const removed: RemovedPaneLocation[] = []; + + for (const [id, workspaceId] of previousLocations) { + if (currentLocations.has(id)) continue; + // A missing owner row means the collection snapshot is not authoritative + // for this pane. This happens during org/provider churn and can happen + // briefly after laptop sleep/wake. Intentional sidebar-row removals clean + // up their pane runtimes before deleting the row. + if (!currentWorkspaceIds.has(workspaceId)) continue; + removed.push({ id, workspaceId }); + } + + return removed; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 438d1b5be51..2cbb3417b05 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -1,5 +1,11 @@ -import type { WorkspaceState } from "@superset/panes"; +import type { Pane, WorkspaceState } from "@superset/panes"; import { useCallback } from "react"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; +import { + extractPaneIds, + type PaneLifecycleRow, +} from "renderer/routes/_authenticated/components/utils/paneLifecycleRows"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; import { PROJECT_CUSTOM_COLORS } from "shared/constants/project-colors"; @@ -80,6 +86,26 @@ function ensureSidebarWorkspaceRecord( }); } +function getTerminalRuntimeId(pane: Pane): string | null { + if (pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const data = pane.data as { terminalId?: unknown }; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +function getBrowserRuntimeId(pane: Pane): string | null { + return pane.kind === "browser" ? pane.id : null; +} + +function cleanupWorkspacePaneRuntimes(rows: PaneLifecycleRow[]): void { + for (const terminalId of extractPaneIds(rows, getTerminalRuntimeId)) { + terminalRuntimeRegistry.release(terminalId); + } + for (const browserId of extractPaneIds(rows, getBrowserRuntimeId)) { + browserRuntimeRegistry.destroy(browserId); + } +} + export function useDashboardSidebarState() { const collections = useCollections(); @@ -403,7 +429,9 @@ export function useDashboardSidebarState() { const removeWorkspaceFromSidebar = useCallback( (workspaceId: string) => { - if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + const workspace = collections.v2WorkspaceLocalState.get(workspaceId); + if (!workspace) return; + cleanupWorkspacePaneRuntimes([workspace]); collections.v2WorkspaceLocalState.delete(workspaceId); }, [collections], @@ -411,11 +439,10 @@ export function useDashboardSidebarState() { const removeProjectFromSidebar = useCallback( (projectId: string) => { - const workspaceIds = Array.from( + const workspaceRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ) - .filter((item) => item.sidebarState.projectId === projectId) - .map((item) => item.workspaceId); + ).filter((item) => item.sidebarState.projectId === projectId); + const workspaceIds = workspaceRows.map((item) => item.workspaceId); const sectionIds = Array.from( collections.v2SidebarSections.state.values(), ) @@ -423,6 +450,7 @@ export function useDashboardSidebarState() { .map((item) => item.sectionId); if (workspaceIds.length > 0) { + cleanupWorkspacePaneRuntimes(workspaceRows); collections.v2WorkspaceLocalState.delete(workspaceIds); } if (sectionIds.length > 0) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 2c65e5b5a48..9aac85bbfa4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -185,9 +185,9 @@ function AuthenticatedLayout() { return ( - + Setup incomplete -

{workspaceName}

+

+ {workspaceName} +

Workspace setup didn't finish. You can retry or remove it.

@@ -213,7 +218,12 @@ export function WorkspaceInitializingView({

Workspace setup failed

-

{workspaceName}

+

+ {workspaceName} +

{progress?.error && (

{progress.error} diff --git a/packages/chat/src/server/trpc/service.test.ts b/packages/chat/src/server/trpc/service.test.ts index 7adf7272d1e..af4f55e508b 100644 --- a/packages/chat/src/server/trpc/service.test.ts +++ b/packages/chat/src/server/trpc/service.test.ts @@ -20,14 +20,17 @@ mock.module("mastracode", () => ({ const { ChatRuntimeService } = await import("./service"); -function createRuntime(): RuntimeSession { +function createRuntime(options?: { + respondToQuestion?: RuntimeSession["harness"]["respondToQuestion"]; +}): RuntimeSession { return { sessionId: SESSION_ID, cwd: CWD, harness: { abort: mock(() => {}), respondToToolApproval: mock(async (payload: unknown) => payload), - respondToQuestion: mock(async (payload: unknown) => payload), + respondToQuestion: + options?.respondToQuestion ?? mock(async (payload: unknown) => payload), respondToPlanApproval: mock(async (payload: unknown) => payload), } as unknown as RuntimeSession["harness"], mcpManager: null as RuntimeSession["mcpManager"], @@ -39,11 +42,13 @@ function createRuntime(): RuntimeSession { path: "/tmp/secret", reason: "Need access", }, + answeredQuestionIds: new Set(), + pendingQuestionResponses: new Map(), }; } -function createServiceHarness() { - const runtime = createRuntime(); +function createServiceHarness(options?: Parameters[0]) { + const runtime = createRuntime(options); const service = new ChatRuntimeService({ headers: async () => ({}), apiUrl: "http://localhost:3000", @@ -134,4 +139,61 @@ describe("ChatRuntimeService control mutations", () => { }); expect(runtime.pendingSandboxQuestion).toBeNull(); }); + + it("does not clear pending question state when question response fails", async () => { + const respondToQuestion = mock(async () => { + throw new Error("failed to answer"); + }) as RuntimeSession["harness"]["respondToQuestion"]; + const { caller, runtime } = createServiceHarness({ respondToQuestion }); + + await expect( + caller.session.question.respond({ + sessionId: SESSION_ID, + cwd: CWD, + payload: { questionId: "sandbox-1", answer: "Yes" }, + }), + ).rejects.toThrow("failed to answer"); + + expect(runtime.answeredQuestionIds.has("sandbox-1")).toBe(false); + expect(runtime.pendingSandboxQuestion).toEqual({ + questionId: "sandbox-1", + path: "/tmp/secret", + reason: "Need access", + }); + }); + + it("deduplicates concurrent responses for the same question", async () => { + let resolveResponse: (value: unknown) => void = () => {}; + const respondToQuestion = mock( + () => + new Promise((resolve) => { + resolveResponse = resolve; + }), + ) as RuntimeSession["harness"]["respondToQuestion"]; + const { caller, runtime } = createServiceHarness({ respondToQuestion }); + const payload = { questionId: "sandbox-1", answer: "Yes" }; + + const firstResponse = caller.session.question.respond({ + sessionId: SESSION_ID, + cwd: CWD, + payload, + }); + const secondResponse = caller.session.question.respond({ + sessionId: SESSION_ID, + cwd: CWD, + payload, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(respondToQuestion).toHaveBeenCalledTimes(1); + expect(runtime.answeredQuestionIds.has("sandbox-1")).toBe(true); + expect(runtime.pendingSandboxQuestion).toBeNull(); + + resolveResponse({ ok: true }); + + await expect(firstResponse).resolves.toEqual({ ok: true }); + await expect(secondResponse).resolves.toEqual({ ok: true }); + expect(runtime.pendingQuestionResponses.size).toBe(0); + }); }); diff --git a/packages/chat/src/server/trpc/service.ts b/packages/chat/src/server/trpc/service.ts index 4a4351d0412..99a23149b0e 100644 --- a/packages/chat/src/server/trpc/service.ts +++ b/packages/chat/src/server/trpc/service.ts @@ -12,6 +12,7 @@ import { getRuntimeMcpOverview, type LifecycleEvent, onUserPromptSubmit, + type RuntimeQuestionResponse, type RuntimeSession, reloadHookConfig, restartRuntimeFromUserMessage, @@ -36,6 +37,56 @@ import { } from "./zod"; const ENABLE_MASTRA_MCP_SERVERS = false; + +type RuntimeQuestionPayload = Parameters< + RuntimeSession["harness"]["respondToQuestion"] +>[0]; + +function respondToQuestionWithOptimisticState( + runtime: RuntimeSession, + payload: RuntimeQuestionPayload, +): Promise { + const questionId = payload.questionId; + const pendingResponse = runtime.pendingQuestionResponses.get(questionId); + if (pendingResponse) return pendingResponse; + + const wasAlreadyAnswered = runtime.answeredQuestionIds.has(questionId); + const previousSandboxQuestion = runtime.pendingSandboxQuestion; + const clearsSandboxQuestion = + previousSandboxQuestion?.questionId === questionId; + + runtime.answeredQuestionIds.add(questionId); + if (clearsSandboxQuestion) { + runtime.pendingSandboxQuestion = null; + } + + let responsePromise: Promise; + responsePromise = Promise.resolve() + .then(() => runtime.harness.respondToQuestion(payload)) + .catch((error) => { + if ( + runtime.pendingQuestionResponses.get(questionId) === responsePromise + ) { + if (!wasAlreadyAnswered) { + runtime.answeredQuestionIds.delete(questionId); + } + if (clearsSandboxQuestion && runtime.pendingSandboxQuestion === null) { + runtime.pendingSandboxQuestion = previousSandboxQuestion; + } + } + throw error; + }) + .finally(() => { + if ( + runtime.pendingQuestionResponses.get(questionId) === responsePromise + ) { + runtime.pendingQuestionResponses.delete(questionId); + } + }); + runtime.pendingQuestionResponses.set(questionId, responsePromise); + return responsePromise; +} + function resolveOmModelFromAuth(): string | undefined { if (process.env.GOOGLE_GENERATIVE_AI_API_KEY) return "google/gemini-2.5-flash"; @@ -139,6 +190,8 @@ export class ChatRuntimeService { mcpManualStatuses: new Map(), lastErrorMessage: null, pendingSandboxQuestion: null, + answeredQuestionIds: new Set(), + pendingQuestionResponses: new Map(), cwd: runtimeCwd, }; syncRuntimeHookSessionId(sessionRuntime); @@ -223,22 +276,30 @@ export class ChatRuntimeService { ? { questionId: runtime.pendingSandboxQuestion.questionId, question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`, + description: runtime.pendingSandboxQuestion.reason, options: [ { label: "Yes", - description: `Allow access. Reason: ${runtime.pendingSandboxQuestion.reason}`, - }, - { - label: "No", - description: "Deny access.", + description: "Allow access.", }, + { label: "No", description: "Deny access." }, ], } : null; + // Skip any pending question whose ID was already answered this turn. + // The harness only clears pendingQuestion on agent_end, so without this + // filter an answered ask_user question would permanently shadow the + // sandbox question that fired in the same turn. + const harnessPendingQuestion = + displayState.pendingQuestion && + !runtime.answeredQuestionIds.has( + displayState.pendingQuestion.questionId, + ) + ? displayState.pendingQuestion + : null; return { ...displayState, - pendingQuestion: - displayState.pendingQuestion ?? sandboxPendingQuestion, + pendingQuestion: harnessPendingQuestion ?? sandboxPendingQuestion, errorMessage: currentMessageError ?? runtime.lastErrorMessage, }; }), @@ -347,13 +408,10 @@ export class ChatRuntimeService { input.sessionId, input.cwd, ); - if ( - runtime.pendingSandboxQuestion?.questionId === - input.payload.questionId - ) { - runtime.pendingSandboxQuestion = null; - } - return runtime.harness.respondToQuestion(input.payload); + return respondToQuestionWithOptimisticState( + runtime, + input.payload, + ); }), }), diff --git a/packages/chat/src/server/trpc/utils/runtime/index.ts b/packages/chat/src/server/trpc/utils/runtime/index.ts index 38ea1bdf76d..5560ce025c4 100644 --- a/packages/chat/src/server/trpc/utils/runtime/index.ts +++ b/packages/chat/src/server/trpc/utils/runtime/index.ts @@ -7,6 +7,7 @@ export { type RuntimeHookManager, type RuntimeMcpManager, type RuntimeMcpServerStatus, + type RuntimeQuestionResponse, type RuntimeSession, reloadHookConfig, restartRuntimeFromUserMessage, diff --git a/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts b/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts index e4af003961a..8c661ac063d 100644 --- a/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts +++ b/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts @@ -58,6 +58,8 @@ function createRuntimeForTest(): { mcpManualStatuses: new Map(), lastErrorMessage: null, pendingSandboxQuestion: null, + answeredQuestionIds: new Set(), + pendingQuestionResponses: new Map(), cwd: "/tmp", }; @@ -109,6 +111,8 @@ function createRuntimeForTitleTest(options?: { mcpManualStatuses: new Map(), lastErrorMessage: null, pendingSandboxQuestion: null, + answeredQuestionIds: new Set(), + pendingQuestionResponses: new Map(), cwd: "/tmp", }; @@ -346,6 +350,8 @@ describe("runtime message restart", () => { mcpManualStatuses: new Map(), lastErrorMessage: "stale error", pendingSandboxQuestion: null, + answeredQuestionIds: new Set(), + pendingQuestionResponses: new Map(), cwd: "/tmp", }; diff --git a/packages/chat/src/server/trpc/utils/runtime/runtime.ts b/packages/chat/src/server/trpc/utils/runtime/runtime.ts index 923e1fcfde1..5a3678eace5 100644 --- a/packages/chat/src/server/trpc/utils/runtime/runtime.ts +++ b/packages/chat/src/server/trpc/utils/runtime/runtime.ts @@ -16,6 +16,9 @@ export type RuntimeMcpManager = Awaited< export type RuntimeHookManager = Awaited< ReturnType >["hookManager"]; +export type RuntimeQuestionResponse = Awaited< + ReturnType +>; export interface RuntimeMcpServerStatus { connected: boolean; @@ -35,6 +38,8 @@ export interface RuntimeSession { path: string; reason: string; } | null; + answeredQuestionIds: Set; + pendingQuestionResponses: Map>; cwd: string; } @@ -237,6 +242,8 @@ export function subscribeToSessionEvents( if (isHarnessAgentStartEvent(event)) { runtime.lastErrorMessage = null; runtime.pendingSandboxQuestion = null; + runtime.answeredQuestionIds.clear(); + runtime.pendingQuestionResponses.clear(); onLifecycleEvent?.({ sessionId: runtime.sessionId, eventType: "Start", @@ -245,6 +252,8 @@ export function subscribeToSessionEvents( } if (isHarnessAgentEndEvent(event)) { runtime.pendingSandboxQuestion = null; + runtime.answeredQuestionIds.clear(); + runtime.pendingQuestionResponses.clear(); const raw = event.reason; const reason = raw === "aborted" || raw === "error" ? raw : "complete"; if (runtime.hookManager) { diff --git a/packages/host-service/src/runtime/chat/chat.ts b/packages/host-service/src/runtime/chat/chat.ts index 8fca5e6ffb7..ed9f83bb871 100644 --- a/packages/host-service/src/runtime/chat/chat.ts +++ b/packages/host-service/src/runtime/chat/chat.ts @@ -56,12 +56,13 @@ interface PendingSandboxQuestion { interface ChatPendingQuestionOption { label: string; - description: string; + description?: string; } interface ChatPendingQuestion { questionId: string; question: string; + description?: string; options: ChatPendingQuestionOption[]; } @@ -99,6 +100,53 @@ interface RuntimeSession { hookManager: RuntimeHookManager; lastErrorMessage: string | null; pendingSandboxQuestion: PendingSandboxQuestion | null; + answeredQuestionIds: Set; + pendingQuestionResponses: Map>; +} + +function respondToQuestionWithOptimisticState( + runtime: RuntimeSession, + payload: ChatQuestionPayload, +): Promise { + const questionId = payload.questionId; + const pendingResponse = runtime.pendingQuestionResponses.get(questionId); + if (pendingResponse) return pendingResponse; + + const wasAlreadyAnswered = runtime.answeredQuestionIds.has(questionId); + const previousSandboxQuestion = runtime.pendingSandboxQuestion; + const clearsSandboxQuestion = + previousSandboxQuestion?.questionId === questionId; + + runtime.answeredQuestionIds.add(questionId); + if (clearsSandboxQuestion) { + runtime.pendingSandboxQuestion = null; + } + + let responsePromise: Promise; + responsePromise = Promise.resolve() + .then(() => runtime.harness.respondToQuestion(payload)) + .catch((error) => { + if ( + runtime.pendingQuestionResponses.get(questionId) === responsePromise + ) { + if (!wasAlreadyAnswered) { + runtime.answeredQuestionIds.delete(questionId); + } + if (clearsSandboxQuestion && runtime.pendingSandboxQuestion === null) { + runtime.pendingSandboxQuestion = previousSandboxQuestion; + } + } + throw error; + }) + .finally(() => { + if ( + runtime.pendingQuestionResponses.get(questionId) === responsePromise + ) { + runtime.pendingQuestionResponses.delete(questionId); + } + }); + runtime.pendingQuestionResponses.set(questionId, responsePromise); + return responsePromise; } interface RuntimeStoredMessage { @@ -339,11 +387,15 @@ export class ChatRuntimeManager { if (isObjectRecord(event) && event.type === "agent_start") { runtime.lastErrorMessage = null; runtime.pendingSandboxQuestion = null; + runtime.answeredQuestionIds.clear(); + runtime.pendingQuestionResponses.clear(); return; } if (isObjectRecord(event) && event.type === "agent_end") { runtime.pendingSandboxQuestion = null; + runtime.answeredQuestionIds.clear(); + runtime.pendingQuestionResponses.clear(); } }); } @@ -386,6 +438,8 @@ export class ChatRuntimeManager { hookManager: runtime.hookManager, lastErrorMessage: null, pendingSandboxQuestion: null, + answeredQuestionIds: new Set(), + pendingQuestionResponses: new Map(), }; this.subscribeToSessionEvents(sessionRuntime); this.runtimes.set(sessionId, sessionRuntime); @@ -438,26 +492,32 @@ export class ChatRuntimeManager { ? currentMessage.errorMessage.trim() : null; + // Skip any pending question whose ID was already answered this turn. + // The harness only clears pendingQuestion on agent_end, so without this + // filter an answered ask_user question would permanently shadow the + // sandbox question that fired in the same turn. + const harnessPendingQuestion = + displayState.pendingQuestion && + !runtime.answeredQuestionIds.has(displayState.pendingQuestion.questionId) + ? displayState.pendingQuestion + : null; + const sandboxPendingQuestion = runtime.pendingSandboxQuestion + ? { + questionId: runtime.pendingSandboxQuestion.questionId, + question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`, + description: runtime.pendingSandboxQuestion.reason, + options: [ + { + label: "Yes", + description: "Allow access.", + }, + { label: "No", description: "Deny access." }, + ], + } + : null; return { ...displayState, - pendingQuestion: - displayState.pendingQuestion ?? - (runtime.pendingSandboxQuestion - ? { - questionId: runtime.pendingSandboxQuestion.questionId, - question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`, - options: [ - { - label: "Yes", - description: `Allow access. Reason: ${runtime.pendingSandboxQuestion.reason}`, - }, - { - label: "No", - description: "Deny access.", - }, - ], - } - : null), + pendingQuestion: harnessPendingQuestion ?? sandboxPendingQuestion, errorMessage: currentMessageError ?? runtime.lastErrorMessage, }; } @@ -537,13 +597,7 @@ export class ChatRuntimeManager { input.workspaceId, ); - if ( - runtime.pendingSandboxQuestion?.questionId === input.payload.questionId - ) { - runtime.pendingSandboxQuestion = null; - } - - return runtime.harness.respondToQuestion(input.payload); + return respondToQuestionWithOptimisticState(runtime, input.payload); } async respondToPlan(input: { diff --git a/packages/host-service/src/runtime/teardown/teardown.ts b/packages/host-service/src/runtime/teardown/teardown.ts index f929f73c98d..b740ae7439b 100644 --- a/packages/host-service/src/runtime/teardown/teardown.ts +++ b/packages/host-service/src/runtime/teardown/teardown.ts @@ -106,6 +106,7 @@ export async function runTeardown({ workspaceId, db, initialCommand, + listed: false, }); if ("error" in session) { return { diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index fc8b859ecb7..cec378c0af0 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -43,6 +43,15 @@ type TerminalServerMessage = | { type: "replay"; data: string }; const MAX_BUFFER_BYTES = 64 * 1024; +const SOCKET_OPEN = 1; +const SOCKET_CLOSING = 2; +const SOCKET_CLOSED = 3; + +type TerminalSocket = { + send: (data: string) => void; + close: (code?: number, reason?: string) => void; + readyState: number; +}; // --------------------------------------------------------------------------- // OSC 133 shell readiness detection (FinalTerm semantic prompt standard). @@ -67,17 +76,16 @@ type ShellReadyState = "pending" | "ready" | "timed_out" | "unsupported"; interface TerminalSession { terminalId: string; + workspaceId: string; pty: IPty; - socket: { - send: (data: string) => void; - close: (code?: number, reason?: string) => void; - readyState: number; - } | null; + sockets: Set; buffer: string[]; bufferBytes: number; + createdAt: number; exited: boolean; exitCode: number; exitSignal: number; + listed: boolean; // Shell readiness (OSC 133) shellReadyState: ShellReadyState; @@ -90,14 +98,82 @@ interface TerminalSession { /** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */ const sessions = new Map(); +function pruneAndCountOpenSockets(session: TerminalSession): number { + let openSockets = 0; + for (const socket of session.sockets) { + if (socket.readyState === SOCKET_OPEN) { + openSockets += 1; + } else if ( + socket.readyState === SOCKET_CLOSING || + socket.readyState === SOCKET_CLOSED + ) { + session.sockets.delete(socket); + } + } + return openSockets; +} + +export interface TerminalSessionSummary { + terminalId: string; + workspaceId: string; + createdAt: number; + exited: boolean; + exitCode: number; + attached: boolean; +} + +export function listTerminalSessions( + options: { workspaceId?: string; includeExited?: boolean } = {}, +): TerminalSessionSummary[] { + const includeExited = options.includeExited ?? true; + + return Array.from(sessions.values()) + .filter((session) => session.listed) + .filter( + (session) => + options.workspaceId === undefined || + session.workspaceId === options.workspaceId, + ) + .filter((session) => includeExited || !session.exited) + .map((session) => ({ + terminalId: session.terminalId, + workspaceId: session.workspaceId, + createdAt: session.createdAt, + exited: session.exited, + exitCode: session.exitCode, + attached: pruneAndCountOpenSockets(session) > 0, + })); +} + function sendMessage( socket: { send: (data: string) => void; readyState: number }, message: TerminalServerMessage, ) { - if (socket.readyState !== 1) return; + if (socket.readyState !== SOCKET_OPEN) return; socket.send(JSON.stringify(message)); } +function broadcastMessage( + session: TerminalSession, + message: TerminalServerMessage, +): number { + let sent = 0; + for (const socket of session.sockets) { + if (socket.readyState !== SOCKET_OPEN) { + if ( + socket.readyState === SOCKET_CLOSING || + socket.readyState === SOCKET_CLOSED + ) { + session.sockets.delete(socket); + } + continue; + } + sendMessage(socket, message); + sent += 1; + } + return sent; +} + function bufferOutput(session: TerminalSession, data: string) { session.buffer.push(data); session.bufferBytes += data.length; @@ -134,14 +210,13 @@ function resolveShellReady( session.shellReadyTimeoutId = null; } // Flush held marker bytes — they weren't part of a full marker. - // Send directly to a connected socket so the output isn't lost; fall back - // to the output buffer when no client is currently attached. + // Broadcast to every attached socket so the output isn't lost; if no + // client is currently attached, fall back to the output buffer. if (session.scanState.heldBytes.length > 0) { const heldBytes = session.scanState.heldBytes; session.scanState.heldBytes = ""; - if (session.socket?.readyState === 1) { - sendMessage(session.socket, { type: "data", data: heldBytes }); - } else { + const sent = broadcastMessage(session, { type: "data", data: heldBytes }); + if (sent === 0) { bufferOutput(session, heldBytes); } } @@ -166,6 +241,10 @@ export function disposeSession(terminalId: string, db: HostDb) { clearTimeout(session.shellReadyTimeoutId); session.shellReadyTimeoutId = null; } + for (const socket of session.sockets) { + socket.close(1000, "Session disposed"); + } + session.sockets.clear(); if (!session.exited) { try { session.pty.kill(); @@ -221,6 +300,8 @@ interface CreateTerminalSessionOptions { db: HostDb; /** Command to run after the shell is ready. Queued behind shellReadyPromise. */ initialCommand?: string; + /** Hidden sessions are process-internal and should not appear in user pickers. */ + listed?: boolean; } export function createTerminalSessionInternal({ @@ -229,9 +310,11 @@ export function createTerminalSessionInternal({ themeType, db, initialCommand, + listed = true, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { const existing = sessions.get(terminalId); if (existing) { + if (listed) existing.listed = true; return existing; } @@ -292,15 +375,18 @@ export function createTerminalSessionInternal({ }; } + const createdAt = Date.now(); + db.insert(terminalSessions) .values({ id: terminalId, originWorkspaceId: workspaceId, status: "active", + createdAt, }) .onConflictDoUpdate({ target: terminalSessions.id, - set: { status: "active", endedAt: null }, + set: { status: "active", createdAt, endedAt: null }, }) .run(); @@ -318,13 +404,16 @@ export function createTerminalSessionInternal({ const session: TerminalSession = { terminalId, + workspaceId, pty, - socket: null, + sockets: new Set(), buffer: [], bufferBytes: 0, + createdAt, exited: false, exitCode: 0, exitSignal: 0, + listed, shellReadyState: shellSupportsReady ? "pending" : "unsupported", shellReadyResolve, shellReadyPromise, @@ -353,9 +442,7 @@ export function createTerminalSessionInternal({ } if (data.length === 0) return; - if (session.socket?.readyState === 1) { - sendMessage(session.socket, { type: "data", data }); - } else { + if (broadcastMessage(session, { type: "data", data }) === 0) { bufferOutput(session, data); } }); @@ -370,13 +457,11 @@ export function createTerminalSessionInternal({ .where(eq(terminalSessions.id, terminalId)) .run(); - if (session.socket?.readyState === 1) { - sendMessage(session.socket, { - type: "exit", - exitCode: session.exitCode, - signal: session.exitSignal, - }); - } + broadcastMessage(session, { + type: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + }); }); if (initialCommand) { @@ -441,13 +526,10 @@ export function registerWorkspaceTerminalRoute({ // REST list — enumerate live terminal sessions app.get("/terminal/sessions", (c) => { - const result = Array.from(sessions.values()).map((s) => ({ - terminalId: s.terminalId, - exited: s.exited, - exitCode: s.exitCode, - attached: s.socket !== null, - })); - return c.json({ sessions: result }); + const workspaceId = c.req.query("workspaceId") || undefined; + return c.json({ + sessions: listTerminalSessions({ workspaceId, includeExited: true }), + }); }); app.get( @@ -491,7 +573,7 @@ export function registerWorkspaceTerminalRoute({ return; } - result.socket = ws; + result.sockets.add(ws); db.update(terminalSessions) .set({ lastAttachedAt: Date.now() }) @@ -500,10 +582,7 @@ export function registerWorkspaceTerminalRoute({ return; } - if (existing.socket && existing.socket !== ws) { - existing.socket.close(4000, "Displaced by new connection"); - } - existing.socket = ws; + existing.sockets.add(ws); db.update(terminalSessions) .set({ lastAttachedAt: Date.now() }) @@ -522,18 +601,16 @@ export function registerWorkspaceTerminalRoute({ onMessage: (event, ws) => { const session = sessions.get(terminalId ?? ""); - if (!session || session.socket !== ws) return; + if (!session || !session.sockets.has(ws)) return; let message: TerminalClientMessage; try { message = JSON.parse(String(event.data)) as TerminalClientMessage; } catch { - if (session.socket) { - sendMessage(session.socket, { - type: "error", - message: "Invalid terminal message payload", - }); - } + sendMessage(ws, { + type: "error", + message: "Invalid terminal message payload", + }); return; } @@ -558,16 +635,12 @@ export function registerWorkspaceTerminalRoute({ onClose: (_event, ws) => { const session = sessions.get(terminalId ?? ""); - if (session?.socket === ws) { - session.socket = null; - } + session?.sockets.delete(ws); }, onError: (_event, ws) => { const session = sessions.get(terminalId ?? ""); - if (session?.socket === ws) { - session.socket = null; - } + session?.sockets.delete(ws); }, }; }), diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index c90dfd7253b..823ceda946e 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -1,6 +1,11 @@ +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; import { z } from "zod"; +import { terminalSessions, workspaces } from "../../../db/schema"; import { createTerminalSessionInternal, + disposeSession, + listTerminalSessions, parseThemeType, } from "../../../terminal/terminal"; import { protectedProcedure, router } from "../../index"; @@ -34,4 +39,58 @@ export const terminalRouter = router({ return { terminalId: result.terminalId, status: "active" as const }; }), + + listSessions: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + }), + ) + .query(({ input }) => ({ + sessions: listTerminalSessions({ + workspaceId: input.workspaceId, + includeExited: false, + }), + })), + + killSession: protectedProcedure + .input( + z.object({ + terminalId: z.string(), + workspaceId: z.string(), + }), + ) + .mutation(({ ctx, input }) => { + const workspace = ctx.db.query.workspaces + .findFirst({ where: eq(workspaces.id, input.workspaceId) }) + .sync(); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + } + + const session = ctx.db.query.terminalSessions + .findFirst({ where: eq(terminalSessions.id, input.terminalId) }) + .sync(); + + if (!session) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Terminal session not found", + }); + } + + if (session.originWorkspaceId !== input.workspaceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Terminal session does not belong to this workspace", + }); + } + + disposeSession(input.terminalId, ctx.db); + return { terminalId: input.terminalId, status: "disposed" as const }; + }), }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts index e54a92f7b44..989066ed357 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts @@ -32,10 +32,13 @@ export const searchGitHubIssues = protectedProcedure repo: repo.name, issue_number: issueNumber, }); - // issues.get returns PRs too — filter them out + // issues.get returns PRs too - filter them out if (issue.pull_request) { return { issues: [] }; } + if (!input.includeClosed && issue.state !== "open") { + return { issues: [] }; + } return { issues: [ { @@ -49,8 +52,9 @@ export const searchGitHubIssues = protectedProcedure }; } + const stateFilter = input.includeClosed ? "" : " is:open"; const query = - `repo:${repo.owner}/${repo.name} is:issue ${effectiveQuery}`.trim(); + `repo:${repo.owner}/${repo.name} is:issue${stateFilter} ${effectiveQuery}`.trim(); const { data } = await octokit.search.issuesAndPullRequests({ q: query, per_page: limit, diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts index 77c0d9bd2d7..d790b560a14 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts @@ -3,6 +3,14 @@ import { normalizeGitHubQuery } from "../normalize-github-query"; import { githubSearchInputSchema } from "../schemas"; import { resolveGithubRepo } from "../shared/project-helpers"; +function normalizePullRequestState( + state: string, + mergedAt: string | null | undefined, +): "open" | "closed" | "merged" { + if (mergedAt) return "merged"; + return state.toLowerCase() === "closed" ? "closed" : "open"; +} + export const searchPullRequests = protectedProcedure .input(githubSearchInputSchema) .query(async ({ ctx, input }) => { @@ -32,13 +40,17 @@ export const searchPullRequests = protectedProcedure repo: repo.name, pull_number: prNumber, }); + const state = normalizePullRequestState(pr.state, pr.merged_at); + if (!input.includeClosed && state !== "open") { + return { pullRequests: [] }; + } return { pullRequests: [ { prNumber: pr.number, title: pr.title, url: pr.html_url, - state: pr.state, + state, isDraft: pr.draft ?? false, authorLogin: pr.user?.login ?? null, }, @@ -46,8 +58,9 @@ export const searchPullRequests = protectedProcedure }; } + const stateFilter = input.includeClosed ? "" : " is:open"; const query = - `repo:${repo.owner}/${repo.name} is:pr ${effectiveQuery}`.trim(); + `repo:${repo.owner}/${repo.name} is:pr${stateFilter} ${effectiveQuery}`.trim(); const { data } = await octokit.search.issuesAndPullRequests({ q: query, per_page: limit, @@ -57,14 +70,20 @@ export const searchPullRequests = protectedProcedure return { pullRequests: data.items .filter((item) => item.pull_request) - .map((item) => ({ - prNumber: item.number, - title: item.title, - url: item.html_url, - state: item.state, - isDraft: item.draft ?? false, - authorLogin: item.user?.login ?? null, - })), + .map((item) => { + const state = normalizePullRequestState( + item.state, + item.pull_request?.merged_at, + ); + return { + prNumber: item.number, + title: item.title, + url: item.html_url, + state, + isDraft: item.draft ?? false, + authorLogin: item.user?.login ?? null, + }; + }), }; } catch (err) { console.warn("[workspaceCreation.searchPullRequests] failed", err); diff --git a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts index b5ea31f492f..bf82f949bc0 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts @@ -112,6 +112,7 @@ export const githubSearchInputSchema = z.object({ projectId: z.string(), query: z.string().optional(), limit: z.number().min(1).max(100).optional(), + includeClosed: z.boolean().optional(), }); export const githubIssueContentInputSchema = z.object({ diff --git a/packages/ui/src/components/ai-elements/braille-spinner.tsx b/packages/ui/src/components/ai-elements/braille-spinner.tsx new file mode 100644 index 00000000000..af6e7151bfa --- /dev/null +++ b/packages/ui/src/components/ai-elements/braille-spinner.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { cn } from "../../lib/utils"; + +const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const INTERVAL = 80; + +export function BrailleSpinner({ className }: { className?: string }) { + const [frame, setFrame] = useState(0); + + useEffect(() => { + const id = setInterval( + () => setFrame((f) => (f + 1) % FRAMES.length), + INTERVAL, + ); + return () => clearInterval(id); + }, []); + + return ( + + ); +} diff --git a/packages/ui/src/components/ai-elements/tool-call-row.tsx b/packages/ui/src/components/ai-elements/tool-call-row.tsx new file mode 100644 index 00000000000..c19ec9e6d5f --- /dev/null +++ b/packages/ui/src/components/ai-elements/tool-call-row.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { + ChevronDownIcon, + ChevronRightIcon, + TriangleAlertIcon, + XCircleIcon, +} from "lucide-react"; +import type { ComponentType, ReactNode } from "react"; +import { useState } from "react"; +import { cn } from "../../lib/utils"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { BrailleSpinner } from "./braille-spinner"; + +export type ToolCallRowProps = { + /** Icon shown in the header (replaced by chevron on hover when expandable). */ + icon: ComponentType<{ className?: string }>; + /** + * Header title. A plain string is wrapped in a ShimmerLabel that pulses while + * `isPending` is true. Any other ReactNode is rendered as-is (useful when the + * title contains interactive elements like clickable file paths). + */ + title: ReactNode; + /** Optional muted text rendered after the title, truncated when too long. */ + description?: ReactNode; + /** When true the title shimmers and the default status shows a spinner. */ + isPending?: boolean; + /** When true the default status shows an X icon. */ + isError?: boolean; + /** When true shows an outlined amber warning triangle inline after the description with a "Not configured" tooltip. */ + isNotConfigured?: boolean; + /** + * Overrides the default status slot (X on error, nothing otherwise). + * Pass `null` to render nothing. Omit (undefined) to use the default. + */ + statusNode?: ReactNode; + /** + * Extra element placed outside (after) the CollapsibleTrigger button — useful + * for action buttons that must not toggle expansion when clicked (e.g. "Open + * in pane"). + */ + headerExtra?: ReactNode; + /** Expandable content rendered inside the collapsible area with the left border. */ + children?: ReactNode; + className?: string; +}; + +/** + * Shared collapsible row used by every tool call type. + * + * Provides a consistent layout: + * [icon/chevron] [title] [description ...] | [status] [headerExtra?] + * └── collapsible content with left border ─────────────────────────────┘ + */ +export function ToolCallRow({ + icon: Icon, + title, + description, + isPending = false, + isError = false, + isNotConfigured = false, + statusNode, + headerExtra, + children, + className, +}: ToolCallRowProps) { + const [isOpen, setIsOpen] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const hasDetails = children != null && children !== false; + + const defaultStatus = isError ? ( + + ) : null; + + const resolvedDescription = + description ?? + (isError ? ( + + + Error + + ) : null); + + const titleContent = + typeof title === "string" ? ( + {title} + ) : ( + title + ); + + return ( + hasDetails && setIsOpen(open)} + open={hasDetails ? isOpen : false} + > +

+ + + + {headerExtra} +
+ {hasDetails && ( + +
{children}
+
+ )} + + ); +}