diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 145f3c65d..d6bb497d6 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -62,6 +62,7 @@ const runningScript: WorkspaceScriptPayload = { lifecycle: "running", health: "healthy", exitCode: null, + terminalId: null, }; function workspace(input: { diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index 6b9b0003b..ed23076a8 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -32,6 +32,7 @@ const runningScript: WorkspaceScriptPayload = { lifecycle: "running", health: "healthy", exitCode: null, + terminalId: null, }; describe("patchWorkspaceScripts", () => { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a3e9ecdc6..80485722d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -697,6 +697,55 @@ function WorkspaceScreenContent({ staleTime: TERMINALS_QUERY_STALE_TIME, }); const terminals = terminalsQuery.data?.terminals ?? []; + const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]); + const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState>( + () => new Map(), + ); + useEffect(() => { + setPendingScriptTerminalIds(new Map()); + }, [normalizedServerId, normalizedWorkspaceId]); + useEffect(() => { + setPendingScriptTerminalIds((pendingTerminalIds) => { + if (pendingTerminalIds.size === 0) { + return pendingTerminalIds; + } + + const liveIds = new Set(liveTerminalIds); + let changed = false; + const nextTerminalIds = new Map(); + for (const [terminalId, listedAt] of pendingTerminalIds) { + if (liveIds.has(terminalId) || terminalsQuery.dataUpdatedAt > listedAt) { + changed = true; + continue; + } + nextTerminalIds.set(terminalId, listedAt); + } + return changed ? nextTerminalIds : pendingTerminalIds; + }); + }, [liveTerminalIds, terminalsQuery.dataUpdatedAt]); + const knownTerminalIds = useMemo(() => { + const terminalIds = new Set(liveTerminalIds); + for (const terminalId of pendingScriptTerminalIds.keys()) { + terminalIds.add(terminalId); + } + return Array.from(terminalIds); + }, [liveTerminalIds, pendingScriptTerminalIds]); + const scriptTerminalIds = useMemo(() => { + const terminalIds = new Set(pendingScriptTerminalIds.keys()); + for (const script of workspaceDescriptor?.scripts ?? []) { + if (script.terminalId) { + terminalIds.add(script.terminalId); + } + } + return terminalIds; + }, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]); + const standaloneTerminalIds = useMemo( + () => + terminals + .filter((terminal) => !scriptTerminalIds.has(terminal.id)) + .map((terminal) => terminal.id), + [scriptTerminalIds, terminals], + ); const createTerminalMutation = useMutation({ mutationFn: async (input?: { paneId?: string }) => { if (!client || !workspaceDirectory) { @@ -948,6 +997,38 @@ function WorkspaceScreenContent({ const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); + const handleScriptTerminalStarted = useCallback( + (terminalId: string) => { + setPendingScriptTerminalIds((pendingTerminalIds) => { + if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) { + return pendingTerminalIds; + } + const nextTerminalIds = new Map(pendingTerminalIds); + nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt); + return nextTerminalIds; + }); + if (persistenceKey) { + openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId }); + } + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + }, + [ + openWorkspaceTabFocused, + persistenceKey, + queryClient, + terminalsQuery.dataUpdatedAt, + terminalsQueryKey, + ], + ); + const handleViewScriptTerminal = useCallback( + (terminalId: string) => { + if (!persistenceKey) { + return; + } + openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId }); + }, + [openWorkspaceTabFocused, persistenceKey], + ); const paneFocusSuppressedRef = useRef(false); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); @@ -1065,7 +1146,8 @@ function WorkspaceScreenContent({ terminalsHydrated: terminalsQuery.isSuccess, activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds), knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds), - standaloneTerminalIds: terminals.map((terminal) => terminal.id), + knownTerminalIds, + standaloneTerminalIds, hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace, }); }, [ @@ -1074,7 +1156,8 @@ function WorkspaceScreenContent({ pendingByDraftId, persistenceKey, reconcileWorkspaceTabs, - terminals, + knownTerminalIds, + standaloneTerminalIds, terminalsQuery.isSuccess, uiTabs, workspaceAgentVisibility, @@ -2195,6 +2278,9 @@ function WorkspaceScreenContent({ serverId={normalizedServerId} workspaceId={normalizedWorkspaceId} scripts={workspaceDescriptor.scripts} + liveTerminalIds={liveTerminalIds} + onScriptTerminalStarted={handleScriptTerminalStarted} + onViewTerminal={handleViewScriptTerminal} hideLabels={showCompactButtonLabels} /> ) : null} diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx new file mode 100644 index 000000000..7c6a1922a --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx @@ -0,0 +1,320 @@ +/** + * @vitest-environment jsdom + */ +import React, { type ReactElement } from "react"; +import { act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { WorkspaceScriptPayload } from "@server/shared/messages"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRoot, type Root } from "react-dom/client"; +import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; + +const { theme, startWorkspaceScriptMock } = vi.hoisted(() => { + const theme = { + spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 }, + borderWidth: { 1: 1 }, + borderRadius: { md: 6 }, + fontSize: { xs: 11, sm: 13 }, + fontWeight: { normal: "400", medium: "500" }, + colors: { + foreground: "#fff", + foregroundMuted: "#aaa", + surface2: "#222", + borderAccent: "#444", + palette: { + blue: { 500: "#0a84ff" }, + green: { 500: "#30d158" }, + red: { 300: "#ff9f99", 500: "#ff453a" }, + }, + }, + }; + + return { + theme, + startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })), + }; +}); + +vi.hoisted(() => { + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/constants/platform", () => ({ + isNative: false, + isWeb: true, +})); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeSnapshot: () => ({ activeConnection: null }), +})); + +vi.mock("@/stores/session-store", () => ({ + useSessionStore: (selector: (state: unknown) => unknown) => + selector({ + sessions: { + "test-server": { + client: { + startWorkspaceScript: startWorkspaceScriptMock, + }, + }, + }, + }), +})); + +vi.mock("@/contexts/toast-context", () => ({ + useToast: () => ({ show: vi.fn(), error: vi.fn() }), +})); + +vi.mock("@/utils/open-external-url", () => ({ + openExternalUrl: vi.fn(), +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => ( +
{children}
+ ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ + children, + testID, + }: { + children: + | React.ReactNode + | ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode); + testID?: string; + }) => ( + + ), +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + React.createElement("span", { + "data-icon": name, + "data-color": props.color, + "data-size": props.size, + "data-testid": props.testID, + }); + return { + ChevronDown: createIcon("ChevronDown"), + ExternalLink: createIcon("ExternalLink"), + Globe: createIcon("Globe"), + Play: createIcon("Play"), + SquareTerminal: createIcon("SquareTerminal"), + }; +}); + +vi.mock("react-native", async () => { + const actual = await vi.importActual>("react-native"); + return actual; +}); + +function script( + input: Partial & Pick, +): WorkspaceScriptPayload { + return { + scriptName: input.scriptName, + type: input.type ?? "script", + hostname: input.hostname ?? input.scriptName, + port: input.port ?? null, + proxyUrl: input.proxyUrl ?? null, + lifecycle: input.lifecycle ?? "stopped", + health: input.health ?? null, + exitCode: input.exitCode ?? null, + terminalId: input.terminalId ?? null, + }; +} + +function renderScripts(scripts: WorkspaceScriptPayload[]): { + rerender: (nextScripts: WorkspaceScriptPayload[]) => Promise; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + function element(nextScripts: WorkspaceScriptPayload[]): ReactElement { + return ( + + + + ); + } + + act(() => { + root.render(element(scripts)); + }); + + return { + rerender: async (nextScripts) => { + await act(async () => { + root.render(element(nextScripts)); + }); + }, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +function requireRow(scriptName: string): HTMLElement { + const row = document.querySelector(`[data-testid="workspace-scripts-item-${scriptName}"]`); + if (!(row instanceof HTMLElement)) { + throw new Error(`Missing script row for ${scriptName}`); + } + return row; +} + +function requirePrimaryIcon(row: HTMLElement): HTMLElement { + const icon = row.querySelector("[data-icon]"); + if (!(icon instanceof HTMLElement)) { + throw new Error("Missing row icon"); + } + return icon; +} + +describe("WorkspaceScriptsButton", () => { + let current: ReturnType | null = null; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + document.body.innerHTML = ""; + startWorkspaceScriptMock.mockClear(); + }); + + afterEach(() => { + current?.unmount(); + current = null; + vi.unstubAllGlobals(); + }); + + it("keeps completed script row icons visible and muted while the menu content stays mounted", async () => { + current = renderScripts([ + script({ + scriptName: "typecheck", + lifecycle: "running", + terminalId: "terminal-script-1", + }), + ]); + + let row = requireRow("typecheck"); + let icon = requirePrimaryIcon(row); + expect(icon.dataset.icon).toBe("SquareTerminal"); + expect(icon.dataset.color).toBe(theme.colors.palette.blue[500]); + + await current.rerender([ + script({ + scriptName: "typecheck", + lifecycle: "stopped", + exitCode: 0, + terminalId: "terminal-script-1", + }), + ]); + + row = requireRow("typecheck"); + icon = requirePrimaryIcon(row); + expect(icon.dataset.icon).toBe("SquareTerminal"); + expect(icon.dataset.color).toBe(theme.colors.foregroundMuted); + expect(row.textContent).toContain("typecheck"); + expect(row.textContent).toContain("exit 0"); + expect(row.textContent).toContain("Run"); + + await current.rerender([ + script({ + scriptName: "typecheck", + lifecycle: "stopped", + exitCode: 7, + terminalId: "terminal-script-1", + }), + ]); + + row = requireRow("typecheck"); + icon = requirePrimaryIcon(row); + expect(icon.dataset.icon).toBe("SquareTerminal"); + expect(icon.dataset.color).toBe(theme.colors.foregroundMuted); + expect(row.textContent).toContain("exit 7"); + expect(row.textContent).toContain("Run"); + }); + + it("uses service icon color for service health and running unknown status only", () => { + current = renderScripts([ + script({ + scriptName: "web", + type: "service", + hostname: "web.paseo.localhost", + lifecycle: "running", + health: "healthy", + port: 3000, + }), + script({ + scriptName: "api", + type: "service", + hostname: "api.paseo.localhost", + lifecycle: "running", + health: "unhealthy", + port: 4000, + }), + script({ + scriptName: "worker", + type: "service", + hostname: "worker.paseo.localhost", + lifecycle: "running", + health: null, + port: 5000, + }), + script({ + scriptName: "old-service", + type: "service", + hostname: "old-service.paseo.localhost", + lifecycle: "stopped", + exitCode: 1, + }), + ]); + + expect(requirePrimaryIcon(requireRow("web")).dataset.color).toBe( + theme.colors.palette.green[500], + ); + expect(requirePrimaryIcon(requireRow("api")).dataset.color).toBe(theme.colors.palette.red[500]); + expect(requirePrimaryIcon(requireRow("worker")).dataset.color).toBe( + theme.colors.palette.blue[500], + ); + expect(requirePrimaryIcon(requireRow("old-service")).dataset.color).toBe( + theme.colors.foregroundMuted, + ); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index 512413b70..bf1184a7c 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -1,14 +1,7 @@ -import { type ReactElement } from "react"; +import { Fragment, useMemo, type ReactElement } from "react"; import { Pressable, Text, View } from "react-native"; import { useMutation } from "@tanstack/react-query"; -import { - ChevronDown, - ExternalLink, - Globe, - LoaderCircle, - Play, - SquareTerminal, -} from "lucide-react-native"; +import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; @@ -16,44 +9,153 @@ import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/contexts/toast-context"; +import { isNative } from "@/constants/platform"; import { openExternalUrl } from "@/utils/open-external-url"; import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; -type Script = WorkspaceDescriptor["scripts"][number]; - -function getScriptHealthColor( - health: Script["health"], - theme: ReturnType["theme"], -): string { - if (health === "healthy") { - return theme.colors.palette.blue[500]; - } - if (health === "unhealthy") { - return theme.colors.palette.red[500]; - } - return theme.colors.foregroundMuted; -} +type ScriptActionIcon = "start" | "view"; interface WorkspaceScriptsButtonProps { serverId: string; workspaceId: string; scripts: WorkspaceDescriptor["scripts"]; + liveTerminalIds?: readonly string[]; + onScriptTerminalStarted?: (terminalId: string) => void; + onViewTerminal?: (terminalId: string) => void; hideLabels?: boolean; } +interface ScriptActionButtonProps { + accessibilityLabel: string; + disabled?: boolean; + icon: ScriptActionIcon; + label: string; + onPress: () => void; + testID: string; +} + +function ScriptActionButton({ + accessibilityLabel, + disabled, + icon, + label, + onPress, + testID, +}: ScriptActionButtonProps): ReactElement { + const { theme } = useUnistyles(); + + return ( + { + event.stopPropagation(); + onPress(); + }} + style={styles.actionButton} + > + {({ hovered }) => { + const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted; + const iconProps = { size: 10, color }; + let iconElement: ReactElement; + if (icon === "view") { + iconElement = ; + } else { + iconElement = ; + } + + return ( + <> + {iconElement} + {label} + + ); + }} + + ); +} + +function stripUrlProtocol(url: string): string { + return url.replace(/^https?:\/\//, ""); +} + +interface HostLinkProps { + label: string; + url: string | null; + scriptName: string; +} + +function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement { + const { theme } = useUnistyles(); + const disabled = !url; + + return ( + { + event.stopPropagation(); + if (url) void openExternalUrl(url); + }} + style={styles.hostRow} + > + {({ hovered }) => { + const showIcon = !disabled && (hovered || isNative); + const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted; + return ( + <> + + {label} + + + {showIcon ? : null} + + + ); + }} + + ); +} + +function ExitCodeBadge({ code }: { code: number }): ReactElement { + const { theme } = useUnistyles(); + const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300]; + return ( + + exit {code} + + ); +} + +interface HostLink { + key: string; + label: string; + url: string | null; +} + export function WorkspaceScriptsButton({ serverId, workspaceId, scripts, + liveTerminalIds = [], + onScriptTerminalStarted, + onViewTerminal, hideLabels, }: WorkspaceScriptsButtonProps): ReactElement | null { const { theme } = useUnistyles(); const toast = useToast(); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; + const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]); const startScriptMutation = useMutation({ mutationFn: async (scriptName: string) => { @@ -71,6 +173,11 @@ export function WorkspaceScriptsButton({ variant: "error", }); }, + onSuccess: (result) => { + if (result.terminalId) { + onScriptTerminalStarted?.(result.terminalId); + } + }, }); if (scripts.length === 0) { @@ -111,50 +218,90 @@ export function WorkspaceScriptsButton({ testID="workspace-scripts-menu" > - {scripts.map((script) => { + {scripts.map((script, index) => { const isRunning = script.lifecycle === "running"; const isService = (script.type ?? "service") === "service"; - const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection }); - const isLinkable = isService && isRunning && !!serviceLink.openUrl; const exitCode = script.exitCode ?? null; + const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection }); + const serviceOpenUrl = isService && isRunning ? serviceLink.openUrl : null; + const liveTerminalId = + script.terminalId && liveTerminalIdSet.has(script.terminalId) + ? script.terminalId + : null; - let dotColor: string; + const hostLinks: HostLink[] = []; + if (isService && isRunning) { + const routedUrl = script.proxyUrl ?? serviceLink.labelUrl; + if (routedUrl) { + hostLinks.push({ + key: "proxy", + label: stripUrlProtocol(routedUrl), + url: serviceOpenUrl, + }); + } + if (script.port !== null) { + const localhostLabel = `localhost:${script.port}`; + const alreadyShown = hostLinks.some((l) => l.label === localhostLabel); + if (!alreadyShown) { + hostLinks.push({ + key: "localhost", + label: localhostLabel, + url: `http://localhost:${script.port}`, + }); + } + } + } + + let iconColor = theme.colors.foregroundMuted; if (isService) { - dotColor = isRunning - ? getScriptHealthColor(script.health, theme) - : theme.colors.foregroundMuted; + if (isRunning && script.health === "healthy") { + iconColor = theme.colors.palette.green[500]; + } else if (isRunning && script.health === "unhealthy") { + iconColor = theme.colors.palette.red[500]; + } else if (isRunning) { + iconColor = theme.colors.palette.blue[500]; + } } else if (isRunning) { - dotColor = theme.colors.palette.blue[500]; - } else if (exitCode === 0) { - dotColor = theme.colors.palette.green[500]; - } else if (exitCode !== null) { - dotColor = theme.colors.palette.red[500]; - } else { - dotColor = theme.colors.foregroundMuted; + iconColor = theme.colors.palette.blue[500]; + } + + const ScriptIcon = isService ? Globe : SquareTerminal; + const showExitBadge = !isRunning && exitCode !== null; + + let primaryAction: ReactElement | null = null; + if (isRunning && liveTerminalId) { + primaryAction = ( + onViewTerminal?.(liveTerminalId)} + /> + ); + } else if (!isRunning) { + primaryAction = ( + startScriptMutation.mutate(script.scriptName)} + /> + ); } return ( - [ - styles.scriptRow, - hovered && isLinkable && styles.scriptRowHovered, - ]} - onPress={ - isLinkable ? () => void openExternalUrl(serviceLink.openUrl!) : undefined - } - disabled={!isLinkable} - > - {({ hovered }) => ( - <> - {isService ? ( - - ) : ( - - )} + + {index > 0 ? : null} + + + {script.scriptName} - {isService && isRunning && serviceLink.labelUrl ? ( - - {serviceLink.labelUrl.replace(/^https?:\/\//, "")} - - ) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? ( - - exit {exitCode} - - ) : ( - - )} - {isRunning ? ( - isLinkable && hovered ? ( - - - - ) : null - ) : ( - { - event.stopPropagation(); - startScriptMutation.mutate(script.scriptName); - }} - style={styles.startButton} - > - {({ hovered: actionHovered }) => - startScriptMutation.isPending && - startScriptMutation.variables === script.scriptName ? ( - - ) : ( - <> - - - Run - - - ) - } - - )} - - )} - + {showExitBadge ? : null} + + {primaryAction} + + {hostLinks.length > 0 ? ( + + {hostLinks.map((link) => ( + + ))} + + ) : null} + + ); })} @@ -270,11 +363,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], justifyContent: "center", }, - splitButtonPrimaryIconOnly: { - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - justifyContent: "center", - }, splitButtonPrimaryHovered: { backgroundColor: theme.colors.surface2, }, @@ -293,16 +381,15 @@ const styles = StyleSheet.create((theme) => ({ scriptList: { paddingVertical: theme.spacing[1], }, - scriptRow: { + scriptItem: { + paddingVertical: 6, + }, + scriptHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], - paddingVertical: 6, - minHeight: 28, - }, - scriptRowHovered: { - backgroundColor: theme.colors.surface2, + minHeight: 24, }, scriptIcon: { flexShrink: 0, @@ -311,36 +398,53 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, lineHeight: 18, - flexShrink: 0, - }, - scriptUrl: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: 18, - flex: 1, + flexShrink: 1, minWidth: 0, - textAlign: "right", }, spacer: { flex: 1, minWidth: 0, }, - externalLinkOverlay: { - position: "absolute", - right: 0, - top: 0, - bottom: 0, - paddingLeft: theme.spacing[4], - paddingRight: theme.spacing[3], + hostList: { + marginTop: 2, + paddingHorizontal: theme.spacing[3], + }, + hostRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + paddingVertical: 2, + minHeight: 18, + }, + hostLabel: { + flexShrink: 1, + fontSize: theme.fontSize.xs, + lineHeight: 14, + }, + hostIconSlot: { + width: 10, + height: 10, alignItems: "center", justifyContent: "center", + flexShrink: 0, }, - startButton: { + exitBadge: { + paddingHorizontal: theme.spacing[1.5], + paddingVertical: 1, + borderRadius: 2, + backgroundColor: theme.colors.surface2, + }, + exitBadgeText: { + fontSize: 10, + lineHeight: 12, + fontWeight: theme.fontWeight.medium, + }, + actionButton: { flexDirection: "row", alignItems: "center", gap: 3, }, - startButtonLabel: { + actionButtonLabel: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 47fc3b8ac..0a576be82 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -61,6 +61,7 @@ describe("normalizeWorkspaceDescriptor", () => { lifecycle: "running" as const, health: "healthy" as const, exitCode: null, + terminalId: null, }, ]; const workspace = normalizeWorkspaceDescriptor({ @@ -88,6 +89,7 @@ describe("normalizeWorkspaceDescriptor", () => { lifecycle: "running", health: "healthy", exitCode: null, + terminalId: null, }, ]); expect(workspace.scripts).not.toBe(scripts); @@ -137,6 +139,7 @@ describe("mergeWorkspaces", () => { lifecycle: "running", health: "healthy", exitCode: null, + terminalId: null, }, ], }), @@ -152,6 +155,7 @@ describe("mergeWorkspaces", () => { lifecycle: "running", health: "healthy", exitCode: null, + terminalId: null, }, ]); }); @@ -263,6 +267,7 @@ describe("patchWorkspaceScripts", () => { lifecycle: "running" as const, health: "healthy" as const, exitCode: null, + terminalId: null, }; const workspace = createWorkspace({ id: "/repo/main", scripts: [script] }); const current = new Map([[workspace.id, workspace]]); diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 0ae040258..11e34f936 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -203,6 +203,7 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; knownAgentIds: Iterable; + knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingDraftCreate?: boolean; } @@ -1454,6 +1455,9 @@ export function reconcileWorkspaceTabs( const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); + const knownTerminalIds = snapshot.knownTerminalIds + ? normalizeStringSet(snapshot.knownTerminalIds) + : standaloneTerminalIds; const visibleAgentIds = new Set(activeAgentIds); for (const agentId of pinnedAgentIds) { if (knownAgentIds.has(agentId)) { @@ -1537,7 +1541,7 @@ export function reconcileWorkspaceTabs( if ( isTerminalTab(tab) && snapshot.terminalsHydrated && - !standaloneTerminalIds.has(tab.target.terminalId) + !knownTerminalIds.has(tab.target.terminalId) ) { nextLayout = closeTabInLayout({ diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 02457e36a..b22312c56 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -1107,6 +1107,47 @@ describe("workspace-layout-store actions", () => { expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); }); + it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const scriptTabId = store.openTabFocused(workspaceKey, { + kind: "terminal", + terminalId: "term-script", + }); + + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + knownAgentIds: [], + knownTerminalIds: ["term-script", "term-manual"], + standaloneTerminalIds: ["term-manual"], + hasActivePendingDraftCreate: false, + }); + + const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]); + expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId); + }); + + it("reconcileTabs does not auto-open live non-standalone terminals", () => { + const workspaceKey = createWorkspaceKey(); + + useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + knownAgentIds: [], + knownTerminalIds: ["term-script"], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + }); + it("explicitly opening an agent tab clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); diff --git a/packages/app/src/utils/workspace-script-links.test.ts b/packages/app/src/utils/workspace-script-links.test.ts index 399d6a703..961d618ce 100644 --- a/packages/app/src/utils/workspace-script-links.test.ts +++ b/packages/app/src/utils/workspace-script-links.test.ts @@ -12,6 +12,7 @@ const runningService: WorkspaceScriptPayload = { lifecycle: "running", health: "healthy", exitCode: null, + terminalId: null, }; function resolveLink(activeConnection: ActiveConnection | null) { diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index ff0e3daf2..54ddb790a 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -57,6 +57,7 @@ function createStubTerminalManager( send: () => {}, subscribe: () => () => {}, onExit: () => () => {}, + onCommandFinished: () => () => {}, getState: () => ({ rows: 1, cols: 1, @@ -69,6 +70,7 @@ function createStubTerminalManager( getSize: () => ({ rows: 1, cols: 1 }), getTitle: () => undefined, getExitInfo: () => null, + killAndWait: async () => {}, }; }, registerCwdEnv() {}, @@ -76,6 +78,7 @@ function createStubTerminalManager( return undefined; }, killTerminal() {}, + async killTerminalAndWait() {}, listDirectories() { return []; }, diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index 846a54c37..36fcc02ab 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -8,6 +8,7 @@ import { buildWorkspaceScriptPayloads, createScriptStatusEmitter, } from "./script-status-projection.js"; +import { WorkspaceScriptPayloadSchema } from "../shared/messages.js"; import type { ScriptHealthState } from "./script-health-monitor.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; @@ -50,6 +51,21 @@ function buildPayloads(input: { } describe("script-status-projection", () => { + it("defaults omitted workspace script terminal ids to null", () => { + expect( + WorkspaceScriptPayloadSchema.parse({ + scriptName: "typecheck", + type: "script", + hostname: "typecheck", + port: null, + proxyUrl: null, + lifecycle: "stopped", + health: null, + exitCode: 0, + }).terminalId, + ).toBeNull(); + }); + it("projects plain scripts and services differently", () => { const workspaceId = "workspace-plain-and-service"; const workspace = createWorkspaceRepo({ @@ -90,6 +106,7 @@ describe("script-status-projection", () => { lifecycle: "stopped", health: null, exitCode: 0, + terminalId: "term-script", }, { scriptName: "web", @@ -100,6 +117,7 @@ describe("script-status-projection", () => { lifecycle: "stopped", health: null, exitCode: null, + terminalId: null, }, ]); } finally { @@ -121,19 +139,19 @@ describe("script-status-projection", () => { const runtimeStore = new WorkspaceScriptRuntimeStore(); try { - expect( - buildPayloads({ - workspaceId, - workspaceDirectory: workspace.repoDir, - routeStore, - runtimeStore, - daemonPort: 6767, - gitMetadata: { - projectSlug: "service-provided", - currentBranch: "feature/from-service", - }, - }), - ).toEqual([ + const payloads = buildPayloads({ + workspaceId, + workspaceDirectory: workspace.repoDir, + routeStore, + runtimeStore, + daemonPort: 6767, + gitMetadata: { + projectSlug: "service-provided", + currentBranch: "feature/from-service", + }, + }); + + expect(payloads).toEqual([ { scriptName: "web", type: "service", @@ -143,6 +161,7 @@ describe("script-status-projection", () => { lifecycle: "stopped", health: null, exitCode: null, + terminalId: null, }, ]); } finally { @@ -198,6 +217,7 @@ describe("script-status-projection", () => { lifecycle: "running", health: "healthy", exitCode: null, + terminalId: "term-web", }, ]); } finally { @@ -252,6 +272,7 @@ describe("script-status-projection", () => { lifecycle: "running", health: null, exitCode: null, + terminalId: "term-web", }, ]); } finally { @@ -299,6 +320,7 @@ describe("script-status-projection", () => { lifecycle: "running", health: null, exitCode: null, + terminalId: "term-docs", }, ]); } finally { @@ -339,6 +361,7 @@ describe("script-status-projection", () => { lifecycle: "running", health: null, exitCode: null, + terminalId: "term-typecheck", }, ]); } finally { @@ -409,6 +432,7 @@ describe("script-status-projection", () => { lifecycle: "running", health: "healthy", exitCode: null, + terminalId: "term-api", }, { scriptName: "typecheck", @@ -419,6 +443,7 @@ describe("script-status-projection", () => { lifecycle: "stopped", health: null, exitCode: null, + terminalId: null, }, ], }, diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 2a34cf339..bb1676187 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -103,6 +103,7 @@ export function buildWorkspaceScriptPayloads( lifecycle: runtimeEntry?.lifecycle ?? "stopped", health: type === "service" ? toWireHealth(options.resolveHealth?.(hostname) ?? null) : null, exitCode: runtimeEntry?.exitCode ?? null, + terminalId: runtimeEntry?.terminalId ?? null, }); } @@ -134,6 +135,7 @@ export function buildWorkspaceScriptPayloads( ? toWireHealth(options.resolveHealth?.(hostname) ?? null) : null, exitCode: runtimeEntry.exitCode, + terminalId: runtimeEntry.terminalId, }); } diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index e5dea0ddb..f233dfdea 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -427,6 +427,11 @@ describe("runAsyncWorktreeBootstrap", () => { }; }, onExit: () => () => {}, + onCommandFinished: () => () => {}, + onTitleChange: () => () => {}, + getSize: () => ({ rows: 0, cols: 0 }), + getTitle: () => undefined, + getExitInfo: () => null, getState: () => ({ rows: 0, cols: 0, @@ -435,6 +440,7 @@ describe("runAsyncWorktreeBootstrap", () => { cursor: { row: 0, col: 0 }, }), kill: () => {}, + killAndWait: async () => {}, }; }, registerCwdEnv() {}, @@ -442,6 +448,7 @@ describe("runAsyncWorktreeBootstrap", () => { return undefined; }, killTerminal() {}, + async killTerminalAndWait() {}, listDirectories() { return []; }, @@ -508,6 +515,11 @@ describe("runAsyncWorktreeBootstrap", () => { send: () => {}, subscribe: () => () => {}, onExit: () => () => {}, + onCommandFinished: () => () => {}, + onTitleChange: () => () => {}, + getSize: () => ({ rows: 1, cols: 1 }), + getTitle: () => undefined, + getExitInfo: () => null, getState: () => ({ rows: 1, cols: 1, @@ -516,6 +528,7 @@ describe("runAsyncWorktreeBootstrap", () => { cursor: { row: 0, col: 0 }, }), kill: () => {}, + killAndWait: async () => {}, }; }, registerCwdEnv(options) { @@ -525,6 +538,7 @@ describe("runAsyncWorktreeBootstrap", () => { return undefined; }, killTerminal() {}, + async killTerminalAndWait() {}, listDirectories() { return []; }, @@ -563,11 +577,15 @@ describe("runAsyncWorktreeBootstrap", () => { interface CreateTerminalCall { cwd: string; name?: string; + title?: string; env?: Record; } interface StubTerminalRecord { + id: string; triggerExit: (exitCode: number) => void; + triggerCommandFinished: (exitCode: number) => void; + sentInputs: string[]; } function createStubTerminalManager( @@ -575,6 +593,7 @@ describe("runAsyncWorktreeBootstrap", () => { terminalRecords: StubTerminalRecord[] = [], ): TerminalManager { let terminalCounter = 0; + const sessionsById = new Map(); return { async getTerminals() { @@ -583,9 +602,16 @@ describe("runAsyncWorktreeBootstrap", () => { async createTerminal(options: CreateTerminalCall): Promise { createTerminalCalls.push(options); terminalCounter += 1; - const terminalId = `term-service-${terminalCounter}`; + const terminalId = `term-${terminalCounter}`; let exitHandler: ((info: { exitCode: number | null }) => void) | null = null; + let commandFinishedHandler: ((info: { exitCode: number | null }) => void) | null = null; + const sentInputs: string[] = []; terminalRecords.push({ + id: terminalId, + sentInputs, + triggerCommandFinished: (exitCode) => { + commandFinishedHandler?.({ exitCode }); + }, triggerExit: (exitCode) => { if (exitHandler) { exitHandler({ exitCode }); @@ -593,11 +619,15 @@ describe("runAsyncWorktreeBootstrap", () => { }, }); - return { + const session: TerminalSession = { id: terminalId, name: options.name ?? "Terminal", cwd: options.cwd, - send: () => {}, + send: (message) => { + if (message.type === "input") { + sentInputs.push(message.data); + } + }, subscribe: () => () => {}, onExit: (handler) => { exitHandler = handler; @@ -607,6 +637,14 @@ describe("runAsyncWorktreeBootstrap", () => { } }; }, + onCommandFinished: (handler) => { + commandFinishedHandler = handler; + return () => { + if (commandFinishedHandler === handler) { + commandFinishedHandler = null; + } + }; + }, getState: () => ({ rows: 1, cols: 1, @@ -621,10 +659,12 @@ describe("runAsyncWorktreeBootstrap", () => { getExitInfo: () => null, killAndWait: async () => {}, }; + sessionsById.set(terminalId, session); + return session; }, registerCwdEnv() {}, - getTerminal() { - return undefined; + getTerminal(id) { + return sessionsById.get(id); }, killTerminal() {}, async killTerminalAndWait() {}, @@ -653,26 +693,29 @@ describe("runAsyncWorktreeBootstrap", () => { return env; } - it("spawns plain scripts without env injection or routes", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - scripts: { - web: { - command: "npm run dev", - }, - }, - }), - ); + function commitPaseoScripts( + scripts: Record, + message = "add script config", + ): void { + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts })); execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add script config'", { + execSync(`git -c commit.gpgsign=false commit -m ${JSON.stringify(message)}`, { cwd: repoDir, stdio: "pipe", }); + } + + it("spawns plain scripts in persistent shell terminals without env injection or routes", async () => { + commitPaseoScripts({ + web: { + command: "npm run dev", + }, + }); const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; const results = await spawnWorktreeScripts({ repoRoot: repoDir, @@ -681,7 +724,7 @@ describe("runAsyncWorktreeBootstrap", () => { daemonPort: null, routeStore, runtimeStore, - terminalManager: createStubTerminalManager(createTerminalCalls), + terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), }); expect(results).toHaveLength(1); @@ -689,40 +732,277 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls[0]?.cwd).toBe(repoDir); expect(createTerminalCalls[0]?.name).toBe("web"); + expect(createTerminalCalls[0]?.title).toBe("web"); expect(createTerminalCalls[0]?.env).toBeUndefined(); + expect(terminalRecords[0]?.sentInputs).toEqual(["npm run dev\r"]); expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({ type: "script", lifecycle: "running", exitCode: null, - terminalId: "term-service-1", + terminalId: "term-1", }); }); - it("spawns services with route registration and injected peer service env vars", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - scripts: { - api: { - type: "service", - command: "npm run api", - }, - "app-server": { - type: "service", - command: "npm run app", - }, + it("records plain script exit codes from shell command completion without terminal exit", async () => { + commitPaseoScripts( + { + typecheck: { + command: 'node -e "process.exit(7)"', }, - }), + }, + "add one-off script config", ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add service script config'", { - cwd: repoDir, - stdio: "pipe", - }); const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords); + + const result = await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-script-exit", + scriptName: "typecheck", + daemonPort: null, + routeStore, + runtimeStore, + terminalManager, + }); + + expect(createTerminalCalls).toHaveLength(1); + terminalRecords[0]?.triggerCommandFinished(7); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({ + type: "script", + lifecycle: "stopped", + terminalId: result.terminalId, + exitCode: 7, + }); + expect(terminalRecords[0]?.sentInputs).toEqual(['node -e "process.exit(7)"\r']); + }); + + it("reuses a live terminal when rerunning after plain script completion", async () => { + commitPaseoScripts( + { + typecheck: { + command: "npm run typecheck", + }, + }, + "add one-off script config", + ); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords); + + const firstResult = await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-script-rerun", + scriptName: "typecheck", + daemonPort: null, + routeStore, + runtimeStore, + terminalManager, + }); + + terminalRecords[0]?.triggerCommandFinished(7); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({ + type: "script", + lifecycle: "stopped", + terminalId: firstResult.terminalId, + exitCode: 7, + }); + + const secondResult = await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-script-rerun", + scriptName: "typecheck", + daemonPort: null, + routeStore, + runtimeStore, + terminalManager, + }); + + expect(secondResult.terminalId).toBe(firstResult.terminalId); + expect(createTerminalCalls).toHaveLength(1); + expect(terminalRecords[0]?.sentInputs).toEqual(["npm run typecheck\r", "npm run typecheck\r"]); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({ + type: "script", + lifecycle: "running", + terminalId: firstResult.terminalId, + exitCode: null, + }); + terminalRecords[0]?.triggerCommandFinished(0); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({ + type: "script", + lifecycle: "stopped", + terminalId: firstResult.terminalId, + exitCode: 0, + }); + }); + + it("tracks command completion when reusing a live terminal from a stopped plain script entry", async () => { + commitPaseoScripts( + { + typecheck: { + command: "npm run typecheck", + }, + }, + "add one-off script config", + ); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords); + const existingTerminal = await terminalManager.createTerminal({ + cwd: repoDir, + name: "typecheck", + title: "typecheck", + }); + runtimeStore.set({ + workspaceId: repoDir, + scriptName: "typecheck", + type: "script", + lifecycle: "stopped", + terminalId: existingTerminal.id, + exitCode: 1, + }); + + const result = await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-script-existing-terminal", + scriptName: "typecheck", + daemonPort: null, + routeStore, + runtimeStore, + terminalManager, + }); + + expect(result.terminalId).toBe(existingTerminal.id); + expect(createTerminalCalls).toHaveLength(1); + expect(terminalRecords[0]?.sentInputs).toEqual(["npm run typecheck\r"]); + + terminalRecords[0]?.triggerCommandFinished(0); + + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({ + type: "script", + lifecycle: "stopped", + terminalId: existingTerminal.id, + exitCode: 0, + }); + }); + + it("uses terminal exit as a fallback before shell command completion", async () => { + commitPaseoScripts( + { + typecheck: { + command: "npm run typecheck", + }, + }, + "add one-off script config", + ); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords); + + const result = await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-script-terminal-exit", + scriptName: "typecheck", + daemonPort: null, + routeStore, + runtimeStore, + terminalManager, + }); + + terminalRecords[0]?.triggerExit(9); + + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({ + type: "script", + lifecycle: "stopped", + terminalId: result.terminalId, + exitCode: 9, + }); + }); + + it("rejects duplicate plain script starts while running", async () => { + commitPaseoScripts( + { + typecheck: { + command: 'node -e "setTimeout(() => {}, 30000)"', + }, + }, + "add long-running one-off script", + ); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls); + + await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-script-duplicate", + scriptName: "typecheck", + daemonPort: null, + routeStore, + runtimeStore, + terminalManager, + }); + + await expect( + spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-script-duplicate", + scriptName: "typecheck", + daemonPort: null, + routeStore, + runtimeStore, + terminalManager, + }), + ).rejects.toThrow("Script 'typecheck' is already running"); + expect(createTerminalCalls).toHaveLength(1); + }); + + it("spawns services with route registration and injected peer service env vars", async () => { + commitPaseoScripts( + { + api: { + type: "service", + command: "npm run api", + }, + "app-server": { + type: "service", + command: "npm run app", + }, + }, + "add service script config", + ); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; const result = await spawnWorkspaceScript({ repoRoot: repoDir, @@ -733,7 +1013,7 @@ describe("runAsyncWorktreeBootstrap", () => { daemonPort: 6767, routeStore, runtimeStore, - terminalManager: createStubTerminalManager(createTerminalCalls), + terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), }); expect(result.scriptName).toBe("api"); @@ -749,6 +1029,7 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls[0]?.cwd).toBe(repoDir); expect(createTerminalCalls[0]?.name).toBe("api"); + expect(terminalRecords[0]?.sentInputs).toEqual(["npm run api\r"]); expect(createTerminalCalls[0]?.env).not.toHaveProperty("PORT"); expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index edb93b0b3..f3b361b91 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -686,11 +686,6 @@ export interface WorktreeScriptResult { terminalId: string; } -interface WorkspaceServiceDeclaration { - scriptName: string; - port?: number; -} - interface SpawnWorkspaceScriptOptions { repoRoot: string; workspaceId: string; @@ -730,26 +725,28 @@ export async function spawnWorkspaceScript( } const serviceScript = isServiceScript(config); + const scriptType = serviceScript ? "service" : "script"; let hostname: string | null = null; let port: number | null = null; - let terminal: TerminalSession | null = null; let runtimeRegistered = false; let routeRegistered = false; + let disposeLifecycleListeners: (() => void) | null = null; try { if (runtimeStore.isRunning({ workspaceId, scriptName })) { throw new Error(`Script '${scriptName}' is already running`); } + const existingRuntimeEntry = runtimeStore.get({ workspaceId, scriptName }); let env: Record | undefined; if (serviceScript) { - const serviceHostname = buildScriptHostname({ + hostname = buildScriptHostname({ projectSlug, branchName, scriptName, }); - hostname = serviceHostname; - const serviceDeclarations: WorkspaceServiceDeclaration[] = []; + + const serviceDeclarations: Array<{ scriptName: string; port?: number }> = []; for (const [configuredScriptName, scriptConfig] of scriptConfigs) { if (isServiceScript(scriptConfig)) { serviceDeclarations.push({ @@ -767,8 +764,7 @@ export async function spawnWorkspaceScript( services: serviceDeclarations, allocatePort: findFreePort, }); - const existingRuntimeEntry = runtimeStore.get({ workspaceId, scriptName }); - const servicePort = + port = existingRuntimeEntry?.lifecycle === "stopped" ? await refreshWorkspaceServicePort({ workspaceId, @@ -777,13 +773,11 @@ export async function spawnWorkspaceScript( }) : requirePlannedWorkspaceServicePort(plannedPorts, scriptName); - port = servicePort; - const peers: WorkspaceServicePeer[] = []; for (const [peerScriptName, peerPort] of plannedPorts) { peers.push({ scriptName: peerScriptName, - port: peerScriptName === scriptName ? servicePort : peerPort, + port: peerScriptName === scriptName ? port : peerPort, }); } @@ -797,8 +791,8 @@ export async function spawnWorkspaceScript( }); routeStore.registerRoute({ - hostname: serviceHostname, - port: servicePort, + hostname, + port, workspaceId, projectSlug, scriptName, @@ -806,47 +800,82 @@ export async function spawnWorkspaceScript( routeRegistered = true; } - const createdTerminal = await terminalManager.createTerminal({ - cwd: repoRoot, - name: scriptName, - env, - }); - terminal = createdTerminal; + let reusableTerminal: TerminalSession | null = null; + if (!serviceScript && existingRuntimeEntry?.terminalId) { + reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null; + } + const terminal = + reusableTerminal ?? + (await terminalManager.createTerminal({ + cwd: repoRoot, + name: scriptName, + title: scriptName, + env, + })); + runtimeStore.set({ workspaceId, scriptName, - type: serviceScript ? "service" : "script", + type: scriptType, lifecycle: "running", - terminalId: createdTerminal.id, + terminalId: terminal.id, exitCode: null, }); runtimeRegistered = true; - terminal.onExit((info) => { - if (hostname) { + const stopRuntimeIfCurrent = (input: { exitCode: number | null; removeRoute: boolean }) => { + const current = runtimeStore.get({ workspaceId, scriptName }); + if (current?.terminalId !== terminal.id || current.lifecycle !== "running") { + return; + } + + disposeLifecycleListeners?.(); + disposeLifecycleListeners = null; + + if (input.removeRoute && hostname) { routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName }); } runtimeStore.set({ workspaceId, scriptName, - type: serviceScript ? "service" : "script", + type: scriptType, lifecycle: "stopped", - terminalId: createdTerminal.id, - exitCode: info.exitCode, + terminalId: terminal.id, + exitCode: input.exitCode, }); onLifecycleChanged?.(); logger?.info( { scriptName, hostname, - exitCode: info.exitCode, - terminalId: createdTerminal.id, + exitCode: input.exitCode, + terminalId: terminal.id, }, "Stopped worktree script", ); + }; + + const unsubscribeExit = terminal.onExit((info) => { + stopRuntimeIfCurrent({ + exitCode: info.exitCode, + removeRoute: true, + }); }); - await waitForTerminalBootstrapReadiness(terminal); + let unsubscribeCommandFinished: (() => void) | null = null; + if (!serviceScript) { + unsubscribeCommandFinished = terminal.onCommandFinished((info) => { + stopRuntimeIfCurrent({ exitCode: info.exitCode, removeRoute: false }); + }); + } + disposeLifecycleListeners = () => { + unsubscribeExit(); + unsubscribeCommandFinished?.(); + }; + + if (!reusableTerminal) { + await waitForTerminalBootstrapReadiness(terminal); + } terminal.send({ type: "input", data: `${config.command}\r` }); logger?.info( @@ -855,7 +884,7 @@ export async function spawnWorkspaceScript( hostname, port, terminalId: terminal.id, - type: serviceScript ? "service" : "script", + type: scriptType, }, serviceScript ? `Registered script proxy: ${hostname} -> 127.0.0.1:${port}` @@ -870,6 +899,7 @@ export async function spawnWorkspaceScript( terminalId: terminal.id, }; } catch (error) { + disposeLifecycleListeners?.(); if (routeRegistered && hostname) { routeStore.removeRoute(hostname); } diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index d02cff6eb..c8b0580bd 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -143,6 +143,7 @@ function createTerminalManagerStub(options?: { }), subscribe: () => () => {}, onExit: () => () => {}, + onCommandFinished: () => () => {}, onTitleChange: () => () => {}, send: (message: { type: string; data: string }) => { if (message.type === "input") { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index c0cec4583..600336655 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1991,6 +1991,7 @@ export const WorkspaceScriptPayloadSchema = z.object({ lifecycle: WorkspaceScriptLifecycleSchema, health: WorkspaceScriptHealthSchema.nullable(), exitCode: z.number().nullable().optional().default(null), + terminalId: z.string().nullable().optional().default(null), }); const WorkspaceGitRuntimePayloadSchema = z diff --git a/packages/server/src/terminal/shell-integration/zsh/paseo-integration.zsh b/packages/server/src/terminal/shell-integration/zsh/paseo-integration.zsh index 3759b84e8..254c2db4f 100644 --- a/packages/server/src/terminal/shell-integration/zsh/paseo-integration.zsh +++ b/packages/server/src/terminal/shell-integration/zsh/paseo-integration.zsh @@ -5,11 +5,26 @@ typeset -g _PASEO_ZSH_INTEGRATION_LOADED=1 autoload -Uz add-zsh-hook +typeset -g _PASEO_ZSH_COMMAND_ACTIVE=0 + +function _paseo_osc633() { + printf '\e]633;%s\a' "$1" +} + function _paseo_precmd() { + local command_status=$? + if [[ "$_PASEO_ZSH_COMMAND_ACTIVE" == "1" ]]; then + _paseo_osc633 "D;${command_status}" + _PASEO_ZSH_COMMAND_ACTIVE=0 + fi printf '\e]2;%s\a' "${PWD/#$HOME/~}" + _paseo_osc633 "A" } function _paseo_preexec() { + _PASEO_ZSH_COMMAND_ACTIVE=1 + _paseo_osc633 "B" + _paseo_osc633 "C" printf '\e]2;%s\a' "$1" } diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 6916bacbd..104dd1104 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -21,6 +21,7 @@ export interface TerminalManager { id?: string; cwd: string; name?: string; + title?: string; env?: Record; command?: string; args?: string[]; @@ -160,6 +161,7 @@ export function createTerminalManager(): TerminalManager { id?: string; cwd: string; name?: string; + title?: string; env?: Record; command?: string; args?: string[]; @@ -178,6 +180,7 @@ export function createTerminalManager(): TerminalManager { ...(options.id ? { id: options.id } : {}), cwd: options.cwd, name: options.name ?? defaultName, + ...(options.title ? { title: options.title } : {}), ...(options.command ? { command: options.command } : {}), ...(options.args ? { args: options.args } : {}), ...(mergedEnv ? { env: mergedEnv } : {}), diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 78352c512..5c5570fc5 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -443,6 +443,87 @@ describe("Terminal", () => { unsubscribeTitle(); }); + it("keeps preset titles instead of applying OSC title updates", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + title: "typecheck", + }), + ); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(session.getTitle()).toBe("typecheck"); + expect(session.getState().title).toBe("typecheck"); + }); + + it("emits command completion from VS Code OSC 633 without visible output", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-command-finished.sh"); + writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([7]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + + unsubscribeCommandFinished(); + }); + + it("ignores malformed VS Code OSC 633 command completion payloads", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh"); + writeFileSync( + scriptPath, + "#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n", + ); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([3]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + + unsubscribeCommandFinished(); + }); + it("debounces rapid title changes and emits only the final title", async () => { const session = trackSession( await createTerminal({ @@ -506,6 +587,42 @@ describe("Terminal", () => { await waitForTitle(session, (title) => title === "sleep 1"); await waitForTitle(session, (title) => title === "~/dev/faro", 4000); }); + + it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "false\r" }); + + await waitForState(session, () => commandCompletions.includes(1)); + + expect(commandCompletions).toEqual([1]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + + unsubscribeCommandFinished(); + }); }); describe("colors", () => { diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 039459c1c..c9ad7e2ee 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -21,6 +21,10 @@ export interface TerminalExitInfo { lastOutputLines: string[]; } +export interface TerminalCommandFinishedInfo { + exitCode: number | null; +} + export type ClientMessage = | { type: "input"; data: string } | { type: "resize"; rows: number; cols: number } @@ -38,6 +42,7 @@ export interface TerminalSession { send(msg: ClientMessage): void; subscribe(listener: (msg: ServerMessage) => void): () => void; onExit(listener: (info: TerminalExitInfo) => void): () => void; + onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void; onTitleChange(listener: (title?: string) => void): () => void; getSize(): { rows: number; cols: number }; getState(): TerminalState; @@ -47,6 +52,23 @@ export interface TerminalSession { killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise; } +function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null { + // OSC 633 is terminal control traffic, but a foreground command can still + // print arbitrary control bytes. Keep this boundary to the exact VS Code + // command-finished shape emitted by our shell integration. + const parts = data.split(";"); + if (parts[0] !== "D") { + return null; + } + if (parts.length === 1) { + return { exitCode: null }; + } + if (parts.length !== 2 || !/^-?\d+$/.test(parts[1])) { + return null; + } + return { exitCode: Number(parts[1]) }; +} + export interface CreateTerminalOptions { id?: string; cwd: string; @@ -55,6 +77,7 @@ export interface CreateTerminalOptions { rows?: number; cols?: number; name?: string; + title?: string; command?: string; args?: string[]; } @@ -516,6 +539,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void>(); const exitListeners = new Set<(info: TerminalExitInfo) => void>(); + const commandFinishedListeners = new Set<(info: TerminalCommandFinishedInfo) => void>(); const titleChangeListeners = new Set<(title?: string) => void>(); let killed = false; let disposed = false; @@ -578,10 +603,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { - if (disposed || killed) { - return; + let disposeTitleChangeSubscription: { dispose(): void } | null = null; + if (!lockedTitle) { + disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => { + if (disposed || killed) { + return; + } + pendingTitle = nextTitle.trim().length > 0 ? nextTitle : undefined; + if (titleDebounceTimer) { + clearTimeout(titleDebounceTimer); + } + titleDebounceTimer = setTimeout(() => { + titleDebounceTimer = null; + emitTitleChange(pendingTitle); + }, TERMINAL_TITLE_DEBOUNCE_MS); + }); + } + + const disposeCommandLifecycleSubscription = terminal.parser.registerOscHandler(633, (data) => { + const commandFinished = parseCommandFinishedOsc(data); + if (!commandFinished) { + return true; } - pendingTitle = nextTitle.trim().length > 0 ? nextTitle : undefined; - if (titleDebounceTimer) { - clearTimeout(titleDebounceTimer); + + for (const listener of Array.from(commandFinishedListeners)) { + try { + listener(commandFinished); + } catch { + // no-op + } } - titleDebounceTimer = setTimeout(() => { - titleDebounceTimer = null; - emitTitleChange(pendingTitle); - }, TERMINAL_TITLE_DEBOUNCE_MS); + return true; }); function buildExitInfo(input?: { @@ -647,10 +693,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void): () => void { + commandFinishedListeners.add(listener); + return () => { + commandFinishedListeners.delete(listener); + }; + } + function onTitleChange(listener: (title?: string) => void): () => void { titleChangeListeners.add(listener); if (title !== undefined) { @@ -854,6 +909,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise