diff --git a/.changeset/wise-suns-listen.md b/.changeset/wise-suns-listen.md new file mode 100644 index 00000000..bc813471 --- /dev/null +++ b/.changeset/wise-suns-listen.md @@ -0,0 +1,7 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Refine workspace navigation and editor management across desktop and mobile by +polishing sidebar section actions, improving quick jump and search flows, and +tightening preview and recovery behavior around open editors. diff --git a/docs/superpowers/plans/2026-05-23-agent-pane-keepalive.md b/docs/superpowers/plans/2026-05-23-agent-pane-keepalive.md new file mode 100644 index 00000000..11c1c103 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-agent-pane-keepalive.md @@ -0,0 +1,1064 @@ +# Agent Pane Keepalive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep desktop agent terminals alive while the editor is foregrounded so same-page `agent -> editor -> agent` switches never rebuild `xterm`, rerun replay, or lose runtime state. + +**Architecture:** Keep `AgentPanes` mounted inside a layered desktop main stage and render the editor as an overlay instead of an either-or branch. Thread a desktop visibility signal down to `XtermHost`, then use that signal to downgrade covered terminals to background hydration, disable interactivity, suppress replay overlays while covered, and refit when visible again without recreating the terminal instance. + +**Tech Stack:** React 19, TypeScript, Jotai, xterm.js, Vitest, CSS + +--- + +## File Map + +- `packages/web/src/features/agent-panes/index.tsx` + Propagates desktop visibility through the pane tree without changing pane layout behavior. +- `packages/web/src/features/agent-panes/index.test.tsx` + Verifies `AgentPanes` passes the visibility signal to every rendered `SessionCard`. +- `packages/web/src/features/agent-panes/views/shared/session-card.tsx` + Forwards the visibility signal from a session card into `XtermHost`. +- `packages/web/src/features/agent-panes/components/session-card.test.tsx` + Verifies `SessionCard` forwards `isVisible` to the terminal host. +- `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` + Converts the desktop main stage from conditional rendering to layered rendering and passes desktop foreground visibility into `AgentPanes`. +- `packages/web/src/features/workspace/index.test.tsx` + Verifies desktop editor mode keeps `AgentPanes` mounted and only toggles foreground visibility. +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` + Makes terminal interactivity, hydration priority, replay overlay visibility, and refit behavior aware of the new `isVisible` prop. +- `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + Verifies hidden terminals downgrade to background, stop accepting input, do not replay again on visibility-only rerenders, refit without refocusing, and suppress closed-session overlays while covered. +- `packages/web/src/styles/components.css` + Adds layered desktop stage styles for the always-mounted agent layer and editor overlay. +- `packages/web/src/styles/components.theme.test.ts` + Verifies the new desktop stage selectors and layout rules exist. + +## Guardrails + +- Leave `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` unchanged. `mainAreaMode` remains a foreground selector, not a mount selector. +- Do not change mobile behavior in this plan. +- Do not add a frontend terminal snapshot cache or runtime manager in this plan. +- Do not remove `terminal.replay` or `terminal.snapshot`; they stay as fallback for true recovery. + +### Task 1: Thread Desktop Visibility Through AgentPanes and SessionCard + +**Files:** +- Modify: `packages/web/src/features/agent-panes/index.tsx` +- Modify: `packages/web/src/features/agent-panes/index.test.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` + +- [ ] **Step 1: Write the failing prop-threading tests** + +Add this test to `packages/web/src/features/agent-panes/index.test.tsx`: + +```tsx +type MockSessionCardProps = { + sessionId: string; + isVisible?: boolean; + onSplitHorizontal?: () => void; + onSplitVertical?: () => void; + onClose?: () => void; +}; + +it("passes visibility to session cards", async () => { + const { store } = createAgentPaneStore(); + + const { rerender } = render( + + + + ); + + await waitFor(() => { + expect(mockSessionCard).toHaveBeenLastCalledWith( + expect.objectContaining({ + sessionId: "sess_1", + isVisible: true, + }) + ); + }); + + rerender( + + + + ); + + await waitFor(() => { + expect(mockSessionCard).toHaveBeenLastCalledWith( + expect.objectContaining({ + sessionId: "sess_1", + isVisible: false, + }) + ); + }); +}); +``` + +Add this test to `packages/web/src/features/agent-panes/components/session-card.test.tsx`: + +```tsx +it("passes terminal visibility through to XtermHost", () => { + const { store } = createSessionStore({ + terminalId: "term-live", + state: "running", + endedAt: undefined, + }); + + render( + + + + ); + + expect(getLastXtermHostProps()).toEqual( + expect.objectContaining({ + terminalId: "term-live", + isVisible: false, + }) + ); +}); +``` + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/agent-panes/index.test.tsx -t "passes visibility to session cards" +pnpm --filter @coder-studio/web test -- src/features/agent-panes/components/session-card.test.tsx -t "passes terminal visibility through to XtermHost" +``` + +Expected: + +```text +FAIL src/features/agent-panes/index.test.tsx + AssertionError: expected last call to contain { isVisible: true } + +FAIL src/features/agent-panes/components/session-card.test.tsx + AssertionError: expected object to contain { isVisible: false } +``` + +- [ ] **Step 3: Implement the prop threading with default-visible behavior** + +In `packages/web/src/features/agent-panes/index.tsx`, replace the `AgentPanesProps` declaration and component signature with: + +```tsx +interface AgentPanesProps { + hydrateSessions?: boolean; + isVisible?: boolean; +} + +export const AgentPanes: FC = ({ + hydrateSessions = true, + isVisible = true, +}) => { + const t = useTranslation(); + const workspace = useAtomValue(activeWorkspaceAtom); + const { workspaceId, sessions, paneLayout } = useWorkspaceSessions(workspace, { + disabled: !hydrateSessions, + }); + const paneActions = usePaneActions(workspaceId); + const sessionActions = useSessionActions(); + const hasLayoutSessions = collectSessionIds(paneLayout).length > 0; + const shouldShowStandaloneDraftLauncher = + sessions.length === 0 && + (hasLayoutSessions || + (paneLayout.type === "leaf" && !paneLayout.sessionId && paneLayout.id === "root")); + + if (!workspace) { + return ( +
+ {t("workspace.no_workspace")}

} + /> +
+ ); + } + + if (shouldShowStandaloneDraftLauncher) { + return ( + + ); + } + + return ( +
+ +
+ ); +}; +``` + +In the same file, replace `PaneNodeRendererProps` and the `SessionCard` render branch with: + +```tsx +interface PaneNodeRendererProps { + node: PaneNode; + workspaceId: string; + isVisible: boolean; + onAssignSession: (paneId: string, sessionId: string) => void; + onCloseDraftPane: (paneId: string) => void; + onCloseSession: (sessionId: string) => void; + onCloseSessionCommand: ( + sessionId: string, + paneDisposition?: "draft" | "remove" + ) => Promise; + onReplaceWithSession: (sessionId: string) => void; + onSplitDraftPane: (paneId: string, direction: "horizontal" | "vertical") => void; + onSplitSession: (sessionId: string, direction: "horizontal" | "vertical") => void; +} + +const PaneNodeRenderer: FC = ({ + node, + workspaceId, + isVisible, + onAssignSession, + onCloseDraftPane, + onCloseSession, + onCloseSessionCommand, + onReplaceWithSession, + onSplitDraftPane, + onSplitSession, +}) => { + if (node.type === "leaf") { + if (node.sessionId) { + return ( + { + onCloseSession(node.sessionId!); + await onCloseSessionCommand(node.sessionId!, "draft"); + }} + onSplitHorizontal={() => onSplitSession(node.sessionId!, "horizontal")} + onSplitVertical={() => onSplitSession(node.sessionId!, "vertical")} + /> + ); + } + + return ( + + ); + } + + const resolvedRatio = readPaneRatio(workspaceId, node.id) ?? node.ratio ?? 0.5; + + return ( + writePaneRatio(workspaceId, node.id, ratio)} + > + {node.children?.map((child) => ( + + ))} + + ); +}; +``` + +In `packages/web/src/features/agent-panes/views/shared/session-card.tsx`, replace the props definition and `XtermHost` usage with: + +```tsx +interface SessionCardProps { + sessionId: string; + isVisible?: boolean; + showHeaderActions?: boolean; + showSupervisorInline?: boolean; + terminalReadOnlyOverride?: boolean; + headerAccessory?: ReactNode; + onClose?: SessionCardAction; + onSplitHorizontal?: SessionCardAction; + onSplitVertical?: SessionCardAction; +} + +export const SessionCard: FC = ({ + sessionId, + isVisible = true, + showHeaderActions = true, + showSupervisorInline = true, + terminalReadOnlyOverride, + headerAccessory, + onClose, + onSplitHorizontal, + onSplitVertical, +}) => { +``` + +```tsx +
+ { + void handleClosedSessionClose(); + }} + onClosedSessionContinue={() => { + void handleClosedSessionContinue(); + }} + terminalId={session.terminalId} + workspaceId={session.workspaceId} + readOnly={terminalReadOnly} + isActiveSession={isActiveSession} + isVisible={isVisible} + terminalKind="agent" + /> +
+``` + +- [ ] **Step 4: Run the focused tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/agent-panes/index.test.tsx -t "passes visibility to session cards" +pnpm --filter @coder-studio/web test -- src/features/agent-panes/components/session-card.test.tsx -t "passes terminal visibility through to XtermHost" +``` + +Expected: + +```text +PASS src/features/agent-panes/index.test.tsx +PASS src/features/agent-panes/components/session-card.test.tsx +``` + +- [ ] **Step 5: Commit** + +```bash +git add \ + packages/web/src/features/agent-panes/index.tsx \ + packages/web/src/features/agent-panes/index.test.tsx \ + packages/web/src/features/agent-panes/views/shared/session-card.tsx \ + packages/web/src/features/agent-panes/components/session-card.test.tsx +git commit -m "refactor: thread desktop terminal visibility" +``` + +### Task 2: Keep AgentPanes Mounted in the Desktop Stage + +**Files:** +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Modify: `packages/web/src/features/workspace/index.test.tsx` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing desktop keepalive and CSS tests** + +In `packages/web/src/features/workspace/index.test.tsx`, change the `AgentPanes` mock to expose lifecycle and visibility: + +```tsx +const agentPaneLifecycle = { mounts: 0, unmounts: 0 }; + +vi.mock("../agent-panes", async () => { + const React = await vi.importActual("react"); + + return { + AgentPanes: ({ isVisible = true }: { isVisible?: boolean }) => { + React.useEffect(() => { + agentPaneLifecycle.mounts += 1; + return () => { + agentPaneLifecycle.unmounts += 1; + }; + }, []); + + return
; + }, + }; +}); +``` + +Reset the lifecycle object in the existing `afterEach` block: + +```tsx +agentPaneLifecycle.mounts = 0; +agentPaneLifecycle.unmounts = 0; +``` + +Add this new workspace test: + +```tsx +it("keeps agent panes mounted beneath the editor overlay", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + await screen.findByTestId("agent-panes"); + expect(agentPaneLifecycle).toEqual({ mounts: 1, unmounts: 0 }); + + act(() => { + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + }); + + expect(screen.getByTestId("agent-panes")).toBeInTheDocument(); + expect(screen.getByTestId("agent-panes")).toHaveAttribute("data-visible", "false"); + expect(screen.getByTestId("code-editor-host")).toBeInTheDocument(); + expect(agentPaneLifecycle).toEqual({ mounts: 1, unmounts: 0 }); + expect(document.querySelector(".workspace-main-stage__agent-layer")).toHaveAttribute( + "aria-hidden", + "true" + ); + expect(document.querySelector(".workspace-main-stage__editor-layer")).not.toBeNull(); +}); +``` + +Replace the two editor-mode assertions that currently remove agent panes with these assertions: + +```tsx +expect(screen.getByTestId("code-editor-host")).toBeInTheDocument(); +expect(screen.getByTestId("agent-panes")).toHaveAttribute("data-visible", "false"); +``` + +In the existing desktop surface test inside `packages/web/src/styles/components.theme.test.ts`, replace the old stage selector capture: + +```ts +const agentPanes = getLastRuleBlock(".workspace-main-stage > .agent-panes"); +``` + +with: + +```ts +const agentLayer = getLastRuleBlock(".workspace-main-stage__agent-layer"); +const coveredAgentLayer = getLastRuleBlock(".workspace-main-stage__agent-layer--covered"); +const editorLayer = getLastRuleBlock(".workspace-main-stage__editor-layer"); +const nestedAgentPanes = getLastRuleBlock(".workspace-main-stage__agent-layer > .agent-panes"); +``` + +Replace the old agent-pane expectations: + +```ts +expect(agentPanes).toContain("flex: 1"); +expect(agentPanes).toContain("min-height: 0"); +expect(agentPanes).toContain("padding: 0"); +``` + +with: + +```ts +expect(mainStage).toContain("position: relative"); +expect(mainStage).toContain("overflow: hidden"); +expect(agentLayer).toContain("display: flex"); +expect(agentLayer).toContain("min-height: 0"); +expect(coveredAgentLayer).toContain("pointer-events: none"); +expect(editorLayer).toContain("position: absolute"); +expect(editorLayer).toContain("inset: 0"); +expect(nestedAgentPanes).toContain("flex: 1"); +expect(nestedAgentPanes).toContain("min-height: 0"); +expect(nestedAgentPanes).toContain("padding: 0"); +``` +``` + +- [ ] **Step 2: Run the focused desktop tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/index.test.tsx -t "keeps agent panes mounted beneath the editor overlay" +pnpm --filter @coder-studio/web test -- src/styles/components.theme.test.ts -t "emits layered desktop workspace stage rules" +``` + +Expected: + +```text +FAIL src/features/workspace/index.test.tsx + Unable to find an element by: [data-testid="agent-panes"] + +FAIL src/styles/components.theme.test.ts + expected CSS rule for .workspace-main-stage__agent-layer +``` + +- [ ] **Step 3: Layer the desktop stage and keep AgentPanes mounted** + +In `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`, replace the current main-stage branch with: + +```tsx +
+
+
+ +
+ + {mainAreaMode === "editor" ? ( +
+ +
+ ) : null} +
+``` + +In `packages/web/src/styles/components.css`, replace the current desktop stage block with: + +```css +.workspace-main-stage { + position: relative; + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.workspace-main-stage__agent-layer { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; +} + +.workspace-main-stage__agent-layer > .agent-panes { + flex: 1; + min-height: 0; + padding: 0; +} + +.workspace-main-stage__agent-layer--covered { + pointer-events: none; +} + +.workspace-main-stage__editor-layer { + position: absolute; + inset: 0; + z-index: 1; + min-width: 0; + min-height: 0; +} + +.workspace-main-stage__editor-layer > * { + height: 100%; + min-height: 0; +} +``` + +- [ ] **Step 4: Run the focused desktop tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/index.test.tsx -t "keeps agent panes mounted beneath the editor overlay" +pnpm --filter @coder-studio/web test -- src/styles/components.theme.test.ts -t "emits layered desktop workspace stage rules" +``` + +Expected: + +```text +PASS src/features/workspace/index.test.tsx +PASS src/styles/components.theme.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add \ + packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx \ + packages/web/src/features/workspace/index.test.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "feat: keep desktop agent panes mounted" +``` + +### Task 3: Make XtermHost Visibility-Aware Without Recreating xterm + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- Modify: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + +- [ ] **Step 1: Write the failing visibility-behavior tests** + +Add this test to `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx`: + +```tsx +it("treats covered desktop terminals as background hydration and disables stdin", async () => { + const store = createStore(); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ status: "ok" }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + expect(hydrationCoordinatorMocks.request).toHaveBeenCalledWith({ + terminalId: "covered-terminal", + tier: "background", + }); + + await waitFor(() => { + expect(mockTerminal.options).toEqual( + expect.objectContaining({ + disableStdin: true, + cursorBlink: false, + }) + ); + }); +}); +``` + +Add this test to the same file: + +```tsx +it("does not rerun recovery when only desktop visibility changes", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.snapshot") { + return Promise.resolve({ status: "unsupported" }); + } + + if (op === "terminal.replay") { + return Promise.resolve({ status: "ok", seq: 0 }); + } + + return Promise.resolve({ ok: true, data: { status: "ok" } }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + store.set(wsClientAtom, { + sendCommand, + subscribe, + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + const { rerender } = render( + + + + ); + + await act(async () => { + rafCallbacks.shift()?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + mockFitAddon.fit.mockClear(); + mockTerminal.focus.mockClear(); + + rerender( + + + + ); + + rerender( + + + + ); + + await act(async () => { + await Promise.resolve(); + }); + + const { Terminal } = await import("@xterm/xterm"); + + expect(Terminal).toHaveBeenCalledTimes(1); + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.snapshot")).toHaveLength(1); + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.replay")).toHaveLength(1); + expect(mockFitAddon.fit).toHaveBeenCalled(); + expect(mockTerminal.focus).not.toHaveBeenCalled(); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; +}); +``` + +Add this test to the same file: + +```tsx +it("suppresses the closed-session overlay while the terminal is covered", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.replay") { + return Promise.resolve({ status: "unknown" }); + } + + return Promise.resolve({ ok: true, data: { status: "ok" } }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + const { rerender } = render( + + + + ); + + await act(async () => { + rafCallbacks.shift()?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.queryByText("当前会话已结束")).not.toBeInTheDocument(); + expect(document.querySelector(".xterm-replay-overlay")).toBeFalsy(); + + rerender( + + + + ); + + await waitFor(() => { + expect(screen.getByText("当前会话已结束")).toBeInTheDocument(); + }); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; +}); +``` + +- [ ] **Step 2: Run the terminal host test file and verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: + +```text +FAIL src/features/terminal-panel/__tests__/xterm-host.test.tsx + Property 'isVisible' does not exist on type 'XtermHostProps' + expected hydration request tier to equal "background" + expected terminal.replay call count to remain 1 + expected overlay not to be rendered while hidden +``` + +- [ ] **Step 3: Add visibility-aware hydration, interactivity, overlay gating, and refit logic** + +In `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`, add a shared helper near the prop types: + +```tsx +function resolveHydrationTier({ + alive, + isActiveSession, + isVisible, +}: { + alive: boolean | undefined; + isActiveSession: boolean; + isVisible: boolean; +}): HydrationTier { + if (!isVisible || alive === false) { + return "background"; + } + + return isActiveSession ? "visible-active" : "visible-other"; +} +``` + +Replace the `XtermHostProps` block and function signature with: + +```tsx +interface XtermHostProps { + terminalId: string; + workspaceId: string; + readOnly?: boolean; + isActiveSession?: boolean; + isVisible?: boolean; + terminalKind?: "agent" | "shell"; + containerRef?: React.RefObject; + closedSessionContinueLabel?: string; + closedSessionProviderLabel?: string; + onClosedSessionContinue?: () => void; + onClosedSessionClose?: () => void; +} + +export function XtermHost({ + closedSessionContinueLabel, + closedSessionProviderLabel, + onClosedSessionClose, + onClosedSessionContinue, + terminalId, + workspaceId, + readOnly = false, + isActiveSession = false, + isVisible = true, + terminalKind: terminalKindProp, +}: XtermHostProps) { +``` + +Replace the interactivity and visibility refs near the top of the component with: + +```tsx + const terminalMetaRef = useRef(meta); + const terminalKind = terminalKindProp ?? meta?.kind ?? "shell"; + const isInteractive = isVisible && !readOnly && meta?.alive !== false; + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const unsubscribeRef = useRef<(() => void) | null>(null); + const fitFrameRef = useRef(null); + const mountedRef = useRef(false); + const fitResolversRef = useRef void>>([]); + const resizeDebounceRef = useRef | null>(null); + const interactiveRef = useRef(true); + const visibleRef = useRef(isVisible); + const previousVisibleRef = useRef(isVisible); + const lastReportedSizeRef = useRef<{ cols: number; rows: number } | null>(null); +``` + +Add this effect after the `terminalMetaRef` sync effect: + +```tsx + useEffect(() => { + visibleRef.current = isVisible; + }, [isVisible]); +``` + +Replace both hydration-tier calculations with the shared helper: + +```tsx + const handle = globalHydrationCoordinator.request({ + terminalId, + tier: resolveHydrationTier({ + alive: meta?.alive, + isActiveSession, + isVisible, + }), + }); +``` + +```tsx + hydrationHandleRef.current?.promote( + resolveHydrationTier({ + alive: meta?.alive, + isActiveSession, + isVisible, + }) + ); + }, [isActiveSession, isVisible, meta?.alive, viewport]); +``` + +Keep the existing focus effect dependency list stable, but gate focus with the visibility ref so covered terminals never refocus: + +```tsx + useEffect(() => { + if ( + viewport !== "mobile" && + hydrationState.kind === "granted" && + meta?.alive && + terminalRef.current && + visibleRef.current + ) { + terminalRef.current.focus(); + } + }, [hydrationState.kind, meta?.alive, viewport]); +``` + +Add a new visibility-restore refit effect immediately after the focus effect: + +```tsx + useEffect(() => { + const wasVisible = previousVisibleRef.current; + previousVisibleRef.current = isVisible; + + if (viewport === "mobile") { + return; + } + + if (!isVisible || wasVisible || hydrationState.kind !== "granted") { + return; + } + + if (terminalRef.current) { + scheduleFit(); + } + }, [hydrationState.kind, isVisible, scheduleFit, viewport]); +``` + +Finally, gate the replay overlay by visibility: + +```tsx + const showReplayOverlay = + (isVisible || viewport === "mobile") && + (replayUiState.kind === "degraded" || + (replayUiState.kind === "loading" && loadingOverlayVisible)) && + (viewport === "mobile" || + hydrationState.kind === "granted" || + activeRecoveryUiModeRef.current === "non_blocking_recovering"); +``` + +- [ ] **Step 4: Run the terminal host test file and verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: + +```text +PASS src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +- [ ] **Step 5: Commit** + +```bash +git add \ + packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx \ + packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +git commit -m "feat: make xterm keepalive visibility-aware" +``` + +## Final Verification + +Run: + +```bash +pnpm --filter @coder-studio/web test -- \ + src/features/agent-panes/index.test.tsx \ + src/features/agent-panes/components/session-card.test.tsx \ + src/features/workspace/index.test.tsx \ + src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: + +```text +PASS src/features/agent-panes/index.test.tsx +PASS src/features/agent-panes/components/session-card.test.tsx +PASS src/features/workspace/index.test.tsx +PASS src/features/terminal-panel/__tests__/xterm-host.test.tsx +PASS src/styles/components.theme.test.ts +``` + +If you want one extra safety check after the test run, use: + +```bash +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: + +```text +Found 0 errors. +``` diff --git a/docs/superpowers/plans/2026-05-24-open-editors-actions.md b/docs/superpowers/plans/2026-05-24-open-editors-actions.md new file mode 100644 index 00000000..6a1bf043 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-open-editors-actions.md @@ -0,0 +1,1194 @@ +# Open Editors Actions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring desktop and mobile `Open Editors` to parity with expand/collapse, file count, per-row close, and `Close all`, while making editor closing follow one deterministic active-file selection rule. + +**Architecture:** Keep the close decision pure in a workspace helper, then reuse a lightweight `useOpenEditorsActions` hook from both the desktop editor header close action and the shared `OpenEditorsSection`. Build the new `Open Editors` chrome in the shared section, add a dedicated localized `Close all` label, and verify desktop/mobile behavior with focused UI and integration tests. + +**Tech Stack:** React 19, Jotai, Vitest, Testing Library, Lucide React, existing workspace atoms, `useOpenLocation`, Monaco model disposal via `monacoModelRegistry`, and shared CSS assertions in `packages/web/src/styles/components.theme.test.ts`. + +**Spec reference:** `docs/superpowers/specs/2026-05-24-open-editors-actions-design.md` + +**Current scope note:** The existing reusable editor-header close action only exists on desktop `CodeEditorHost`. Mobile file detail uses the sheet header/back flow instead of an editor close button, so this plan updates mobile `Open Editors` list actions but does not introduce a new mobile detail close control. + +--- + +## File Structure + +**Create:** +- `packages/web/src/features/workspace/actions/open-editors-close.ts` — pure helper that resolves removal targets, next active path, and editor-exit intent +- `packages/web/src/features/workspace/actions/open-editors-close.test.ts` — unit coverage for ordered close behavior and `closeAll` +- `packages/web/src/features/workspace/actions/use-open-editors-actions.ts` — shared hook that mutates workspace editor atoms and disposes Monaco models +- `packages/web/src/features/workspace/views/shared/open-editors-section.test.tsx` — shared section tests for collapse, count, close-row, and close-all behavior + +**Modify:** +- `packages/web/src/features/code-editor/actions/use-code-editor-actions.ts` — replace inline close logic with shared `useOpenEditorsActions` +- `packages/web/src/features/code-editor/index.test.tsx` — verify editor-header close switches to next editor and exits on final file +- `packages/web/src/features/workspace/views/shared/open-editors-section.tsx` — add header chrome, collapse state, close buttons, and shared actions wiring +- `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` — verify mobile explorer renders the shared controls and active-row close behavior +- `packages/web/src/features/workspace/index.test.tsx` — verify desktop main area falls back to session/agent when `Close all` removes the last open editor +- `packages/web/src/styles/components.css` — add shared `Open Editors` layout, right-side actions, and single-line truncation rules +- `packages/web/src/styles/components.theme.test.ts` — assert the new selectors keep compact single-line rows +- `packages/web/src/locales/en.json` — add `action.close_all` +- `packages/web/src/locales/zh.json` — add `action.close_all` + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/actions/open-editors-close.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/code-editor/index.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/open-editors-section.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx src/features/workspace/index.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/actions/open-editors-close.test.ts src/features/code-editor/index.test.tsx src/features/workspace/views/shared/open-editors-section.test.tsx src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx src/features/workspace/index.test.tsx src/styles/components.theme.test.ts` + +--- + +### Task 1: Add Pure Open-Editor Close Decisions + +**Files:** +- Create: `packages/web/src/features/workspace/actions/open-editors-close.test.ts` +- Create: `packages/web/src/features/workspace/actions/open-editors-close.ts` + +- [ ] **Step 1: Write the failing helper tests** + +Create `packages/web/src/features/workspace/actions/open-editors-close.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { OpenFile } from "../atoms"; +import { resolveOpenEditorsClose } from "./open-editors-close"; + +function createFile(path: string): OpenFile { + return { + kind: "text", + path, + content: path, + savedContent: path, + baseHash: `${path}-hash`, + isDirty: false, + }; +} + +describe("resolveOpenEditorsClose", () => { + it("keeps the active file when closing a non-active editor", () => { + const openFiles = { + "README.md": createFile("README.md"), + "src/app.tsx": createFile("src/app.tsx"), + "src/view.tsx": createFile("src/view.tsx"), + }; + + expect( + resolveOpenEditorsClose({ + openFiles, + activeFilePath: "src/app.tsx", + targetPath: "README.md", + }) + ).toEqual({ + orderedPaths: ["README.md", "src/app.tsx", "src/view.tsx"], + removedPaths: ["README.md"], + nextActiveFilePath: "src/app.tsx", + shouldExitEditor: false, + }); + }); + + it("selects the next editor when closing the active file with a later entry", () => { + const openFiles = { + "README.md": createFile("README.md"), + "src/app.tsx": createFile("src/app.tsx"), + "src/view.tsx": createFile("src/view.tsx"), + }; + + expect( + resolveOpenEditorsClose({ + openFiles, + activeFilePath: "src/app.tsx", + targetPath: "src/app.tsx", + }) + ).toEqual({ + orderedPaths: ["README.md", "src/app.tsx", "src/view.tsx"], + removedPaths: ["src/app.tsx"], + nextActiveFilePath: "src/view.tsx", + shouldExitEditor: false, + }); + }); + + it("selects the previous editor when closing the active last entry", () => { + const openFiles = { + "README.md": createFile("README.md"), + "src/app.tsx": createFile("src/app.tsx"), + }; + + expect( + resolveOpenEditorsClose({ + openFiles, + activeFilePath: "src/app.tsx", + targetPath: "src/app.tsx", + }) + ).toEqual({ + orderedPaths: ["README.md", "src/app.tsx"], + removedPaths: ["src/app.tsx"], + nextActiveFilePath: "README.md", + shouldExitEditor: false, + }); + }); + + it("signals editor exit when the last remaining file closes", () => { + const openFiles = { + "src/app.tsx": createFile("src/app.tsx"), + }; + + expect( + resolveOpenEditorsClose({ + openFiles, + activeFilePath: "src/app.tsx", + targetPath: "src/app.tsx", + }) + ).toEqual({ + orderedPaths: ["src/app.tsx"], + removedPaths: ["src/app.tsx"], + nextActiveFilePath: null, + shouldExitEditor: true, + }); + }); + + it("clears every open file for closeAll", () => { + const openFiles = { + "README.md": createFile("README.md"), + "src/app.tsx": createFile("src/app.tsx"), + }; + + expect( + resolveOpenEditorsClose({ + openFiles, + activeFilePath: "src/app.tsx", + closeAll: true, + }) + ).toEqual({ + orderedPaths: ["README.md", "src/app.tsx"], + removedPaths: ["README.md", "src/app.tsx"], + nextActiveFilePath: null, + shouldExitEditor: true, + }); + }); +}); +``` + +- [ ] **Step 2: Run the helper test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/actions/open-editors-close.test.ts +``` + +Expected: +- FAIL with `Cannot find module './open-editors-close'` + +- [ ] **Step 3: Implement the pure close helper** + +Create `packages/web/src/features/workspace/actions/open-editors-close.ts`: + +```ts +import type { OpenFile } from "../atoms"; + +interface ResolveOpenEditorsCloseInput { + openFiles: Record; + activeFilePath: string | null; + targetPath?: string; + closeAll?: boolean; +} + +interface ResolveOpenEditorsCloseResult { + orderedPaths: string[]; + removedPaths: string[]; + nextActiveFilePath: string | null; + shouldExitEditor: boolean; +} + +export function orderOpenEditorPaths(openFiles: Record): string[] { + return Object.keys(openFiles).sort((left, right) => left.localeCompare(right)); +} + +export function resolveOpenEditorsClose( + input: ResolveOpenEditorsCloseInput +): ResolveOpenEditorsCloseResult { + const orderedPaths = orderOpenEditorPaths(input.openFiles); + + if (input.closeAll) { + return { + orderedPaths, + removedPaths: orderedPaths, + nextActiveFilePath: null, + shouldExitEditor: true, + }; + } + + const targetPath = input.targetPath; + if (!targetPath || !input.openFiles[targetPath]) { + return { + orderedPaths, + removedPaths: [], + nextActiveFilePath: input.activeFilePath, + shouldExitEditor: false, + }; + } + + const targetIndex = orderedPaths.indexOf(targetPath); + const remainingPaths = orderedPaths.filter((path) => path !== targetPath); + const isActiveTarget = input.activeFilePath === targetPath; + + if (!isActiveTarget) { + return { + orderedPaths, + removedPaths: [targetPath], + nextActiveFilePath: input.activeFilePath, + shouldExitEditor: remainingPaths.length === 0, + }; + } + + const nextActiveFilePath = + remainingPaths[targetIndex] ?? remainingPaths[targetIndex - 1] ?? null; + + return { + orderedPaths, + removedPaths: [targetPath], + nextActiveFilePath, + shouldExitEditor: nextActiveFilePath === null, + }; +} +``` + +- [ ] **Step 4: Run the helper test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/actions/open-editors-close.test.ts +``` + +Expected: +- PASS for all `resolveOpenEditorsClose` cases + +- [ ] **Step 5: Commit the helper layer** + +```bash +git add \ + packages/web/src/features/workspace/actions/open-editors-close.ts \ + packages/web/src/features/workspace/actions/open-editors-close.test.ts +git commit -m "feat: add open editor close helper" +``` + +--- + +### Task 2: Share Close Actions With The Desktop Editor Header + +**Files:** +- Create: `packages/web/src/features/workspace/actions/use-open-editors-actions.ts` +- Modify: `packages/web/src/features/code-editor/actions/use-code-editor-actions.ts` +- Modify: `packages/web/src/features/code-editor/index.test.tsx` + +- [ ] **Step 1: Write the failing editor close tests** + +Append these cases in `packages/web/src/features/code-editor/index.test.tsx` near the current close-button coverage: + +```tsx + it("switches to the next sorted open file when the header closes the active editor", () => { + const { store } = setupStore({ + activePath: "src/app.tsx", + openFiles: { + "README.md": { + kind: "text", + path: "README.md", + content: "docs", + savedContent: "docs", + baseHash: "readme-hash", + isDirty: false, + }, + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "app", + savedContent: "app", + baseHash: "app-hash", + isDirty: false, + }, + "src/view.tsx": { + kind: "text", + path: "src/view.tsx", + content: "view", + savedContent: "view", + baseHash: "view-hash", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/view.tsx"); + expect(store.get(openFilesAtomFamily("ws-1"))["src/app.tsx"]).toBeUndefined(); + expect(mockRegistryDisposeFile).toHaveBeenCalledWith("/tmp/ws", "src/app.tsx"); + }); + + it("returns to the empty editor state when the header closes the final remaining file", () => { + const { store } = setupStore({ + activePath: "src/only.ts", + openFiles: { + "src/only.ts": { + kind: "text", + path: "src/only.ts", + content: "only", + savedContent: "only", + baseHash: "only-hash", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + }); +``` + +- [ ] **Step 2: Run the editor test file to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/code-editor/index.test.tsx +``` + +Expected: +- FAIL because `handleClose` still clears `activeFilePath` directly instead of selecting the next editor + +- [ ] **Step 3: Implement shared open-editor actions and reuse them** + +Create `packages/web/src/features/workspace/actions/use-open-editors-actions.ts`: + +```ts +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useCallback } from "react"; +import { activeWorkspaceAtom } from "../../../atoms/workspaces"; +import { monacoModelRegistry } from "../../code-editor/monaco/model-registry"; +import { + activeFilePathAtomFamily, + editorModeAtomFamily, + gitDiffPreviewAtomFamily, + type OpenFile, + openFilesAtomFamily, +} from "../atoms"; +import { resolveOpenEditorsClose } from "./open-editors-close"; + +interface UseOpenEditorsActionsOptions { + onExitEditor?: () => void; +} + +export function useOpenEditorsActions( + workspaceId: string, + options?: UseOpenEditorsActionsOptions +) { + const workspace = useAtomValue(activeWorkspaceAtom); + const workspaceRootPath = workspace?.id === workspaceId ? workspace.path : null; + const [activeFilePath, setActiveFilePath] = useAtom(activeFilePathAtomFamily(workspaceId)); + const [openFiles, setOpenFiles] = useAtom(openFilesAtomFamily(workspaceId)); + const [diffPreview, setDiffPreview] = useAtom(gitDiffPreviewAtomFamily(workspaceId)); + const setEditorMode = useSetAtom(editorModeAtomFamily(workspaceId)); + + const disposePaths = useCallback( + (paths: string[], snapshot: Record) => { + if (!workspaceRootPath) { + return; + } + + for (const path of paths) { + const file = snapshot[path]; + if (file?.kind === "text") { + monacoModelRegistry.disposeFile(workspaceRootPath, path); + } + } + }, + [workspaceRootPath] + ); + + const applyRemoval = useCallback( + ( + removedPaths: string[], + nextActiveFilePath: string | null, + shouldExitEditor: boolean, + resetMode: boolean + ) => { + if (removedPaths.length === 0) { + return; + } + + disposePaths(removedPaths, openFiles); + setOpenFiles((previous) => { + const next = { ...previous }; + for (const path of removedPaths) { + delete next[path]; + } + return next; + }); + setActiveFilePath(nextActiveFilePath); + + const shouldClearPreview = + shouldExitEditor || + (diffPreview?.source === "file" && removedPaths.includes(diffPreview.path)); + + if (shouldClearPreview) { + setDiffPreview(null); + } + + if (resetMode || shouldExitEditor) { + setEditorMode("edit"); + } + + if (shouldExitEditor) { + options?.onExitEditor?.(); + } + }, + [ + diffPreview, + disposePaths, + openFiles, + options, + setActiveFilePath, + setDiffPreview, + setEditorMode, + setOpenFiles, + ] + ); + + const closePath = useCallback( + (targetPath: string) => { + const decision = resolveOpenEditorsClose({ + openFiles, + activeFilePath, + targetPath, + }); + + applyRemoval( + decision.removedPaths, + decision.nextActiveFilePath, + decision.shouldExitEditor, + activeFilePath === targetPath + ); + + return decision; + }, + [activeFilePath, applyRemoval, openFiles] + ); + + const closeAll = useCallback(() => { + const decision = resolveOpenEditorsClose({ + openFiles, + activeFilePath, + closeAll: true, + }); + + applyRemoval(decision.removedPaths, null, true, true); + return decision; + }, [activeFilePath, applyRemoval, openFiles]); + + return { + closeAll, + closePath, + }; +} +``` + +Then update `packages/web/src/features/code-editor/actions/use-code-editor-actions.ts`: + +```ts +import { useOpenEditorsActions } from "../../workspace/actions/use-open-editors-actions"; +``` + +```ts + const { closePath } = useOpenEditorsActions(workspaceId ?? ""); +``` + +Replace the current `handleClose` callback with: + +```ts + const handleClose = useCallback(() => { + if (!currentFile?.path) { + return; + } + + closePath(currentFile.path); + setSaveError(null); + }, [closePath, currentFile?.path, setSaveError]); +``` + +- [ ] **Step 4: Run the editor test file to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/code-editor/index.test.tsx +``` + +Expected: +- PASS for the two new close-flow cases +- PASS for existing editor tests, including diff-preview payload handling + +- [ ] **Step 5: Commit the shared editor close integration** + +```bash +git add \ + packages/web/src/features/workspace/actions/use-open-editors-actions.ts \ + packages/web/src/features/code-editor/actions/use-code-editor-actions.ts \ + packages/web/src/features/code-editor/index.test.tsx +git commit -m "feat: share open editor close actions" +``` + +--- + +### Task 3: Build The Shared Open Editors Section UI + +**Files:** +- Create: `packages/web/src/features/workspace/views/shared/open-editors-section.test.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/open-editors-section.tsx` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing shared section tests** + +Create `packages/web/src/features/workspace/views/shared/open-editors-section.test.tsx`: + +```tsx +// @vitest-environment jsdom + +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { localeAtom } from "../../../../atoms/app-ui"; +import { activeWorkspaceIdAtom } from "../../../../atoms/workspaces"; +import { seedReadyWorkspaceState } from "../../../../test-utils/workspace-state"; +import { + activeFilePathAtomFamily, + editorModeAtomFamily, + gitDiffPreviewAtomFamily, + openFilesAtomFamily, +} from "../../atoms"; +import { OpenEditorsSection } from "./open-editors-section"; + +const disposeFileMock = vi.fn(); + +vi.mock("../../../code-editor/monaco/model-registry", () => ({ + monacoModelRegistry: { + getOrCreate: vi.fn(), + updateFromDisk: vi.fn(), + disposeFile: disposeFileMock, + disposeWorkspace: vi.fn(), + }, +})); + +function createStoreState() { + const store = createStore(); + store.set(localeAtom, "en"); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/tmp/ws-test", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(activeWorkspaceIdAtom, "ws-test"); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + store.set(editorModeAtomFamily("ws-test"), "diff"); + store.set(openFilesAtomFamily("ws-test"), { + "README.md": { + kind: "text", + path: "README.md", + content: "docs", + savedContent: "docs", + baseHash: "readme-hash", + isDirty: false, + }, + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "app", + savedContent: "app", + baseHash: "app-hash", + isDirty: false, + }, + }); + store.set(gitDiffPreviewAtomFamily("ws-test"), { + path: "src/app.tsx", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + staged: false, + source: "file", + }); + return store; +} + +describe("OpenEditorsSection", () => { + afterEach(() => { + disposeFileMock.mockReset(); + vi.restoreAllMocks(); + }); + + it("renders the file count and lets users collapse and re-expand rows", () => { + const store = createStoreState(); + + render( + + + + ); + + const heading = screen.getByRole("heading", { name: /Open Editors/i }); + const section = heading.closest("section"); + + expect(heading).toHaveTextContent("Open Editors"); + expect(heading).toHaveTextContent("(2)"); + + const collapseButton = within(section as HTMLElement).getByRole("button", { + name: "Collapse", + }); + expect(collapseButton).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(collapseButton); + + expect(screen.queryByRole("button", { name: "README.md" })).toBeNull(); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + + fireEvent.click( + within(section as HTMLElement).getByRole("button", { name: "Expand" }) + ); + + expect(screen.getByRole("button", { name: "README.md" })).toBeInTheDocument(); + }); + + it("closes a non-active row without changing the active file or diff mode", () => { + const store = createStoreState(); + + render( + + + + ); + + const readmeRow = screen + .getByRole("button", { name: "README.md" }) + .closest(".workspace-open-editors__row"); + + fireEvent.click( + within(readmeRow as HTMLElement).getByRole("button", { name: "Close README.md" }) + ); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(store.get(editorModeAtomFamily("ws-test"))).toBe("diff"); + expect(store.get(openFilesAtomFamily("ws-test"))["README.md"]).toBeUndefined(); + expect(store.get(gitDiffPreviewAtomFamily("ws-test"))).toEqual({ + path: "src/app.tsx", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + staged: false, + source: "file", + }); + expect(disposeFileMock).toHaveBeenCalledWith("/tmp/ws-test", "README.md"); + }); + + it("closes all rows from the header action", () => { + const store = createStoreState(); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Close all" })); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-test"))).toEqual({}); + expect(store.get(gitDiffPreviewAtomFamily("ws-test"))).toBeNull(); + expect(store.get(editorModeAtomFamily("ws-test"))).toBe("edit"); + }); +}); +``` + +- [ ] **Step 2: Run the shared section test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/open-editors-section.test.tsx +``` + +Expected: +- FAIL because the section still renders only a heading plus plain path buttons + +- [ ] **Step 3: Implement the shared header, localized close-all action, and styles** + +Update `packages/web/src/features/workspace/views/shared/open-editors-section.tsx`: + +```tsx +import { ChevronDown, ChevronRight, X } from "lucide-react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useState } from "react"; +import { IconButton, Tooltip } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { orderOpenEditorPaths } from "../../actions/open-editors-close"; +import { useOpenEditorsActions } from "../../actions/use-open-editors-actions"; +import { + activeFilePathAtomFamily, + deriveEditorModeForPath, + editorModeAtomFamily, + openFilesAtomFamily, +} from "../../atoms"; + +interface OpenEditorsSectionProps { + workspaceId: string; + onSelectFile?: (path: string) => void; + title?: string; +} + +export function OpenEditorsSection({ workspaceId, onSelectFile, title }: OpenEditorsSectionProps) { + const t = useTranslation(); + const [collapsed, setCollapsed] = useState(false); + const openFiles = useAtomValue(openFilesAtomFamily(workspaceId)); + const activeFilePath = useAtomValue(activeFilePathAtomFamily(workspaceId)); + const setEditorMode = useSetAtom(editorModeAtomFamily(workspaceId)); + const { closeAll, closePath } = useOpenEditorsActions(workspaceId); + const { openLocation } = useOpenLocation(workspaceId); + const openEditorPaths = orderOpenEditorPaths(openFiles); + + return ( +
+
+
+ + : } + onClick={() => setCollapsed((value) => !value)} + size="sm" + /> + +

+ {title ?? t("workspace.sidebar.open_editors")} + ({openEditorPaths.length}) +

+
+ + +
+ + {!collapsed && openEditorPaths.length > 0 ? ( +
+ {openEditorPaths.map((path) => ( +
+ + + + } + onClick={() => closePath(path)} + size="sm" + /> + +
+ ))} +
+ ) : null} +
+ ); +} +``` + +Add the new locale key in `packages/web/src/locales/en.json`: + +```json + "close": "Close", + "close_all": "Close all", + "apply": "Apply", +``` + +Add the new locale key in `packages/web/src/locales/zh.json`: + +```json + "close": "关闭", + "close_all": "全部关闭", + "apply": "应用", +``` + +Extend `packages/web/src/styles/components.css` near the existing `workspace-open-editors` block: + +```css +.workspace-open-editors__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-tight); + margin-bottom: var(--sp-2); +} + +.workspace-open-editors__header-main { + display: flex; + align-items: center; + gap: var(--gap-micro); + min-width: 0; +} + +.workspace-open-editors__title { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + min-width: 0; +} + +.workspace-open-editors__count { + color: var(--text-quaternary); +} + +.workspace-open-editors__close-all { + flex: 0 0 auto; + border: none; + background: transparent; + color: var(--text-tertiary); + font: inherit; +} + +.workspace-open-editors__close-all:disabled { + opacity: 0.5; +} + +.workspace-open-editors__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--gap-micro); + align-items: center; +} + +.workspace-open-editors__item { + display: flex; + align-items: center; + min-width: 0; +} + +.workspace-open-editors__item-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-open-editors__item-close { + flex: 0 0 auto; +} +``` + +Extend `packages/web/src/styles/components.theme.test.ts` with: + +```ts + it("keeps open editors rows single-line with compact right-side actions", () => { + const header = getLastRuleBlock(".workspace-open-editors__header"); + const item = getLastRuleBlock(".workspace-open-editors__item"); + const row = getLastRuleBlock(".workspace-open-editors__row"); + const label = getLastRuleBlock(".workspace-open-editors__item-label"); + const closeAll = getLastRuleBlock(".workspace-open-editors__close-all"); + + expect(header).toContain("justify-content: space-between"); + expect(row).toContain("grid-template-columns: minmax(0, 1fr) auto"); + expect(item).toContain("min-width: 0"); + expect(label).toContain("text-overflow: ellipsis"); + expect(label).toContain("white-space: nowrap"); + expect(closeAll).toContain("background: transparent"); + }); +``` + +- [ ] **Step 4: Run the shared section and theme tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/open-editors-section.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- PASS for count, collapse, non-active-row close, and close-all behavior +- PASS for the compact single-line CSS assertions + +- [ ] **Step 5: Commit the shared `Open Editors` UI** + +```bash +git add \ + packages/web/src/features/workspace/views/shared/open-editors-section.tsx \ + packages/web/src/features/workspace/views/shared/open-editors-section.test.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "feat: add open editors controls" +``` + +--- + +### Task 4: Verify Desktop And Mobile Integration + +**Files:** +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` +- Modify: `packages/web/src/features/workspace/index.test.tsx` + +- [ ] **Step 1: Write the failing integration tests** + +Extend `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` with: + +```tsx + it("renders the shared open editors controls on mobile and closes the active row to the next file", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args: { query?: string }) => { + if (op === "file.search") { + return { + files: [ + { path: "README.md", name: "README.md", kind: "file" }, + { + path: "src/mobile-files-sheet.tsx", + name: "mobile-files-sheet.tsx", + kind: "file", + }, + ].filter((file) => file.path.toLowerCase().includes((args.query ?? "").toLowerCase())), + }; + } + + return { ok: true }; + }); + + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + store.set(activeFilePathAtomFamily("ws-test"), "src/mobile-files-sheet.tsx"); + store.set(openFilesAtomFamily("ws-test"), { + "README.md": { + kind: "text", + path: "README.md", + content: "# docs", + savedContent: "# docs", + baseHash: "base-readme", + isDirty: false, + }, + "src/mobile-files-sheet.tsx": { + kind: "text", + path: "src/mobile-files-sheet.tsx", + content: "export function MobileFilesSheet() {}\n", + savedContent: "export function MobileFilesSheet() {}\n", + baseHash: "base-mobile-files-sheet", + isDirty: false, + }, + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Close all" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /Open Editors/i })).toHaveTextContent("(2)"); + + fireEvent.click( + screen.getByRole("button", { name: "Close src/mobile-files-sheet.tsx" }) + ); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("README.md"); + }); +``` + +Extend `packages/web/src/features/workspace/index.test.tsx` with: + +```tsx + it("returns the desktop main area to agent panes when close all removes the last open editor", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + store.set(openFilesAtomFamily("ws-test"), { + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "const app = 1;", + savedContent: "const app = 1;", + baseHash: "hash-app", + isDirty: false, + }, + }); + + render( + + + + } /> + + + + ); + + await screen.findByTestId("code-editor-host"); + + fireEvent.click(screen.getByRole("button", { name: "Close all" })); + + expect(screen.getByTestId("agent-panes")).toBeInTheDocument(); + expect(screen.queryByTestId("code-editor-host")).not.toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run the integration tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx \ + src/features/workspace/index.test.tsx +``` + +Expected: +- FAIL because the current mobile explorer has no `Close all` or row-close buttons +- FAIL because desktop explorer has no `Close all` action yet + +- [ ] **Step 3: Keep the existing desktop and mobile composition unchanged** + +Do not introduce new wrappers. The shared section already sits in the right place for both layouts: + +```tsx + +``` + +```tsx + +``` + +No implementation file changes are required in this task unless the tests expose a missing prop or duplicated wrapper. + +- [ ] **Step 4: Run the full targeted suite** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/actions/open-editors-close.test.ts \ + src/features/code-editor/index.test.tsx \ + src/features/workspace/views/shared/open-editors-section.test.tsx \ + src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx \ + src/features/workspace/index.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- PASS for helper logic, desktop editor-header close flow, shared section UI, mobile explorer controls, desktop main-area fallback, and theme assertions + +- [ ] **Step 5: Commit the integration coverage** + +```bash +git add \ + packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx \ + packages/web/src/features/workspace/index.test.tsx +git commit -m "test: cover open editors integration" +``` + +--- + +## Self-Review + +**Spec coverage:** The plan covers shared `Open Editors` header chrome, file count, collapse/expand, per-row close, localized `Close all`, deterministic close ordering, shared editor-header close logic on desktop, desktop main-area fallback, mobile explorer parity, and single-line truncation rules. No accepted requirement is left without a task. + +**Placeholder scan:** No `TODO`, `TBD`, or “implement later” placeholders remain. Every task includes concrete file paths, code snippets, commands, and expected outcomes. + +**Type consistency:** The plan uses one close helper (`resolveOpenEditorsClose`), one shared hook (`useOpenEditorsActions`), and the same ordered-path rule (`orderOpenEditorPaths`) everywhere. `action.close_all` is explicitly introduced before the UI starts using it. diff --git a/docs/superpowers/plans/2026-05-24-performance-monitoring.md b/docs/superpowers/plans/2026-05-24-performance-monitoring.md new file mode 100644 index 00000000..ac7c9fc1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-performance-monitoring.md @@ -0,0 +1,4027 @@ +# Performance Monitoring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a first-class `/monitoring` experience that shows host CPU and memory pressure, Coder Studio runtime footprint, and `workspace -> session/agent -> subprocess` attribution with configurable sampling, explicit degradation states, and a settings-driven enable switch. + +**Architecture:** Add a shared `monitoring` domain in `@coder-studio/core`, a server-owned `MonitoringService` that samples host and managed-process data on a configurable interval, and a routed web page that loads the current snapshot once and stays updated via `monitoring.snapshot.updated`. Sampling remains disabled by default, settings own all mutability, and the UI is read-only, with clear disabled and partial-collection empty states instead of ambiguous failures. + +**Tech Stack:** TypeScript, React 19, Jotai, Vitest, Testing Library, Node `os`, existing websocket command dispatch, existing server settings storage, existing `node-pty` PTY host, and shared styles in `packages/web/src/styles/components.css`. + +**Spec reference:** `docs/superpowers/specs/2026-05-24-monitoring-design.md` + +**Git hygiene:** The main worktree already contains unrelated user edits. Stage only the files listed in each task, do not revert user changes, and commit the plan file before creating the implementation worktree so the execution branch can consume it directly. + +--- + +## File Structure + +**New files:** +- `packages/core/src/domain/monitoring.ts` — shared monitoring types, settings helpers, interval validation, mode derivation, and empty-state factories +- `packages/core/src/domain/monitoring.test.ts` — shared monitoring helper coverage and topic contract assertions +- `packages/server/src/monitoring/types.ts` — server-only collector, registry, and telemetry contracts +- `packages/server/src/monitoring/managed-process-registry.ts` — internal registry for server, terminal, and background process roots plus late session binding +- `packages/server/src/monitoring/host-collector.ts` — host metrics sampling with CPU deltas, memory, uptime, and load average capability handling +- `packages/server/src/monitoring/process-table/darwin.ts` — macOS `ps` adapter +- `packages/server/src/monitoring/process-table/linux.ts` — Linux `ps` adapter +- `packages/server/src/monitoring/process-table/win32.ts` — Windows PowerShell adapter +- `packages/server/src/monitoring/process-table/index.ts` — platform adapter selection and process-row parsing orchestration +- `packages/server/src/monitoring/aggregation.ts` — tree indexing, host pressure derivation, runtime summary, workspace/session grouping, and subprocess truncation +- `packages/server/src/monitoring/history-store.ts` — bounded in-memory history retention for host/runtime/workspace/session/subprocess series +- `packages/server/src/monitoring/service.ts` — settings-driven lifecycle, sampling scheduler, broadcasting, immediate recheck, and runtime synchronization +- `packages/server/src/commands/monitoring.ts` — websocket commands for `monitoring.get` and `monitoring.recheck` +- `packages/server/src/__tests__/monitoring/managed-process-registry.test.ts` — registry behavior and late session binding tests +- `packages/server/src/__tests__/monitoring/host-collector.test.ts` — host metric delta, load average capability, and pressure calculation tests +- `packages/server/src/__tests__/monitoring/process-table.test.ts` — parser and platform adapter normalization tests +- `packages/server/src/__tests__/monitoring/aggregation.test.ts` — runtime/workspace/session/subprocess aggregation and degradation tests +- `packages/server/src/__tests__/monitoring/service.test.ts` — service scheduling, history trimming, broadcasting, and partial-failure tests +- `packages/server/src/__tests__/monitoring/commands.test.ts` — command dispatch coverage for `monitoring.get` and `monitoring.recheck` +- `packages/server/src/__tests__/server-monitoring-hydration.test.ts` — persisted settings, server startup wiring, and stop cleanup verification +- `packages/web/src/features/monitoring/index.ts` — feature exports +- `packages/web/src/features/monitoring/page.tsx` — routed monitoring page with desktop/mobile variants, disabled and degraded states, refresh, sorting, and time-window controls +- `packages/web/src/features/monitoring/sparkline.tsx` — lightweight SVG sparkline renderer for short-term history +- `packages/web/src/features/monitoring/formatters.ts` — byte, percent, uptime, load-average, and timestamp format helpers +- `packages/web/src/features/monitoring/page.test.tsx` — page rendering, subscription, refresh, disabled state, partial-collection, and mobile behavior tests +- `packages/web/src/features/settings/components/monitoring-settings-card.tsx` — reusable `Settings > General` monitoring configuration block + +**Modified files:** +- `packages/core/src/domain/types.ts` — add optional `pid` to the shared `Terminal` DTO +- `packages/core/src/index.ts` — export monitoring domain types +- `packages/core/src/protocol/topics.ts` — add `monitoring.snapshot.updated` +- `packages/server/src/terminal/types.ts` — expose PTY `pid` as a first-class runtime property +- `packages/server/src/terminal/active-terminal.ts` — persist `pid` into terminal DTOs +- `packages/server/src/terminal/pty-host.ts` — forward `node-pty` process PID through the abstraction +- `packages/server/src/terminal/manager.ts` — keep terminal DTOs PID-aware and ensure active terminals stay queryable for monitoring sync +- `packages/server/src/storage/repositories/terminal-repo.ts` — persist and hydrate terminal `pid` +- `packages/server/src/terminal/active-terminal.test.ts` — assert `pid` is preserved in DTOs +- `packages/server/src/terminal/manager.test.ts` — update PTY fakes for `pid` and assert DTO persistence +- `packages/server/src/__tests__/terminal-events.test.ts` — update PTY fakes for `pid` +- `packages/server/src/__tests__/session-manager-api.test.ts` — update PTY fakes for `pid` +- `packages/server/src/__tests__/session-integration.test.ts` — update PTY fakes for `pid` +- `packages/server/src/__tests__/session-terminal-exit.test.ts` — update PTY fakes for `pid` +- `packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts` — update PTY fakes for `pid` +- `packages/server/src/commands/settings.ts` — extend settings schema with monitoring keys and trigger monitoring reloads on relevant updates +- `packages/server/src/commands/settings.test.ts` — cover monitoring settings persistence and reload hooks +- `packages/server/src/commands/index.ts` — register monitoring commands +- `packages/server/src/server.ts` — construct `ManagedProcessRegistry` and `MonitoringService`, hydrate them from persisted settings, and stop them cleanly +- `packages/server/src/ws/dispatch.ts` — inject `monitoringService` into command context +- `packages/web/src/shells/desktop-shell.tsx` — route `/monitoring` and bypass auth-loading for it +- `packages/web/src/shells/mobile-shell/index.tsx` — route `/monitoring` and bypass auth-loading for it +- `packages/web/src/shells/desktop-shell.test.tsx` — auth-bypass and route assertions for `/monitoring` +- `packages/web/src/shells/mobile-shell/index.test.tsx` — auth-bypass and route assertions for `/monitoring` +- `packages/web/src/features/command-palette/components/command-palette.tsx` — add `Open Monitoring` +- `packages/web/src/features/command-palette/components/command-palette.test.tsx` — verify `Open Monitoring` command on desktop and mobile +- `packages/web/src/features/settings/components/settings-page.tsx` — hydrate monitoring settings, persist updates, and mount the monitoring settings card inside `General` +- `packages/web/src/features/settings/components/settings-page.test.tsx` — verify monitoring settings interactions, dependency rules, and disabled-state controls +- `packages/web/src/locales/en.json` — monitoring page, settings, and command-palette copy +- `packages/web/src/locales/zh.json` — Chinese monitoring copy +- `packages/web/src/styles/components.css` — monitoring page, settings card, sparklines, tree/detail layout, empty states, and mobile tabs +- `packages/web/src/styles/components.theme.test.ts` — lock monitoring surfaces to theme tokens + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/core exec vitest run src/domain/monitoring.test.ts` +- `pnpm --filter @coder-studio/server exec vitest run src/terminal/active-terminal.test.ts src/terminal/manager.test.ts src/__tests__/terminal-events.test.ts src/__tests__/session-manager-api.test.ts src/__tests__/session-integration.test.ts src/__tests__/session-terminal-exit.test.ts src/__tests__/terminal-ring-buffer-tail.test.ts` +- `pnpm --filter @coder-studio/server exec vitest run src/__tests__/monitoring/managed-process-registry.test.ts src/__tests__/monitoring/host-collector.test.ts src/__tests__/monitoring/process-table.test.ts src/__tests__/monitoring/aggregation.test.ts src/__tests__/monitoring/service.test.ts src/__tests__/monitoring/commands.test.ts src/__tests__/server-monitoring-hydration.test.ts src/commands/settings.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/monitoring/page.test.tsx src/features/settings/components/settings-page.test.tsx src/features/command-palette/components/command-palette.test.tsx src/shells/desktop-shell.test.tsx src/shells/mobile-shell/index.test.tsx src/styles/components.theme.test.ts` +- `pnpm ci:typecheck` + +--- + +### Task 1: Add The Shared Monitoring Domain And Topic Contract + +**Files:** +- Create: `packages/core/src/domain/monitoring.ts` +- Create: `packages/core/src/domain/monitoring.test.ts` +- Modify: `packages/core/src/index.ts` +- Modify: `packages/core/src/protocol/topics.ts` + +- [ ] **Step 1: Write the failing shared-domain test** + +Add `packages/core/src/domain/monitoring.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { Topics } from "../protocol/topics"; +import { + MONITORING_SAMPLE_INTERVAL_OPTIONS, + createDefaultMonitoringSettings, + deriveMonitoringMode, + isMonitoringSampleIntervalMs, + resolveMonitoringSettings, +} from "./monitoring"; + +describe("monitoring domain helpers", () => { + it("creates the default monitoring settings shape", () => { + expect(createDefaultMonitoringSettings()).toEqual({ + enabled: false, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 2000, + }); + }); + + it("exposes the supported sample intervals", () => { + expect(MONITORING_SAMPLE_INTERVAL_OPTIONS).toEqual([1000, 2000, 5000, 10000]); + expect(isMonitoringSampleIntervalMs(2000)).toBe(true); + expect(isMonitoringSampleIntervalMs(3000)).toBe(false); + }); + + it("derives mode labels after applying dependency normalization", () => { + expect( + deriveMonitoringMode( + resolveMonitoringSettings({ + "monitoring.enabled": false, + }) + ) + ).toBe("disabled"); + + expect( + deriveMonitoringMode( + resolveMonitoringSettings({ + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": false, + "monitoring.workspaceAttributionEnabled": false, + "monitoring.subprocessDrilldownEnabled": false, + }) + ) + ).toBe("light"); + + expect( + deriveMonitoringMode( + resolveMonitoringSettings({ + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + }) + ) + ).toBe("standard"); + + expect( + deriveMonitoringMode( + resolveMonitoringSettings({ + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": true, + }) + ) + ).toBe("deep"); + }); + + it("defines the websocket topic for monitoring snapshot broadcasts", () => { + expect(Topics.monitoringSnapshotUpdated).toBe("monitoring.snapshot.updated"); + }); +}); +``` + +- [ ] **Step 2: Run the core test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/core exec vitest run src/domain/monitoring.test.ts +``` + +Expected: FAIL because `monitoring.ts` and `Topics.monitoringSnapshotUpdated` do not exist yet. + +- [ ] **Step 3: Add the shared monitoring types, helpers, and topic** + +Create `packages/core/src/domain/monitoring.ts`: + +```ts +export const MONITORING_SAMPLE_INTERVAL_OPTIONS = [1000, 2000, 5000, 10000] as const; +export const DEFAULT_MONITORING_SAMPLE_INTERVAL_MS = 2000; + +export type MonitoringSampleIntervalMs = (typeof MONITORING_SAMPLE_INTERVAL_OPTIONS)[number]; +export type MonitoringMode = "disabled" | "light" | "standard" | "deep"; +export type MonitoringPressure = "normal" | "elevated" | "hot" | "unknown"; + +export interface MonitoringSettings { + enabled: boolean; + hostMetricsEnabled: boolean; + runtimeSummaryEnabled: boolean; + workspaceAttributionEnabled: boolean; + subprocessDrilldownEnabled: boolean; + sampleIntervalMs: MonitoringSampleIntervalMs; +} + +export interface MonitoringSeriesPoint { + sampledAt: number; + cpuPercent: number | null; + memoryBytes: number | null; + processCount?: number; +} + +export interface MonitoringHostSummary { + cpuPercent: number | null; + memoryUsedBytes: number | null; + memoryTotalBytes: number | null; + memoryAvailableBytes: number | null; + loadAverage: [number, number, number] | null; + uptimeSec: number | null; + pressure: MonitoringPressure; +} + +export interface MonitoringRuntimeSummary { + serverCpuPercent: number | null; + serverMemoryBytes: number | null; + totalManagedCpuPercent: number | null; + totalManagedMemoryBytes: number | null; + managedProcessCount: number; + cpuShareOfHostPercent: number | null; + memoryShareOfHostPercent: number | null; +} + +export interface MonitoringEntitySummary { + id: string; + kind: "workspace" | "session" | "subprocess_group" | "background_group"; + parentId?: string; + workspaceId?: string; + sessionId?: string; + terminalId?: string; + label: string; + cpuPercent: number | null; + memoryBytes: number | null; + processCount: number; + uptimeSec: number | null; + trend: "rising" | "steady" | "falling" | "unknown"; + childCount?: number; +} + +export interface MonitoringSnapshot { + sampledAt: number; + mode: MonitoringMode; + host: MonitoringHostSummary | null; + runtime: MonitoringRuntimeSummary | null; + workspaces: MonitoringEntitySummary[]; + sessions: MonitoringEntitySummary[]; + subprocessGroups: MonitoringEntitySummary[]; + backgroundGroups: MonitoringEntitySummary[]; +} + +export interface MonitoringSeriesBundle { + points: MonitoringSeriesPoint[]; +} + +export interface MonitoringHistoryBundle { + host: MonitoringSeriesBundle; + runtime: MonitoringSeriesBundle | null; + workspaces: Record; + sessions: Record; + subprocessGroups: Record; +} + +export interface MonitoringCapabilities { + loadAverageAvailable: boolean; + processMetricsAvailable: boolean; + subprocessHistoryLimited: boolean; +} + +export interface MonitoringSamplingTelemetry { + durationMs: number; + processRowCount: number; + subprocessGroupCount: number; + historyTrimmed: boolean; + degraded: boolean; + failureReason?: string; +} + +export interface MonitoringResponse { + settings: MonitoringSettings; + snapshot: MonitoringSnapshot; + history: MonitoringHistoryBundle; + capabilities: MonitoringCapabilities; + telemetry: MonitoringSamplingTelemetry | null; +} + +export function isMonitoringSampleIntervalMs( + value: unknown +): value is MonitoringSampleIntervalMs { + return ( + typeof value === "number" && + MONITORING_SAMPLE_INTERVAL_OPTIONS.includes(value as MonitoringSampleIntervalMs) + ); +} + +export function createDefaultMonitoringSettings(): MonitoringSettings { + return { + enabled: false, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: DEFAULT_MONITORING_SAMPLE_INTERVAL_MS, + }; +} + +function normalizeMonitoringDependencies(settings: MonitoringSettings): MonitoringSettings { + if (!settings.workspaceAttributionEnabled) { + settings.subprocessDrilldownEnabled = false; + } + if (!settings.runtimeSummaryEnabled) { + settings.workspaceAttributionEnabled = false; + settings.subprocessDrilldownEnabled = false; + } + return settings; +} + +export function resolveMonitoringSettings( + values: + | Record + | { + get: (key: string) => T | undefined; + } + | undefined +): MonitoringSettings { + const defaults = createDefaultMonitoringSettings(); + const read = (key: string) => + values && "get" in values ? values.get(key) : values?.[key]; + + return normalizeMonitoringDependencies({ + enabled: typeof read("monitoring.enabled") === "boolean" ? Boolean(read("monitoring.enabled")) : defaults.enabled, + hostMetricsEnabled: + typeof read("monitoring.hostMetricsEnabled") === "boolean" + ? Boolean(read("monitoring.hostMetricsEnabled")) + : defaults.hostMetricsEnabled, + runtimeSummaryEnabled: + typeof read("monitoring.runtimeSummaryEnabled") === "boolean" + ? Boolean(read("monitoring.runtimeSummaryEnabled")) + : defaults.runtimeSummaryEnabled, + workspaceAttributionEnabled: + typeof read("monitoring.workspaceAttributionEnabled") === "boolean" + ? Boolean(read("monitoring.workspaceAttributionEnabled")) + : defaults.workspaceAttributionEnabled, + subprocessDrilldownEnabled: + typeof read("monitoring.subprocessDrilldownEnabled") === "boolean" + ? Boolean(read("monitoring.subprocessDrilldownEnabled")) + : defaults.subprocessDrilldownEnabled, + sampleIntervalMs: isMonitoringSampleIntervalMs(read("monitoring.sampleIntervalMs")) + ? (read("monitoring.sampleIntervalMs") as MonitoringSampleIntervalMs) + : defaults.sampleIntervalMs, + }); +} + +export function deriveMonitoringMode(settings: MonitoringSettings): MonitoringMode { + if (!settings.enabled) { + return "disabled"; + } + if (settings.subprocessDrilldownEnabled) { + return "deep"; + } + if (settings.workspaceAttributionEnabled) { + return "standard"; + } + return "light"; +} + +export function createEmptyMonitoringResponse(settings = createDefaultMonitoringSettings()): MonitoringResponse { + return { + settings, + snapshot: { + sampledAt: 0, + mode: deriveMonitoringMode(settings), + host: null, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }, + history: { + host: { points: [] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: process.platform !== "win32", + processMetricsAvailable: false, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; +} +``` + +Update `packages/core/src/protocol/topics.ts`: + +```ts + updateStateChanged: "update.state.changed", + monitoringSnapshotUpdated: "monitoring.snapshot.updated", +``` + +Update `packages/core/src/index.ts`: + +```ts +export * from "./domain/monitoring"; +``` + +- [ ] **Step 4: Run the core test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/core exec vitest run src/domain/monitoring.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the shared monitoring contract** + +```bash +git add packages/core/src/domain/monitoring.ts \ + packages/core/src/domain/monitoring.test.ts \ + packages/core/src/protocol/topics.ts \ + packages/core/src/index.ts +git commit -m "feat(core): add monitoring domain contracts" +``` + +### Task 2: Promote PTY PID To A First-Class Terminal Capability + +**Files:** +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/server/src/terminal/types.ts` +- Modify: `packages/server/src/terminal/pty-host.ts` +- Modify: `packages/server/src/terminal/active-terminal.ts` +- Modify: `packages/server/src/storage/repositories/terminal-repo.ts` +- Modify: `packages/server/src/terminal/active-terminal.test.ts` +- Modify: `packages/server/src/terminal/manager.test.ts` +- Modify: `packages/server/src/__tests__/terminal-events.test.ts` +- Modify: `packages/server/src/__tests__/session-manager-api.test.ts` +- Modify: `packages/server/src/__tests__/session-integration.test.ts` +- Modify: `packages/server/src/__tests__/session-terminal-exit.test.ts` +- Modify: `packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts` + +- [ ] **Step 1: Write the failing PID persistence tests** + +Update `packages/server/src/terminal/active-terminal.test.ts` with PID expectations: + +```ts +const mockPty: PtyProcess = { + pid: 43210, + onData: () => {}, + onExit: () => {}, + write: () => {}, + resize: () => {}, + kill: async () => {}, +}; + +it("should convert to DTO correctly", () => { + const dto = active.toDTO(); + + expect(dto).toEqual({ + id, + workspaceId: spec.workspaceId, + kind: spec.kind, + title: spec.title, + cwd: spec.cwd, + argv: spec.argv, + cols: spec.cols, + rows: spec.rows, + pid: 43210, + alive: true, + createdAt, + endedAt: undefined, + exitCode: undefined, + }); +}); +``` + +Update `packages/server/src/terminal/manager.test.ts`: + +```ts +mockPty = { + pid: 43210, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn().mockResolvedValue(undefined), +}; + +it("should create terminal with PTY process", () => { + const terminal = manager.create(spec); + expect(terminal.pid).toBe(43210); +}); +``` + +Update every other PTY fake listed in this task to add `pid: 43210` so the type-level break shows the full blast radius immediately. + +- [ ] **Step 2: Run the terminal-focused server tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/terminal/active-terminal.test.ts \ + src/terminal/manager.test.ts \ + src/__tests__/terminal-events.test.ts \ + src/__tests__/session-manager-api.test.ts \ + src/__tests__/session-integration.test.ts \ + src/__tests__/session-terminal-exit.test.ts \ + src/__tests__/terminal-ring-buffer-tail.test.ts +``` + +Expected: FAIL because `PtyProcess.pid` and `Terminal.pid` do not exist yet. + +- [ ] **Step 3: Add PID to the terminal DTO, PTY abstraction, and persistence** + +Update `packages/core/src/domain/types.ts`: + +```ts +export interface Terminal { + id: string; + workspaceId: string; + kind: "agent" | "shell"; + title: string; + cwd: string; + argv: string[]; + env?: Record; + cols: number; + rows: number; + pid?: number; + alive: boolean; + createdAt: number; + endedAt?: number; + exitCode?: number; +} +``` + +Update `packages/server/src/terminal/types.ts`: + +```ts +export interface PtyProcess { + readonly pid: number; + onData(callback: (data: string) => void): void; + onExit(callback: (event: { exitCode: number }) => void): void; + write(data: Buffer | string): void; + resize(cols: number, rows: number): void; + kill(signal?: NodeJS.Signals): Promise; +} +``` + +Update `packages/server/src/terminal/pty-host.ts`: + +```ts + return { + pid: ptyProcess.pid, + onData: (callback) => { + ptyProcess.onData(callback); + }, + onExit: (callback) => { + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => callback({ exitCode })); + }, + write: (data) => { + if (Buffer.isBuffer(data)) { + ptyProcess.write(data.toString("utf-8")); + } else { + ptyProcess.write(data); + } + }, + resize: (cols, rows) => { + ptyProcess.resize(cols, rows); + }, + kill: async (signal: NodeJS.Signals = "SIGTERM") => { + const pid = ptyProcess.pid; + // existing kill logic remains here + }, + }; +``` + +Update `packages/server/src/terminal/active-terminal.ts`: + +```ts + cols: this.spec.cols ?? 120, + rows: this.spec.rows ?? 30, + pid: this.pty.pid > 0 ? this.pty.pid : undefined, + alive: this.alive, + createdAt: this.createdAt, + endedAt: this.alive ? undefined : Date.now(), + exitCode: this.exitCode, +``` + +Update `packages/server/src/storage/repositories/terminal-repo.ts`: + +```ts + return ( + typeof value.id === "string" && + typeof value.workspaceId === "string" && + (value.kind === "agent" || value.kind === "shell") && + typeof value.cwd === "string" && + Array.isArray(value.argv) && + typeof value.cols === "number" && + typeof value.rows === "number" && + (value.pid === undefined || typeof value.pid === "number") && + typeof value.alive === "boolean" && + typeof value.createdAt === "number" + ); +``` + +and in `create()` / `insert()`: + +```ts + pid: terminal.pid, +``` + +Keep the test fake updates minimal: add `pid: 43210` to each `PtyProcess` literal and keep the rest of the test body unchanged except for the new DTO assertions. + +- [ ] **Step 4: Run the terminal-focused server tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/terminal/active-terminal.test.ts \ + src/terminal/manager.test.ts \ + src/__tests__/terminal-events.test.ts \ + src/__tests__/session-manager-api.test.ts \ + src/__tests__/session-integration.test.ts \ + src/__tests__/session-terminal-exit.test.ts \ + src/__tests__/terminal-ring-buffer-tail.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the PID promotion** + +```bash +git add packages/core/src/domain/types.ts \ + packages/server/src/terminal/types.ts \ + packages/server/src/terminal/pty-host.ts \ + packages/server/src/terminal/active-terminal.ts \ + packages/server/src/storage/repositories/terminal-repo.ts \ + packages/server/src/terminal/active-terminal.test.ts \ + packages/server/src/terminal/manager.test.ts \ + packages/server/src/__tests__/terminal-events.test.ts \ + packages/server/src/__tests__/session-manager-api.test.ts \ + packages/server/src/__tests__/session-integration.test.ts \ + packages/server/src/__tests__/session-terminal-exit.test.ts \ + packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts +git commit -m "feat(server): expose managed terminal pids" +``` + +### Task 3: Build The Managed Process Registry + +**Files:** +- Create: `packages/server/src/monitoring/types.ts` +- Create: `packages/server/src/monitoring/managed-process-registry.ts` +- Create: `packages/server/src/__tests__/monitoring/managed-process-registry.test.ts` + +- [ ] **Step 1: Write the failing registry tests** + +Create `packages/server/src/__tests__/monitoring/managed-process-registry.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { ManagedProcessRegistry } from "../../monitoring/managed-process-registry.js"; + +describe("ManagedProcessRegistry", () => { + it("registers the server process only once", () => { + const registry = new ManagedProcessRegistry({ now: () => 10 }); + + registry.registerServerProcess(9001); + registry.registerServerProcess(9001); + + expect(registry.listRoots()).toEqual([ + expect.objectContaining({ + ownerId: "server:9001", + rootPid: 9001, + kind: "server", + label: "Coder Studio server", + }), + ]); + }); + + it("stores terminal roots before a session binding exists and patches them later", () => { + const registry = new ManagedProcessRegistry({ now: () => 20 }); + + registry.upsertTerminalRoot({ + terminalId: "term-1", + workspaceId: "ws-1", + pid: 43210, + kind: "agent", + title: "Claude", + }); + + registry.bindSessionToTerminal("term-1", { + sessionId: "sess-1", + providerId: "claude", + label: "Claude", + }); + + expect(registry.listRoots()).toEqual([ + expect.objectContaining({ + ownerId: "terminal:term-1", + rootPid: 43210, + workspaceId: "ws-1", + sessionId: "sess-1", + providerId: "claude", + }), + ]); + }); + + it("unregisters terminal roots cleanly", () => { + const registry = new ManagedProcessRegistry({ now: () => 30 }); + + registry.upsertTerminalRoot({ + terminalId: "term-1", + workspaceId: "ws-1", + pid: 43210, + kind: "shell", + title: "bash", + }); + + registry.unregisterByOwner("terminal:term-1"); + + expect(registry.listRoots()).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the registry test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/monitoring/managed-process-registry.test.ts +``` + +Expected: FAIL because the registry does not exist yet. + +- [ ] **Step 3: Implement the registry and its server-only contracts** + +Create `packages/server/src/monitoring/types.ts`: + +```ts +export interface ManagedProcessRoot { + ownerId: string; + rootPid: number; + kind: "server" | "terminal" | "session_helper" | "lsp" | "installer" | "background"; + label: string; + workspaceId?: string; + sessionId?: string; + terminalId?: string; + providerId?: string; + startedAt: number; +} + +export interface MonitoringCollectorTelemetry { + processRowCount: number; + subprocessGroupCount: number; + historyTrimmed: boolean; + degraded: boolean; + failureReason?: string; +} + +export interface ProcessStatRow { + pid: number; + ppid: number; + cpuPercent: number | null; + rssBytes: number | null; + elapsedSec?: number; + command?: string; + executable?: string; +} +``` + +Create `packages/server/src/monitoring/managed-process-registry.ts`: + +```ts +import type { ManagedProcessRoot } from "./types.js"; + +export class ManagedProcessRegistry { + private readonly roots = new Map(); + + constructor(private readonly deps: { now?: () => number } = {}) {} + + private now(): number { + return this.deps.now?.() ?? Date.now(); + } + + registerServerProcess(pid: number): void { + this.roots.set(`server:${pid}`, { + ownerId: `server:${pid}`, + rootPid: pid, + kind: "server", + label: "Coder Studio server", + startedAt: this.now(), + }); + } + + upsertTerminalRoot(input: { + terminalId: string; + workspaceId: string; + pid?: number; + kind: "agent" | "shell"; + title: string; + }): void { + if (!input.pid || input.pid <= 0) { + return; + } + + const ownerId = `terminal:${input.terminalId}`; + const existing = this.roots.get(ownerId); + this.roots.set(ownerId, { + ownerId, + rootPid: input.pid, + kind: "terminal", + label: input.kind === "shell" ? input.title || "Standalone terminal" : input.title || "Agent terminal", + workspaceId: input.workspaceId, + terminalId: input.terminalId, + sessionId: existing?.sessionId, + providerId: existing?.providerId, + startedAt: existing?.startedAt ?? this.now(), + }); + } + + bindSessionToTerminal( + terminalId: string, + input: { sessionId: string; providerId?: string; label: string } + ): void { + const ownerId = `terminal:${terminalId}`; + const existing = this.roots.get(ownerId); + if (!existing) { + return; + } + + this.roots.set(ownerId, { + ...existing, + sessionId: input.sessionId, + providerId: input.providerId, + label: input.label || existing.label, + }); + } + + registerBackgroundRoot(root: ManagedProcessRoot): void { + this.roots.set(root.ownerId, root); + } + + unregisterByOwner(ownerId: string): void { + this.roots.delete(ownerId); + } + + listRoots(): ManagedProcessRoot[] { + return [...this.roots.values()].sort((left, right) => left.startedAt - right.startedAt); + } +} +``` + +- [ ] **Step 4: Run the registry test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/monitoring/managed-process-registry.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the managed process registry** + +```bash +git add packages/server/src/monitoring/types.ts \ + packages/server/src/monitoring/managed-process-registry.ts \ + packages/server/src/__tests__/monitoring/managed-process-registry.test.ts +git commit -m "feat(server): add managed process registry" +``` + +### Task 4: Add Host And Process Table Collectors + +**Files:** +- Create: `packages/server/src/monitoring/host-collector.ts` +- Create: `packages/server/src/monitoring/process-table/darwin.ts` +- Create: `packages/server/src/monitoring/process-table/linux.ts` +- Create: `packages/server/src/monitoring/process-table/win32.ts` +- Create: `packages/server/src/monitoring/process-table/index.ts` +- Create: `packages/server/src/__tests__/monitoring/host-collector.test.ts` +- Create: `packages/server/src/__tests__/monitoring/process-table.test.ts` + +- [ ] **Step 1: Write the failing collector tests** + +Create `packages/server/src/__tests__/monitoring/host-collector.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { HostCollector } from "../../monitoring/host-collector.js"; + +describe("HostCollector", () => { + it("computes cpu deltas and host pressure", () => { + const collector = new HostCollector({ + platform: "linux", + cpus: () => [ + { times: { user: 100, nice: 0, sys: 0, idle: 900, irq: 0 } }, + { times: { user: 100, nice: 0, sys: 0, idle: 900, irq: 0 } }, + ] as NodeJS.CpuInfo[], + totalmem: () => 1000, + freemem: () => 300, + uptime: () => 120, + loadavg: () => [0.4, 0.3, 0.2], + }); + + collector.collect(); + const summary = collector.collect({ + cpus: [ + { times: { user: 160, nice: 0, sys: 0, idle: 940, irq: 0 } }, + { times: { user: 160, nice: 0, sys: 0, idle: 940, irq: 0 } }, + ] as NodeJS.CpuInfo[], + }); + + expect(summary.cpuPercent).toBe(75); + expect(summary.memoryUsedBytes).toBe(700); + expect(summary.pressure).toBe("elevated"); + }); + + it("marks load average unavailable on windows without failing the snapshot", () => { + const collector = new HostCollector({ + platform: "win32", + cpus: () => [{ times: { user: 10, nice: 0, sys: 0, idle: 90, irq: 0 } }] as NodeJS.CpuInfo[], + totalmem: () => 1000, + freemem: () => 600, + uptime: () => 60, + loadavg: () => [0, 0, 0], + }); + + const summary = collector.collect(); + + expect(summary.loadAverage).toBeNull(); + expect(summary.pressure).toBe("unknown"); + }); +}); +``` + +Create `packages/server/src/__tests__/monitoring/process-table.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + parseDarwinPsRows, + parseLinuxPsRows, + parseWindowsProcessRows, +} from "../../monitoring/process-table/index.js"; + +describe("process table adapters", () => { + it("parses macOS ps output into normalized rows", () => { + const rows = parseDarwinPsRows( + " 101 1 6.5 2048 42 /usr/bin/node node server.js\n 202 101 1.5 1024 20 /bin/bash bash" + ); + + expect(rows).toEqual([ + { + pid: 101, + ppid: 1, + cpuPercent: 6.5, + rssBytes: 2048 * 1024, + elapsedSec: 42, + executable: "/usr/bin/node", + command: "node server.js", + }, + { + pid: 202, + ppid: 101, + cpuPercent: 1.5, + rssBytes: 1024 * 1024, + elapsedSec: 20, + executable: "/bin/bash", + command: "bash", + }, + ]); + }); + + it("parses linux ps output into normalized rows", () => { + const rows = parseLinuxPsRows( + "101 1 12.0 8096 99 /usr/bin/node node server.js\n202 101 0.8 2048 12 /usr/bin/python python worker.py" + ); + + expect(rows[0]?.pid).toBe(101); + expect(rows[0]?.rssBytes).toBe(8096 * 1024); + expect(rows[1]?.ppid).toBe(101); + }); + + it("parses windows powershell json rows into normalized rows", () => { + const rows = parseWindowsProcessRows([ + { + Id: 500, + ParentProcessId: 1, + CpuPercent: 4.25, + WorkingSet64: 4096, + ElapsedSec: 30, + Path: "C:\\\\node.exe", + CommandLine: "node server.js", + }, + ]); + + expect(rows).toEqual([ + { + pid: 500, + ppid: 1, + cpuPercent: 4.25, + rssBytes: 4096, + elapsedSec: 30, + executable: "C:\\\\node.exe", + command: "node server.js", + }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run the collector tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/monitoring/host-collector.test.ts \ + src/__tests__/monitoring/process-table.test.ts +``` + +Expected: FAIL because the collectors and parsers do not exist yet. + +- [ ] **Step 3: Implement host and process table collection** + +Create `packages/server/src/monitoring/host-collector.ts`: + +```ts +import os from "node:os"; +import type { MonitoringHostSummary } from "@coder-studio/core"; + +type CpuTimes = Pick; + +function sumCpuTimes(cpus: NodeJS.CpuInfo[]): CpuTimes { + return cpus.reduce( + (acc, cpu) => ({ + user: acc.user + cpu.times.user, + nice: acc.nice + cpu.times.nice, + sys: acc.sys + cpu.times.sys, + idle: acc.idle + cpu.times.idle, + irq: acc.irq + cpu.times.irq, + }), + { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } + ); +} + +export class HostCollector { + private previousCpu: CpuTimes | null = null; + + constructor( + private readonly deps: { + platform?: NodeJS.Platform; + cpus?: () => NodeJS.CpuInfo[]; + totalmem?: () => number; + freemem?: () => number; + uptime?: () => number; + loadavg?: () => number[]; + } = {} + ) {} + + collect(overrides: { cpus?: NodeJS.CpuInfo[] } = {}): MonitoringHostSummary { + const cpus = overrides.cpus ?? this.deps.cpus?.() ?? os.cpus(); + const currentCpu = sumCpuTimes(cpus); + const previousCpu = this.previousCpu; + this.previousCpu = currentCpu; + + let cpuPercent: number | null = null; + if (previousCpu) { + const totalDelta = + currentCpu.user + + currentCpu.nice + + currentCpu.sys + + currentCpu.idle + + currentCpu.irq - + (previousCpu.user + + previousCpu.nice + + previousCpu.sys + + previousCpu.idle + + previousCpu.irq); + const busyDelta = + currentCpu.user + + currentCpu.nice + + currentCpu.sys + + currentCpu.irq - + (previousCpu.user + previousCpu.nice + previousCpu.sys + previousCpu.irq); + + if (totalDelta > 0) { + cpuPercent = Number(((busyDelta / totalDelta) * 100).toFixed(2)); + } + } + + const total = this.deps.totalmem?.() ?? os.totalmem(); + const free = this.deps.freemem?.() ?? os.freemem(); + const used = total - free; + const loadAverage = + (this.deps.platform ?? process.platform) === "win32" + ? null + : ((this.deps.loadavg?.() ?? os.loadavg()).slice(0, 3) as [number, number, number]); + + const memoryRatio = total > 0 ? used / total : null; + const pressure = + cpuPercent == null || memoryRatio == null + ? "unknown" + : cpuPercent >= 90 || memoryRatio >= 0.9 + ? "hot" + : cpuPercent >= 70 || memoryRatio >= 0.75 + ? "elevated" + : "normal"; + + return { + cpuPercent, + memoryUsedBytes: used, + memoryTotalBytes: total, + memoryAvailableBytes: free, + loadAverage, + uptimeSec: this.deps.uptime?.() ?? os.uptime(), + pressure, + }; + } +} +``` + +Create `packages/server/src/monitoring/process-table/index.ts`: + +```ts +import { runCommandAsString, type CommandRunner } from "../../provider-runtime/command-runner.js"; +import type { ProcessStatRow } from "../types.js"; +import { collectDarwinProcessRows, parseDarwinPsRows } from "./darwin.js"; +import { collectLinuxProcessRows, parseLinuxPsRows } from "./linux.js"; +import { collectWindowsProcessRows, parseWindowsProcessRows } from "./win32.js"; + +export { parseDarwinPsRows, parseLinuxPsRows, parseWindowsProcessRows }; + +export interface ProcessTableCollector { + collect(): Promise; +} + +export function createProcessTableCollector( + platform: NodeJS.Platform = process.platform, + runCommand: CommandRunner = runCommandAsString +): ProcessTableCollector { + if (platform === "darwin") { + return { collect: () => collectDarwinProcessRows(runCommand) }; + } + if (platform === "linux") { + return { collect: () => collectLinuxProcessRows(runCommand) }; + } + return { collect: () => collectWindowsProcessRows(runCommand) }; +} +``` + +Create `packages/server/src/monitoring/process-table/darwin.ts`: + +```ts +import type { CommandRunner } from "../../provider-runtime/command-runner.js"; +import type { ProcessStatRow } from "../types.js"; + +const DARWIN_PS_ARGS = ["-Ao", "pid=,ppid=,%cpu=,rss=,etimes=,comm=,args="]; + +export function parseDarwinPsRows(stdout: string): ProcessStatRow[] { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const match = line.match(/^(\d+)\s+(\d+)\s+([0-9.]+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); + if (!match) { + return null; + } + + const [, pid, ppid, cpu, rss, elapsedSec, executable, command] = match; + return { + pid: Number(pid), + ppid: Number(ppid), + cpuPercent: Number(cpu), + rssBytes: Number(rss) * 1024, + elapsedSec: Number(elapsedSec), + executable, + command, + } satisfies ProcessStatRow; + }) + .filter((row): row is ProcessStatRow => row !== null); +} + +export async function collectDarwinProcessRows(runCommand: CommandRunner): Promise { + const result = await runCommand("ps", DARWIN_PS_ARGS); + return parseDarwinPsRows(result.stdout); +} +``` + +Create `packages/server/src/monitoring/process-table/linux.ts`: + +```ts +import type { CommandRunner } from "../../provider-runtime/command-runner.js"; +import type { ProcessStatRow } from "../types.js"; + +const LINUX_PS_ARGS = ["-eo", "pid=,ppid=,%cpu=,rss=,etimes=,comm=,args="]; + +export function parseLinuxPsRows(stdout: string): ProcessStatRow[] { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const match = line.match(/^(\d+)\s+(\d+)\s+([0-9.]+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); + if (!match) { + return null; + } + + const [, pid, ppid, cpu, rss, elapsedSec, executable, command] = match; + return { + pid: Number(pid), + ppid: Number(ppid), + cpuPercent: Number(cpu), + rssBytes: Number(rss) * 1024, + elapsedSec: Number(elapsedSec), + executable, + command, + } satisfies ProcessStatRow; + }) + .filter((row): row is ProcessStatRow => row !== null); +} + +export async function collectLinuxProcessRows(runCommand: CommandRunner): Promise { + const result = await runCommand("ps", LINUX_PS_ARGS); + return parseLinuxPsRows(result.stdout); +} +``` + +Create `packages/server/src/monitoring/process-table/win32.ts`: + +```ts +import type { CommandRunner } from "../../provider-runtime/command-runner.js"; +import type { ProcessStatRow } from "../types.js"; + +const WINDOWS_SCRIPT = [ + "$processes = Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine, ExecutablePath, CreationDate;", + "$cpuByPid = @{};", + "Get-Counter '\\Process(*)\\% Processor Time' | Select-Object -ExpandProperty CounterSamples | ForEach-Object {", + " if ($_.InstanceName -match '^(?!_Total|Idle)') {", + " $pid = [int]$_.CookedValue.ToString().Split('.')[0];", + " }", + "};", + "$payload = $processes | ForEach-Object {", + " [pscustomobject]@{", + " Id = $_.ProcessId;", + " ParentProcessId = $_.ParentProcessId;", + " CpuPercent = $null;", + " WorkingSet64 = $null;", + " ElapsedSec = $null;", + " Path = $_.ExecutablePath;", + " CommandLine = $_.CommandLine;", + " }", + "};", + "$payload | ConvertTo-Json -Compress", +].join(" "); + +export function parseWindowsProcessRows(rows: unknown[]): ProcessStatRow[] { + return rows + .map((row) => { + if (!row || typeof row !== "object") { + return null; + } + const candidate = row as Record; + if (typeof candidate.Id !== "number" || typeof candidate.ParentProcessId !== "number") { + return null; + } + + return { + pid: candidate.Id, + ppid: candidate.ParentProcessId, + cpuPercent: typeof candidate.CpuPercent === "number" ? candidate.CpuPercent : null, + rssBytes: typeof candidate.WorkingSet64 === "number" ? candidate.WorkingSet64 : null, + elapsedSec: typeof candidate.ElapsedSec === "number" ? candidate.ElapsedSec : undefined, + executable: typeof candidate.Path === "string" ? candidate.Path : undefined, + command: typeof candidate.CommandLine === "string" ? candidate.CommandLine : undefined, + } satisfies ProcessStatRow; + }) + .filter((row): row is ProcessStatRow => row !== null); +} + +export async function collectWindowsProcessRows( + runCommand: CommandRunner +): Promise { + const result = await runCommand("powershell", ["-NoProfile", "-Command", WINDOWS_SCRIPT]); + const parsed = JSON.parse(result.stdout) as unknown; + const rows = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []; + return parseWindowsProcessRows(rows); +} +``` + +The Windows adapter is allowed to emit `cpuPercent: null` in the first implementation pass; later aggregation must treat `null` as unavailable instead of failing the entire snapshot. + +- [ ] **Step 4: Run the collector tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/monitoring/host-collector.test.ts \ + src/__tests__/monitoring/process-table.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the collectors** + +```bash +git add packages/server/src/monitoring/host-collector.ts \ + packages/server/src/monitoring/process-table/darwin.ts \ + packages/server/src/monitoring/process-table/linux.ts \ + packages/server/src/monitoring/process-table/win32.ts \ + packages/server/src/monitoring/process-table/index.ts \ + packages/server/src/__tests__/monitoring/host-collector.test.ts \ + packages/server/src/__tests__/monitoring/process-table.test.ts +git commit -m "feat(server): add monitoring collectors" +``` + +### Task 5: Build Aggregation, History Retention, And MonitoringService + +**Files:** +- Create: `packages/server/src/monitoring/aggregation.ts` +- Create: `packages/server/src/monitoring/history-store.ts` +- Create: `packages/server/src/monitoring/service.ts` +- Create: `packages/server/src/__tests__/monitoring/aggregation.test.ts` +- Create: `packages/server/src/__tests__/monitoring/service.test.ts` + +- [ ] **Step 1: Write the failing aggregation and service tests** + +Create `packages/server/src/__tests__/monitoring/aggregation.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { createDefaultMonitoringSettings } from "@coder-studio/core"; +import { buildMonitoringSnapshot } from "../../monitoring/aggregation.js"; + +describe("buildMonitoringSnapshot", () => { + it("aggregates managed roots into runtime, workspace, session, and subprocess views", () => { + const response = buildMonitoringSnapshot({ + settings: { + ...createDefaultMonitoringSettings(), + enabled: true, + subprocessDrilldownEnabled: true, + }, + sampledAt: 100, + host: { + cpuPercent: 80, + memoryUsedBytes: 800, + memoryTotalBytes: 1000, + memoryAvailableBytes: 200, + loadAverage: [1, 1, 1], + uptimeSec: 300, + pressure: "elevated", + }, + roots: [ + { + ownerId: "server:1", + rootPid: 1, + kind: "server", + label: "Coder Studio server", + startedAt: 1, + }, + { + ownerId: "terminal:term-1", + rootPid: 100, + kind: "terminal", + label: "Claude", + workspaceId: "ws-1", + sessionId: "sess-1", + terminalId: "term-1", + providerId: "claude", + startedAt: 2, + }, + ], + processRows: [ + { pid: 1, ppid: 0, cpuPercent: 10, rssBytes: 100, elapsedSec: 400, command: "node server.js" }, + { pid: 100, ppid: 1, cpuPercent: 20, rssBytes: 200, elapsedSec: 90, command: "claude" }, + { pid: 101, ppid: 100, cpuPercent: 5, rssBytes: 50, elapsedSec: 30, command: "python tool.py" }, + ], + previousSnapshot: null, + }); + + expect(response.snapshot.runtime?.totalManagedCpuPercent).toBe(35); + expect(response.snapshot.runtime?.managedProcessCount).toBe(3); + expect(response.snapshot.workspaces[0]).toEqual( + expect.objectContaining({ + id: "workspace:ws-1", + cpuPercent: 25, + memoryBytes: 250, + }) + ); + expect(response.snapshot.sessions[0]).toEqual( + expect.objectContaining({ + id: "session:sess-1", + cpuPercent: 25, + processCount: 2, + }) + ); + expect(response.snapshot.subprocessGroups[0]?.parentId).toBe("session:sess-1"); + }); + + it("keeps host data when process collection fails", () => { + const response = buildMonitoringSnapshot({ + settings: { + ...createDefaultMonitoringSettings(), + enabled: true, + }, + sampledAt: 100, + host: { + cpuPercent: 50, + memoryUsedBytes: 400, + memoryTotalBytes: 1000, + memoryAvailableBytes: 600, + loadAverage: [0.5, 0.4, 0.3], + uptimeSec: 300, + pressure: "normal", + }, + roots: [], + processRows: null, + previousSnapshot: null, + failureReason: "ps failed", + }); + + expect(response.snapshot.host?.cpuPercent).toBe(50); + expect(response.snapshot.runtime).toBeNull(); + expect(response.telemetry.degraded).toBe(true); + }); +}); +``` + +Create `packages/server/src/__tests__/monitoring/service.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { createDefaultMonitoringSettings } from "@coder-studio/core"; +import { ManagedProcessRegistry } from "../../monitoring/managed-process-registry.js"; +import { MonitoringService } from "../../monitoring/service.js"; + +describe("MonitoringService", () => { + it("does not schedule sampling when monitoring is disabled", () => { + const broadcaster = { broadcast: vi.fn() }; + const setIntervalSpy = vi.fn(); + + const service = new MonitoringService({ + broadcaster, + settingsRepo: { + get: (key: string) => (key === "monitoring.enabled" ? false : undefined), + }, + registry: new ManagedProcessRegistry({ now: () => 1 }), + sessionMgr: { getAll: () => [], findSessionIdByTerminal: () => undefined }, + terminalMgr: { getAll: () => [] }, + hostCollector: { collect: vi.fn() }, + processCollector: { collect: vi.fn() }, + setInterval: setIntervalSpy, + clearInterval: vi.fn(), + now: () => 1, + }); + + service.start(); + + expect(setIntervalSpy).not.toHaveBeenCalled(); + expect(service.getResponse().settings.enabled).toBe(false); + }); + + it("reloads the schedule and broadcasts snapshots when monitoring is enabled", async () => { + const broadcaster = { broadcast: vi.fn() }; + const setIntervalSpy = vi.fn(() => ({ unref: vi.fn() })); + + const service = new MonitoringService({ + broadcaster, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + "monitoring.sampleIntervalMs": 2000, + } as Record; + return settings[key]; + }, + }, + registry: new ManagedProcessRegistry({ now: () => 10 }), + sessionMgr: { + getAll: () => [{ id: "sess-1", workspaceId: "ws-1", terminalId: "term-1", providerId: "claude" }], + findSessionIdByTerminal: () => "sess-1", + }, + terminalMgr: { + getAll: () => [ + { + id: "term-1", + spec: { workspaceId: "ws-1", kind: "agent", title: "Claude" }, + toDTO: () => ({ + id: "term-1", + workspaceId: "ws-1", + kind: "agent", + title: "Claude", + cwd: "/tmp", + argv: ["claude"], + cols: 120, + rows: 30, + pid: 100, + alive: true, + createdAt: 1, + }), + }, + ], + }, + hostCollector: { + collect: () => ({ + cpuPercent: 40, + memoryUsedBytes: 400, + memoryTotalBytes: 1000, + memoryAvailableBytes: 600, + loadAverage: [0.2, 0.2, 0.1], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { + collect: async () => [ + { pid: 100, ppid: 1, cpuPercent: 10, rssBytes: 100, elapsedSec: 5, command: "claude" }, + ], + }, + setInterval: setIntervalSpy, + clearInterval: vi.fn(), + now: () => 10, + }); + + service.start(); + await service.recheck(); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 2000); + expect(broadcaster.broadcast).toHaveBeenCalledWith( + "monitoring.snapshot.updated", + expect.objectContaining({ + snapshot: expect.objectContaining({ + mode: "standard", + }), + }) + ); + }); +}); +``` + +- [ ] **Step 2: Run the aggregation and service tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/monitoring/aggregation.test.ts \ + src/__tests__/monitoring/service.test.ts +``` + +Expected: FAIL because aggregation, history, and service code do not exist yet. + +- [ ] **Step 3: Implement aggregation, history, and the monitoring service** + +Create `packages/server/src/monitoring/history-store.ts`: + +```ts +import type { + MonitoringHistoryBundle, + MonitoringSeriesBundle, + MonitoringSeriesPoint, + MonitoringSnapshot, +} from "@coder-studio/core"; + +const DEFAULT_RETENTION_MS = 30 * 60 * 1000; +const MAX_SUBPROCESS_HISTORY_GROUPS = 24; + +function trimPoints(points: MonitoringSeriesPoint[], minSampledAt: number): MonitoringSeriesPoint[] { + return points.filter((point) => point.sampledAt >= minSampledAt); +} + +function appendPoint(bundle: MonitoringSeriesBundle, point: MonitoringSeriesPoint, minSampledAt: number) { + bundle.points = trimPoints([...bundle.points, point], minSampledAt); +} + +export class MonitoringHistoryStore { + private readonly history: MonitoringHistoryBundle = { + host: { points: [] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }; + + constructor(private readonly deps: { retentionMs?: number } = {}) {} + + clear(): void { + this.history.host = { points: [] }; + this.history.runtime = null; + this.history.workspaces = {}; + this.history.sessions = {}; + this.history.subprocessGroups = {}; + } + + record(snapshot: MonitoringSnapshot): { trimmed: boolean; subprocessHistoryLimited: boolean } { + const minSampledAt = snapshot.sampledAt - (this.deps.retentionMs ?? DEFAULT_RETENTION_MS); + let trimmed = false; + + if (snapshot.host) { + appendPoint(this.history.host, { + sampledAt: snapshot.sampledAt, + cpuPercent: snapshot.host.cpuPercent, + memoryBytes: snapshot.host.memoryUsedBytes, + }, minSampledAt); + } + + if (snapshot.runtime) { + this.history.runtime ??= { points: [] }; + appendPoint(this.history.runtime, { + sampledAt: snapshot.sampledAt, + cpuPercent: snapshot.runtime.totalManagedCpuPercent, + memoryBytes: snapshot.runtime.totalManagedMemoryBytes, + processCount: snapshot.runtime.managedProcessCount, + }, minSampledAt); + } + + for (const entity of snapshot.workspaces) { + const bundle = (this.history.workspaces[entity.id] ??= { points: [] }); + appendPoint(bundle, { + sampledAt: snapshot.sampledAt, + cpuPercent: entity.cpuPercent, + memoryBytes: entity.memoryBytes, + processCount: entity.processCount, + }, minSampledAt); + } + + for (const entity of snapshot.sessions) { + const bundle = (this.history.sessions[entity.id] ??= { points: [] }); + appendPoint(bundle, { + sampledAt: snapshot.sampledAt, + cpuPercent: entity.cpuPercent, + memoryBytes: entity.memoryBytes, + processCount: entity.processCount, + }, minSampledAt); + } + + const hottestSubprocessIds = [...snapshot.subprocessGroups] + .sort((left, right) => (right.cpuPercent ?? 0) - (left.cpuPercent ?? 0)) + .slice(0, MAX_SUBPROCESS_HISTORY_GROUPS) + .map((entity) => entity.id); + + const allowedSubprocessIds = new Set(hottestSubprocessIds); + for (const entity of snapshot.subprocessGroups) { + if (!allowedSubprocessIds.has(entity.id)) { + trimmed = true; + continue; + } + + const bundle = (this.history.subprocessGroups[entity.id] ??= { points: [] }); + appendPoint(bundle, { + sampledAt: snapshot.sampledAt, + cpuPercent: entity.cpuPercent, + memoryBytes: entity.memoryBytes, + processCount: entity.processCount, + }, minSampledAt); + } + + for (const id of Object.keys(this.history.subprocessGroups)) { + if (!allowedSubprocessIds.has(id)) { + delete this.history.subprocessGroups[id]; + trimmed = true; + } + } + + return { + trimmed, + subprocessHistoryLimited: trimmed, + }; + } + + snapshot(): MonitoringHistoryBundle { + return { + host: { points: [...this.history.host.points] }, + runtime: this.history.runtime ? { points: [...this.history.runtime.points] } : null, + workspaces: Object.fromEntries( + Object.entries(this.history.workspaces).map(([id, bundle]) => [id, { points: [...bundle.points] }]) + ), + sessions: Object.fromEntries( + Object.entries(this.history.sessions).map(([id, bundle]) => [id, { points: [...bundle.points] }]) + ), + subprocessGroups: Object.fromEntries( + Object.entries(this.history.subprocessGroups).map(([id, bundle]) => [id, { points: [...bundle.points] }]) + ), + }; + } +} +``` + +Create `packages/server/src/monitoring/aggregation.ts`: + +```ts +import { + createEmptyMonitoringResponse, + deriveMonitoringMode, + type MonitoringEntitySummary, + type MonitoringHostSummary, + type MonitoringResponse, + type MonitoringSettings, + type MonitoringSnapshot, +} from "@coder-studio/core"; +import type { ManagedProcessRoot, ProcessStatRow } from "./types.js"; + +function createTrend( + current: number | null, + previous: number | null +): MonitoringEntitySummary["trend"] { + if (current == null || previous == null) { + return "unknown"; + } + if (current > previous + 1) { + return "rising"; + } + if (current < previous - 1) { + return "falling"; + } + return "steady"; +} + +function buildIndexes(rows: ProcessStatRow[]) { + const byPid = new Map(); + const childrenByPpid = new Map(); + + for (const row of rows) { + byPid.set(row.pid, row); + const children = childrenByPpid.get(row.ppid) ?? []; + children.push(row); + childrenByPpid.set(row.ppid, children); + } + + return { byPid, childrenByPpid }; +} + +function collectTree(rootPid: number, indexes: ReturnType): ProcessStatRow[] { + const root = indexes.byPid.get(rootPid); + if (!root) { + return []; + } + + const result: ProcessStatRow[] = []; + const stack = [root]; + const seen = new Set(); + + while (stack.length > 0) { + const current = stack.pop()!; + if (seen.has(current.pid)) { + continue; + } + seen.add(current.pid); + result.push(current); + + for (const child of indexes.childrenByPpid.get(current.pid) ?? []) { + stack.push(child); + } + } + + return result; +} + +function summarizeRows(rows: ProcessStatRow[]) { + return rows.reduce( + (acc, row) => ({ + cpuPercent: acc.cpuPercent + (row.cpuPercent ?? 0), + memoryBytes: acc.memoryBytes + (row.rssBytes ?? 0), + processCount: acc.processCount + 1, + uptimeSec: Math.max(acc.uptimeSec, row.elapsedSec ?? 0), + }), + { cpuPercent: 0, memoryBytes: 0, processCount: 0, uptimeSec: 0 } + ); +} + +export function buildMonitoringSnapshot(input: { + settings: MonitoringSettings; + sampledAt: number; + host: MonitoringHostSummary | null; + roots: ManagedProcessRoot[]; + processRows: ProcessStatRow[] | null; + previousSnapshot: MonitoringSnapshot | null; + failureReason?: string; +}): MonitoringResponse { + const empty = createEmptyMonitoringResponse(input.settings); + const mode = deriveMonitoringMode(input.settings); + + if (!input.settings.enabled) { + return { + ...empty, + settings: input.settings, + snapshot: { + ...empty.snapshot, + sampledAt: input.sampledAt, + mode, + }, + }; + } + + if (!input.processRows) { + return { + ...empty, + settings: input.settings, + snapshot: { + ...empty.snapshot, + sampledAt: input.sampledAt, + mode, + host: input.host, + }, + telemetry: { + durationMs: 0, + processRowCount: 0, + subprocessGroupCount: 0, + historyTrimmed: false, + degraded: true, + failureReason: input.failureReason, + }, + }; + } + + const indexes = buildIndexes(input.processRows); + const previousEntities = new Map( + [...(input.previousSnapshot?.workspaces ?? []), ...(input.previousSnapshot?.sessions ?? []), ...(input.previousSnapshot?.subprocessGroups ?? [])] + .map((entity) => [entity.id, entity.cpuPercent ?? null]) + ); + + const workspaceMap = new Map(); + const sessionMap = new Map(); + const subprocessGroups: MonitoringEntitySummary[] = []; + const backgroundGroups: MonitoringEntitySummary[] = []; + const serverRoot = input.roots.find((root) => root.kind === "server"); + const serverRows = serverRoot ? collectTree(serverRoot.rootPid, indexes) : []; + let totalManagedCpuPercent = 0; + let totalManagedMemoryBytes = 0; + let managedProcessCount = 0; + + for (const root of input.roots) { + const treeRows = collectTree(root.rootPid, indexes); + if (treeRows.length === 0) { + continue; + } + + const summary = summarizeRows(treeRows); + totalManagedCpuPercent += summary.cpuPercent; + totalManagedMemoryBytes += summary.memoryBytes; + managedProcessCount += summary.processCount; + + if (!root.workspaceId) { + backgroundGroups.push({ + id: `background:${root.ownerId}`, + kind: "background_group", + label: root.label, + cpuPercent: summary.cpuPercent, + memoryBytes: summary.memoryBytes, + processCount: summary.processCount, + uptimeSec: summary.uptimeSec, + trend: createTrend(summary.cpuPercent, previousEntities.get(`background:${root.ownerId}`) ?? null), + }); + continue; + } + + const workspaceId = `workspace:${root.workspaceId}`; + const workspace = workspaceMap.get(workspaceId) ?? { + id: workspaceId, + kind: "workspace", + workspaceId: root.workspaceId, + label: root.workspaceId, + cpuPercent: 0, + memoryBytes: 0, + processCount: 0, + uptimeSec: 0, + trend: "unknown", + childCount: 0, + }; + workspace.cpuPercent = (workspace.cpuPercent ?? 0) + summary.cpuPercent; + workspace.memoryBytes = (workspace.memoryBytes ?? 0) + summary.memoryBytes; + workspace.processCount += summary.processCount; + workspace.uptimeSec = Math.max(workspace.uptimeSec ?? 0, summary.uptimeSec); + workspace.childCount = (workspace.childCount ?? 0) + 1; + workspace.trend = createTrend(workspace.cpuPercent, previousEntities.get(workspaceId) ?? null); + workspaceMap.set(workspaceId, workspace); + + if (root.sessionId) { + const sessionId = `session:${root.sessionId}`; + sessionMap.set(sessionId, { + id: sessionId, + kind: "session", + parentId: workspaceId, + workspaceId: root.workspaceId, + sessionId: root.sessionId, + terminalId: root.terminalId, + label: root.label, + cpuPercent: summary.cpuPercent, + memoryBytes: summary.memoryBytes, + processCount: summary.processCount, + uptimeSec: summary.uptimeSec, + trend: createTrend(summary.cpuPercent, previousEntities.get(sessionId) ?? null), + childCount: Math.max(0, treeRows.length - 1), + }); + + if (input.settings.subprocessDrilldownEnabled) { + for (const child of treeRows.filter((row) => row.pid !== root.rootPid)) { + const id = `subprocess:${root.sessionId}:${child.pid}`; + subprocessGroups.push({ + id, + kind: "subprocess_group", + parentId: sessionId, + workspaceId: root.workspaceId, + sessionId: root.sessionId, + terminalId: root.terminalId, + label: child.command ?? child.executable ?? `pid ${child.pid}`, + cpuPercent: child.cpuPercent, + memoryBytes: child.rssBytes, + processCount: 1, + uptimeSec: child.elapsedSec ?? null, + trend: createTrend(child.cpuPercent, previousEntities.get(id) ?? null), + }); + } + } + } + } + + const serverSummary = summarizeRows(serverRows); + const hostCpu = input.host?.cpuPercent ?? null; + const hostMemory = input.host?.memoryTotalBytes ?? null; + + return { + ...empty, + settings: input.settings, + capabilities: { + loadAverageAvailable: input.host?.loadAverage !== null, + processMetricsAvailable: true, + subprocessHistoryLimited: false, + }, + snapshot: { + sampledAt: input.sampledAt, + mode, + host: input.host, + runtime: input.settings.runtimeSummaryEnabled + ? { + serverCpuPercent: serverSummary.cpuPercent || null, + serverMemoryBytes: serverSummary.memoryBytes || null, + totalManagedCpuPercent, + totalManagedMemoryBytes, + managedProcessCount, + cpuShareOfHostPercent: + hostCpu != null && hostCpu > 0 + ? Number(((totalManagedCpuPercent / hostCpu) * 100).toFixed(2)) + : null, + memoryShareOfHostPercent: + hostMemory != null && hostMemory > 0 + ? Number(((totalManagedMemoryBytes / hostMemory) * 100).toFixed(2)) + : null, + } + : null, + workspaces: input.settings.workspaceAttributionEnabled + ? [...workspaceMap.values()].sort((left, right) => (right.cpuPercent ?? 0) - (left.cpuPercent ?? 0)) + : [], + sessions: input.settings.workspaceAttributionEnabled + ? [...sessionMap.values()].sort((left, right) => (right.cpuPercent ?? 0) - (left.cpuPercent ?? 0)) + : [], + subprocessGroups: input.settings.subprocessDrilldownEnabled + ? subprocessGroups.sort((left, right) => (right.cpuPercent ?? 0) - (left.cpuPercent ?? 0)) + : [], + backgroundGroups: backgroundGroups.sort((left, right) => (right.cpuPercent ?? 0) - (left.cpuPercent ?? 0)), + }, + telemetry: { + durationMs: 0, + processRowCount: input.processRows.length, + subprocessGroupCount: subprocessGroups.length, + historyTrimmed: false, + degraded: false, + failureReason: input.failureReason, + }, + }; +} +``` + +Create `packages/server/src/monitoring/service.ts`: + +```ts +import { + Topics, + createEmptyMonitoringResponse, + deriveMonitoringMode, + resolveMonitoringSettings, + type MonitoringResponse, +} from "@coder-studio/core"; +import type { Session, Terminal } from "@coder-studio/core"; +import { buildMonitoringSnapshot } from "./aggregation.js"; +import { MonitoringHistoryStore } from "./history-store.js"; +import { ManagedProcessRegistry } from "./managed-process-registry.js"; +import type { HostCollector } from "./host-collector.js"; +import type { ProcessTableCollector } from "./process-table/index.js"; + +export class MonitoringService { + private timer: NodeJS.Timeout | null = null; + private latest = createEmptyMonitoringResponse(); + private latestSampledSnapshot = this.latest.snapshot; + private readonly history = new MonitoringHistoryStore(); + + constructor( + private readonly deps: { + broadcaster: { broadcast(topic: string, payload: unknown): void }; + settingsRepo: { get(key: string): T | undefined }; + registry: ManagedProcessRegistry; + sessionMgr: { + getAll(): Session[]; + findSessionIdByTerminal(terminalId: string): string | undefined; + }; + terminalMgr: { + getAll(): Array<{ toDTO(): Terminal; spec?: { workspaceId: string; kind: "agent" | "shell"; title?: string } }>; + }; + hostCollector: Pick; + processCollector: Pick; + setInterval?: typeof global.setInterval; + clearInterval?: typeof global.clearInterval; + now?: () => number; + } + ) {} + + start(): void { + this.deps.registry.registerServerProcess(process.pid); + this.reloadFromSettings(); + } + + stop(): void { + if (this.timer) { + (this.deps.clearInterval ?? clearInterval)(this.timer); + this.timer = null; + } + } + + getResponse(): MonitoringResponse { + return this.latest; + } + + async recheck(): Promise { + await this.sampleOnce(); + return this.latest; + } + + reloadFromSettings(): void { + this.stop(); + const settings = resolveMonitoringSettings(this.deps.settingsRepo); + if (!settings.enabled) { + this.history.clear(); + this.latest = { + ...createEmptyMonitoringResponse(settings), + settings, + snapshot: { + ...createEmptyMonitoringResponse(settings).snapshot, + sampledAt: this.now(), + mode: deriveMonitoringMode(settings), + }, + }; + return; + } + + const intervalHandle = (this.deps.setInterval ?? setInterval)(() => { + void this.sampleOnce(); + }, settings.sampleIntervalMs); + intervalHandle.unref?.(); + this.timer = intervalHandle; + } + + private now(): number { + return this.deps.now?.() ?? Date.now(); + } + + private syncManagedTerminalRoots(): void { + const sessionsByTerminal = new Map( + this.deps.sessionMgr.getAll().map((session) => [session.terminalId, session]) + ); + + for (const activeTerminal of this.deps.terminalMgr.getAll()) { + const terminal = activeTerminal.toDTO(); + this.deps.registry.upsertTerminalRoot({ + terminalId: terminal.id, + workspaceId: terminal.workspaceId, + pid: terminal.pid, + kind: terminal.kind, + title: terminal.title, + }); + + const session = + sessionsByTerminal.get(terminal.id) ?? + ((): Session | undefined => { + const sessionId = this.deps.sessionMgr.findSessionIdByTerminal(terminal.id); + return sessionId + ? this.deps.sessionMgr.getAll().find((candidate) => candidate.id === sessionId) + : undefined; + })(); + + if (session) { + this.deps.registry.bindSessionToTerminal(terminal.id, { + sessionId: session.id, + providerId: session.providerId, + label: session.title ?? terminal.title, + }); + } + } + } + + private async sampleOnce(): Promise { + const settings = resolveMonitoringSettings(this.deps.settingsRepo); + const startedAt = this.now(); + this.syncManagedTerminalRoots(); + + const host = settings.hostMetricsEnabled ? this.deps.hostCollector.collect() : null; + + let processRows = null; + let failureReason: string | undefined; + if (settings.runtimeSummaryEnabled) { + try { + processRows = await this.deps.processCollector.collect(); + } catch (error) { + failureReason = error instanceof Error ? error.message : String(error); + } + } + + const response = buildMonitoringSnapshot({ + settings, + sampledAt: startedAt, + host, + roots: this.deps.registry.listRoots(), + processRows, + previousSnapshot: this.latestSampledSnapshot.sampledAt > 0 ? this.latestSampledSnapshot : null, + failureReason, + }); + + const historyState = this.history.record(response.snapshot); + this.latestSampledSnapshot = response.snapshot; + this.latest = { + ...response, + history: this.history.snapshot(), + capabilities: { + ...response.capabilities, + subprocessHistoryLimited: historyState.subprocessHistoryLimited, + }, + telemetry: response.telemetry + ? { + ...response.telemetry, + durationMs: this.now() - startedAt, + historyTrimmed: historyState.trimmed, + } + : null, + }; + + this.deps.broadcaster.broadcast(Topics.monitoringSnapshotUpdated, this.latest); + } +} +``` + +- [ ] **Step 4: Run the aggregation and service tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/monitoring/aggregation.test.ts \ + src/__tests__/monitoring/service.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit aggregation and service** + +```bash +git add packages/server/src/monitoring/aggregation.ts \ + packages/server/src/monitoring/history-store.ts \ + packages/server/src/monitoring/service.ts \ + packages/server/src/__tests__/monitoring/aggregation.test.ts \ + packages/server/src/__tests__/monitoring/service.test.ts +git commit -m "feat(server): add monitoring aggregation service" +``` + +### Task 6: Wire Monitoring Into Server Lifecycle, Commands, And Settings Reloads + +**Files:** +- Create: `packages/server/src/commands/monitoring.ts` +- Create: `packages/server/src/__tests__/monitoring/commands.test.ts` +- Create: `packages/server/src/__tests__/server-monitoring-hydration.test.ts` +- Modify: `packages/server/src/commands/index.ts` +- Modify: `packages/server/src/ws/dispatch.ts` +- Modify: `packages/server/src/commands/settings.ts` +- Modify: `packages/server/src/commands/settings.test.ts` +- Modify: `packages/server/src/server.ts` + +- [ ] **Step 1: Write the failing command, settings, and server lifecycle tests** + +Create `packages/server/src/__tests__/monitoring/commands.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../../ws/dispatch.js"; +import { dispatch } from "../../ws/dispatch.js"; +import "../../commands/monitoring.js"; + +describe("monitoring commands", () => { + it("dispatches monitoring.get", async () => { + const ctx = { + monitoringService: { + getResponse: vi.fn(() => ({ snapshot: { sampledAt: 1 } })), + }, + } as unknown as CommandContext; + + const result = await dispatch( + { kind: "command", id: crypto.randomUUID(), op: "monitoring.get", args: {} }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ snapshot: { sampledAt: 1 } }); + }); + + it("dispatches monitoring.recheck", async () => { + const ctx = { + monitoringService: { + recheck: vi.fn(async () => ({ snapshot: { sampledAt: 2 } })), + }, + } as unknown as CommandContext; + + const result = await dispatch( + { kind: "command", id: crypto.randomUUID(), op: "monitoring.recheck", args: {} }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ snapshot: { sampledAt: 2 } }); + }); +}); +``` + +Add a new test to `packages/server/src/commands/settings.test.ts`: + +```ts + it("settings.update persists monitoring settings and reloads the monitoring service", async () => { + const monitoringService = { + reloadFromSettings: vi.fn(), + }; + ctx.monitoringService = monitoringService as never; + + const result = await dispatch( + { + kind: "command", + id: "settings-update-monitoring", + op: "settings.update", + args: { + settings: { + monitoring: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 5000, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(settingsRepo.get("monitoring.enabled")).toBe(true); + expect(settingsRepo.get("monitoring.sampleIntervalMs")).toBe(5000); + expect(monitoringService.reloadFromSettings).toHaveBeenCalledTimes(1); + }); +``` + +Create `packages/server/src/__tests__/server-monitoring-hydration.test.ts`: + +```ts +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createServer, type Server } from "../server.js"; +import { SettingsRepo } from "../storage/index.js"; + +describe("server monitoring hydration", () => { + let server: Server | undefined; + let stateDir: string; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "coder-studio-monitoring-state-")); + }); + + afterEach(async () => { + if (server) { + await server.stop(); + server = undefined; + } + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("hydrates persisted monitoring settings into the monitoring service on startup", async () => { + const settingsRepo = new SettingsRepo({ + filePath: join(stateDir, "state", "settings.json"), + }); + settingsRepo.set("monitoring.enabled", true); + settingsRepo.set("monitoring.sampleIntervalMs", 5000); + + server = await createServer({ + stateDir, + host: "127.0.0.1", + port: 0, + }); + + expect(server.__test__?.commandContext.monitoringService?.getResponse().settings).toEqual( + expect.objectContaining({ + enabled: true, + sampleIntervalMs: 5000, + }) + ); + }); +}); +``` + +- [ ] **Step 2: Run the server wiring tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/monitoring/commands.test.ts \ + src/__tests__/server-monitoring-hydration.test.ts \ + src/commands/settings.test.ts +``` + +Expected: FAIL because monitoring commands, settings schema, and server wiring are missing. + +- [ ] **Step 3: Implement commands, settings reload hooks, and server lifecycle wiring** + +Create `packages/server/src/commands/monitoring.ts`: + +```ts +import { z } from "zod"; +import { registerCommand } from "../ws/dispatch.js"; + +registerCommand("monitoring.get", z.object({}).default({}), async (_args, ctx) => { + return ctx.monitoringService?.getResponse(); +}); + +registerCommand("monitoring.recheck", z.object({}).default({}), async (_args, ctx) => { + if (!ctx.monitoringService) { + throw Object.assign(new Error("Monitoring service unavailable"), { + code: "monitoring_unavailable", + }); + } + return await ctx.monitoringService.recheck(); +}); +``` + +Update `packages/server/src/ws/dispatch.ts`: + +```ts +import type { MonitoringService } from "../monitoring/service.js"; + +export interface CommandContext { + // existing fields + monitoringService?: MonitoringService; +} +``` + +Update `packages/server/src/commands/index.ts`: + +```ts +import "./monitoring.js"; +``` + +Update `packages/server/src/commands/settings.ts`: + +```ts +import { isMonitoringSampleIntervalMs } from "@coder-studio/core"; + +const SettingsSchema = z.object({ + // existing sections + monitoring: z + .object({ + enabled: z.boolean().optional(), + hostMetricsEnabled: z.boolean().optional(), + runtimeSummaryEnabled: z.boolean().optional(), + workspaceAttributionEnabled: z.boolean().optional(), + subprocessDrilldownEnabled: z.boolean().optional(), + sampleIntervalMs: z.number().int().refine(isMonitoringSampleIntervalMs).optional(), + }) + .optional(), +}); +``` + +and inside `settings.update`: + +```ts + if ( + flatSettings["monitoring.enabled"] !== undefined || + flatSettings["monitoring.hostMetricsEnabled"] !== undefined || + flatSettings["monitoring.runtimeSummaryEnabled"] !== undefined || + flatSettings["monitoring.workspaceAttributionEnabled"] !== undefined || + flatSettings["monitoring.subprocessDrilldownEnabled"] !== undefined || + flatSettings["monitoring.sampleIntervalMs"] !== undefined + ) { + ctx.monitoringService?.reloadFromSettings(); + } +``` + +Update `packages/server/src/server.ts` by adding imports: + +```ts +import { HostCollector } from "./monitoring/host-collector.js"; +import { ManagedProcessRegistry } from "./monitoring/managed-process-registry.js"; +import { createProcessTableCollector } from "./monitoring/process-table/index.js"; +import { MonitoringService } from "./monitoring/service.js"; +``` + +Construct the service after `sessionMgr` and `terminalMgr` exist: + +```ts + const managedProcessRegistry = new ManagedProcessRegistry(); + const monitoringService = new MonitoringService({ + broadcaster: wsHub, + settingsRepo, + registry: managedProcessRegistry, + sessionMgr, + terminalMgr, + hostCollector: new HostCollector(), + processCollector: createProcessTableCollector(), + }); +``` + +Inject it into `commandContext`: + +```ts + monitoringService, +``` + +Start and stop it alongside the other services: + +```ts + monitoringService.start(); +``` + +and in `stop()`: + +```ts + monitoringService.stop(); +``` + +Expose it in `__test__` through the existing `commandContext`. + +- [ ] **Step 4: Run the server wiring tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/monitoring/commands.test.ts \ + src/__tests__/server-monitoring-hydration.test.ts \ + src/commands/settings.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the server wiring** + +```bash +git add packages/server/src/commands/monitoring.ts \ + packages/server/src/__tests__/monitoring/commands.test.ts \ + packages/server/src/__tests__/server-monitoring-hydration.test.ts \ + packages/server/src/commands/index.ts \ + packages/server/src/ws/dispatch.ts \ + packages/server/src/commands/settings.ts \ + packages/server/src/commands/settings.test.ts \ + packages/server/src/server.ts +git commit -m "feat(server): wire monitoring service into commands" +``` + +### Task 7: Add The Routed Monitoring Page With Live Updates + +**Files:** +- Create: `packages/web/src/features/monitoring/index.ts` +- Create: `packages/web/src/features/monitoring/formatters.ts` +- Create: `packages/web/src/features/monitoring/sparkline.tsx` +- Create: `packages/web/src/features/monitoring/page.tsx` +- Create: `packages/web/src/features/monitoring/page.test.tsx` +- Modify: `packages/web/src/shells/desktop-shell.tsx` +- Modify: `packages/web/src/shells/mobile-shell/index.tsx` +- Modify: `packages/web/src/shells/desktop-shell.test.tsx` +- Modify: `packages/web/src/shells/mobile-shell/index.test.tsx` + +- [ ] **Step 1: Write the failing monitoring page and route tests** + +Create `packages/web/src/features/monitoring/page.test.tsx`: + +```ts +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { localeAtom } from "../../atoms/app-ui"; +import { connectionStatusAtom, wsClientAtom } from "../../atoms/connection"; +import { MonitoringPage } from "./page"; + +const viewportMocks = vi.hoisted(() => ({ + viewport: "desktop" as "desktop" | "mobile", +})); + +vi.mock("../../hooks/use-viewport", () => ({ + useViewport: () => viewportMocks.viewport, +})); + +function renderMonitoringPage(response: unknown, viewport: "desktop" | "mobile" = "desktop") { + viewportMocks.viewport = viewport; + + const subscribe = vi.fn((_topics: string[], handler: (topic: string, payload: unknown) => void) => { + handler("monitoring.snapshot.updated", response); + return () => {}; + }); + + const sendCommand = vi + .fn() + .mockResolvedValueOnce(response) + .mockResolvedValueOnce(response); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand, subscribe } as never); + + return { + sendCommand, + subscribe, + ...render( + + + + } /> + SettingsPage
} /> + + + + ), + }; +} + +describe("MonitoringPage", () => { + it("loads the snapshot, subscribes for updates, and renders host plus runtime sections", async () => { + const response = { + settings: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 2000, + }, + snapshot: { + sampledAt: 10, + mode: "standard", + host: { + cpuPercent: 72, + memoryUsedBytes: 800, + memoryTotalBytes: 1000, + memoryAvailableBytes: 200, + loadAverage: [1, 1, 1], + uptimeSec: 60, + pressure: "elevated", + }, + runtime: { + serverCpuPercent: 10, + serverMemoryBytes: 100, + totalManagedCpuPercent: 30, + totalManagedMemoryBytes: 300, + managedProcessCount: 4, + cpuShareOfHostPercent: 41.67, + memoryShareOfHostPercent: 30, + }, + workspaces: [ + { + id: "workspace:ws-1", + kind: "workspace", + label: "ws-1", + cpuPercent: 30, + memoryBytes: 300, + processCount: 4, + uptimeSec: 60, + trend: "steady", + }, + ], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }, + history: { + host: { points: [{ sampledAt: 10, cpuPercent: 72, memoryBytes: 800 }] }, + runtime: { points: [{ sampledAt: 10, cpuPercent: 30, memoryBytes: 300, processCount: 4 }] }, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: true, + processMetricsAvailable: true, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; + + const { sendCommand, subscribe } = renderMonitoringPage(response); + + expect(await screen.findByText("Performance monitoring")).toBeInTheDocument(); + expect(sendCommand).toHaveBeenCalledWith("monitoring.get", {}, undefined); + expect(subscribe).toHaveBeenCalledWith(["monitoring.snapshot.updated"], expect.any(Function)); + expect(screen.getByText("Host overview")).toBeInTheDocument(); + expect(screen.getByText("Coder Studio footprint")).toBeInTheDocument(); + expect(screen.getByText("ws-1")).toBeInTheDocument(); + }); + + it("renders a disabled empty state that links to settings", async () => { + const response = { + settings: { + enabled: false, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 2000, + }, + snapshot: { + sampledAt: 0, + mode: "disabled", + host: null, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }, + history: { + host: { points: [] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: true, + processMetricsAvailable: false, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; + + renderMonitoringPage(response); + + expect(await screen.findByText("Monitoring disabled")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open Settings" })); + expect(await screen.findByText("SettingsPage")).toBeInTheDocument(); + }); + + it("falls back to mobile tabbed layout", async () => { + const response = { + settings: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: false, + workspaceAttributionEnabled: false, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 5000, + }, + snapshot: { + sampledAt: 10, + mode: "light", + host: { + cpuPercent: 30, + memoryUsedBytes: 300, + memoryTotalBytes: 1000, + memoryAvailableBytes: 700, + loadAverage: [0.3, 0.2, 0.1], + uptimeSec: 60, + pressure: "normal", + }, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }, + history: { + host: { points: [{ sampledAt: 10, cpuPercent: 30, memoryBytes: 300 }] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: true, + processMetricsAvailable: false, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; + + renderMonitoringPage(response, "mobile"); + + expect(await screen.findByRole("tab", { name: "Overview" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Attribution" })).toBeInTheDocument(); + expect(screen.getByText("Enable runtime summary in settings")).toBeInTheDocument(); + }); +}); +``` + +Add a desktop shell route test to `packages/web/src/shells/desktop-shell.test.tsx`: + +```ts +vi.mock("../features/monitoring", () => ({ + MonitoringPage: () =>
MonitoringPage
, +})); + +it("renders MonitoringPage on /monitoring while auth status is still unknown", () => { + window.history.replaceState({}, "", "/monitoring"); + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, null); + store.set(authenticatedAtom, false); + + renderShell(store); + + expect(screen.getByText("MonitoringPage")).toBeInTheDocument(); + expect(screen.queryByText("正在连接工作区...")).not.toBeInTheDocument(); +}); +``` + +Add the corresponding mobile shell test to `packages/web/src/shells/mobile-shell/index.test.tsx` with the same `authEnabledAtom = null` bypass expectation for `/monitoring`. + +- [ ] **Step 2: Run the web monitoring and route tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/monitoring/page.test.tsx \ + src/shells/desktop-shell.test.tsx \ + src/shells/mobile-shell/index.test.tsx +``` + +Expected: FAIL because the monitoring page and routes do not exist yet. + +- [ ] **Step 3: Implement the monitoring page, sparkline helper, and routes** + +Create `packages/web/src/features/monitoring/index.ts`: + +```ts +export { MonitoringPage } from "./page"; +``` + +Create `packages/web/src/features/monitoring/formatters.ts`: + +```ts +export function formatPercent(value: number | null): string { + return value == null ? "Unavailable" : `${value.toFixed(1)}%`; +} + +export function formatBytes(value: number | null): string { + if (value == null) return "Unavailable"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let current = value; + let unitIndex = 0; + while (current >= 1024 && unitIndex < units.length - 1) { + current /= 1024; + unitIndex += 1; + } + return `${current.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +export function formatUptime(value: number | null): string { + if (value == null) return "Unavailable"; + if (value < 60) return `${Math.round(value)}s`; + if (value < 3600) return `${Math.round(value / 60)}m`; + return `${Math.round(value / 3600)}h`; +} + +export function formatLoadAverage(value: [number, number, number] | null): string { + return value == null ? "Unavailable" : value.map((item) => item.toFixed(2)).join(" / "); +} + +export function formatRefreshInterval(ms: number): string { + return `Refresh every ${ms / 1000}s`; +} +``` + +Create `packages/web/src/features/monitoring/sparkline.tsx`: + +```tsx +import type { MonitoringSeriesPoint } from "@coder-studio/core"; + +export function Sparkline({ + points, + metric, + width = 96, + height = 28, +}: { + points: MonitoringSeriesPoint[]; + metric: "cpuPercent" | "memoryBytes"; + width?: number; + height?: number; +}) { + const values = points + .map((point) => point[metric] ?? null) + .filter((value): value is number => value !== null); + + if (values.length === 0) { + return
; + } + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const coordinates = values.map((value, index) => { + const x = (index / Math.max(values.length - 1, 1)) * width; + const y = height - ((value - min) / range) * height; + return `${x},${y}`; + }); + + return ( + + ); +} +``` + +Create `packages/web/src/features/monitoring/page.tsx`: + +```tsx +import type { MonitoringEntitySummary, MonitoringResponse } from "@coder-studio/core"; +import { Topics } from "@coder-studio/core"; +import { useAtomValue } from "jotai"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { connectionStatusAtom, dispatchCommandAtom, wsClientAtom } from "../../atoms/connection"; +import { Button, EmptyState, Notice, SegmentedControl, Tag } from "../../components/ui"; +import { useViewport } from "../../hooks/use-viewport"; +import { useTranslation } from "../../lib/i18n"; +import { MobilePageHeader } from "../shared/components/mobile-page-header"; +import { PageHeader } from "../shared/components/page-header"; +import { + formatBytes, + formatLoadAverage, + formatPercent, + formatRefreshInterval, + formatUptime, +} from "./formatters"; +import { Sparkline } from "./sparkline"; + +type RangeMinutes = 5 | 15 | 30; +type SortMetric = "cpu" | "memory"; +type MobileSection = "overview" | "attribution" | "process"; + +function sortEntities(entities: MonitoringEntitySummary[], metric: SortMetric) { + const key = metric === "cpu" ? "cpuPercent" : "memoryBytes"; + return [...entities].sort((left, right) => (Number(right[key] ?? 0) - Number(left[key] ?? 0))); +} + +function filterPointsByRange( + points: T[], + rangeMinutes: RangeMinutes, + sampledAt: number +) { + const cutoff = sampledAt - rangeMinutes * 60_000; + return points.filter((point) => point.sampledAt >= cutoff); +} + +export function MonitoringPage() { + const t = useTranslation(); + const navigate = useNavigate(); + const viewport = useViewport(); + const isMobile = viewport === "mobile"; + const connectionStatus = useAtomValue(connectionStatusAtom); + const dispatch = useAtomValue(dispatchCommandAtom); + const wsClient = useAtomValue(wsClientAtom); + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [rangeMinutes, setRangeMinutes] = useState(15); + const [sortMetric, setSortMetric] = useState("cpu"); + const [selectedEntityId, setSelectedEntityId] = useState(null); + const [mobileSection, setMobileSection] = useState("overview"); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + setLoading(true); + const result = await dispatch("monitoring.get", {}); + if (cancelled) return; + if (!result.ok || !result.data) { + setLoadError(result.error?.message ?? "Monitoring load failed"); + } else { + setResponse(result.data); + setLoadError(null); + } + setLoading(false); + }; + + void load(); + const unsubscribe = + wsClient?.subscribe([Topics.monitoringSnapshotUpdated], (_topic, payload) => { + setResponse(payload as MonitoringResponse); + }) ?? (() => {}); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [dispatch, wsClient]); + + const selectedEntity = useMemo(() => { + const all = [ + ...(response?.snapshot.workspaces ?? []), + ...(response?.snapshot.sessions ?? []), + ...(response?.snapshot.subprocessGroups ?? []), + ...(response?.snapshot.backgroundGroups ?? []), + ]; + return all.find((entity) => entity.id === selectedEntityId) ?? all[0] ?? null; + }, [response, selectedEntityId]); + + const sortedWorkspaces = useMemo( + () => sortEntities(response?.snapshot.workspaces ?? [], sortMetric), + [response, sortMetric] + ); + const sortedSessions = useMemo( + () => sortEntities(response?.snapshot.sessions ?? [], sortMetric), + [response, sortMetric] + ); + const sortedProcesses = useMemo( + () => sortEntities(response?.snapshot.subprocessGroups ?? [], sortMetric), + [response, sortMetric] + ); + + const refresh = async () => { + const result = await dispatch("monitoring.recheck", {}); + if (result.ok && result.data) { + setResponse(result.data); + setLoadError(null); + return; + } + setLoadError(result.error?.message ?? "Monitoring refresh failed"); + }; + + const header = isMobile ? ( + navigate(-1)} backLabel={t("action.back")} /> + ) : ( + navigate(-1)} backLabel={t("action.back")} /> + ); + + if (loading) { + return ( +
+
{header}
+
+ +
+
+ ); + } + + if (loadError) { + return ( +
+
{header}
+
+ +
+
+ ); + } + + if (!response || !response.settings.enabled) { + return ( +
+
{header}
+
+ navigate("/settings?section=general")}> + {t("monitoring.open_settings")} + + } + /> +
+
+ ); + } + + const selectedHistory = + (selectedEntity && response.history.workspaces[selectedEntity.id]) || + (selectedEntity && response.history.sessions[selectedEntity.id]) || + (selectedEntity && response.history.subprocessGroups[selectedEntity.id]) || + { points: [] }; + const hostHistoryPoints = filterPointsByRange( + response.history.host.points, + rangeMinutes, + response.snapshot.sampledAt + ); + const runtimeHistoryPoints = filterPointsByRange( + response.history.runtime?.points ?? [], + rangeMinutes, + response.snapshot.sampledAt + ); + const selectedHistoryPoints = filterPointsByRange( + selectedHistory.points, + rangeMinutes, + response.snapshot.sampledAt + ); + const processCollectionDegraded = + response.settings.runtimeSummaryEnabled && + !response.capabilities.processMetricsAvailable && + response.telemetry?.degraded === true; + + const overview = ( + <> +
+
+
+

{t("monitoring.host_overview")}

+ {response.snapshot.host?.pressure ?? "unknown"} +
+
+
{t("monitoring.cpu")}
{formatPercent(response.snapshot.host?.cpuPercent ?? null)}
+
{t("monitoring.memory")}
{formatBytes(response.snapshot.host?.memoryUsedBytes ?? null)} / {formatBytes(response.snapshot.host?.memoryTotalBytes ?? null)}
+
{t("monitoring.available_memory")}
{formatBytes(response.snapshot.host?.memoryAvailableBytes ?? null)}
+
{t("monitoring.load_average")}
{formatLoadAverage(response.snapshot.host?.loadAverage ?? null)}
+
{t("monitoring.uptime")}
{formatUptime(response.snapshot.host?.uptimeSec ?? null)}
+
+ +
+ +
+
+

{t("monitoring.runtime_summary_title")}

+ {response.snapshot.mode} +
+ {response.snapshot.runtime ? ( + <> +
+
{t("monitoring.server_cpu")}
{formatPercent(response.snapshot.runtime.serverCpuPercent)}
+
{t("monitoring.server_memory")}
{formatBytes(response.snapshot.runtime.serverMemoryBytes)}
+
{t("monitoring.managed_cpu")}
{formatPercent(response.snapshot.runtime.totalManagedCpuPercent)}
+
{t("monitoring.managed_memory")}
{formatBytes(response.snapshot.runtime.totalManagedMemoryBytes)}
+
{t("monitoring.process_count")}
{response.snapshot.runtime.managedProcessCount}
+
+ + + ) : processCollectionDegraded ? ( + + ) : ( + + )} +
+
+ + ); + + const attribution = ( +
+
+

{t("monitoring.attribution_tree")}

+ {sortedWorkspaces.length === 0 && sortedSessions.length === 0 ? ( + + ) : ( + <> + {sortedWorkspaces.map((entity) => ( + + ))} + {sortedSessions.map((entity) => ( + + ))} + {sortedProcesses.map((entity) => ( + + ))} + + )} +
+ +
+

{t("monitoring.detail_panel")}

+ {selectedEntity ? ( + <> +
+
{t("monitoring.cpu")}
{formatPercent(selectedEntity.cpuPercent)}
+
{t("monitoring.memory")}
{formatBytes(selectedEntity.memoryBytes)}
+
{t("monitoring.process_count")}
{selectedEntity.processCount}
+
{t("monitoring.uptime")}
{formatUptime(selectedEntity.uptimeSec)}
+
+ + + ) : ( + + )} +
+
+ ); + + const processPane = + response.snapshot.subprocessGroups.length > 0 ? ( +
+ {sortedProcesses.map((entity) => ( + + ))} +
+ ) : processCollectionDegraded ? ( + + ) : ( + + ); + + return ( +
+
{header}
+
+
+
+ {response.snapshot.mode} + {formatRefreshInterval(response.settings.sampleIntervalMs)} + {response.snapshot.sampledAt > 0 ? new Date(response.snapshot.sampledAt).toLocaleTimeString() : "—"} + {connectionStatus} +
+
+ setSortMetric(value as SortMetric)} + options={[ + { value: "cpu", label: t("monitoring.cpu") }, + { value: "memory", label: t("monitoring.memory") }, + ]} + /> + setRangeMinutes(Number(value) as RangeMinutes)} + options={[ + { value: "5", label: "5m" }, + { value: "15", label: "15m" }, + { value: "30", label: "30m" }, + ]} + /> + +
+
+ + {isMobile ? ( + <> + setMobileSection(value as MobileSection)} + options={[ + { value: "overview", label: t("monitoring.mobile_overview") }, + { value: "attribution", label: t("monitoring.mobile_attribution") }, + { value: "process", label: t("monitoring.mobile_process") }, + ]} + /> + {mobileSection === "overview" ? overview : null} + {mobileSection === "attribution" ? attribution : null} + {mobileSection === "process" ? processPane : null} + + ) : ( + <> + {overview} + {attribution} + + )} +
+
+ ); +} +``` + +Update `packages/web/src/shells/desktop-shell.tsx`: + +```tsx +import { MonitoringPage } from "../features/monitoring"; + + const shouldBypassAuthLoading = + location.pathname.startsWith("/settings") || + location.pathname.startsWith("/diagnostics") || + location.pathname.startsWith("/monitoring") || + location.pathname === "/session-gate"; + + } /> +``` + +Update `packages/web/src/shells/mobile-shell/index.tsx` with the same bypass and route additions. + +- [ ] **Step 4: Run the web monitoring and route tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/monitoring/page.test.tsx \ + src/shells/desktop-shell.test.tsx \ + src/shells/mobile-shell/index.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the monitoring page** + +```bash +git add packages/web/src/features/monitoring/index.ts \ + packages/web/src/features/monitoring/formatters.ts \ + packages/web/src/features/monitoring/sparkline.tsx \ + packages/web/src/features/monitoring/page.tsx \ + packages/web/src/features/monitoring/page.test.tsx \ + packages/web/src/shells/desktop-shell.tsx \ + packages/web/src/shells/mobile-shell/index.tsx \ + packages/web/src/shells/desktop-shell.test.tsx \ + packages/web/src/shells/mobile-shell/index.test.tsx +git commit -m "feat(web): add monitoring route and live page" +``` + +### Task 8: Add Monitoring Settings, Command Palette Entry, Copy, And Styling + +**Files:** +- Create: `packages/web/src/features/settings/components/monitoring-settings-card.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/features/command-palette/components/command-palette.tsx` +- Modify: `packages/web/src/features/command-palette/components/command-palette.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing settings and command-palette tests** + +Add to `packages/web/src/features/settings/components/settings-page.test.tsx`: + +```ts + it("renders monitoring settings in General and enforces dependency rules", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + "monitoring.enabled": false, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + "monitoring.sampleIntervalMs": 2000, + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + + expect(await screen.findByText("Performance monitoring")).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Enable performance monitoring" })).not.toBeChecked(); + + fireEvent.click(screen.getByRole("switch", { name: "Enable performance monitoring" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + monitoring: expect.objectContaining({ + enabled: true, + }), + }, + }, + undefined + ); + }); + + fireEvent.click(screen.getByRole("switch", { name: "Subprocess drill-down" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + monitoring: expect.objectContaining({ + subprocessDrilldownEnabled: true, + workspaceAttributionEnabled: true, + runtimeSummaryEnabled: true, + }), + }, + }, + undefined + ); + }); + }); +``` + +Add to `packages/web/src/features/command-palette/components/command-palette.test.tsx`: + +```ts + it("opens Monitoring from the quick actions list", () => { + const store = createStore(); + store.set(localeAtom, "en"); + store.set(commandPaletteOpenAtom, true); + store.set(workspacesAtom, { + "ws-1": createWorkspace("ws-1", "/tmp/one"), + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(workspacesLoadStateAtom, "ready"); + + render( + + + + ); + + fireEvent.click(screen.getByText("Monitoring")); + + expect(routerMocks.navigate).toHaveBeenCalledWith("/monitoring"); + }); +``` + +Add a theme-surface assertion to `packages/web/src/styles/components.theme.test.ts`: + +```ts + it("routes monitoring surfaces through theme tokens", () => { + expect(getLastRuleBlock(".monitoring-card")).toContain("var(--surface-elevated)"); + expect(getLastRuleBlock(".monitoring-entity-row")).toContain("var(--border-subtle)"); + }); +``` + +- [ ] **Step 2: Run the settings, palette, and theme tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx \ + src/features/command-palette/components/command-palette.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: FAIL because the monitoring settings card, palette entry, locales, and styles do not exist yet. + +- [ ] **Step 3: Implement monitoring settings, command entry, copy, and CSS** + +Create `packages/web/src/features/settings/components/monitoring-settings-card.tsx`: + +```tsx +import type { MonitoringMode, MonitoringSampleIntervalMs, MonitoringSettings } from "@coder-studio/core"; +import { Button, Notice, SegmentedControl, Switch, Tag } from "../../../components/ui"; +import { useTranslation } from "../../../lib/i18n"; + +type MonitoringPreset = "light" | "standard" | "deep" | "custom"; + +function toPreset(settings: MonitoringSettings): MonitoringPreset { + if (settings.subprocessDrilldownEnabled) return "deep"; + if (settings.workspaceAttributionEnabled) return "standard"; + if (settings.hostMetricsEnabled || settings.runtimeSummaryEnabled) return "light"; + return "custom"; +} + +export function MonitoringSettingsCard({ + settings, + mode, + onChange, + onOpenMonitoring, +}: { + settings: MonitoringSettings; + mode: MonitoringMode; + onChange(next: MonitoringSettings): Promise; + onOpenMonitoring: () => void; +}) { + const t = useTranslation(); + + const applyDependencies = (next: MonitoringSettings): MonitoringSettings => { + if (!next.runtimeSummaryEnabled) { + next.workspaceAttributionEnabled = false; + next.subprocessDrilldownEnabled = false; + } + if (!next.workspaceAttributionEnabled) { + next.subprocessDrilldownEnabled = false; + } + if (next.subprocessDrilldownEnabled) { + next.workspaceAttributionEnabled = true; + next.runtimeSummaryEnabled = true; + } + if (next.workspaceAttributionEnabled) { + next.runtimeSummaryEnabled = true; + } + return next; + }; + + const applyPreset = async (preset: MonitoringPreset) => { + const base = { + ...settings, + enabled: true, + hostMetricsEnabled: true, + }; + + if (preset === "light") { + await onChange( + applyDependencies({ + ...base, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: false, + subprocessDrilldownEnabled: false, + }) + ); + return; + } + + if (preset === "standard") { + await onChange( + applyDependencies({ + ...base, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + }) + ); + return; + } + + if (preset === "deep") { + await onChange( + applyDependencies({ + ...base, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: true, + }) + ); + } + }; + + return ( +
+
+
+

{t("monitoring.settings_group")}

+

{t("monitoring.settings_description")}

+
+
+ {mode} + +
+
+ +
+
+ +

{t("monitoring.enable_monitoring_hint")}

+
+ void onChange({ ...settings, enabled: checked })} + aria-label={t("monitoring.enable_monitoring")} + /> +
+ + void applyPreset(value as MonitoringPreset)} + options={[ + { value: "light", label: "Light" }, + { value: "standard", label: "Standard" }, + { value: "deep", label: "Deep" }, + { value: "custom", label: "Custom" }, + ]} + /> + + {!settings.enabled ? ( + + ) : null} + +
+ void onChange(applyDependencies({ ...settings, hostMetricsEnabled: checked }))} + aria-label={t("monitoring.host_metrics")} + /> + void onChange(applyDependencies({ ...settings, runtimeSummaryEnabled: checked }))} + aria-label={t("monitoring.runtime_summary_setting")} + disabled={!settings.enabled} + /> + void onChange(applyDependencies({ ...settings, workspaceAttributionEnabled: checked }))} + aria-label={t("monitoring.workspace_attribution")} + disabled={!settings.enabled} + /> + void onChange(applyDependencies({ ...settings, subprocessDrilldownEnabled: checked }))} + aria-label={t("monitoring.subprocess_drilldown")} + disabled={!settings.enabled} + /> +
+ + + void onChange({ ...settings, sampleIntervalMs: Number(value) as MonitoringSampleIntervalMs }) + } + options={[ + { value: "1000", label: "1s" }, + { value: "2000", label: "2s" }, + { value: "5000", label: "5s" }, + { value: "10000", label: "10s" }, + ]} + /> +
+ ); +} +``` + +Update `packages/web/src/features/settings/components/settings-page.tsx` by importing from `@coder-studio/core`: + +```ts + createDefaultMonitoringSettings, + deriveMonitoringMode, + resolveMonitoringSettings, + type MonitoringSettings, +``` + +Add state: + +```ts + const [monitoringSettings, setMonitoringSettings] = useState( + createDefaultMonitoringSettings() + ); +``` + +When hydrating `settings.get`, resolve and store monitoring settings: + +```ts + setMonitoringSettings(resolveMonitoringSettings(result.data ?? {})); +``` + +Add a save helper beside the existing `saveUpdateSettings`: + +```ts + const saveMonitoringSettings = async (next: MonitoringSettings) => { + const previous = monitoringSettings; + setMonitoringSettings(next); + + const result = await dispatch("settings.update", { + settings: { + monitoring: { + enabled: next.enabled, + hostMetricsEnabled: next.hostMetricsEnabled, + runtimeSummaryEnabled: next.runtimeSummaryEnabled, + workspaceAttributionEnabled: next.workspaceAttributionEnabled, + subprocessDrilldownEnabled: next.subprocessDrilldownEnabled, + sampleIntervalMs: next.sampleIntervalMs, + }, + }, + }); + + if (result === null || !result.ok) { + setMonitoringSettings(previous); + } + }; +``` + +Mount the new card inside `GeneralSettings` by extending props: + +```tsx + monitoringSettings={monitoringSettings} + onMonitoringSettingsChange={saveMonitoringSettings} +``` + +and inside `GeneralSettings`: + +```tsx + navigate("/monitoring")} + /> +``` + +Update `packages/web/src/features/command-palette/components/command-palette.tsx`: + +```ts + { + id: "open-monitoring", + label: t("monitoring.command_label"), + description: t("monitoring.command_description"), + action: () => { + navigate("/monitoring"); + }, + }, +``` + +Add locale keys to both locale files: + +```json +{ + "monitoring.title": "Performance monitoring", + "monitoring.command_label": "Monitoring", + "monitoring.command_description": "Open the performance monitoring page", + "monitoring.settings_group": "Performance monitoring", + "monitoring.settings_description": "Control whether runtime sampling is enabled and how deep it goes.", + "monitoring.enable_monitoring": "Enable performance monitoring", + "monitoring.enable_monitoring_hint": "Sampling is disabled by default to avoid background overhead.", + "monitoring.disabled_title": "Monitoring disabled", + "monitoring.disabled_description": "No background sampling is running. Enable monitoring in settings before using this page.", + "monitoring.disabled_settings_hint": "Turn monitoring on to start collecting host and runtime data.", + "monitoring.open_settings": "Open Settings", + "monitoring.open_monitoring": "Open Monitoring", + "monitoring.loading": "Loading monitoring snapshot…", + "monitoring.load_failed": "Monitoring failed to load", + "monitoring.host_overview": "Host overview", + "monitoring.runtime_summary_title": "Coder Studio footprint", + "monitoring.runtime_summary_disabled": "Runtime summary disabled", + "monitoring.enable_runtime_summary": "Enable runtime summary in settings", + "monitoring.process_collection_degraded": "Process metrics unavailable", + "monitoring.process_collection_unavailable": "Process collection is temporarily unavailable.", + "monitoring.attribution_tree": "Attribution tree", + "monitoring.attribution_disabled": "Attribution disabled", + "monitoring.enable_attribution": "Enable workspace and session attribution in settings", + "monitoring.subprocess_disabled": "Subprocess drill-down disabled", + "monitoring.enable_subprocess": "Enable subprocess drill-down in settings", + "monitoring.detail_panel": "Detail panel", + "monitoring.select_entity": "Select a workspace, session, or process to inspect details.", + "monitoring.cpu": "CPU", + "monitoring.memory": "Memory", + "monitoring.available_memory": "Available memory", + "monitoring.load_average": "Load average", + "monitoring.uptime": "Uptime", + "monitoring.server_cpu": "Server CPU", + "monitoring.server_memory": "Server memory", + "monitoring.managed_cpu": "Managed CPU", + "monitoring.managed_memory": "Managed memory", + "monitoring.process_count": "Process count", + "monitoring.sort_by": "Sort by", + "monitoring.time_window": "Time window", + "monitoring.refresh_rate": "Refresh rate", + "monitoring.preset": "Preset", + "monitoring.host_metrics": "Host metrics", + "monitoring.runtime_summary_setting": "Runtime summary", + "monitoring.workspace_attribution": "Workspace and session attribution", + "monitoring.subprocess_drilldown": "Subprocess drill-down", + "monitoring.mobile_section": "Monitoring section", + "monitoring.mobile_overview": "Overview", + "monitoring.mobile_attribution": "Attribution", + "monitoring.mobile_process": "Process" +} +``` + +Add matching Chinese translations to `packages/web/src/locales/zh.json`. + +Update `packages/web/src/styles/components.css` with monitoring styles that only reference theme tokens already used elsewhere: + +```css +.monitoring-page { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.monitoring-content { + display: flex; + flex-direction: column; + gap: var(--sp-5); + padding: var(--sp-5); +} + +.monitoring-toolbar, +.monitoring-overview-grid, +.monitoring-attribution { + display: grid; + gap: var(--sp-4); +} + +.monitoring-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.monitoring-attribution { + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); +} + +.monitoring-card, +.settings-card--monitoring, +.monitoring-tree, +.monitoring-detail, +.monitoring-process-list { + border: 1px solid var(--border-subtle); + background: var(--surface-elevated); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); +} + +.monitoring-card { + padding: var(--sp-4); +} + +.monitoring-entity-row { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border: 0; + border-bottom: 1px solid var(--border-subtle); + background: transparent; + color: inherit; + text-align: left; +} + +.monitoring-entity-row--child { + padding-left: var(--sp-7); +} + +.monitoring-sparkline { + color: var(--accent-11); +} + +.monitoring-page--mobile .monitoring-content, +.settings-page--mobile .monitoring-settings-grid { + gap: var(--sp-3); +} + +@media (max-width: 900px) { + .monitoring-overview-grid, + .monitoring-attribution { + grid-template-columns: 1fr; + } +} +``` + +- [ ] **Step 4: Run the settings, palette, and theme tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx \ + src/features/command-palette/components/command-palette.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the settings and presentation layer** + +```bash +git add packages/web/src/features/settings/components/monitoring-settings-card.tsx \ + packages/web/src/features/settings/components/settings-page.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/features/command-palette/components/command-palette.tsx \ + packages/web/src/features/command-palette/components/command-palette.test.tsx \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "feat(web): add monitoring settings and controls" +``` + +### Task 9: Run Cross-Layer Monitoring Verification And Finish The Feature + +**Files:** +- Modify: `packages/web/src/features/monitoring/page.test.tsx` +- Modify: `packages/server/src/__tests__/monitoring/service.test.ts` +- Modify: `packages/server/src/__tests__/monitoring/aggregation.test.ts` + +- [ ] **Step 1: Add the final gap-closing tests before the broad verification run** + +Extend `packages/web/src/features/monitoring/page.test.tsx` with two more cases: + +```ts + it("keeps the page usable when process collection is degraded", async () => { + const response = { + settings: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: true, + sampleIntervalMs: 2000, + }, + snapshot: { + sampledAt: 10, + mode: "deep", + host: { + cpuPercent: 95, + memoryUsedBytes: 900, + memoryTotalBytes: 1000, + memoryAvailableBytes: 100, + loadAverage: [2, 2, 2], + uptimeSec: 60, + pressure: "hot", + }, + runtime: null, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [], + }, + history: { + host: { points: [{ sampledAt: 10, cpuPercent: 95, memoryBytes: 900 }] }, + runtime: null, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: true, + processMetricsAvailable: false, + subprocessHistoryLimited: false, + }, + telemetry: { + durationMs: 50, + processRowCount: 0, + subprocessGroupCount: 0, + historyTrimmed: false, + degraded: true, + failureReason: "ps failed", + }, + }; + + renderMonitoringPage(response); + + expect(await screen.findByText("Host overview")).toBeInTheDocument(); + expect(screen.getByText("Process metrics unavailable")).toBeInTheDocument(); + }); +``` + +Extend `packages/server/src/__tests__/monitoring/service.test.ts`: + +```ts + it("clears history when monitoring is turned off", async () => { + let enabled = true; + const service = new MonitoringService({ + broadcaster: { broadcast: vi.fn() }, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": enabled, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": true, + "monitoring.sampleIntervalMs": 2000, + } as Record; + return settings[key]; + }, + }, + registry: new ManagedProcessRegistry({ now: () => 10 }), + sessionMgr: { getAll: () => [], findSessionIdByTerminal: () => undefined }, + terminalMgr: { getAll: () => [] }, + hostCollector: { + collect: () => ({ + cpuPercent: 30, + memoryUsedBytes: 300, + memoryTotalBytes: 1000, + memoryAvailableBytes: 700, + loadAverage: [0.3, 0.2, 0.1], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { collect: async () => [] }, + setInterval: vi.fn(() => ({ unref: vi.fn() })), + clearInterval: vi.fn(), + now: () => 10, + }); + + service.start(); + await service.recheck(); + enabled = false; + service.reloadFromSettings(); + + expect(service.getResponse().history.host.points).toEqual([]); + expect(service.getResponse().snapshot.mode).toBe("disabled"); + }); +``` + +- [ ] **Step 2: Run the focused web and server monitoring suites** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/monitoring/managed-process-registry.test.ts \ + src/__tests__/monitoring/host-collector.test.ts \ + src/__tests__/monitoring/process-table.test.ts \ + src/__tests__/monitoring/aggregation.test.ts \ + src/__tests__/monitoring/service.test.ts \ + src/__tests__/monitoring/commands.test.ts \ + src/__tests__/server-monitoring-hydration.test.ts \ + src/commands/settings.test.ts +``` + +Expected: PASS. + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/monitoring/page.test.tsx \ + src/features/settings/components/settings-page.test.tsx \ + src/features/command-palette/components/command-palette.test.tsx \ + src/shells/desktop-shell.test.tsx \ + src/shells/mobile-shell/index.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run the shared-domain and typecheck verification** + +Run: + +```bash +pnpm --filter @coder-studio/core exec vitest run src/domain/monitoring.test.ts +``` + +Expected: PASS. + +Run: + +```bash +pnpm ci:typecheck +``` + +Expected: PASS across `@coder-studio/core`, `@coder-studio/server`, and `@coder-studio/web`. + +- [ ] **Step 4: Review the full monitoring diff and commit the final gap-fixes** + +Run: + +```bash +git status --short +git log --oneline --decorate --max-count=12 +``` + +Expected: `git status --short` is clean and the recent commit log only contains the monitoring feature commits from Tasks 1-8 plus this task's final gap-fix commit if one was needed. + +If the final test additions in this task changed files, commit them: + +```bash +git add packages/web/src/features/monitoring/page.test.tsx \ + packages/server/src/__tests__/monitoring/service.test.ts \ + packages/server/src/__tests__/monitoring/aggregation.test.ts +git commit -m "test: close monitoring verification gaps" +``` + +- [ ] **Step 5: Prepare review handoff** + +Record the verification commands and their pass/fail status in the task log that the final code-review subagent will use: + +```text +core monitoring test: PASS +server monitoring suite: PASS +web monitoring suite: PASS +typecheck: PASS +``` + +Use that log, plus the final commit range for the feature branch, when dispatching the full-feature review subagent after implementation. diff --git a/docs/superpowers/specs/2026-05-23-agent-pane-keepalive-design.md b/docs/superpowers/specs/2026-05-23-agent-pane-keepalive-design.md new file mode 100644 index 00000000..8ede383b --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-agent-pane-keepalive-design.md @@ -0,0 +1,468 @@ +# Agent Pane Keepalive Design + +> **Date:** 2026-05-23 +> **Status:** Draft +> **Scope:** `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`, `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts`, `packages/web/src/features/agent-panes/*`, `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`, related desktop workspace CSS + +## 1. Goal + +Stop desktop editor switches from destroying agent terminal runtime state. + +The target outcome is: + +- switching the desktop main area from agent view to editor view does not unmount `AgentPanes` +- switching back to the agent view reuses the original `xterm` instances instead of rebuilding them +- same-page editor switches do not trigger `terminal.replay` or `terminal.snapshot` +- terminal output, scrollback, cursor state, and websocket subscriptions remain continuous while the editor is in front +- existing server recovery remains the fallback for page refresh, reconnect, sequence gaps, and real cold starts + +## 2. Current Problem + +Today the desktop workspace main stage renders either the editor or the agent panes. + +That conditional rendering couples view mode to terminal runtime lifetime: + +- when `mainAreaMode === "editor"`, the desktop view unmounts `AgentPanes` +- unmounting `AgentPanes` unmounts `SessionCard` and `XtermHost` +- `XtermHost` cleanup disposes the `xterm` instance, removes terminal subscriptions, and drops component-local runtime state + +This causes two user-visible problems: + +- a simple editor switch behaves like a terminal cold start when the user comes back +- if the session or terminal changes state while the editor is in front, returning to the agent view can surface replay or closed-session UI that exists only because the terminal had to be rebuilt + +## 3. Root Cause + +The root cause is not terminal recovery itself. The root cause is that desktop view switching currently destroys the terminal host. + +The architecture treats: + +- "this layer is not visible right now" + +as if it were: + +- "this runtime is no longer needed" + +That is the wrong boundary for long-lived interactive terminals. + +View visibility and terminal runtime lifetime must be separated. + +## 4. Decision Summary + +Adopt a desktop-only keepalive model for agent panes. + +### 4.1 Main Decision + +- keep `AgentPanes` mounted at all times inside the desktop workspace main stage +- render the editor as a frontmost overlay layer when `mainAreaMode === "editor"` +- treat the agent layer as `covered`, not removed, while the editor is visible + +### 4.2 Terminal Decision + +- preserve existing `xterm` instances during desktop view switches +- add an `isVisible` signal to terminal hosts so they can downgrade interaction while covered +- do not introduce a new frontend terminal screen model or terminal snapshot cache in the first phase + +### 4.3 Recovery Decision + +- keep existing `terminal.replay` and `terminal.snapshot` flows for true recovery scenarios +- explicitly remove desktop editor switches from the set of events that imply terminal recovery + +This is the recommended design because it fixes the actual regression boundary with the smallest change set. It avoids introducing a second terminal state model while preserving existing server recovery guarantees. + +## 5. In Scope + +This design includes: + +- desktop workspace main-stage restructuring so `AgentPanes` remain mounted +- a covered/foreground visibility model for the desktop agent layer +- `XtermHost` visibility-aware interaction changes +- hydration priority changes for covered terminals +- overlay gating so covered terminals do not surface interactive dialogs behind the editor +- tests that prove editor switching no longer causes terminal remount or replay + +## 6. Out of Scope + +This design does not include: + +- mobile workspace behavior changes +- editor keepalive behavior +- persistent frontend terminal snapshots across page reloads +- a new standalone terminal runtime manager +- a custom frontend terminal screen model +- removing or redesigning server-side replay and snapshot recovery +- optimizing hidden terminal DOM cost beyond basic interaction suppression + +## 7. High-Level Architecture + +### 7.1 Desktop Main Stage + +`workspace-main-stage` becomes a layered stage rather than an either-or content slot. + +The stage contains: + +- an always-mounted agent layer +- an editor layer that mounts only when the editor is the active foreground surface + +The editor layer visually covers the agent layer without changing the agent layer's layout box. + +### 7.2 Agent Layer + +The agent layer owns: + +- `AgentPanes` +- `SessionCard` +- `XtermHost` +- the live `xterm` runtime and its subscriptions + +The layer remains mounted even when the editor is in front. + +### 7.3 Editor Layer + +The editor layer remains view-owned rather than runtime-owned in phase 1. + +It may still mount and unmount with `mainAreaMode`, because the regression being fixed is terminal destruction, not editor lifecycle. + +### 7.4 CSS Strategy + +The keepalive model depends on keeping the agent layer measurable. + +The first phase must not hide the agent layer with `display: none`. + +Instead: + +- the stage becomes a relative positioning container +- the editor layer uses absolute positioning with `inset: 0` +- the agent layer continues to occupy the full stage dimensions underneath + +This keeps terminal sizing stable and avoids zero-sized parent measurements. + +## 8. State Model + +### 8.1 Foreground Mode + +The existing `mainAreaMode` stays as: + +- `"agent"` +- `"editor"` + +Its meaning changes slightly: + +- it indicates which layer is in the foreground +- it does not determine whether the agent layer exists + +### 8.2 Derived Visibility + +Desktop agent visibility is derived, not independently stored: + +- `agentLayerCovered = mainAreaMode === "editor"` +- `terminalIsVisible = mainAreaMode === "agent"` + +The design does not require a new global atom for covered state in phase 1. + +### 8.3 Covered State Semantics + +When the editor is in front, the agent layer enters `covered` state. + +Covered terminals: + +- remain mounted +- continue receiving output +- continue updating scrollback and internal buffer state +- continue receiving terminal exit and session state changes + +Covered terminals must not: + +- accept stdin +- receive pointer interaction +- auto-focus +- render a blinking cursor +- promote themselves to focused hydration priority +- surface interactive replay or closed-session dialogs as frontmost UI + +### 8.4 Focus Rules + +When switching from agent view to editor view: + +- focus moves to the editor layer +- covered terminals immediately disable input + +When switching back to agent view: + +- terminals become interactive again +- the active terminal may refit +- phase 1 does not automatically return focus to the terminal + +That conservative focus rule avoids hidden-terminal keyboard capture and avoids introducing a new focus memory feature into the first release. + +## 9. Component Responsibilities + +### 9.1 `WorkspaceDesktopView` + +`WorkspaceDesktopView` becomes the composition point for keepalive. + +Responsibilities: + +- always render the agent layer +- mount the editor layer only when needed +- apply visibility and interaction classes to the agent layer +- mark the covered agent layer as hidden from accessibility and pointer interaction + +### 9.2 `useWorkspaceScreenModel` + +The screen model continues to compute `mainAreaMode`. + +Its responsibility remains product intent: + +- determine whether agent or editor should be in front + +It no longer implies that the other layer should be removed. + +### 9.3 `AgentPanes` and `SessionCard` + +The agent panes stack remains structurally the same. + +Phase 1 changes are limited to threading a visibility signal downward so terminal hosts can distinguish: + +- active and visible +- active but covered + +### 9.4 `XtermHost` + +`XtermHost` remains the owner of the live `xterm` instance. + +Phase 1 changes: + +- accept an `isVisible` input +- gate interaction behavior on `isVisible` +- keep mount lifetime independent from desktop main-area view changes + +### 9.5 CSS Layer Classes + +Desktop workspace CSS gains explicit layer classes for: + +- stage container +- agent layer +- covered agent layer +- editor overlay layer + +## 10. `XtermHost` Behavior Changes + +### 10.1 New Visibility Input + +`XtermHost` receives `isVisible: boolean`. + +This flag does not affect: + +- terminal identity +- terminal keys +- mount lifetime +- replay strategy + +It only affects foreground interaction behavior. + +### 10.2 Effective Interactivity + +Current interactivity is driven by terminal liveness and read-only state. + +Phase 1 adds visibility to that contract: + +- visible terminal: existing interactivity rules still apply +- covered terminal: input is always disabled even if the session is otherwise interactive + +Practical consequences: + +- `disableStdin = true` while covered +- `cursorBlink = false` while covered + +### 10.3 Hydration Priority + +Covered terminals must not compete with visible terminals for visible hydration tiers. + +Phase 1 rule: + +- covered terminals request or promote to `background` +- visible terminals continue using existing `focused`, `visible-active`, and `visible-other` tiers + +This preserves recovery and hydration fairness when the editor is in front. + +### 10.4 Fit on Return + +When `isVisible` transitions from `false` to `true`, the terminal schedules a refit. + +The refit is a display correction, not a recovery action. + +It exists to avoid stale row or column calculations after overlay transitions. + +### 10.5 Overlay Gating + +Interactive replay or closed-session overlays may still become internally relevant while a terminal is covered, but they must not become the foreground interactive surface. + +Phase 1 rule: + +- covered terminals may track degraded or closed state +- interactive overlay actions are only enabled when the terminal is visible + +## 11. Recovery Semantics + +### 11.1 Recovery Flows That Remain + +The following scenarios continue to use existing recovery logic: + +- real initial mount +- page refresh +- websocket reconnect +- terminal sequence gaps +- replay too old fallback +- snapshot rebuild fallback +- truly closed or unavailable terminals + +### 11.2 Recovery Flows Removed From View Switching + +The following transitions must no longer imply historical recovery: + +- desktop `agent -> editor` +- desktop `editor -> agent` + +After keepalive, those transitions are visibility updates only. + +### 11.3 Consequence + +Returning from the editor to the agent view must not: + +- create a new `xterm` instance +- request replay because of the view switch itself +- request snapshot because of the view switch itself +- surface a replay loading overlay because of the view switch itself + +If replay or snapshot occurs after this design lands, it must be explainable by a real recovery trigger rather than a foreground change. + +## 12. Product Behavior + +### 12.1 Switching to Editor + +When the user opens a file or diff preview: + +- the editor layer appears on top of the stage +- the agent layer remains mounted underneath +- terminal output continues to accumulate +- hidden terminals become non-interactive + +### 12.2 Switching Back to Agent + +When the editor closes and the user returns to the agent view: + +- the editor layer unmounts +- the agent layer becomes visible again +- the existing terminal instance is still present +- the terminal may refit +- the terminal does not cold-start + +### 12.3 Terminal Ends While Covered + +If a session or terminal ends while the editor is in front: + +- state updates continue flowing into the store +- the covered terminal may become ended or closed internally +- no hidden interactive dialog should take over the foreground + +When the user returns to the agent layer, the resulting ended or closed state is shown as a normal foreground pane state. + +## 13. Validation Strategy + +The primary validation target is lifecycle, not recovery correctness. + +### 13.1 Must-Prove Behaviors + +- switching to the editor does not unmount `AgentPanes` +- switching to the editor does not unmount `XtermHost` +- switching back to agent view does not remount terminal hosts +- terminal output continues while the editor is visible +- returning to agent view does not trigger new `terminal.replay` or `terminal.snapshot` requests +- covered terminals cannot accept stdin +- returning to visibility performs a refit without a recovery overlay + +### 13.2 Suggested Test Coverage + +- component or integration coverage for desktop stage composition +- `XtermHost` visibility tests for input gating and fit-on-return behavior +- e2e coverage that runs a live session, switches to the editor, waits for more output, then returns to verify continuity without replay UI + +## 14. Risks and Mitigations + +### 14.1 Hidden-Terminal DOM Cost + +Risk: + +- covered terminals still render live output, which may cost CPU and memory + +Mitigation: + +- accept this cost in phase 1 as the price of preserving terminal continuity +- evaluate runtime/view separation only if measured cost becomes material + +### 14.2 Focus Pollution + +Risk: + +- hidden terminals may continue to capture focus or keyboard input + +Mitigation: + +- disable stdin while covered +- suppress auto-focus while covered +- route foreground focus to the editor layer on mode switch + +### 14.3 Overlay Leakage + +Risk: + +- interactive terminal overlays may exist behind the editor and still affect accessibility or focus + +Mitigation: + +- gate interactive overlay mode on terminal visibility +- mark the covered agent layer inaccessible to pointer and accessibility traversal + +### 14.4 Hydration Priority Waste + +Risk: + +- covered terminals may still consume visible hydration priority + +Mitigation: + +- downgrade covered terminals to `background` hydration tier + +## 15. Phasing + +### 15.1 Phase 1 + +Implement desktop keepalive only: + +- always-mounted agent layer +- editor overlay +- `XtermHost.isVisible` +- input and overlay gating +- fit on visibility return + +### 15.2 Later Work + +If needed after measurement: + +- keepalive for editor +- terminal runtime/view separation +- persistent terminal snapshots across reloads +- hidden-terminal rendering optimizations + +## 16. Why This Design + +This design directly addresses the real failure boundary: + +- the terminal should not die just because another desktop surface is temporarily in front of it + +It keeps server recovery where it belongs: + +- as fallback for true continuity problems + +It also avoids the complexity of introducing a second frontend terminal state system before the product has exhausted the simpler option of preserving the original runtime. diff --git a/docs/superpowers/specs/2026-05-23-mobile-workspace-three-view-alignment-design.md b/docs/superpowers/specs/2026-05-23-mobile-workspace-three-view-alignment-design.md new file mode 100644 index 00000000..5f79b6a0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-mobile-workspace-three-view-alignment-design.md @@ -0,0 +1,432 @@ +# 移动端资源管理面板三视图对齐 PC 设计文档 + +> Status: Draft +> Date: 2026-05-23 +> Scope: `packages/web/src/features/workspace/views/mobile/*`, `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx`, `packages/web/src/features/workspace/views/shared/search-panel.tsx`, related styles and tests + +## Goal + +把移动端资源管理面板从当前的 `Files / Git` 双视图,升级为与 PC 信息架构对齐的三视图: + +- `资源管理器` +- `搜索` +- `Git` + +同时保留当前移动端已经在用的 segmented tab 切换手感,不引入桌面 Activity Bar。 + +本轮还需要补齐两个移动端缺口: + +- `资源管理器` 内补上 `打开的编辑器` +- 把原文件树里的文件名搜索明确改名为 `快速跳转` + +## User-Confirmed Requirements + +本轮已和用户确认以下约束: + +- 移动端采用三视图,而不是继续停留在 `Files / Git` 双视图 +- 顶部切换仍保留当前 tab 效果,不改成交互完全不同的新控件 +- `搜索` 语义与 PC 一致,负责文件内容搜索 +- `快速跳转` 只负责按文件名或路径打开文件 +- 用户口中的“最近打开”本轮按 `打开的编辑器` 处理,不新增独立 recent-history 数据模型 +- 顶部 tab 不再使用纯文本标题,改为与 PC 一致的 icon 语义 + +## Non-Goals + +本轮不包含: + +- 把移动端改造成桌面那种 Activity Bar + Sidebar 双列布局 +- 给移动端引入桌面 `Ctrl/Cmd+P` 式全局 overlay +- 引入真正的最近访问文件时序记录 +- 改动文件详情页、diff 详情页、终端页的导航结构 +- 修改 Git 核心业务逻辑 + +## Problem + +当前移动端资源管理面板与 PC 存在三类不一致: + +1. 信息架构不一致 + - PC 已经拆成 `Explorer / Search / Source Control` + - Mobile 仍然只有 `Files / Git` + +2. 资源管理器职责混杂 + - 当前文件树自带的搜索其实是文件名/路径跳转 + - 这个能力在语义上更接近 Quick Open,而不是 Search + +3. 关键导航块缺失 + - 移动端没有 `打开的编辑器` + - 移动端没有独立的内容搜索视图 + +结果就是移动端用户只能在一个过载的文件页里完成多种不同任务,且术语已经和 PC 脱节。 + +## Approaches Considered + +### Approach A: 继续保留 `Files / Git`,只在 `Files` 里补几个区块 + +优点: + +- 改动最小 +- 视觉风险最低 + +缺点: + +- 搜索职责仍然不清晰 +- 无法真正与 PC 的 `Explorer / Search / Source Control` 模型对齐 +- 未来继续演进时还会把 `Files` 变成杂糅容器 + +### Approach B: 移动端改成三视图,但保留现有 segmented tab 交互 + +优点: + +- 与用户要求最一致 +- 与 PC 的 mental model 对齐 +- 不需要把移动端硬套成桌面双列布局 + +缺点: + +- 需要改动 mobile sheet 的状态模型和测试 +- 需要把原文件树内的搜索逻辑拆出来重新归位 + +### Approach C: 把 `搜索` 和 `快速跳转` 都做成 overlay,tab 里只保留文件树和 Git + +优点: + +- 视觉最轻 +- 接近桌面 Quick Open 的单独入口思路 + +缺点: + +- 移动端 discoverability 更差 +- 不符合用户这轮明确要补齐“面板内容”的预期 +- 交互路径会变长 + +## Decision + +采用 **Approach B**。 + +具体定义: + +- 顶部 segmented tab 从两项改成三项 +- tab 仍保留当前移动端激活态和切换体验 +- tab 内容改为 `资源管理器 / 搜索 / Git` +- tab 展示改为 icon-only,图标与 PC `WorkspaceActivityBar` 保持同语义 + +## Information Architecture + +### Top-Level Tabs + +移动端资源管理面板顶层改为: + +- `资源管理器` +- `搜索` +- `Git` + +显示方式: + +- 使用和当前移动端一致的 segmented tab 容器 +- 每个 tab 中心显示 icon,而不是文字 +- 仍保留无障碍名称,`aria-label` 使用对应文案 +- 激活态继续保留当前 underline/active treatment,不引入新的视觉模式 + +图标映射与 PC 对齐: + +- `资源管理器` -> `FolderTree` +- `搜索` -> `Search` +- `Git` -> `GitBranch` + +### Detail Route + +移动端文件详情态保持不变: + +- 当 route 是 `detail` 时,继续直接进入编辑器 / diff 内容面 +- 顶层三视图只作用于 root 态资源管理面板 + +## View Design + +## 1. 资源管理器 + +`资源管理器` 是移动端对齐 PC Explorer 的主入口。 + +### Structure + +自上而下包含三个区块: + +1. `打开的编辑器` +2. `快速跳转` +3. `工作区` + +### 1.1 打开的编辑器 + +用途: + +- 展示当前工作区已打开文件 +- 作为移动端“最近打开”误称的实际落点 + +行为: + +- 点击条目直接打开对应文件 +- 当前激活文件高亮 +- 首版不加关闭按钮 +- 首版不加额外拖拽和排序能力 + +数据策略: + +- 复用现有 `openFilesAtomFamily(workspaceId)` 数据 +- 不新增 recent 文件历史存储 +- 语义与 PC 当前 `Open Editors` 一致 + +### 1.2 快速跳转 + +这是对原 `FileTreePanel` 文件搜索框的重新归类。 + +语义: + +- 只负责按文件名或路径进行跳转 +- 不负责内容搜索 + +文案调整: + +- 区块标题改为 `快速跳转` +- 输入 placeholder 改为 `输入文件名或路径` +- 原先 `搜索文件` 的误导性文案不再保留 + +交互: + +- 输入后调用现有 `file.search` +- 结果列表展示匹配文件 +- 点击结果直接打开文件 +- 结果属于独立区块,不再占据整个文件树主体 + +这意味着: + +- 文件树区域本身不再承载“我正在搜索文件”的语义 +- `快速跳转` 和 `工作区树` 变成两个平级但职责不同的块 + +### 1.3 工作区 + +继续承载文件树浏览能力: + +- 展开/收起目录 +- lazy load 子目录 +- 新建/重命名/删除/上下文菜单 +- 移动端长按菜单行为保持现状 + +调整点: + +- 文件树自身不再显示原来的搜索输入 +- `FileTreePanel` 在移动端 Explorer 模式下以 `showSearch={false}` 运行 + +## 2. 搜索 + +`搜索` 独立成第二个 tab,语义与 PC Search 对齐。 + +### Responsibility + +- 搜索当前工作区内的文件内容 +- 不承担文件名跳转 + +### Behavior + +- 复用现有 `file.searchContent` +- 输入框 placeholder 与 PC 一致,表达“搜索文件内容” +- 按文件分组展示结果 +- 每个匹配项展示行号与 snippet +- 点击匹配项打开文件并跳到对应位置 +- 切换文件后仍保留搜索面板上下文 + +### Mobile Presentation + +移动端应复用现有 `SearchPanel` 的结果语义,但收敛为适合 sheet 的形态: + +- 不重复渲染桌面侧栏式 panel header +- 使用更贴近移动 sheet 的内边距和滚动容器 +- 保持紧凑、文本优先、无大卡片感 + +## 3. Git + +`Git` 作为第三个 tab 保留现有 Git 功能。 + +范围: + +- 继续复用现有 `GitPanel` +- 不新增 Git 业务功能 +- 仅调整它在移动端 root 态中的位置和切换入口 + +顶层变化仅是: + +- 从原先的 `Files / Git` 双 tab,变成三 tab 的第三项 +- 图标语义与 PC 对齐 + +## Header Actions + +顶部右侧操作区只在 `资源管理器` tab 显示: + +- 新建文件 +- 新建文件夹 +- 折叠全部 + +在 `搜索` 和 `Git` tab 下: + +- 不显示这组三个文件操作 +- 避免把文件树动作错误地延续到非 Explorer 语义里 + +## Visual Direction + +本轮沿用已确认的移动端扁平化方向,并增加三视图约束: + +- 顶部仍然是 segmented tab,不改模式 +- tab 内由文本切换为 icon-only +- 图标对齐 PC,但外层视觉仍是移动端当前的 flat segmented 样式 +- Explorer / Search / Git 三个视图共享同一块 content panel 语言 + +不采用桌面 Activity Bar 的原因: + +- 移动端横向空间有限 +- 当前 sheet 结构更适合顶部切换 +- 用户明确要求保留当前 tab 效果 + +## Component Boundaries + +推荐按以下边界落实现有代码: + +### `mobile-files-sheet.tsx` + +负责: + +- 顶层三 tab 的切换 +- detail/root 两种 route 分流 +- header actions 的按 tab 条件渲染 + +建议: + +- `activeTab` 从 `"files" | "git"` 升级为三态 view +- 名称建议对齐桌面:`"explorer" | "search" | "source-control"` + +### 新增移动端 Explorer 内容组件 + +建议新增一个专门的移动端 Explorer 容器,例如: + +- `mobile-explorer-panel.tsx` + +负责: + +- 组合 `打开的编辑器` +- 组合 `快速跳转` +- 渲染 `FileTreePanel(showSearch={false})` + +### 打开的编辑器抽取 + +桌面 `ExplorerPanel` 和移动端 Explorer 都需要相同的 `Open Editors` 列表语义。 + +建议抽出共享展示组件,例如: + +- `open-editors-section.tsx` + +这样可以: + +- 避免桌面和移动端重复维护列表渲染逻辑 +- 保持标题、激活态和点击行为一致 + +### 快速跳转抽取 + +原 `FileTreePanel` 内的文件名搜索逻辑建议抽成独立块,而不是继续留在树组件内部。 + +建议: + +- 抽出共享 hook 或小组件来承接 `file.search` +- 移动端 Explorer 内以内联 section 形式使用 + +这样做的好处是: + +- 语义上不再把“跳转”伪装成“树搜索” +- 未来如果桌面 Quick Open 和移动端快速跳转要共用查询逻辑,也更容易收敛 + +### `search-panel.tsx` + +建议支持移动端复用,例如: + +- `variant?: "desktop" | "mobile"` +- 或 `showHeader?: boolean` + +目标是: + +- 保留结果语义和状态处理 +- 去掉桌面侧栏特有的 header chrome + +## State Model + +移动端当前 `mobileFilesTab` 需要升级为三态。 + +推荐: + +- `mobileWorkspaceView = "explorer" | "search" | "source-control"` + +状态约束: + +- root 态保留当前 tab view +- detail 态不改变当前 root view,只是临时进入详情 +- 关闭详情返回 root 时,回到之前所在 tab + +## Testing Impact + +至少需要覆盖以下测试面: + +1. `MobileFilesSheet` + - 三个 tab 是否存在 + - tab 是否改为 icon 驱动且保留无障碍名称 + - 仅 Explorer tab 显示文件操作按钮 + - 切到 Search / Git 时不显示 Explorer actions + +2. 移动端 Explorer + - 渲染 `打开的编辑器` + - 渲染 `快速跳转` + - 文件树在该模式下不再自带旧搜索框 + +3. 移动端 Search + - 搜索输入调用 `file.searchContent` + - 结果分组与跳转行为延续 PC 语义 + +4. 共享组件回归 + - Desktop Explorer 不被这次抽取破坏 + - `FileTreePanel(showSearch={false})` 的无搜索模式继续工作 + +5. 样式测试 + - 新 tab icon 样式 + - Explorer/Search/Git 内容容器一致性 + - 移动端 segmented tab 激活态未回退 + +## Risks + +### 风险 1:`快速跳转` 与 `工作区树` 关系不清 + +应对: + +- 在结构上把它明确成独立 section +- 用标题和 placeholder 强化“跳转”语义 + +### 风险 2:移动端 SearchPanel 直接复用桌面样式会显得过重 + +应对: + +- 复用逻辑,不强行复用完整外壳 +- 通过 `variant` 或 `showHeader` 降低桌面壳层感 + +### 风险 3:顶部 icon-only tab 可理解性下降 + +应对: + +- 使用与 PC 一致的三枚图标,降低学习成本 +- 保留 tooltip 或 aria-label +- 激活态保持明确,不只靠颜色变化 + +## Summary + +本轮推荐方案是: + +- 移动端资源管理面板采用 `资源管理器 / 搜索 / Git` 三视图 +- 顶部切换继续保留现有 segmented tab 效果 +- tab 从文本切换为与 PC 对齐的 icon-only 语义 +- `资源管理器` 内补齐 `打开的编辑器` 和 `快速跳转` +- `搜索` 独立成内容搜索视图 +- `Git` 维持现有能力,仅作为第三个顶层视图归位 + +这是当前最稳妥、也最符合用户目标的移动端对齐方案。 diff --git a/docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md b/docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md new file mode 100644 index 00000000..5dfce0cd --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md @@ -0,0 +1,357 @@ +# Workspace Search And Quick Open Visual Refresh Design + +> Status: Draft +> Date: 2026-05-23 +> Scope: `packages/web/src/features/workspace/views/shared/search-panel.tsx`, `packages/web/src/features/quick-open/components/quick-open.tsx`, corresponding `packages/web/src/styles/components.css` selectors and tests + +## Goal + +Refine the new desktop `Search` sidebar and `Quick Open` overlay so they read and behave much closer to VS Code. + +This design does not introduce new search domains. It improves the existing file-content search sidebar and file-only quick open surface so they feel like editor tooling instead of generic app panels. + +The target outcome: + +- `Search` becomes easier to scan in narrow sidebar width +- result groups become understandable at a glance +- repeated file/path noise is removed from every match row +- `Quick Open` feels like a file jump surface rather than a general search modal + +## Relationship To Existing Spec + +This document refines the UI treatment from: + +- [2026-05-23-workspace-sidebar-search-quick-open-design.md](/home/spencer/workspace/coder-studio/docs/superpowers/specs/2026-05-23-workspace-sidebar-search-quick-open-design.md) + +That earlier spec defines the feature set and high-level information architecture. + +This document only covers the visual hierarchy, result grouping behavior, and interaction details for the desktop `Search` sidebar and `Quick Open`. + +## In Scope + +- desktop `Search` sidebar visual hierarchy +- grouped search results by file with per-file expand and collapse +- default expand behavior for new search results +- compact result row treatment for content matches +- `Quick Open` file-only visual treatment +- `Quick Open` result row structure and active state +- copy and spacing updates needed to support the new hierarchy + +## Out Of Scope + +- search and replace +- regex, case sensitivity, or whole-word toggles +- command, symbol, or recent-item results inside `Quick Open` +- mobile `FileTreePanel` search changes +- command palette redesign +- backend ranking changes +- new search commands or API changes + +## Problem + +The current `Search` sidebar is functionally correct but visually weak: + +- the summary, file identity, path, and match rows compete at the same weight +- file path text runs directly into the file title instead of forming a readable group header +- every match row feels detached from its file +- the sidebar lacks the compact, inspectable rhythm users expect from editor search + +The current `Quick Open` works as a file opener but still reads more like a generic search list: + +- result rows are too flat +- file identity and path hierarchy are weak +- the overlay does not yet feel like a focused file jump tool + +## Decision Summary + +Adopt a strict VS Code-leaning presentation for both surfaces. + +### Search Sidebar + +- keep the single search input and summary line +- render results as collapsible file groups +- default all file groups to expanded after each successful query +- let users collapse or expand each file independently +- move file identity to the group header and keep match rows compact + +### Quick Open + +- keep file-only results +- render results in a two-line structure +- show file name as the primary line +- show the relative path as the secondary, de-emphasized line +- keep keyboard behavior unchanged + +This is the recommended design because it addresses the actual usability problem, not just surface styling. + +## Search Sidebar Design + +## Overall Tone + +The sidebar should feel like editor chrome: + +- compact +- low-radius +- low-shadow +- text-first +- dense enough for scanning + +Avoid card-like grouping, large empty blocks, or decorative emphasis. + +## Search Header Area + +The top of the panel keeps the existing title and single search field. + +Visual treatment: + +- the search input should be narrower in height and closer to VS Code field chrome +- corners should be restrained rather than pill-like +- focus state should read as an editor input, not a marketing form field + +Below the input, show a compact summary line: + +- empty query: instructional text +- loading: loading text +- populated query: `X results in Y files` + +If the backend truncates results, show a short secondary note directly below the summary. + +## Search Result Grouping + +Search results are rendered as file groups. + +Each group consists of: + +- a clickable group header +- a collapsible list of match rows + +### Group Header Content + +Each file header shows: + +- chevron for expand or collapse +- file name as primary text +- relative path as secondary text +- match count right-aligned + +The file path should be visually subordinate and never run into the title as unstructured text. + +### Default Expansion + +After each successful search query: + +- all returned file groups start expanded + +This matches the expected scan-first workflow and is closest to VS Code. + +### Collapse Behavior + +Users can collapse or expand a file group by clicking the group header. + +Collapse state is local to the current result set and should reset when a new successful query returns. + +This means: + +- typing a new search term restores all groups to expanded +- retrying a failed query also restores all groups to expanded on success + +Persisting collapse state across queries is out of scope because it creates confusing carry-over between different result sets. + +## Search Match Rows + +Each match row should become a compact editor-style result line. + +Row structure: + +- fixed-width line number column +- preview text column + +Behavior: + +- clicking a match opens the file at that location +- the sidebar remains open after navigation + +Visual rules: + +- line numbers are low emphasis and right-aligned +- preview text is primary +- highlighted match text uses the existing search highlight treatment, but should not overpower the row +- hover and active states are single-layer backgrounds, not cards + +Rows must not repeat file name or path, because that context already exists in the group header. + +## Search States + +All non-result states should stay compact and tool-like. + +### Idle + +Show a short prompt such as: + +- `Type to search across file contents.` + +### Loading + +Show a lightweight loading line in the results area. + +### No Results + +Show a short no-results message without large empty-state chrome. + +### Error + +Keep the retry affordance, but reduce visual bulk: + +- short error text +- compact retry button below or beside it + +## Quick Open Design + +## Behavioral Scope + +`Quick Open` remains file-only in this iteration. + +It does not include: + +- commands +- symbols +- recents +- workspace actions + +This keeps the overlay aligned with its current data model and prevents the UI refresh from turning into a mixed-result redesign. + +## Overlay Tone + +The overlay should feel like a focused file switcher: + +- restrained chrome +- compact input bar +- dense result list +- clear active-row highlight + +It should visually move closer to VS Code and away from a generic modal sheet. + +## Result Row Structure + +Each result row becomes a two-line item: + +- primary line: file name +- secondary line: relative path + +Hierarchy rules: + +- file name carries most of the contrast +- path is smaller or lower-contrast +- the active row uses a single background fill + +This keeps rows readable when many files share similar names across directories. + +## Keyboard And Pointer Behavior + +Keep the current interaction contract: + +- `Ctrl/Cmd+P` opens +- up and down arrows move active selection +- `Enter` opens the active file +- `Escape` closes +- hover updates the active row +- click opens and closes + +No command-prefix parsing is added in this pass. + +## Component And State Changes + +## Search Sidebar + +The `SearchPanel` client state should add a per-query expand map keyed by file path. + +Required behavior: + +- initialize all returned paths to expanded on successful search +- toggle individual paths from the group header +- keep match rows mounted only when the group is expanded + +The backend payload already groups matches by file, so no server contract changes are required. + +## Quick Open + +`QuickOpen` keeps its current fetch model and selection model. + +The change is limited to: + +- row markup hierarchy +- spacing +- active styling +- empty and loading presentation polish + +## Accessibility + +Search group headers should be keyboard reachable controls with clear expanded state semantics. + +Required expectations: + +- collapsed and expanded state must be conveyed to assistive tech +- hit targets should remain usable in narrow sidebar widths +- active quick-open item contrast must remain clear +- highlighted search text must not become unreadable in dark theme + +## Testing + +## Search Sidebar + +Update and extend tests to cover: + +- grouped file rendering still works +- each file group starts expanded after results load +- clicking a group header collapses that file's matches +- clicking again re-expands the matches +- a new successful query resets returned groups to expanded +- navigation from a match row still opens the correct location + +Style-oriented tests should verify: + +- group header structure selectors exist +- line number and preview columns keep the compact hierarchy +- summary and truncation note remain in the expected order + +## Quick Open + +Update tests to cover: + +- two-line result structure +- active row styling hook remains present +- keyboard navigation and enter-to-open behavior stay unchanged +- no mixed result types are introduced + +## Risks And Mitigations + +### Risk: Search Sidebar Gets Too Dense + +Mitigation: + +- keep line height readable +- use contrast hierarchy instead of shrinking everything +- keep only one secondary text line in group headers + +### Risk: Collapse State Feels Unstable + +Mitigation: + +- define reset semantics clearly: every successful query returns to fully expanded + +### Risk: Quick Open Becomes Visually Inconsistent With Existing Shared Layers + +Mitigation: + +- preserve existing overlay sizing and focus management +- limit the change to row hierarchy and chrome polish + +## Acceptance Criteria + +- desktop `Search` results render as per-file groups with headers and collapsible match lists +- all file groups are expanded by default after each successful search +- file name, path, and match count are clearly separated in the file header +- match rows no longer repeat the file identity +- `Quick Open` remains file-only +- `Quick Open` rows display a primary file name line and a secondary path line +- both surfaces feel visually closer to VS Code than the current implementation diff --git a/docs/superpowers/specs/2026-05-24-monitoring-design.md b/docs/superpowers/specs/2026-05-24-monitoring-design.md new file mode 100644 index 00000000..c1692fe0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-monitoring-design.md @@ -0,0 +1,830 @@ +# Performance Monitoring — Design + +Date: 2026-05-24 +Status: Draft +Owner: spencer + +## Problem + +Coder Studio 现在有独立的诊断页,但没有正式的运行时性能监控能力。 + +这带来两个直接缺口: + +- 用户无法快速判断“当前这台机器是不是已经接近吃满” +- 用户无法进一步归因“到底是哪个 workspace、哪个 agent 会话、还是哪个子进程在吃 CPU / 内存” + +对这个项目来说,这不是一个纯后端指标问题,而是一个跨层问题: + +- 服务端需要知道整机状态 +- 服务端需要知道自己管理的进程树 +- 前端需要把“整机压力”和“Coder Studio 自身消耗”放在同一张页面里 +- 监控本身还必须可关、可降频、可降级,避免功能反过来制造额外负担 + +## Goals + +- 提供独立的 `/monitoring` 页面,支持桌面端和移动端完整可用。 +- 同时展示: + - 宿主机 CPU / 内存压力 + - Coder Studio 当前服务和受管进程的总体资源消耗 + - `workspace -> session/agent -> subprocess` 的资源归因链路 +- 支持实时监控和最近 `5 / 15 / 30 min` 的短时趋势。 +- 在 `Settings` 中提供监控总开关、采集层选择和刷新频率配置。 +- 在监控关闭、部分采样关闭、采样失败、深层采样过重等情况下提供明确降级状态。 +- v1 覆盖 `macOS / Linux / Windows`。 + +## Non-Goals + +- v1 不做跨天历史持久化。 +- v1 不做阈值告警中心、通知规则或自动处理动作。 +- v1 不在监控面板内直接提供 “stop session / close workspace / disable feature” 控制动作。 +- v1 不采集磁盘 I/O、网络 I/O、fd、线程数、event loop lag 等更重的指标。 +- v1 不把监控面板嵌进 workspace 主工作台视图。 + +## User Decisions Captured + +- 监控对象同时包括: + - 宿主机整体资源 + - Coder Studio 当前服务和受管进程 + - workspace / agent / 子进程归因 +- “是否吃满”的判断依据以整机 CPU 和内存为主。 +- 监控页面第一版是只读,不直接做关闭 workspace / agent 等控制动作。 +- 归因主视图按 `workspace -> session/agent -> subprocess` 组织。 +- 入口是独立的 `Monitoring` 页面,不放成工作区常驻面板。 +- 桌面端和手机端都要完整可用。 +- 指标范围第一版收敛到: + - CPU + - Memory + - Process count + - Uptime + - Load average +- 监控功能必须有设置总开关、分层启用项和刷新频率配置。 +- 默认值采用“默认关闭,进入 Monitoring 后再引导启用”。 + +## Approaches Considered + +### Option A: 页面进入时按需拉取一次快照,前端自己轮询 + +优点: + +- 服务端改动最少。 +- 初版容易做出可用页面。 + +缺点: + +- 无法稳定复用现有 WebSocket 实时模型。 +- 页面打开才开始采样,历史趋势不可靠。 +- 很难统一控制采样频率、缓存窗口和多端一致性。 +- 页面刷新、移动端切页和桌面端切换会让监控状态断裂。 + +### Option B: 服务端常驻采样器 + 设置驱动开关/频率 + 独立 Monitoring 页面(推荐) + +优点: + +- 监控数据有单一真源,符合当前 server-first 架构。 +- 能复用现有 command / topic / 页面加载模式。 +- 可在服务端统一做采样预算、历史缓存、平台适配和降级。 +- 容易同时服务桌面端和移动端。 + +缺点: + +- 需要新增监控域、采样器、进程归因、前端页面和设置项。 +- 需要补齐服务端“受管进程注册表”能力。 + +### Option C: 默认全开深度监控,尽量采一切细项 + +优点: + +- 看起来“最完整”。 + +缺点: + +- 与用户要求相冲突:监控本身会制造明显开销。 +- 在跨平台实现、进程树遍历和历史缓存上都过重。 +- 会把 v1 拖成性能排查工具而不是产品级监控页。 + +## Final Choice + +采用 Option B。 + +监控能力作为独立的 `monitoring` 领域接入现有 server / ws / settings 体系: + +- 服务端按设置决定是否启动采样器 +- 采样器维护宿主机、运行时汇总、workspace/session 聚合和可选的 subprocess 明细 +- Web 端通过 `/monitoring` 独立页面展示 +- 设置页负责总开关、采集层和刷新频率 +- 页面状态标签 `disabled / light / standard / deep` 由设置派生,不单独持久化 + +## Scope + +### Included In v1 + +- 新增 `/monitoring` 桌面端和移动端页面 +- 宿主机总览: + - CPU usage + - Memory used / total + - Available memory + - System uptime + - Load average +- Coder Studio footprint: + - server process CPU / memory + - 全部受管进程树 CPU / memory 汇总 + - managed process count + - 占整机 CPU / 内存比例 +- 归因视图: + - workspace 聚合 + - session / agent 聚合 + - 可选 subprocess 明细 +- 最近 `30 min` 短时历史缓存 +- `Settings > General` 中新增监控配置块 +- 监控关闭 / 部分启用 / 数据不可用 / 深层采样受限等状态 + +### Excluded From v1 + +- 跨服务重启的历史保留 +- 告警与通知 +- 自动阈值策略配置 +- 杀进程、停会话、关工作区等控制动作 +- 磁盘 / 网络 / fd / 线程等指标 + +## Current Product Constraints + +### Separate Routed Pages Already Exist + +当前项目已经有与工作台分离的独立页面形态,`DiagnosticsPage` 是最直接的参考: + +- [`packages/web/src/features/diagnostics/page.tsx`](../../../packages/web/src/features/diagnostics/page.tsx) +- [`packages/web/src/shells/desktop-shell.tsx`](../../../packages/web/src/shells/desktop-shell.tsx) +- [`packages/web/src/shells/mobile-shell/index.tsx`](../../../packages/web/src/shells/mobile-shell/index.tsx) + +这意味着 `Monitoring` 适合沿用“独立路由 + 独立页面状态机”的模式,而不是塞进 workspace 主视图。 + +### Settings Are Server-Backed and Flat-Key Persisted + +当前设置通过 `settings.get` / `settings.update` 走服务端仓储,支持嵌套对象输入和 dot-key 存储: + +- [`packages/server/src/commands/settings.ts`](../../../packages/server/src/commands/settings.ts) + +因此监控配置应该直接进入统一 settings 模型,而不是单独引入新的配置文件。 + +### Session / Workspace Metadata Already Exists + +项目已稳定维护 `workspace`、`session`、`terminal` 元数据: + +- [`packages/core/src/domain/types.ts`](../../../packages/core/src/domain/types.ts) +- [`packages/server/src/session/manager.ts`](../../../packages/server/src/session/manager.ts) +- [`packages/server/src/terminal/manager.ts`](../../../packages/server/src/terminal/manager.ts) + +这为“按 workspace / session 聚合资源消耗”提供了足够的业务主键。 + +### PTY Root PID Is Not Yet Exposed As a First-Class Runtime Primitive + +当前 PTY 抽象层没有把 `pid` 作为正式接口暴露给监控域: + +- [`packages/server/src/terminal/types.ts`](../../../packages/server/src/terminal/types.ts) + +虽然底层 `node-pty` 运行时有 PID,但监控设计不能依赖隐式细节。v1 必须补一层正式的“受管进程根信息”能力。 + +## Architecture + +### 1. 新增独立的 Monitoring 域 + +建议新增: + +- `packages/core/src/domain/monitoring.ts` +- `packages/core/src/protocol/topics.ts` 中新增 monitoring topic +- `packages/server/src/monitoring/*` +- `packages/server/src/commands/monitoring.ts` +- `packages/web/src/features/monitoring/*` + +职责拆分: + +- `core` + - 共享类型 + - settings schema 相关常量 / helper + - topic 常量 +- `server` + - host collector + - process table collector + - managed process registry + - aggregation + history buffers + - commands + broadcast +- `web` + - monitoring route + - desktop / mobile page rendering + - settings UI + +### 2. Managed Process Registry + +为避免监控逻辑直接耦合 `TerminalManager`、`Supervisor`、`LSP`、未来 installer 等多个运行时模块,新增一个服务端内部的 `ManagedProcessRegistry`。 + +职责: + +- 注册当前服务进程本身 +- 注册每个受管 PTY / session 的根 PID +- 注册其他由服务端拉起的长生命周期或中生命周期子进程 +- 为每个根进程附带业务归属信息 + +建议的 registry entry 结构: + +```ts +interface ManagedProcessRoot { + rootPid: number; + kind: "server" | "terminal" | "session_helper" | "lsp" | "installer" | "background"; + workspaceId?: string; + sessionId?: string; + terminalId?: string; + providerId?: string; + label: string; + startedAt: number; +} +``` + +关键规则: + +- server 进程固定注册一次,`rootPid = process.pid` +- terminal 创建时注册,结束时注销 +- session 相关非 PTY 子进程如果有明确 workspace / session 归属,也注册进来 +- 没有明确 workspace 归属的后台进程归到 `background runtime` 分组 + +这个 registry 是 monitoring 域的输入,不直接暴露给前端。 + +### 3. Sampling Service + +新增 `MonitoringService`,由 server 生命周期托管。 + +职责: + +- 根据 settings 判断是否启用 +- 根据 sample interval 启动 / 更新定时器 +- 在每一轮采样中收集 host + process 数据 +- 聚合成页面所需快照 +- 写入短时历史缓存 +- 通过 command 和 topic 暴露给前端 + +采样循环: + +1. 读取当前 monitoring settings +2. 若 `enabled = false`,停止定时器并清空内存历史 +3. 若 `enabled = true`,按 `sampleIntervalMs` 执行采样 +4. 每一轮生成单一 `snapshot` +5. snapshot 写入缓存后再广播 + +### 4. Host Metrics Collector + +host 层优先用 Node 原生能力完成: + +- CPU usage: 基于 `os.cpus()` 时间片做 delta 计算 +- Total / free memory: `os.totalmem()` / `os.freemem()` +- Available memory: 先用规范化字段,没有时回退 `freemem` +- System uptime: `os.uptime()` +- Load average: `os.loadavg()` + +平台策略: + +- `macOS / Linux`: load average 正常展示 +- `Windows`: load average 标记为 `unavailable`,不让整页失败 + +### 5. Process Table Collector + +为了跨平台支持 `CPU / RSS / PPID / elapsed time / command`,新增按平台适配的进程表采集器。 + +建议分为三个 adapter: + +- `darwin` +- `linux` +- `win32` + +产出统一结构: + +```ts +interface ProcessStatRow { + pid: number; + ppid: number; + cpuPercent: number; + rssBytes: number; + elapsedSec?: number; + command?: string; + executable?: string; +} +``` + +实现要求: + +- 采集器失败时不能让 host metrics 一起失败 +- 采集器返回部分字段缺失时,聚合逻辑仍可工作 +- Windows 不要求和 Unix 用同一命令来源,只要求输出结构统一 + +### 6. Tree Aggregation Strategy + +`runtime summary`、`workspace attribution`、`session attribution` 和 `subprocess drill-down` 不应该各自重复扫描系统进程表。 + +单轮采样流程应该是: + +1. 收集完整 process table +2. 建 `pid -> row` 和 `ppid -> children[]` 索引 +3. 以 `ManagedProcessRegistry` 中的 root PID 为起点构建受管进程树 +4. 计算: + - `server process` + - `all managed processes total` + - `workspace aggregate` + - `session aggregate` + - `subprocess groups`(仅在 deep 模式或对应采集层开启时输出) + +重要约束: + +- `workspace/session attribution` 即使不展示 leaf 明细,也仍然需要树聚合,否则 agent 根进程会低估真实消耗 +- `subprocess drill-down` 只是在已有树聚合之上多输出 leaf/group 级明细,而不是开启另一套独立采样 + +### 7. Attribution Rules + +#### 7.1 Host + +永远只代表整机状态,不混入任何 Coder Studio 逻辑。 + +#### 7.2 Coder Studio Footprint + +由以下部分组成: + +- server 主进程 +- 所有受管 terminal/session 根进程及其子树 +- 明确注册的后台运行时进程 + +#### 7.3 Workspace + +一个 workspace 的资源占用是其名下所有 session / terminal / workspace-scoped background 进程之和。 + +#### 7.4 Session / Agent + +session 占用以对应 terminal root 为主键,向下合并其整棵子树。 + +#### 7.5 Standalone Shell + +没有 session 归属的 shell terminal 仍然归属到 workspace,但单独显示为 `Standalone terminal` 分组,不伪装成 agent。 + +#### 7.6 Background Runtime + +没有 workspace 归属的后台进程显示为 `Background runtime` 分组,出现在 `Coder Studio footprint` 的细分列表里,但不进入 workspace 树。 + +### 8. History Retention + +v1 只做内存态短时历史。 + +默认规则: + +- 保留窗口:`30 min` +- 默认页面窗口:`15 min` +- 可切换窗口:`5 / 15 / 30 min` +- 历史数据随 monitoring 关闭或 server 重启而丢失 + +为控制内存体积,历史分层保留: + +- `host`:完整 `30 min` +- `runtime summary`:完整 `30 min` +- `workspace aggregate`:完整 `30 min` +- `session aggregate`:完整 `30 min` +- `subprocess groups`:完整 `30 min` 仅保留最近最热的有限集合,冷组只保留当前样本并在空闲后淘汰 + +建议 v1 对 subprocess history 设定内存上限: + +- 只为最近活跃的 top-N leaf groups 保留完整序列 +- 超出预算的 leaf group 仅保留当前值,不阻塞整体页面 + +这保证首屏和主要归因路径稳定,同时防止 deep 模式在大进程树上无限膨胀。 + +### 9. Commands and Topics + +建议新增: + +- `monitoring.get` + - 返回当前 settings 派生状态、最新完整快照、页面所需历史数据 +- `monitoring.recheck` + - 手动触发一次立即采样,不改变定时器节奏 + +建议新增 topic: + +- `monitoring.snapshot.updated` + +前端模式: + +- 进入页面时调用 `monitoring.get` +- 页面订阅 `monitoring.snapshot.updated` +- 连接恢复或页面手动刷新时重新执行 `monitoring.get` + +这与当前 diagnostics 风格一致,但数据模型是持续流,而非一次性检查结果。 + +## Settings Model + +### 1. Settings Shape + +建议把监控配置并入统一 settings: + +```ts +interface MonitoringSettings { + enabled: boolean; + hostMetricsEnabled: boolean; + runtimeSummaryEnabled: boolean; + workspaceAttributionEnabled: boolean; + subprocessDrilldownEnabled: boolean; + sampleIntervalMs: 1000 | 2000 | 5000 | 10000; +} +``` + +存储路径: + +- `monitoring.enabled` +- `monitoring.hostMetricsEnabled` +- `monitoring.runtimeSummaryEnabled` +- `monitoring.workspaceAttributionEnabled` +- `monitoring.subprocessDrilldownEnabled` +- `monitoring.sampleIntervalMs` + +### 2. Derived Mode Label + +`Light / Standard / Deep` 是 UI 派生状态,不单独写入 settings。 + +建议规则: + +- `disabled` + - `enabled = false` +- `light` + - 只开 host,或 host + runtime summary +- `standard` + - 开到 workspace attribution +- `deep` + - 开到 subprocess drill-down + +### 3. Dependency Rules + +设置项允许“有选择地启用”,但不能允许互相矛盾的组合。 + +建议依赖关系: + +- `hostMetricsEnabled` 可单独开启 +- `runtimeSummaryEnabled` 开启后才有服务占用汇总 +- `workspaceAttributionEnabled` 依赖 `runtimeSummaryEnabled` +- `subprocessDrilldownEnabled` 依赖 `workspaceAttributionEnabled` + +行为规则: + +- 开启高层级项时,自动开启其依赖项 +- 关闭低层依赖项时,自动关闭依赖它的更深层项 + +### 4. Default Values + +默认值: + +- `enabled = false` +- `hostMetricsEnabled = true` +- `runtimeSummaryEnabled = true` +- `workspaceAttributionEnabled = true` +- `subprocessDrilldownEnabled = false` +- `sampleIntervalMs = 2000` + +解释: + +- 默认总开关关闭,避免无感启动后台采样 +- 用户第一次进入 `/monitoring` 时看到引导 +- 一旦启用,推荐的起步配置是 `standard`,而不是 `deep` + +## UX and Interaction + +### 1. Route and Entry + +新增独立路由: + +- `/monitoring` + +接入: + +- desktop shell 路由 +- mobile shell 路由 +- command palette 增加 “Open Monitoring” +- settings 内提供跳转入口 + +v1 不要求新增 topbar 常驻按钮。 + +### 2. Desktop Layout + +桌面端采用“两层判断 + 一层钻取”的平衡式布局。 + +#### 2.1 First Screen + +首屏上半部分同时展示两块: + +- `Host overview` +- `Coder Studio footprint` + +它们必须同屏可见,不做二选一。 + +#### 2.2 Lower Area + +下半部分采用双栏: + +- 左栏:`Attribution tree` + - `workspace -> session/agent -> subprocess` + - 默认按 `CPU` 排序,可切到 `Memory` +- 右栏:`Detail panel` + - 当前选中实体的当前值、趋势、归属信息和热点子项 + +#### 2.3 Visible Controls + +以下控制在桌面端必须一眼可见,不藏进二级菜单: + +- `CPU / Memory` 排序切换 +- `5 / 15 / 30 min` 时间窗 +- 手动刷新 +- 当前 monitoring mode 标签 + +### 3. Mobile Layout + +移动端不照搬桌面双栏。 + +建议结构: + +- 顶部:总览卡片 + 当前 mode / last updated / refresh frequency +- 中段:分段切换 + - `Overview` + - `Attribution` + - `Process` +- 详情:点击某个 workspace / session / subprocess 后进入独立详情层 + +原则: + +- 功能完整 +- 信息密度比桌面低 +- 不在一个小屏上并排塞进归因树和详情面板 + +### 4. Disabled State + +当 `monitoring.enabled = false`: + +- 页面不假装加载中 +- 直接展示 `Monitoring disabled` 空状态 +- 说明不会进行后台采样 +- 提供 “Open settings” 跳转到设置页对应分组 + +### 5. Partial Collection State + +当某些层未开启: + +- 不把区域渲染成错误 +- 显示明确的解释性空态 + +例如: + +- 未开启 runtime summary: + - 只展示 host + - `Coder Studio footprint` 显示 “Enable runtime summary in settings” +- 未开启 subprocess drill-down: + - 归因树展示 workspace/session 聚合 + - subprocess 区显示 “Enable subprocess drill-down in settings” + +### 6. Visual State Labels + +页面顶部展示: + +- `Disabled` +- `Light` +- `Standard` +- `Deep` + +同时显示: + +- `Last updated` +- `Refresh every 2s / 5s / ...` + +## Monitoring Settings UI + +### 1. Placement + +不新增独立 settings section,先放在 `Settings > General` 里新增 `Performance monitoring` 分组。 + +理由: + +- 这是运行时行为配置,不是外观或 provider 配置 +- 能避免 settings 左侧导航继续膨胀 + +### 2. Controls + +推荐控件: + +- 主开关: + - `Enable performance monitoring` +- 预设 pills: + - `Light` + - `Standard` + - `Deep` +- 高级自定义开关: + - `Host metrics` + - `Runtime summary` + - `Workspace and session attribution` + - `Subprocess drill-down` +- 频率 pills: + - `1s` + - `2s` + - `5s` + - `10s` + +交互规则: + +- 选预设时直接写入对应组合 +- 用户手动改高级开关后,预设标签切到 `Custom` +- 关闭主开关时,其余选项保留但禁用显示,重新开启时恢复上次选择 + +### 3. Page/Header Reflection + +`Monitoring` 页面顶部只读展示当前策略,不直接在页面内修改设置。 + +这保持“监控页是读操作,设置页是配置入口”的边界清晰。 + +## Domain Model + +以下为建议共享类型轮廓。 + +### Host Summary + +```ts +interface MonitoringHostSummary { + cpuPercent: number | null; + memoryUsedBytes: number | null; + memoryTotalBytes: number | null; + memoryAvailableBytes: number | null; + loadAverage: [number, number, number] | null; + uptimeSec: number | null; + pressure: "normal" | "elevated" | "hot" | "unknown"; +} +``` + +### Runtime Summary + +```ts +interface MonitoringRuntimeSummary { + serverCpuPercent: number | null; + serverMemoryBytes: number | null; + totalManagedCpuPercent: number | null; + totalManagedMemoryBytes: number | null; + managedProcessCount: number; + cpuShareOfHostPercent: number | null; + memoryShareOfHostPercent: number | null; +} +``` + +### Attribution Entity + +```ts +interface MonitoringEntitySummary { + id: string; + kind: "workspace" | "session" | "subprocess_group" | "background_group"; + parentId?: string; + workspaceId?: string; + sessionId?: string; + label: string; + cpuPercent: number | null; + memoryBytes: number | null; + processCount: number; + uptimeSec: number | null; + trend: "rising" | "steady" | "falling" | "unknown"; + childCount?: number; +} +``` + +### Snapshot + +```ts +interface MonitoringSnapshot { + sampledAt: number; + mode: "disabled" | "light" | "standard" | "deep"; + host: MonitoringHostSummary | null; + runtime: MonitoringRuntimeSummary | null; + workspaces: MonitoringEntitySummary[]; + sessions: MonitoringEntitySummary[]; + subprocessGroups?: MonitoringEntitySummary[]; + backgroundGroups?: MonitoringEntitySummary[]; +} +``` + +### Page Response + +```ts +interface MonitoringResponse { + settings: MonitoringSettings; + snapshot: MonitoringSnapshot; + history: { + host: MonitoringSeriesBundle; + runtime: MonitoringSeriesBundle | null; + workspaces: Record; + sessions: Record; + }; + capabilities: { + loadAverageAvailable: boolean; + subprocessHistoryLimited: boolean; + }; +} +``` + +## Pressure and Status Rules + +### 1. Host Pressure + +整机压力标签只基于 host CPU 和 memory。 + +- `normal` +- `elevated` +- `hot` +- `unknown` + +`load average` 只做辅助显示,不作为“是否吃满”的主判断。 + +阈值先做成服务端常量,不进 v1 设置面板。 + +### 2. Monitoring Mode + +页面 mode 和 host pressure 是两个独立维度: + +- mode 说明“当前监控采了多深” +- pressure 说明“机器当前压力有多高” + +不能混成一个状态。 + +## Degradation and Failure Handling + +### 1. Missing Host Fields + +- host 某单项缺失时,只让该字段显示 `unavailable` +- 不让整页失败 + +### 2. Process Collection Failure + +如果进程表采集失败: + +- host 区仍然显示 +- runtime / attribution 区显示降级说明 +- 页面 mode 不自动变为 disabled,因为配置仍然是启用状态 + +### 3. Deep Sampling Too Costly + +如果 subprocess deep collection 在大工作区上开销过大: + +- 首先保 host 和 runtime summary 可用 +- 然后保 workspace / session 聚合 +- 最后才牺牲 subprocess leaf 完整度 + +也就是说,降级顺序必须是: + +1. subprocess detail +2. session/workspace detail richness +3. runtime summary +4. host metrics + +### 4. Sampling Telemetry + +监控模块自身要记录: + +- 本轮采样耗时 +- process table 行数 +- leaf group 数量 +- 是否发生历史裁剪 / 降级 + +这些值先不对用户大面积展示,但至少要能在日志和页面提示中使用。 + +## Testing Strategy + +### Core + +- monitoring settings 常量和类型 +- interval schema / helper +- topic 常量 + +### Server + +- monitoring settings 开关和依赖规则 +- service 启停和频率切换即时生效 +- host summary 聚合 +- process table 聚合 +- workspace / session / subprocess 归因 +- monitoring 关闭时不采样、不广播 +- 平台字段缺失和采样失败降级 +- subprocess history 裁剪不影响上层 summary + +### Web + +- `/monitoring` 桌面端渲染 +- `/monitoring` 移动端渲染 +- disabled / light / standard / deep 各状态 +- host-only / partial attribution / deep leaf 三类空态 +- 时间窗与排序切换 +- settings 中监控块的交互和依赖规则 + +### Integration + +- 启动多个 workspace / agent / shell,验证聚合值与归属链路一致 +- session 退出后热点列表和 process count 及时收敛 +- settings 改频率、开关和采集层后,server 采样策略同步切换 +- deep 模式在大进程树下仍能保持首页可用 + +## Open Implementation Notes + +- 监控页的图表不需要在 v1 引入复杂图表库;小型折线 / sparkline 即可。 +- `Monitoring` 页面视觉语言应复用 diagnostics/settings 的 surface 和 page header 体系,避免另起一套设计系统。 +- 如果后续要支持告警或控制动作,优先在当前 `Monitoring` 域上扩展,不再新起并行“performance tools”页面。 + diff --git a/docs/superpowers/specs/2026-05-24-open-editors-actions-design.md b/docs/superpowers/specs/2026-05-24-open-editors-actions-design.md new file mode 100644 index 00000000..d4d110d1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-open-editors-actions-design.md @@ -0,0 +1,186 @@ +# Open Editors Actions Design + +## Goal + +Bring the `Open Editors` section on desktop and mobile to feature parity with a shared interaction model for: + +- expand and collapse +- file count display +- close current file from the list +- close all open files +- deterministic active-file switching when closing files + +## Problem + +The current `Open Editors` section is only a simple list of open file paths. + +It is missing the controls that users expect from the desktop-style workspace model: + +- no expand and collapse control +- no file count in the section header +- no per-file close action +- no close-all action +- no guaranteed rule for which file becomes active after closing from the list + +The current editor-header close flow also clears the active file without selecting the next sensible editor target. That creates an inconsistent experience between the editor surface and the sidebar list. + +## Desired Behavior + +### Shared section structure + +Desktop and mobile must render the same `Open Editors` control model. + +- left side: expand/collapse button +- center/primary label: `Open Editors` title followed by file count +- right side: `Close all` action + +Example structure: + +- `打开的编辑器 (3)` or `Open Editors (3)` depending on locale + +### List rows + +Each open file row must contain: + +- a selectable file target on the left +- a single-file close action on the right + +The row label must stay on one line and truncate with ellipsis when space is limited. This applies to desktop and mobile. + +### Close behavior + +Closing a file must follow one shared rule everywhere the workspace closes open editors, including: + +- the `Open Editors` list +- the existing editor header close button + +Behavior: + +1. Closing a non-active file removes it from `openFiles` and keeps the current editor selection unchanged. +2. Closing the active file selects the next open file if one exists later in the rendered order. +3. If the active file is the last item, closing it selects the previous open file if one exists. +4. If the last remaining open file is closed, the editor state is cleared and the main area returns from `editor` mode to the session/agent view. +5. `Close all` clears all open files, clears the active file, resets editor-only state that depends on an active file, and returns the main area to the session/agent view. + +### Expand/collapse behavior + +- expand/collapse only affects visibility of the list rows +- collapsing does not change the active file +- collapsing does not switch the main area away from the editor +- the section may remain visible when empty so the header actions stay discoverable, but the empty list body should not render row chrome + +## Scope + +This change covers the workspace UI in `packages/web`. + +### In scope + +- shared `Open Editors` layout and actions +- desktop explorer panel integration +- mobile explorer panel integration +- shared close behavior used by sidebar list and editor header +- file count display in the section title +- truncation and layout updates needed for the row/action chrome + +### Out of scope + +- changing how files are opened +- adding persistence for expanded/collapsed state across reloads +- reordering open editors +- changing search, quick jump, git, or file tree behavior beyond what is needed for shared layout consistency + +## Architecture + +### 1. Centralize close-open-editor decisions + +Introduce a shared close helper in the workspace/editor action layer so all close entry points use the same logic. + +The helper must accept: + +- current `openFiles` +- current `activeFilePath` +- a target path or a `closeAll` intent + +The helper must produce the next editor selection decision: + +- which file to remove +- which file should become active next, if any +- whether the editor surface should exit back to the session/agent view + +This keeps the behavior consistent between: + +- editor header close button +- desktop `Open Editors` rows +- mobile `Open Editors` rows +- `Close all` + +### 2. Expand the shared `OpenEditorsSection` + +`OpenEditorsSection` should remain the shared rendering primitive used by both desktop and mobile explorer panels. + +It should take responsibility for: + +- rendering the header chrome +- showing the count next to the title +- managing section-local expand/collapse UI state +- wiring row click to open/select an editor +- wiring row close and close-all actions to shared close helpers + +Desktop and mobile containers should continue to provide navigation-specific callbacks such as mobile detail routing where needed. + +### 3. Preserve existing main-area mode derivation + +The workspace already derives `mainAreaMode` from whether an active file exists. + +This change should preserve that model instead of introducing a second visibility state for the editor: + +- if a close action leaves another active file, `mainAreaMode` remains `editor` +- if a close action clears the final active file, `mainAreaMode` naturally falls back to `agent` + +This keeps the “last file closes editor and returns to session” rule aligned with the existing screen model. + +## File Boundaries + +Primary files expected to change: + +- `packages/web/src/features/workspace/views/shared/open-editors-section.tsx` +- `packages/web/src/features/code-editor/actions/use-code-editor-actions.ts` +- `packages/web/src/features/workspace/views/shared/explorer-panel.tsx` +- `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx` + +Likely supporting files: + +- shared styling for `workspace-open-editors` +- tests covering desktop workspace behavior +- tests covering editor close behavior +- tests covering mobile explorer rendering + +## Testing Strategy + +1. Add or update editor-action tests for close sequencing: + - closing a non-active file keeps the active file + - closing the active file switches to the next file + - closing the last active file with earlier files switches to the previous file + - closing the final remaining file clears the editor selection + - `Close all` clears all open files and active file +2. Add or update component tests for `OpenEditorsSection`: + - header shows file count + - expand/collapse toggles row visibility without changing selection + - each row exposes a close button + - long paths truncate instead of wrapping +3. Add or update integration coverage for desktop and mobile: + - desktop explorer shows the new controls + - mobile explorer shows the same controls + - closing the last open file causes the workspace to render the session/agent surface instead of the editor + +## Acceptance Criteria + +- desktop and mobile both show `Open Editors` with expand/collapse, file count, and `Close all` +- each open file row shows a single-file close action on the right +- row labels do not wrap and truncate with ellipsis when needed +- closing a non-active file does not change the active editor +- closing the active file selects the next file when possible +- closing the active last item selects the previous file when possible +- closing the final remaining open file exits the editor and returns the main area to the session/agent view +- `Close all` exits the editor and returns the main area to the session/agent view +- editor-header close and sidebar-list close use the same selection rules diff --git a/docs/superpowers/specs/2026-05-24-system-dependency-installer-design.md b/docs/superpowers/specs/2026-05-24-system-dependency-installer-design.md new file mode 100644 index 00000000..9d275f5f --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-system-dependency-installer-design.md @@ -0,0 +1,697 @@ +# System Dependency Installer — Design + +Date: 2026-05-24 +Status: Draft +Owner: spencer + +## Problem + +诊断页当前可以展示基础环境状态,但对 `git`、`node` 这类系统依赖只做到“检测并提示缺失”,没有安装流程,也没有可恢复的诊断闭环。 + +仓库现状里已经存在两套相近但不完全适用的能力: + +- Provider CLI 安装: + - [`packages/server/src/provider-runtime/install-manager.ts`](../../../packages/server/src/provider-runtime/install-manager.ts) + - [`packages/server/src/commands/provider.ts`](../../../packages/server/src/commands/provider.ts) +- LSP 工具安装: + - [`packages/server/src/lsp-tools/install-manager.ts`](../../../packages/server/src/lsp-tools/install-manager.ts) + - [`packages/server/src/commands/lsp.ts`](../../../packages/server/src/commands/lsp.ts) + +这两套流程都具备结构化 job、平台策略选择、前端轮询状态等优点,但它们的执行模型是非交互式命令运行,默认假设“安装命令直接跑完即可得出结果”。这与系统依赖安装的核心约束不一致: + +- `git` / `node` 安装往往依赖系统包管理器。 +- Linux / macOS 的安装经常需要 `sudo`。 +- 用户要求在网页内完成交互式提权,而不是跳到外部终端。 + +因此当前缺口不是“少一个按钮”,而是缺少一条适配系统依赖、支持网页内交互式提权的正式安装链路。 + +## Goals + +- 为 `git` 与 `node` 提供一条独立的系统依赖安装能力。 +- 在诊断页内支持一键安装,并在网页内完成交互式提权。 +- 复用现有 installer 的 job/strategy 思路,但不把系统依赖伪装成 provider。 +- 安装成功后自动重新检查诊断状态,形成闭环。 +- v1 覆盖 `Linux + macOS`。 + +## Non-Goals + +- v1 不支持 Windows 交互式管理员提权。 +- v1 不支持任意系统命令执行。 +- v1 不把系统依赖安装入口合并到普通终端面板。 +- v1 不支持 `git`、`node` 之外的其他系统依赖。 +- v1 不尝试在 provider installer 内直接塞入 `sudo` 密码交互。 + +## User Decisions Captured + +- 目标是“完整版”,不是只补文档链接。 +- 服务端允许直接调用系统包管理器。 +- 需要网页内交互式提权,而不是要求用户切到外部终端。 +- v1 平台范围限定为 `Linux + macOS`。 +- 现有 `claude` / `codex` 安装流程只复用共性,不直接复用为系统依赖 installer 本体。 + +## Approaches Considered + +### Option A: 新增独立的系统依赖 installer,并使用 PTY 支撑网页内交互式提权(推荐) + +优点: + +- 能完整覆盖 `sudo` 密码输入、安装日志、取消安装、重新验证等流程。 +- 保持系统依赖和 provider 安装的领域边界清晰。 +- 后续可以扩展到更多基础依赖,而不污染 provider 语义。 +- 仍可复用现有 installer 的 job/strategy/failure 模式。 + +缺点: + +- 需要新增一套 manager、命令和前端安装面板。 +- 需要把 PTY 输出和结构化 job 状态做桥接。 + +### Option B: 继续使用当前 provider installer 模型,把 `git` / `node` 伪装成 provider + +优点: + +- 表面上改动路径更短。 + +缺点: + +- 领域语义错误:`git`、`node` 不是 provider。 +- 会混淆 `provider.runtimeStatus`、provider 设置页、会话启动语义。 +- 现有 provider installer 不支持网页内交互式提权。 + +### Option C: 诊断页只拉起一个专用终端,让安装在终端面板中完成 + +优点: + +- 复用现有 terminal/PTY 基础设施最多。 + +缺点: + +- 体验割裂,诊断页拿不到结构化步骤和交互状态。 +- 用户需要理解普通终端,而不是在诊断场景内完成修复。 +- 很难把“安装成功后自动 recheck”做成自然闭环。 + +## Final Choice + +采用 Option A。 + +实现一套独立的 `systemDeps.*` 能力:服务端新增系统依赖安装 manager,规划基础依赖安装策略;一旦进入可能需要提权的安装步骤,就切换到 PTY 驱动的交互式安装会话;前端诊断页以内嵌安装面板承载实时输出与密码输入;安装成功后自动重新跑诊断检查。 + +## Scope + +### Included In v1 + +- 依赖项:`git`、`node` +- 平台:`darwin`、`linux` +- Linux 包管理器识别: + - `apt-get` + - `dnf` + - `yum` + - `pacman` + - `zypper` +- macOS 包管理器识别: + - `brew` +- 诊断页中的安装、进度、密码输入、取消、重新检查 + +### Excluded From v1 + +- Windows 的 `winget` / `choco` / UAC 流程 +- 多依赖批量安装 +- 安装输出的长期持久化 +- 普通终端列表中的可见安装终端 + +## Current Product Constraints + +### Diagnostics Today + +诊断命令当前在手动检查时会直接读取 `git --version` 与 `node --version`,并把结果映射为 `git_ready` / `git_missing`、`nodejs_ready` / `nodejs_missing`: + +- [`packages/server/src/commands/diagnostics.ts`](../../../packages/server/src/commands/diagnostics.ts) + +前端诊断页当前仅展示文案、版本、缺失命令和文档链接,没有安装动作: + +- [`packages/web/src/features/diagnostics/page.tsx`](../../../packages/web/src/features/diagnostics/page.tsx) + +### Installer Patterns Worth Reusing + +现有 provider installer 已经验证了以下模式可用: + +- 单资源单活动 job +- `start/get` 轮询接口 +- 平台策略和 prerequisite 规划 +- 结构化 `steps` / `failure` + +参考: + +- [`packages/server/src/provider-runtime/install-manager.ts`](../../../packages/server/src/provider-runtime/install-manager.ts) +- [`packages/core/src/domain/provider-install.ts`](../../../packages/core/src/domain/provider-install.ts) + +### PTY Capability Already Exists + +项目已经有稳定的 PTY 能力和终端输入输出命令: + +- PTY host: + - [`packages/server/src/terminal/pty-host.ts`](../../../packages/server/src/terminal/pty-host.ts) +- Terminal commands: + - [`packages/server/src/commands/terminal.ts`](../../../packages/server/src/commands/terminal.ts) + +这意味着“网页内交互式提权”的底层技术不需要从零开始发明,但需要为系统依赖安装封装成专用的隐藏安装会话。 + +## Architecture + +### 1. 独立的系统依赖安装域 + +新增 `systemDeps` 领域,与 `provider-runtime`、`lsp-tools` 平行,而不是依附在 provider 域下。 + +建议目录: + +- `packages/core/src/domain/system-dependency-install.ts` +- `packages/server/src/system-deps/definitions.ts` +- `packages/server/src/system-deps/runtime-status.ts` +- `packages/server/src/system-deps/install-manager.ts` +- `packages/server/src/commands/system-deps.ts` + +职责拆分: + +- `definitions.ts` + - 定义 `git` / `node` 的检测命令、文档链接、平台安装策略。 +- `runtime-status.ts` + - 读取当前依赖状态与版本,并计算是否支持自动安装。 +- `install-manager.ts` + - 管理 job 生命周期、策略规划、PTY 安装会话、输出解析、状态更新。 +- `commands/system-deps.ts` + - 暴露 `runtimeStatus`、`install.start`、`install.get`、`install.input`、`install.cancel`。 + +### 2. 结构化 job + 隐藏 PTY 会话 + +系统依赖安装同时包含两类状态: + +- 结构化安装状态: + - 当前正在检测什么、安装什么、验证什么、是否失败、失败分类是什么 +- 交互式终端状态: + - 包管理器当前输出了什么、是否在等 `sudo` 密码、用户输入是否已提交 + +v1 采用“双层模型”: + +- `SystemDependencyInstallJobSnapshot` + - 诊断页用于渲染结构化状态 +- 隐藏 PTY 会话 + - manager 用它执行真实安装命令,并把输出解析回 job + +PTY 会话不出现在普通终端列表中,不允许用户把它当通用 shell 使用。 + +### 3. 复用 installer 共性,不复用 installer 实体 + +以下能力直接沿用现有 provider installer 的思路: + +- 单依赖单活动 job +- `start/get` 幂等语义 +- `step` / `failure` snapshot 风格 +- 平台策略选择 +- prerequisite 检查 + +以下能力是 system dependency installer 独有的: + +- `install.input` +- `install.cancel` +- `interaction` 状态 +- PTY 输出解析 +- `sudo` 密码交互 + +## Domain Model + +新增共享类型,建议放在 [`packages/core/src/domain`](../../../packages/core/src/domain)。 + +### Dependency Id + +```ts +export type SystemDependencyId = "git" | "node"; +``` + +### Runtime Status + +```ts +export interface SystemDependencyRuntimeEntry { + dependencyId: SystemDependencyId; + available: boolean; + version?: string; + autoInstallSupported: boolean; + installReadiness: "ready" | "unsupported_platform" | "unsupported_package_manager"; + packageManager?: "brew" | "apt-get" | "dnf" | "yum" | "pacman" | "zypper"; + manualGuideKeys: string[]; + docUrl?: string; +} +``` + +### Install Interaction + +```ts +export interface SystemDependencyInstallInteraction { + kind: "none" | "sudo_password" | "confirm"; + promptExcerpt?: string; + echo: boolean; +} +``` + +### Install Step + +```ts +export interface SystemDependencyInstallStepSnapshot { + id: string; + titleKey: string; + kind: "check" | "install" | "verify"; + command: string; + args: string[]; + status: "pending" | "running" | "succeeded" | "failed"; + startedAt?: number; + finishedAt?: number; + exitCode?: number; + stdoutExcerpt?: string; + stderrExcerpt?: string; +} +``` + +### Install Failure + +```ts +export interface SystemDependencyInstallFailure { + code: + | "unsupported_platform" + | "unsupported_package_manager" + | "permission_denied" + | "user_cancelled" + | "pty_disconnected" + | "command_not_found" + | "command_failed" + | "verification_failed" + | "unknown_failure"; + dependencyId: SystemDependencyId; + failedStepId: string; + message: string; + command: string; + args: string[]; + exitCode?: number; + stdoutExcerpt?: string; + stderrExcerpt?: string; + packageManager?: string; + manualGuideKeys: string[]; + docUrl?: string; +} +``` + +### Install Job + +```ts +export interface SystemDependencyInstallJobSnapshot { + jobId: string; + dependencyId: SystemDependencyId; + status: "queued" | "running" | "waiting_input" | "succeeded" | "failed" | "cancelled"; + packageManager?: string; + currentStepId?: string; + steps: SystemDependencyInstallStepSnapshot[]; + interaction: SystemDependencyInstallInteraction; + failure?: SystemDependencyInstallFailure; +} +``` + +## Server Design + +### Runtime Status + +新增 `systemDeps.runtimeStatus` 命令,返回结构化基础依赖状态,而不是复用现有 `diagnostics.get` 的通用 checks 结果。 + +建议语义: + +- 对每个支持的依赖运行版本探测: + - `git --version` + - `node --version` +- 识别当前平台和包管理器 +- 计算: + - `available` + - `version` + - `autoInstallSupported` + - `installReadiness` + - `manualGuideKeys` + - `docUrl` + +诊断命令可以在需要时复用这份运行时状态,而不是再次散落地手写探测逻辑。 + +### Package Manager Detection + +Linux / macOS 的自动安装能力不取决于“平台是否支持”,还取决于“能否识别可用包管理器”。 + +建议检测顺序: + +- `darwin` + - `brew` +- `linux` + - `apt-get` + - `dnf` + - `yum` + - `pacman` + - `zypper` + +返回策略: + +- 找到包管理器: + - `autoInstallSupported = true` + - `installReadiness = "ready"` +- 平台支持但包管理器未识别: + - `autoInstallSupported = false` + - `installReadiness = "unsupported_package_manager"` +- 平台不支持: + - `autoInstallSupported = false` + - `installReadiness = "unsupported_platform"` + +### Strategy Planning + +系统依赖安装不需要像 provider installer 那样有多层 prerequisite 依赖树,但仍建议保留“计划步骤”的机制。 + +示例: + +- `git` + `brew` + - `detect-package-manager` + - `install-git` + - `verify-git` +- `node` + `apt-get` + - `detect-package-manager` + - `install-node` + - `verify-node` + +示例命令: + +- macOS + - `brew install git` + - `brew install node` +- Linux + - `sudo apt-get update` + - `sudo apt-get install -y git` + - `sudo apt-get install -y nodejs npm` + - `sudo dnf install -y git` + - `sudo dnf install -y nodejs` + - `sudo yum install -y git` + - `sudo yum install -y nodejs` + - `sudo pacman -Sy --noconfirm git` + - `sudo pacman -Sy --noconfirm nodejs npm` + - `sudo zypper --non-interactive install git` + - `sudo zypper --non-interactive install nodejs` + +v1 只允许执行服务端策略表中预定义的命令,不接受前端传入任意命令。 + +### PTY Execution Model + +非交互式命令执行继续使用现有 `runCommandAsString()` 适合的场景: + +- 初始版本探测 +- 包管理器探测 +- 最终版本验证 + +安装步骤改为走 PTY 会话。原因: + +- `sudo` 提示需要伪终端环境 +- 包管理器可能直接把交互提示写到 TTY +- 网页需要把密码输入实时回写到会话 + +实现建议: + +- manager 通过 `TerminalManager` 创建隐藏安装终端 +- 安装终端使用内建 argv,直接启动 shell: + - macOS / Linux: `["/bin/sh", "-lc", ""]` +- manager 订阅该终端输出流并更新 job +- `systemDeps.install.input` 调用时,把输入写入该隐藏终端 + +### Hidden Install Terminal + +不要让安装终端出现在普通 terminal 列表里。 + +建议方式二选一: + +1. 为 terminal 增加 `visibility: "public" | "internal"` 字段,并让 `terminal.list` 只返回 `public` +2. manager 不经公开 terminal 命令,而是直接通过 `TerminalManager` 内部 API 创建不入库的内部会话 + +推荐第 2 种:范围更小,不影响终端业务语义,也不需要改普通终端 UI 的过滤逻辑。 + +### Interaction Detection + +manager 需要从 PTY 输出中识别“当前需要用户输入”。 + +v1 只识别有限模式: + +- `sudo` 密码提示 + - 常见模式: + - `[sudo] password for ...:` + - `Password:` +- 包管理器确认提示 + - 如果策略里无法完全避免交互,则识别 `Proceed? [Y/n]` 等模式 + +状态转换示例: + +- 默认: + - `interaction.kind = "none"` +- 检测到密码提示: + - `status = "waiting_input"` + - `interaction.kind = "sudo_password"` + - `interaction.echo = false` +- 用户提交输入后: + - `status = "running"` + - `interaction.kind = "none"` + +密码输入不写入任何日志、snapshot excerpt 或持久化字段。 + +### Cancellation + +新增 `systemDeps.install.cancel`: + +- 终止当前隐藏 PTY +- 将 job 标记为 `cancelled` +- 清理 active job 映射 + +如果 PTY 异常退出且当前 job 不是成功状态,则映射为: + +- `pty_disconnected` + 或 +- `command_failed` + +### Verification + +成功标准不依赖单个安装命令退出码,而依赖最终重新验证: + +- `git --version` +- `node --version` + +只有最终验证通过,job 才能标记为 `succeeded`。 + +## Diagnostics Integration + +### Which Contexts Show Base Runtime Checks + +`manual_check`: + +- 一定显示 `git` / `node` 的基础依赖状态。 + +`session_start`: + +- 补进基础依赖状态。 +- 原因:provider CLI 即使安装成功,基础依赖缺失时仍可能无法实际使用。 + +`workspace_open`: + +- 显示基础依赖状态,但不因 `git_missing` 阻塞工作区打开。 + +`mobile_continue`: + +- 继续只关心 host / auth,不额外引入 `git` / `node` 阻塞逻辑。 + +### Diagnostics Check Enrichment + +现有 `DiagnosticsCheck` 需要为基础依赖安装增加可操作字段。建议新增: + +```ts +dependencyId?: "git" | "node"; +autoInstallSupported?: boolean; +installReadiness?: "ready" | "unsupported_platform" | "unsupported_package_manager"; +manualGuideKeys?: string[]; +docUrl?: string; +``` + +这样 `git_missing` / `nodejs_missing` 卡片就能从“纯展示”升级为“可安装”。 + +## Web UX + +### Diagnostics Card Actions + +对 `git_missing` / `nodejs_missing`: + +- 若 `autoInstallSupported = true`: + - 显示 `Install` 按钮 +- 若 `autoInstallSupported = false`: + - 显示手动引导文案 + - 如有 `docUrl`,显示官方文档按钮 + +### Embedded Install Panel + +点击 `Install` 后,不跳走,不打开普通 terminal 面板,而是在诊断页当前卡片下展开安装面板。 + +面板包含: + +- 当前依赖名 +- 包管理器标识 +- job 状态 +- 当前步骤 +- 实时日志区 +- 输入区 +- `Cancel` 按钮 + +### Input States + +`interaction.kind = "sudo_password"` 时: + +- 展示密码输入框 +- `type="password"` +- 不回显 +- 回车提交到 `systemDeps.install.input` + +`interaction.kind = "confirm"` 时: + +- 展示普通文本输入或确认按钮 +- v1 优先通过安装命令参数规避确认型交互,尽量不依赖该态 + +### Recheck Loop + +job 进入 `succeeded` 后: + +- 自动触发 `diagnostics.recheck` +- 当前依赖卡片更新为 `ready` +- 若该诊断上下文依赖此项才能继续,则主动作恢复可用 + +示例: + +- `session_start` 场景下,`node` 缺失会先阻塞继续 +- 安装成功并 recheck 后,`Continue Session` 变为可用 + +## Security + +### Password Handling + +- 密码只通过 PTY stdin 写入 +- 不落库 +- 不进入 job snapshot +- 不进入 stdout/stderr excerpt +- 不写 server log + +### Command Safety + +- 前端不能提交任意命令 +- 所有安装命令都来自服务端内建策略表 +- 同一时刻只允许一个依赖一个活动安装 job +- v1 不支持并发多依赖安装,避免包管理器锁冲突 + +### Surface Isolation + +- 安装终端不进入普通终端面板 +- 普通 terminal 输入命令无法劫持安装 job +- 诊断页只暴露与当前 job 绑定的最小输入能力 + +## Failure Model + +以下情况都应明确映射为失败: + +- 平台不支持 +- 平台支持但包管理器不可识别 +- 用户取消安装 +- `sudo` 密码错误导致安装失败 +- 包管理器命令退出非 0 +- PTY 异常断开 +- 最终版本验证失败 + +失败后诊断页应保留: + +- 当前 job 的失败摘要 +- 当前依赖的手动修复文案 +- `Recheck` 能力 +- 再次 `Install` 的能力(创建新 job) + +## Testing Strategy + +### Core + +新增共享类型和状态模型测试: + +- `packages/core/src/domain/system-dependency-install.ts` +- 对应 domain 测试文件 + +覆盖点: + +- snapshot 类型收敛 +- failure code 枚举 +- interaction 类型 + +### Server + +新增测试: + +- package manager 检测优先级 +- `git` / `node` runtime status +- installer job 生命周期 +- `start` 幂等返回活动 job +- PTY 输出触发 `waiting_input` +- 密码输入后恢复 `running` +- 取消安装 +- 验证成功与验证失败 +- diagnostics wiring 在不同 context 下的包含/排除逻辑 + +### Web + +新增测试: + +- 诊断页缺失 `git` / `node` 时渲染 `Install` +- 安装中展开日志面板 +- `sudo` 密码输入态 +- 成功后自动 recheck +- 失败后展示手动指引与重试入口 +- `workspace_open` 不因 `git_missing` 阻塞继续 + +## Implementation Notes + +### Shared Abstraction Opportunity + +本次不强制把 provider installer 与 system dependency installer 抽成完全统一的抽象基类。 + +原因: + +- 现有 provider installer 是非交互式 install runner +- 新系统依赖 installer 是 PTY 驱动的交互式 install runner +- 过早抽象容易把两条执行模型强行统一,反而提高复杂度 + +推荐做法: + +- 先只复用类型风格、失败码风格、步骤规划思路 +- 等 system dependency installer 稳定后,再评估是否抽共用 helper,例如: + - `cloneJobSnapshot` + - excerpt 裁剪 + - install strategy helpers + +### Docs And Copy + +需要补齐中英文文案: + +- `diagnostics.checks.git_missing` +- `diagnostics.checks.nodejs_missing` +- `system_deps.install.*` +- 手动修复 guide keys + +需要为每个受支持依赖提供官方文档链接: + +- Git 官方安装文档 +- Node.js 官方安装文档 + +## Rollout Summary + +v1 交付后,诊断页对于基础依赖将从“只报告问题”升级为“可在原地修复问题”的闭环体验: + +- 检测缺失 +- 一键开始安装 +- 网页内输入提权密码 +- 实时查看日志 +- 自动重新检查 +- 继续原本被阻塞的操作 + +这是一个新的系统依赖安装能力,不是 provider installer 的变体;但它会刻意沿用现有 installer 已经验证可行的状态模型和策略规划方式。 diff --git a/packages/cli/src/update-worker.test.ts b/packages/cli/src/update-worker.test.ts index 253bcd1c..3ae72169 100644 --- a/packages/cli/src/update-worker.test.ts +++ b/packages/cli/src/update-worker.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { runUpdateWorker } from "./update-worker.js"; +import { runRestartHandoff, runUpdateWorker } from "./update-worker.js"; describe("update-worker", () => { const tempDirs: string[] = []; @@ -29,13 +29,16 @@ describe("update-worker", () => { }; } - it("writes restarting state after install success and restart handoff", async () => { + it("writes restarting state and spawns a detached restart handoff after install success", async () => { const env = createEnv(); const runCommand = vi.fn(async () => {}); + const spawnDetachedProcess = vi.fn(async () => {}); await runUpdateWorker(env, { runCommand, now: () => 1000, + processId: 4242, + spawnDetachedProcess, }); const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as { updateStatus: string }; @@ -46,11 +49,13 @@ describe("update-worker", () => { ["install", "-g", "@spencer-kit/coder-studio@0.5.0"], expect.any(Object) ); - expect(runCommand).toHaveBeenNthCalledWith( - 2, - "coder-studio", - ["serve", "--restart"], - expect.any(Object) + expect(spawnDetachedProcess).toHaveBeenCalledWith( + process.execPath, + expect.any(Array), + expect.objectContaining({ + CODER_STUDIO_UPDATE_WORKER_MODE: "restart-handoff", + CODER_STUDIO_UPDATE_PARENT_PID: "4242", + }) ); }); @@ -77,14 +82,14 @@ describe("update-worker", () => { it("marks restart failures with manual restart guidance", async () => { const env = createEnv(); - const runCommand = vi - .fn() - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error("pm2 restart failed")); + const runCommand = vi.fn().mockRejectedValueOnce(new Error("pm2 restart failed")); + const waitForProcessExit = vi.fn(async () => {}); - await runUpdateWorker(env, { + await runRestartHandoff(env, { runCommand, now: () => 1000, + waitForProcessExit, + restartParentPid: 999, }); const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as { @@ -95,11 +100,13 @@ describe("update-worker", () => { expect(state.updateStatus).toBe("failed"); expect(state.manualCommand).toBe("coder-studio serve --restart"); expect(state.errorSummary).toContain("restart failed"); + expect(waitForProcessExit).toHaveBeenCalledWith(999); }); it("sanitizes pm2 and runtime override env before invoking install and restart commands", async () => { const env = createEnv(); const runCommand = vi.fn(async () => {}); + const spawnDetachedProcess = vi.fn(async () => {}); const originalEnv = { PM2_HOME: process.env.PM2_HOME, PM2_PROGRAMMATIC: process.env.PM2_PROGRAMMATIC, @@ -130,6 +137,8 @@ describe("update-worker", () => { await runUpdateWorker(env, { runCommand, now: () => 1000, + processId: 4242, + spawnDetachedProcess, }); } finally { for (const [key, value] of Object.entries(originalEnv)) { @@ -155,5 +164,37 @@ describe("update-worker", () => { expect(options.env?.CODER_STUDIO_UPDATE_STATE_PATH).toBeUndefined(); expect(options.env?.pm_id).toBeUndefined(); } + + const handoffEnv = spawnDetachedProcess.mock.calls[0]?.[2] as NodeJS.ProcessEnv | undefined; + expect(handoffEnv?.PM2_HOME).toBe("/tmp/custom-pm2-home"); + expect(handoffEnv?.PM2_PROGRAMMATIC).toBeUndefined(); + expect(handoffEnv?.PM2_JSON_PROCESSING).toBeUndefined(); + expect(handoffEnv?.PM2_INTERACTOR_PROCESSING).toBeUndefined(); + expect(handoffEnv?.NODE_APP_INSTANCE).toBeUndefined(); + expect(handoffEnv?.NODE_CHANNEL_FD).toBeUndefined(); + expect(handoffEnv?.NODE_CHANNEL_SERIALIZATION_MODE).toBeUndefined(); + expect(handoffEnv?.CODER_STUDIO_RUNTIME_JSON_PATH).toBeUndefined(); + expect(handoffEnv?.CODER_STUDIO_SESSION_ID).toBeUndefined(); + expect(handoffEnv?.pm_id).toBeUndefined(); + }); + + it("waits for the install worker to exit before running the restart command", async () => { + const env = createEnv(); + const waitForProcessExit = vi.fn(async () => {}); + const runCommand = vi.fn(async () => {}); + + await runRestartHandoff(env, { + runCommand, + now: () => 1000, + waitForProcessExit, + restartParentPid: 777, + }); + + expect(waitForProcessExit).toHaveBeenCalledWith(777); + expect(runCommand).toHaveBeenCalledWith( + "coder-studio", + ["serve", "--restart"], + expect.any(Object) + ); }); }); diff --git a/packages/cli/src/update-worker.ts b/packages/cli/src/update-worker.ts index a83f91af..c552fa68 100644 --- a/packages/cli/src/update-worker.ts +++ b/packages/cli/src/update-worker.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { createWriteStream } from "node:fs"; import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; interface UpdateStateSnapshot { version: 1; @@ -37,6 +38,13 @@ interface WorkerEnv { installArgsPrefix: string[]; } +type WorkerMode = "install" | "restart-handoff"; + +const RESTART_HANDOFF_MODE: WorkerMode = "restart-handoff"; +const DEFAULT_MODE: WorkerMode = "install"; +const RESTART_HANDOFF_WAIT_MS = 5_000; +const WORKER_ENTRY_PATH = fileURLToPath(import.meta.url); + async function writeState(filePath: string, value: UpdateStateSnapshot): Promise { await mkdir(dirname(filePath), { recursive: true }); await import("node:fs/promises").then(({ writeFile }) => @@ -44,6 +52,16 @@ async function writeState(filePath: string, value: UpdateStateSnapshot): Promise ); } +function closeLogStream(stream: NodeJS.WritableStream): Promise { + return new Promise((resolve, reject) => { + stream.once("error", reject); + stream.end(() => { + stream.off("error", reject); + resolve(); + }); + }); +} + function parseJsonArray(value: string | undefined, fallback: string[]): string[] { if (!value) { return fallback; @@ -97,6 +115,22 @@ function buildManualCommand(input: WorkerEnv): string { ].join("\n"); } +function readWorkerMode(env = process.env): WorkerMode { + return env.CODER_STUDIO_UPDATE_WORKER_MODE === RESTART_HANDOFF_MODE + ? RESTART_HANDOFF_MODE + : DEFAULT_MODE; +} + +function readRestartParentPid(env = process.env): number | null { + const raw = env.CODER_STUDIO_UPDATE_PARENT_PID; + if (!raw) { + return null; + } + + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; +} + const INTERNAL_ENV_KEYS = new Set([ "CODER_STUDIO_RUNTIME_JSON_PATH", "CODER_STUDIO_SESSION_ID", @@ -125,6 +159,71 @@ function buildChildProcessEnv(env = process.env): NodeJS.ProcessEnv { return nextEnv; } +function buildWorkerEnv(input: WorkerEnv): NodeJS.ProcessEnv { + return { + CODER_STUDIO_UPDATE_STATE_PATH: input.stateFilePath, + CODER_STUDIO_UPDATE_LOG_PATH: input.logFilePath, + CODER_STUDIO_UPDATE_PACKAGE_NAME: input.packageName, + CODER_STUDIO_UPDATE_TARGET_VERSION: input.targetVersion, + CODER_STUDIO_UPDATE_CLI_COMMAND: input.cliCommand, + CODER_STUDIO_UPDATE_CURRENT_VERSION: input.currentVersion, + CODER_STUDIO_UPDATE_NPM_COMMAND: input.npmCommand, + CODER_STUDIO_UPDATE_RESTART_ARGS: JSON.stringify(input.restartArgs), + CODER_STUDIO_UPDATE_INSTALL_ARGS_PREFIX: JSON.stringify(input.installArgsPrefix), + }; +} + +function spawnDetachedProcess( + command: string, + args: string[], + env: NodeJS.ProcessEnv +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + env, + }); + + child.on("error", reject); + child.unref(); + resolve(); + }); +} + +const isMissingProcessError = (error: unknown): boolean => + Boolean( + error && + typeof error === "object" && + "code" in error && + (error as NodeJS.ErrnoException).code === "ESRCH" + ); + +async function waitForProcessExit(pid: number, waitMs = RESTART_HANDOFF_WAIT_MS): Promise { + const deadline = Date.now() + waitMs; + + while (Date.now() <= deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if (isMissingProcessError(error)) { + return; + } + + throw error; + } + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + + await new Promise((resolve) => { + setTimeout(resolve, Math.min(100, remainingMs)); + }); + } +} + function runCommand( command: string, args: string[], @@ -163,6 +262,8 @@ export async function runUpdateWorker( deps?: { runCommand?: typeof runCommand; now?: () => number; + processId?: number; + spawnDetachedProcess?: typeof spawnDetachedProcess; } ): Promise { const now = deps?.now ?? Date.now; @@ -170,6 +271,8 @@ export async function runUpdateWorker( const logStream = createWriteStream(input.logFilePath, { flags: "a" }); const execute = deps?.runCommand ?? runCommand; const childEnv = buildChildProcessEnv(process.env); + const processId = deps?.processId ?? process.pid; + const spawnRestartHandoff = deps?.spawnDetachedProcess ?? spawnDetachedProcess; try { await execute( @@ -196,7 +299,7 @@ export async function runUpdateWorker( manualCommand: permissionRelated ? buildManualCommand(input) : null, errorSummary: message, }); - logStream.end(); + await closeLogStream(logStream); return; } @@ -216,6 +319,55 @@ export async function runUpdateWorker( }); try { + await spawnRestartHandoff(process.execPath, [WORKER_ENTRY_PATH], { + ...childEnv, + ...buildWorkerEnv(input), + CODER_STUDIO_UPDATE_WORKER_MODE: RESTART_HANDOFF_MODE, + CODER_STUDIO_UPDATE_PARENT_PID: String(processId), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await writeState(input.stateFilePath, { + version: 1, + currentVersion: input.currentVersion, + latestVersion: input.targetVersion, + availability: "update_available", + updateStatus: "failed", + lastCheckedAt: now(), + targetVersion: input.targetVersion, + startedAt: now(), + finishedAt: now(), + requiresManualStep: true, + manualCommand: `${input.cliCommand} ${input.restartArgs.join(" ")}`, + errorSummary: `new version installed but service restart failed: ${message}`, + }); + } finally { + await closeLogStream(logStream); + } +} + +export async function runRestartHandoff( + input = readEnv(), + deps?: { + runCommand?: typeof runCommand; + now?: () => number; + waitForProcessExit?: typeof waitForProcessExit; + restartParentPid?: number | null; + } +): Promise { + const now = deps?.now ?? Date.now; + await mkdir(dirname(input.logFilePath), { recursive: true }); + const logStream = createWriteStream(input.logFilePath, { flags: "a" }); + const execute = deps?.runCommand ?? runCommand; + const waitForParentExit = deps?.waitForProcessExit ?? waitForProcessExit; + const childEnv = buildChildProcessEnv(process.env); + const restartParentPid = deps?.restartParentPid ?? readRestartParentPid(process.env); + + try { + if (restartParentPid !== null) { + await waitForParentExit(restartParentPid); + } + await execute(input.cliCommand, input.restartArgs, { logStream, env: childEnv }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -234,12 +386,15 @@ export async function runUpdateWorker( errorSummary: `new version installed but service restart failed: ${message}`, }); } finally { - logStream.end(); + await closeLogStream(logStream); } } if (process.env.CODER_STUDIO_UPDATE_STATE_PATH) { - void runUpdateWorker().catch((error) => { + const run = + readWorkerMode(process.env) === RESTART_HANDOFF_MODE ? runRestartHandoff : runUpdateWorker; + + void run().catch((error) => { console.error("[update-worker]", error); process.exitCode = 1; }); diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts index b6a11a3a..a464d1b4 100644 --- a/packages/core/src/domain/types.ts +++ b/packages/core/src/domain/types.ts @@ -151,6 +151,30 @@ export interface FileNode { mtime?: number; } +export interface SearchContentMatch { + line: number; + column: number; + endColumn: number; + preview: string; + previewColumnStart: number; + previewColumnEnd: number; +} + +export interface SearchContentFileResult { + path: string; + name: string; + matchCount: number; + hasMoreMatches: boolean; + matches: SearchContentMatch[]; +} + +export interface SearchContentResult { + files: SearchContentFileResult[]; + totalMatchCount: number; + hasMoreFiles: boolean; + truncatedMatchFileCount: number; +} + export interface Settings { defaultProviderId: string; notifications: { diff --git a/packages/server/src/__tests__/file-commands.test.ts b/packages/server/src/__tests__/file-commands.test.ts index e4ec0259..4ec4aeb5 100644 --- a/packages/server/src/__tests__/file-commands.test.ts +++ b/packages/server/src/__tests__/file-commands.test.ts @@ -36,6 +36,8 @@ describe("File Commands", () => { await writeFile(join(testDir, "README.md"), "readme\n"); await writeFile(join(testDir, "src.ts"), "export const src = true;\n"); + await mkdir(join(testDir, "src")); + await writeFile(join(testDir, "src", "guide.md"), "guide\n"); await mkdir(join(testDir, "docs")); await writeFile(join(testDir, "docs", "src-note.md"), "note\n"); await writeFile(join(testDir, "docs", "readme-copy.md"), "copy\n"); @@ -101,7 +103,7 @@ describe("File Commands", () => { expect(files.some((item) => item.path === "src.ts")).toBe(false); }); - it("matches by filename only and ignores directory names", async () => { + it("matches directory paths while keeping filename hits ahead of path-only matches", async () => { const result = await dispatch( { kind: "command", @@ -109,7 +111,8 @@ describe("File Commands", () => { op: "file.search", args: { workspaceId, - query: "docs", + query: "src", + limit: 10, }, }, ctx @@ -117,7 +120,27 @@ describe("File Commands", () => { expect(result.ok).toBe(true); const files = (result.data as { files: Array<{ path: string }> }).files; - expect(files).toHaveLength(0); + expect(files[0]?.path).toBe("src.ts"); + expect(files.some((item) => item.path === "src/guide.md")).toBe(true); + + const directoryResult = await dispatch( + { + kind: "command", + id: "file-search-2-path", + op: "file.search", + args: { + workspaceId, + query: "docs", + limit: 10, + }, + }, + ctx + ); + + expect(directoryResult.ok).toBe(true); + const directoryFiles = (directoryResult.data as { files: Array<{ path: string }> }).files; + expect(directoryFiles.length).toBeGreaterThan(0); + expect(directoryFiles.every((item) => item.path.startsWith("docs/"))).toBe(true); }); it("keeps .gitignore filtering for search results", async () => { @@ -142,6 +165,51 @@ describe("File Commands", () => { expect(files).toHaveLength(0); }); + it("dispatches file.searchContent and returns grouped content matches", async () => { + await writeFile(join(testDir, "alpha.ts"), "const hit = 'match';\nconst second = 'match';\n"); + await writeFile(join(testDir, "notes.md"), "match in docs\n"); + + const result = await dispatch( + { + kind: "command", + id: "file-search-content-1", + op: "file.searchContent", + args: { + workspaceId, + query: "match", + maxFiles: 1, + maxMatchesPerFile: 1, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + totalMatchCount: 3, + hasMoreFiles: true, + truncatedMatchFileCount: 1, + files: [ + { + path: "alpha.ts", + name: "alpha.ts", + matchCount: 2, + hasMoreMatches: true, + matches: [ + { + line: 1, + column: 14, + endColumn: 19, + preview: "const hit = 'match';", + previewColumnStart: 14, + previewColumnEnd: 19, + }, + ], + }, + ], + }); + }); + it("shows dotfiles and node_modules in file.readTree while still hiding .git", async () => { await writeFile(join(testDir, ".gitignore"), "*.log\nnode_modules/\n"); await writeFile(join(testDir, ".env"), "secret\n"); diff --git a/packages/server/src/__tests__/fs/content-search.test.ts b/packages/server/src/__tests__/fs/content-search.test.ts new file mode 100644 index 00000000..ae0ea1ee --- /dev/null +++ b/packages/server/src/__tests__/fs/content-search.test.ts @@ -0,0 +1,186 @@ +import { execFile as execFileCallback } from "child_process"; +import { mkdir, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { promisify } from "util"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { searchFileContents } from "../../fs/content-search.js"; + +const execFile = promisify(execFileCallback); + +describe("searchFileContents", () => { + let testDir: string; + + beforeEach(async () => { + testDir = join( + tmpdir(), + `content-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(testDir, { recursive: true }); + + await execFile("git", ["init"], { cwd: testDir }); + await execFile("git", ["config", "user.name", "Test"], { cwd: testDir }); + await execFile("git", ["config", "user.email", "test@example.com"], { cwd: testDir }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("groups matches by file and returns preview highlight metadata", async () => { + await writeFile( + join(testDir, "alpha.ts"), + [ + "const alpha = 'alpha';", + "const beta = alpha + '-match';", + "const gamma = 'done';", + "", + ].join("\n") + ); + await writeFile(join(testDir, "notes.md"), "match on the first line\n"); + + const result = await searchFileContents(testDir, { + query: "match", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.totalMatchCount).toBe(2); + expect(result.hasMoreFiles).toBe(false); + expect(result.truncatedMatchFileCount).toBe(0); + expect(result.files.map((file) => file.path)).toEqual(["alpha.ts", "notes.md"]); + + expect(result.files[0]).toMatchObject({ + path: "alpha.ts", + name: "alpha.ts", + matchCount: 1, + hasMoreMatches: false, + }); + expect(result.files[0]?.matches).toEqual([ + { + line: 2, + column: 24, + endColumn: 29, + preview: "const beta = alpha + '-match';", + previewColumnStart: 24, + previewColumnEnd: 29, + }, + ]); + expect(result.files[1]?.matches[0]).toMatchObject({ + line: 1, + column: 1, + endColumn: 6, + preview: "match on the first line", + previewColumnStart: 1, + previewColumnEnd: 6, + }); + }); + + it("falls back to Node scanner when rg unavailable", async () => { + await writeFile(join(testDir, "fallback.txt"), "plain text fallback match\n"); + vi.stubEnv("PATH", ""); + + const result = await searchFileContents(testDir, { + query: "fallback", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.files).toHaveLength(1); + expect(result.files[0]).toMatchObject({ + path: "fallback.txt", + matchCount: 1, + hasMoreMatches: false, + }); + expect(result.totalMatchCount).toBe(1); + }); + + it("reports Unicode-aware columns from the ripgrep path", async () => { + await writeFile(join(testDir, "unicode.txt"), "cafematch\ncafematch\ncafematch\n"); + await writeFile(join(testDir, "utf8.txt"), "咖啡match\n"); + + const result = await searchFileContents(testDir, { + query: "match", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.files.find((file) => file.path === "utf8.txt")?.matches[0]).toMatchObject({ + line: 1, + column: 3, + endColumn: 8, + previewColumnStart: 3, + previewColumnEnd: 8, + }); + }); + + it("keeps .gitignore filtering on the ripgrep path outside a git repository", async () => { + const plainDir = join( + tmpdir(), + `content-search-plain-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(plainDir, { recursive: true }); + + try { + await writeFile(join(plainDir, ".gitignore"), "ignored.txt\n"); + await writeFile(join(plainDir, "ignored.txt"), "match\n"); + await writeFile(join(plainDir, "visible.txt"), "match\n"); + + const result = await searchFileContents(plainDir, { + query: "match", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.files.map((file) => file.path)).toEqual(["visible.txt"]); + } finally { + await rm(plainDir, { recursive: true, force: true }); + } + }); + + it("respects .gitignore, skips binary files, and reports truncation", async () => { + await writeFile(join(testDir, ".gitignore"), "ignored.txt\n"); + await writeFile(join(testDir, "ignored.txt"), "match should be hidden\n"); + await writeFile(join(testDir, "first.txt"), "match one\nmatch two\nmatch three\n"); + await writeFile(join(testDir, "second.txt"), "match four\n"); + await writeFile( + join(testDir, "binary.bin"), + Buffer.from([0x00, 0x01, 0x6d, 0x61, 0x74, 0x63, 0x68]) + ); + + const result = await searchFileContents(testDir, { + query: "match", + maxFiles: 1, + maxMatchesPerFile: 2, + }); + + expect(result.files).toHaveLength(1); + expect(result.files[0]).toMatchObject({ + path: "first.txt", + matchCount: 3, + hasMoreMatches: true, + }); + expect(result.files[0]?.matches).toHaveLength(2); + expect(result.totalMatchCount).toBe(4); + expect(result.hasMoreFiles).toBe(true); + expect(result.truncatedMatchFileCount).toBe(1); + expect(result.files.some((file) => file.path === "ignored.txt")).toBe(false); + expect(result.files.some((file) => file.path === "binary.bin")).toBe(false); + }); + + it("skips oversized files in the Node fallback scanner", async () => { + await writeFile(join(testDir, "large.txt"), "x".repeat(1_000_001) + "match"); + await writeFile(join(testDir, "small.txt"), "match\n"); + vi.stubEnv("PATH", ""); + + const result = await searchFileContents(testDir, { + query: "match", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.files.map((file) => file.path)).toEqual(["small.txt"]); + }); +}); diff --git a/packages/server/src/commands/file.ts b/packages/server/src/commands/file.ts index 1d2dc2f4..2cb5a565 100644 --- a/packages/server/src/commands/file.ts +++ b/packages/server/src/commands/file.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; +import { searchFileContents } from "../fs/content-search.js"; import { createDirectory, createFile, @@ -49,6 +50,29 @@ registerCommand( } ); +// file.searchContent +registerCommand( + "file.searchContent", + z.object({ + workspaceId: z.string(), + query: z.string(), + maxFiles: z.number().int().positive().max(100), + maxMatchesPerFile: z.number().int().positive().max(100), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + return searchFileContents(workspace.path, { + query: args.query, + maxFiles: args.maxFiles, + maxMatchesPerFile: args.maxMatchesPerFile, + }); + } +); + // file.read registerCommand( "file.read", diff --git a/packages/server/src/fs/content-search.ts b/packages/server/src/fs/content-search.ts new file mode 100644 index 00000000..fc3d2401 --- /dev/null +++ b/packages/server/src/fs/content-search.ts @@ -0,0 +1,333 @@ +import type { + SearchContentFileResult, + SearchContentMatch, + SearchContentResult, +} from "@coder-studio/core"; +import { spawn } from "child_process"; +import { existsSync } from "fs"; +import { readdir, readFile, stat } from "fs/promises"; +import { basename, join, relative } from "path"; +import { createInterface } from "readline"; +import { createGitignoreFilter } from "./gitignore.js"; + +const FALLBACK_MAX_FILE_BYTES = 1_000_000; + +export interface SearchFileContentsOptions { + query: string; + maxFiles: number; + maxMatchesPerFile: number; +} + +interface FileAccumulator { + path: string; + name: string; + matches: SearchContentMatch[]; + matchCount: number; +} + +interface SearchAccumulatorResult { + files: FileAccumulator[]; + totalMatchCount: number; + hasMoreFiles: boolean; +} + +export async function searchFileContents( + rootPath: string, + options: SearchFileContentsOptions +): Promise { + const query = options.query.trim(); + if (!query) { + return { + files: [], + totalMatchCount: 0, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + }; + } + + const result = await searchWithRipgrep(rootPath, query, options.maxFiles).catch( + async (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + return searchWithNode(rootPath, query, options.maxFiles); + } + + throw error; + } + ); + + return finalizeResults(result, options.maxFiles, options.maxMatchesPerFile); +} + +async function searchWithRipgrep( + rootPath: string, + query: string, + maxFiles: number +): Promise { + const hasGitignore = existsSync(join(rootPath, ".gitignore")); + const args = [ + "--json", + "--line-number", + "--column", + "--fixed-strings", + "--sort", + "path", + "--with-filename", + "--glob", + "!**/.git/**", + "--glob", + "!**/node_modules/**", + ]; + + if (hasGitignore) { + args.push("--hidden"); + args.push("--no-require-git"); + } + + args.push(query, "."); + + return new Promise((resolve, reject) => { + const child = spawn("rg", args, { cwd: rootPath, stdio: ["ignore", "pipe", "pipe"] }); + const stdout = createInterface({ input: child.stdout }); + const files = new Map(); + let totalMatchCount = 0; + let hasMoreFiles = false; + let stderr = ""; + + stdout.on("line", (line) => { + if (!line.trim()) { + return; + } + + const event = JSON.parse(line) as { + type?: string; + data?: { + path?: { text?: string }; + line_number?: number; + lines?: { text?: string }; + submatches?: Array<{ start: number; end: number }>; + }; + }; + + if (event.type !== "match") { + return; + } + + const rawPath = event.data?.path?.text; + if (!rawPath) { + return; + } + + const relativePath = normalizeRelativePath(relative(rootPath, join(rootPath, rawPath))); + const preview = (event.data?.lines?.text ?? "").replace(/\r?\n$/, ""); + const lineNumber = event.data?.line_number ?? 1; + const submatches = event.data?.submatches ?? []; + + totalMatchCount += submatches.length; + + if (!files.has(relativePath) && files.size >= maxFiles) { + hasMoreFiles = true; + return; + } + + for (const submatch of submatches) { + pushMatch(files, relativePath, { + line: lineNumber, + column: byteOffsetToColumn(preview, submatch.start), + endColumn: byteOffsetToColumn(preview, submatch.end), + preview, + previewColumnStart: byteOffsetToColumn(preview, submatch.start), + previewColumnEnd: byteOffsetToColumn(preview, submatch.end), + }); + } + }); + + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + void stdout.close(); + reject(error); + }); + + child.on("close", (code) => { + void stdout.close(); + + if (code === 0 || code === 1) { + resolve({ + files: sortAccumulators(files), + totalMatchCount, + hasMoreFiles, + }); + return; + } + + reject( + Object.assign(new Error(stderr || `rg exited with code ${code ?? "unknown"}`), { code }) + ); + }); + }); +} + +async function searchWithNode( + rootPath: string, + query: string, + maxFiles: number +): Promise { + const files = new Map(); + let totalMatchCount = 0; + let hasMoreFiles = false; + + async function walk(dirPath: string): Promise { + const filter = createGitignoreFilter(rootPath, dirPath); + const entries = await readdir(dirPath, { withFileTypes: true }); + const filteredEntries = entries.filter((entry) => filter(entry.name)); + filteredEntries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of filteredEntries) { + const fullPath = join(dirPath, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const fileStat = await stat(fullPath); + if (fileStat.size > FALLBACK_MAX_FILE_BYTES) { + continue; + } + + const buffer = await readFile(fullPath); + if (isBinaryFile(buffer)) { + continue; + } + + const relativePath = normalizeRelativePath(relative(rootPath, fullPath)); + const file = collectMatchesFromText(relativePath, buffer.toString("utf-8"), query); + if (!file) { + continue; + } + + totalMatchCount += file.matchCount; + + if (files.size >= maxFiles) { + hasMoreFiles = true; + continue; + } + + files.set(relativePath, file); + } + } + + await walk(rootPath); + return { + files: sortAccumulators(files), + totalMatchCount, + hasMoreFiles, + }; +} + +function collectMatchesFromText( + relativePath: string, + content: string, + query: string +): FileAccumulator | null { + const file: FileAccumulator = { + path: relativePath, + name: basename(relativePath), + matches: [], + matchCount: 0, + }; + const lines = content.split(/\r?\n/); + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const preview = lines[lineIndex] ?? ""; + if (!preview) { + continue; + } + + let fromIndex = 0; + while (fromIndex <= preview.length) { + const matchIndex = preview.indexOf(query, fromIndex); + if (matchIndex === -1) { + break; + } + + const startColumn = matchIndex + 1; + const endColumn = startColumn + query.length; + file.matches.push({ + line: lineIndex + 1, + column: startColumn, + endColumn, + preview, + previewColumnStart: startColumn, + previewColumnEnd: endColumn, + }); + file.matchCount += 1; + fromIndex = matchIndex + Math.max(query.length, 1); + } + } + + return file.matchCount > 0 ? file : null; +} + +function pushMatch( + files: Map, + relativePath: string, + match: SearchContentMatch +): void { + let file = files.get(relativePath); + if (!file) { + file = { + path: relativePath, + name: basename(relativePath), + matches: [], + matchCount: 0, + }; + files.set(relativePath, file); + } + + file.matches.push(match); + file.matchCount += 1; +} + +function sortAccumulators(files: Map): FileAccumulator[] { + return Array.from(files.values()).sort((a, b) => a.path.localeCompare(b.path)); +} + +function finalizeResults( + result: SearchAccumulatorResult, + maxFiles: number, + maxMatchesPerFile: number +): SearchContentResult { + const visibleFiles = result.files.slice(0, maxFiles).map((file) => ({ + path: file.path, + name: file.name, + matchCount: file.matchCount, + hasMoreMatches: file.matchCount > maxMatchesPerFile, + matches: file.matches.slice(0, maxMatchesPerFile), + })); + + return { + files: visibleFiles, + totalMatchCount: result.totalMatchCount, + hasMoreFiles: result.hasMoreFiles || result.files.length > maxFiles, + truncatedMatchFileCount: visibleFiles.filter((file) => file.hasMoreMatches).length, + }; +} + +function normalizeRelativePath(path: string): string { + return path.replace(/\\/g, "/"); +} + +function byteOffsetToColumn(preview: string, byteOffset: number): number { + return Buffer.from(preview, "utf8").subarray(0, byteOffset).toString("utf8").length + 1; +} + +function isBinaryFile(buffer: Buffer): boolean { + const sample = buffer.subarray(0, 8000); + return sample.includes(0); +} diff --git a/packages/server/src/fs/tree.ts b/packages/server/src/fs/tree.ts index 09f9348b..bf07450d 100644 --- a/packages/server/src/fs/tree.ts +++ b/packages/server/src/fs/tree.ts @@ -103,7 +103,7 @@ export async function searchFiles( } if (entry.isFile()) { - const rank = scoreFilenameMatch(entry.name, normalizedQuery); + const rank = scoreFileMatch(relPath, entry.name, normalizedQuery); if (rank === null) { continue; } @@ -153,6 +153,15 @@ export async function searchFiles( return { files }; } +function scoreFileMatch(path: string, name: string, query: string): number | null { + const filenameRank = scoreFilenameMatch(name, query); + if (filenameRank !== null) { + return filenameRank; + } + + return scorePathMatch(path, query); +} + function scoreFilenameMatch(name: string, query: string): number | null { const normalizedName = name.toLowerCase(); const baseName = normalizedName.replace(/\.[^.]+$/, ""); @@ -188,6 +197,28 @@ function scoreFilenameMatch(name: string, query: string): number | null { return null; } +function scorePathMatch(path: string, query: string): number | null { + const normalizedPath = path.toLowerCase(); + + if (normalizedPath === query) { + return 7; + } + + if (normalizedPath.startsWith(query)) { + return 8; + } + + if (normalizedPath.includes(query)) { + return 9; + } + + if (isSubsequence(query, normalizedPath)) { + return 10; + } + + return null; +} + function isSubsequence(query: string, candidate: string): boolean { let index = 0; diff --git a/packages/server/src/supervisor/evaluator.test.ts b/packages/server/src/supervisor/evaluator.test.ts index 8e722b53..736cdbef 100644 --- a/packages/server/src/supervisor/evaluator.test.ts +++ b/packages/server/src/supervisor/evaluator.test.ts @@ -534,20 +534,26 @@ describe("SupervisorEvaluator", () => { ).rejects.toThrow(); const prompt = (logger.warn.mock.calls[0]?.[0] as { prompt?: string } | undefined)?.prompt; - expect(prompt).toContain("You are an autonomous supervisor for a target-scoped software task."); - expect(prompt).toContain("Return JSON only."); - expect(prompt).toContain("No prose before or after the JSON."); expect(prompt).toContain( - 'Prefer "continue" over "stop" whenever the objective is not yet verified complete and there is a concrete next action.' + "You are an autonomous planner-supervisor for this target-scoped software task." + ); + expect(prompt).toContain( + "Your purpose is to drive the work from objective to high-quality delivery with minimal babysitting." ); expect(prompt).toContain( - "Do not ask the user to decide, clarify, or choose among implementation options." + 'Do not optimize for merely reaching "done"; optimize for a result that is correct, verified, coherent, and not obviously low-quality or rushed.' ); + expect(prompt).toContain("Return JSON only."); + expect(prompt).toContain("No prose before or after the JSON."); + expect(prompt).toContain("Act as an autonomous execution supervisor."); expect(prompt).toContain( - "Do not treat the agent's claims, summaries, or self-reports as sufficient evidence of completion." + "Your job is to keep the agent moving toward the objective, maintain delivery quality, detect low-yield paths early, and redirect work when needed." ); expect(prompt).toContain( - "If the agent asks a question or presents multiple options, choose the most conservative reasonable option yourself and direct the next action." + "Do not passively observe progress; actively steer it toward successful, high-quality completion." + ); + expect(prompt).toContain( + "Drive execution through the supervised agent rather than by independently performing the work yourself." ); expect(prompt).toContain("Use the target memory as the current supervision state."); expect(prompt).toContain("Identify which decomposition item is currently active."); @@ -564,16 +570,18 @@ describe("SupervisorEvaluator", () => { "Advance to the next item only after the current item's deliverable or acceptanceCriteria are supported by observable evidence." ); expect(prompt).toContain( - "If the agent appears stuck or repeated the same action, give a different concrete next action." + "If the current path is low-yield, brittle, repetitive, or producing low-quality output, redirect early." ); - expect(prompt).toContain("Do not stop only because the agent says the work is complete"); - expect(prompt).toContain('Guidance requirements for "continue":'); expect(prompt).toContain( - "Be specific enough for the supervised agent to act without asking the user." + "Maintain commitment to the objective, not blind commitment to the current tactic." ); + expect(prompt).toContain("Delivery quality bar:"); + expect(prompt).toContain("Do not accept shallow, brittle, or obviously rushed solutions."); expect(prompt).toContain( - "If the agent asked a question, answer it directly in the guidance and continue with a concrete next action." + "If a solution technically works but is low-quality, incomplete, poorly verified, or obviously a shortcut, treat the milestone as not yet complete." ); + expect(prompt).toContain("Completion standard:"); + expect(prompt).toContain("Optimize for finished, verified, and defensible delivery."); expect(prompt).toContain("Use itemUpdates to reflect evidence-backed status changes only."); expect(prompt).toContain( "If evidence is missing or ambiguous, prefer verification over further implementation." @@ -590,7 +598,7 @@ describe("SupervisorEvaluator", () => { expect(prompt).toContain('"stop"'); }); - it("builds a decompose prompt that forbids questions and requires autonomous decisions", async () => { + it("builds a decompose prompt that emphasizes execution planning and delivery quality", async () => { const logger = createLogger(); const evaluator = new SupervisorEvaluator({ providerRegistry: [createProvider("codex", "")], @@ -620,15 +628,31 @@ describe("SupervisorEvaluator", () => { const prompt = (logger.warn.mock.calls[0]?.[0] as { prompt?: string } | undefined)?.prompt; expect(prompt).toContain("Return JSON only."); - expect(prompt).toContain("Do not ask the user any questions."); - expect(prompt).toContain("Do not ask for clarification, confirmation, or approval."); - expect(prompt).toContain("Do not propose options for the user to choose from."); expect(prompt).toContain( - "If information is incomplete, make the most conservative reasonable assumptions and decide the decomposition yourself." + "You are an autonomous planner-supervisor for this target-scoped software task." ); expect(prompt).toContain( - "Your job is to return the best useful decomposition now, not to begin a discussion or planning workflow." + "Your purpose is to drive the work from objective to high-quality delivery with minimal babysitting." ); + expect(prompt).toContain("Create an execution plan, not just a task list."); + expect(prompt).toContain( + "Break the objective into the smallest reasonable set of milestones that maximize clarity, reduce uncertainty, and preserve steady forward progress." + ); + expect(prompt).toContain( + "Order milestones by dependency, risk reduction, and delivery leverage." + ); + expect(prompt).toContain( + "Build the plan so it can recover from failed attempts: prefer decompositions that allow narrowing scope, isolating failures, checking assumptions, and restoring a working baseline when needed." + ); + expect(prompt).toContain( + "Include quality and verification checkpoints where they materially improve the final result." + ); + expect(prompt).toContain("Do not decompose in a way that encourages superficial completion."); + expect(prompt).toContain("Do not ask the user any questions."); + expect(prompt).toContain("Do not ask for clarification, confirmation, or approval."); + expect(prompt).toContain("Do not propose options for the user to choose from."); + expect(prompt).toContain("Planning boundary:"); + expect(prompt).toContain("Do not hard-code unnecessary implementation detail too early."); expect(prompt).toContain("No prose before or after the JSON."); }); diff --git a/packages/server/src/supervisor/evaluator.ts b/packages/server/src/supervisor/evaluator.ts index 05bcf767..eaf2a0c4 100644 --- a/packages/server/src/supervisor/evaluator.ts +++ b/packages/server/src/supervisor/evaluator.ts @@ -176,13 +176,19 @@ export class SupervisorEvaluator { function buildPrompt(context: SupervisorEvaluationContext, mode: "decompose" | "evaluate"): string { if (mode === "decompose") { return [ - "You are an autonomous supervisor for a target-scoped software task.", + "You are an autonomous planner-supervisor for this target-scoped software task.", + "Your purpose is to drive the work from objective to high-quality delivery with minimal babysitting.", + 'Do not optimize for merely reaching "done"; optimize for a result that is correct, verified, coherent, and not obviously low-quality or rushed.', "Your first job is to decompose the target into a supervision structure before evaluation begins.", "", "Return JSON only.", "No prose before or after the JSON.", "", "Decomposition policy:", + "- Create an execution plan, not just a task list.", + "- Break the objective into the smallest reasonable set of milestones that maximize clarity, reduce uncertainty, and preserve steady forward progress.", + "- Order milestones by dependency, risk reduction, and delivery leverage.", + "- Prefer a plan structure that makes execution easier, verification clearer, and replanning cheaper.", "- Do not ask the user any questions.", "- Do not ask for clarification, confirmation, or approval.", "- Do not propose options for the user to choose from.", @@ -196,6 +202,30 @@ function buildPrompt(context: SupervisorEvaluationContext, mode: "decompose" | " "- Each item must be concrete, milestone-sized, and useful for subsequent evaluation.", "- Do not leave the structure empty.", "", + "Decomposition principles:", + "- Prefer milestones that produce a concrete artifact, observable behavior change, test result, or verification result.", + "- Make dependencies explicit.", + "- Separate implementation, verification, integration, and cleanup when that improves delivery reliability.", + "- If a step is too vague to verify independently, split it further.", + "- Prefer plans that keep the agent moving with minimal ambiguity between milestones.", + "- Use stage-based planning by default unless there are clearly independent deliverables that justify subtargets.", + "- Build the plan so it can recover from failed attempts: prefer decompositions that allow narrowing scope, isolating failures, checking assumptions, and restoring a working baseline when needed.", + "- Keep the decomposition practical for execution, not merely neat on paper.", + "", + "Planning quality bar:", + "- Prefer fewer, stronger milestones over many thin or vague ones.", + "- Every item should imply a concrete deliverable and observable acceptance criteria.", + '- Avoid vague items such as "improve", "clean up", or "refactor" unless tied to a specific delivery or verification target.', + "- Include quality and verification checkpoints where they materially improve the final result.", + "- Do not decompose in a way that encourages superficial completion.", + "", + "Planning boundary:", + "- You are responsible for execution structure, sequencing, quality control, and verification structure.", + "- Do not hard-code unnecessary implementation detail too early.", + "- If multiple implementation paths exist, prefer a plan that keeps execution adaptable until evidence makes one path clearly better.", + "- Do not hide assumptions inside the plan.", + "- Do not create a brittle plan that depends on perfect execution.", + "", "Item requirements:", '- Each item must include "id", "kind", "title", "objective", "deliverable", "acceptanceCriteria", and "status".', '- "kind" must match the selected decompositionMode: all "stage" or all "subtarget".', @@ -229,8 +259,13 @@ function buildPrompt(context: SupervisorEvaluationContext, mode: "decompose" | " } const lines: string[] = [ - "You are an autonomous supervisor for a target-scoped software task.", - "Your job is to keep the agent moving toward the objective until the objective is complete.", + "You are an autonomous planner-supervisor for this target-scoped software task.", + "Your purpose is to drive the work from objective to high-quality delivery with minimal babysitting.", + 'Do not optimize for merely reaching "done"; optimize for a result that is correct, verified, coherent, and not obviously low-quality or rushed.', + "Act as an autonomous execution supervisor.", + "Your job is to keep the agent moving toward the objective, maintain delivery quality, detect low-yield paths early, and redirect work when needed.", + "Do not passively observe progress; actively steer it toward successful, high-quality completion.", + "Drive execution through the supervised agent rather than by independently performing the work yourself.", "", "Return JSON only.", "No prose before or after the JSON.", @@ -261,7 +296,12 @@ function buildPrompt(context: SupervisorEvaluationContext, mode: "decompose" | " '- When advancing to the next item, mark the previous item as "done" and set activeItemId to the next item explicitly.', "- If the active item is blocked, give guidance that is most likely to unblock it.", "- If the active item is obsolete, explain the reason briefly and move to the next useful item.", - "- If the agent appears stuck or repeated the same action, give a different concrete next action.", + "- If the current path is low-yield, brittle, repetitive, or producing low-quality output, redirect early.", + "- Diagnose stalls precisely: implementation failure, verification failure, environment failure, scope misframing, weak solution quality, or missing evidence.", + "- Choose the next action that most improves objective-level progress, not merely the most local continuation.", + "- Do not repeat the same tactic after failure unless new evidence justifies retrying it.", + "- Maintain commitment to the objective, not blind commitment to the current tactic.", + "- Replan locally when needed, but keep the overall execution coherent and objective-driven.", "- Do not rewrite the decomposition structure during normal evaluation cycles.", "", "Allowed statuses:", @@ -286,6 +326,21 @@ function buildPrompt(context: SupervisorEvaluationContext, mode: "decompose" | " "- If implementation is needed, point to the likely area, behavior, or file/module based on available evidence.", "- If the agent asked a question, answer it directly in the guidance and continue with a concrete next action.", "", + "Delivery quality bar:", + "- Do not accept shallow, brittle, or obviously rushed solutions.", + "- Do not optimize for the smallest change if it leads to poor maintainability, weak verification, or fragile behavior.", + "- Prefer solutions that are robust, coherent with the existing codebase, and likely to hold up beyond the happy path.", + "- Require appropriate verification for the kind of work being done.", + "- Consider edge cases, integration impact, regressions, and maintainability where relevant.", + "- If a solution technically works but is low-quality, incomplete, poorly verified, or obviously a shortcut, treat the milestone as not yet complete.", + "- Do not let superficial progress masquerade as real delivery.", + "", + "Completion standard:", + "- A milestone is complete only when its deliverable and acceptanceCriteria are supported by observable evidence and the result meets a reasonable quality bar.", + "- The objective is complete only when the final result is implemented, verified, and not obviously compromised in quality.", + "- Do not mark work complete merely because code was changed, a command passed once, or a minimal patch exists.", + "- Optimize for finished, verified, and defensible delivery.", + "", "Evaluation policy:", "- Update progress incrementally against the existing decomposition.", "- Use itemUpdates to reflect evidence-backed status changes only.", diff --git a/packages/web/src/atoms/app-ui.ts b/packages/web/src/atoms/app-ui.ts index 26745e1a..290bf4cf 100644 --- a/packages/web/src/atoms/app-ui.ts +++ b/packages/web/src/atoms/app-ui.ts @@ -72,6 +72,11 @@ export const authenticatedAtom = atom(false); */ export const commandPaletteOpenAtom = atom(false); +/** + * Quick Open overlay state + */ +export const quickOpenOpenAtom = atom(false); + /** * Pending session-focus request. * diff --git a/packages/web/src/features/code-editor/actions/pending-editor-loads.test.ts b/packages/web/src/features/code-editor/actions/pending-editor-loads.test.ts new file mode 100644 index 00000000..d6aea1ae --- /dev/null +++ b/packages/web/src/features/code-editor/actions/pending-editor-loads.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + __getPendingEditorLoadWorkspaceCountForTests, + __resetPendingEditorLoadsForTests, + beginPendingEditorLoad, + cancelPendingEditorLoad, + finishPendingEditorLoad, + hasAnyPendingEditorLoads, + shouldIgnorePendingEditorLoadResult, +} from "./pending-editor-loads"; + +describe("pending editor loads tracker", () => { + beforeEach(() => { + __resetPendingEditorLoadsForTests(); + }); + + it("cleans up workspace state after a finished load settles", () => { + const requestId = beginPendingEditorLoad("ws-1", "src/app.ts"); + + expect(__getPendingEditorLoadWorkspaceCountForTests()).toBe(1); + + finishPendingEditorLoad("ws-1", "src/app.ts", requestId); + + expect(__getPendingEditorLoadWorkspaceCountForTests()).toBe(0); + expect(shouldIgnorePendingEditorLoadResult("ws-1", "src/app.ts", requestId)).toBe(true); + }); + + it("cleans up workspace state after a cancelled load is ignored", () => { + const requestId = beginPendingEditorLoad("ws-1", "src/app.ts"); + + cancelPendingEditorLoad("ws-1", "src/app.ts"); + + expect(__getPendingEditorLoadWorkspaceCountForTests()).toBe(1); + expect(shouldIgnorePendingEditorLoadResult("ws-1", "src/app.ts", requestId)).toBe(true); + expect(__getPendingEditorLoadWorkspaceCountForTests()).toBe(0); + }); + + it("keeps request ids safe across cleanup cycles so old late results stay ignored", () => { + const firstRequestId = beginPendingEditorLoad("ws-1", "src/app.ts"); + finishPendingEditorLoad("ws-1", "src/app.ts", firstRequestId); + + expect(__getPendingEditorLoadWorkspaceCountForTests()).toBe(0); + + const secondRequestId = beginPendingEditorLoad("ws-1", "src/app.ts"); + + expect(secondRequestId).toBeGreaterThan(firstRequestId); + expect(shouldIgnorePendingEditorLoadResult("ws-1", "src/app.ts", firstRequestId)).toBe(true); + expect(shouldIgnorePendingEditorLoadResult("ws-1", "src/app.ts", secondRequestId)).toBe(false); + }); + + it("tracks whether a workspace still has any pending editor loads", () => { + const firstRequestId = beginPendingEditorLoad("ws-1", "src/a.ts"); + const secondRequestId = beginPendingEditorLoad("ws-1", "src/b.ts"); + + expect(hasAnyPendingEditorLoads("ws-1")).toBe(true); + + finishPendingEditorLoad("ws-1", "src/a.ts", firstRequestId); + expect(hasAnyPendingEditorLoads("ws-1")).toBe(true); + + finishPendingEditorLoad("ws-1", "src/b.ts", secondRequestId); + expect(hasAnyPendingEditorLoads("ws-1")).toBe(false); + }); + + it("cleans up a cancelled tombstone when the same path is reopened and later finishes", () => { + const firstRequestId = beginPendingEditorLoad("ws-1", "src/app.ts"); + + cancelPendingEditorLoad("ws-1", "src/app.ts"); + expect(__getPendingEditorLoadWorkspaceCountForTests()).toBe(1); + + const secondRequestId = beginPendingEditorLoad("ws-1", "src/app.ts"); + finishPendingEditorLoad("ws-1", "src/app.ts", secondRequestId); + + expect(__getPendingEditorLoadWorkspaceCountForTests()).toBe(0); + expect(shouldIgnorePendingEditorLoadResult("ws-1", "src/app.ts", firstRequestId)).toBe(true); + }); +}); diff --git a/packages/web/src/features/code-editor/actions/pending-editor-loads.ts b/packages/web/src/features/code-editor/actions/pending-editor-loads.ts new file mode 100644 index 00000000..68d3e1d4 --- /dev/null +++ b/packages/web/src/features/code-editor/actions/pending-editor-loads.ts @@ -0,0 +1,151 @@ +type WorkspacePendingLoadState = { + pendingByPath: Record; + cancelledByPath: Record; +}; + +const workspacePendingLoads = new Map(); +const workspacePendingLoadListeners = new Map void>>(); +let nextPendingEditorLoadRequestId = 0; + +function getWorkspacePendingLoadState(workspaceId: string): WorkspacePendingLoadState { + const existing = workspacePendingLoads.get(workspaceId); + if (existing) { + return existing; + } + + const created: WorkspacePendingLoadState = { + pendingByPath: {}, + cancelledByPath: {}, + }; + workspacePendingLoads.set(workspaceId, created); + return created; +} + +function cleanupWorkspacePendingLoadState(workspaceId: string, state: WorkspacePendingLoadState) { + if ( + Object.keys(state.pendingByPath).length === 0 && + Object.keys(state.cancelledByPath).length === 0 + ) { + workspacePendingLoads.delete(workspaceId); + } +} + +function notifyPendingEditorLoadListeners(workspaceId: string) { + const listeners = workspacePendingLoadListeners.get(workspaceId); + if (!listeners) { + return; + } + + for (const listener of listeners) { + listener(); + } +} + +export function beginPendingEditorLoad(workspaceId: string, path: string): number { + const state = getWorkspacePendingLoadState(workspaceId); + const requestId = nextPendingEditorLoadRequestId + 1; + nextPendingEditorLoadRequestId = requestId; + delete state.cancelledByPath[path]; + state.pendingByPath[path] = requestId; + notifyPendingEditorLoadListeners(workspaceId); + return requestId; +} + +export function shouldIgnorePendingEditorLoadResult( + workspaceId: string, + path: string, + requestId: number +): boolean { + const state = workspacePendingLoads.get(workspaceId); + if (!state) { + return true; + } + + if (state.cancelledByPath[path] === requestId) { + delete state.cancelledByPath[path]; + cleanupWorkspacePendingLoadState(workspaceId, state); + return true; + } + + return state.pendingByPath[path] !== requestId; +} + +export function finishPendingEditorLoad(workspaceId: string, path: string, requestId: number) { + const state = workspacePendingLoads.get(workspaceId); + if (!state) { + return; + } + + if (state.pendingByPath[path] === requestId) { + delete state.pendingByPath[path]; + } + if (state.cancelledByPath[path] === requestId) { + delete state.cancelledByPath[path]; + } + + cleanupWorkspacePendingLoadState(workspaceId, state); + notifyPendingEditorLoadListeners(workspaceId); +} + +export function cancelPendingEditorLoad(workspaceId: string, path: string) { + const state = workspacePendingLoads.get(workspaceId); + if (!state) { + return; + } + + const requestId = state.pendingByPath[path]; + if (requestId === undefined) { + return; + } + + state.cancelledByPath[path] = requestId; + delete state.pendingByPath[path]; + notifyPendingEditorLoadListeners(workspaceId); +} + +export function cancelAllPendingEditorLoads(workspaceId: string) { + const state = workspacePendingLoads.get(workspaceId); + if (!state) { + return; + } + + for (const [path, requestId] of Object.entries(state.pendingByPath)) { + state.cancelledByPath[path] = requestId; + } + + state.pendingByPath = {}; + notifyPendingEditorLoadListeners(workspaceId); +} + +export function hasPendingEditorLoad(workspaceId: string, path: string): boolean { + const state = workspacePendingLoads.get(workspaceId); + return state?.pendingByPath[path] !== undefined; +} + +export function hasAnyPendingEditorLoads(workspaceId: string): boolean { + const state = workspacePendingLoads.get(workspaceId); + return state !== undefined && Object.keys(state.pendingByPath).length > 0; +} + +export function subscribeToPendingEditorLoads(workspaceId: string, listener: () => void) { + const listeners = workspacePendingLoadListeners.get(workspaceId) ?? new Set<() => void>(); + listeners.add(listener); + workspacePendingLoadListeners.set(workspaceId, listeners); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + workspacePendingLoadListeners.delete(workspaceId); + } + }; +} + +export function __resetPendingEditorLoadsForTests() { + workspacePendingLoads.clear(); + workspacePendingLoadListeners.clear(); + nextPendingEditorLoadRequestId = 0; +} + +export function __getPendingEditorLoadWorkspaceCountForTests(): number { + return workspacePendingLoads.size; +} diff --git a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts index 72f6f941..14509c2c 100644 --- a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts +++ b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts @@ -2,6 +2,7 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { dispatchCommandAtom } from "../../../atoms/connection"; import { activeWorkspaceAtom } from "../../../atoms/workspaces"; +import { useOpenEditorsActions } from "../../workspace/actions/use-open-editors-actions"; import { activeFilePathAtomFamily, deriveDocumentPreviewKind, @@ -16,6 +17,13 @@ import { type WorkspaceEditorMode, } from "../../workspace/atoms"; import { monacoModelRegistry } from "../monaco/model-registry"; +import { + beginPendingEditorLoad, + cancelPendingEditorLoad, + finishPendingEditorLoad, + hasPendingEditorLoad, + shouldIgnorePendingEditorLoadResult, +} from "./pending-editor-loads"; import { usePreviewSession } from "./use-preview-session"; type FileReadTextPayload = { @@ -52,8 +60,8 @@ export function useCodeEditorActions() { const dispatch = useAtomValue(dispatchCommandAtom); const setDiffPreview = useSetAtom(gitDiffPreviewAtomFamily(workspace?.id ?? "")); - const [isSaving, setIsSaving] = useState(false); - const [saveError, setSaveError] = useState(null); + const [savingPaths, setSavingPaths] = useState>(() => new Set()); + const [saveError, setSaveError] = useState<{ path: string; message: string } | null>(null); const [fileLoadError, setFileLoadError] = useState<{ path: string; message: string } | null>( null ); @@ -70,6 +78,13 @@ export function useCodeEditorActions() { const diffPreview = useAtomValue(gitDiffPreviewAtomFamily(workspaceId ?? "")); const gitState = useAtomValue(gitStateAtomFamily(workspaceId ?? "")); const lastSeededModePathRef = useRef(null); + const pendingActivePathRef = useRef(null); + const nextSaveRequestIdRef = useRef(0); + const activeSaveRequestIdByPathRef = useRef>(new Map()); + const previousOpenFilePathsRef = useRef(null); + const { closePath } = useOpenEditorsActions(workspaceId ?? "", { + workspaceRootPath, + }); const currentFile: OpenFile | undefined = workspaceId ? openFiles[activeFilePath ?? ""] @@ -94,19 +109,84 @@ export function useCodeEditorActions() { } }, [activeFilePath, currentFile, diffPreview, mode, setMode, workspaceId]); + useEffect(() => { + setSaveError((current) => (current?.path === activeFilePath ? current : null)); + setFileLoadError((current) => (current?.path === activeFilePath ? current : null)); + setExternalStatus((current) => (current?.path === activeFilePath ? current : null)); + }, [activeFilePath]); + + const invalidateSaveStateForPaths = useCallback((paths: string[]) => { + if (paths.length === 0) { + return; + } + + const removedPaths = new Set(paths); + for (const path of removedPaths) { + activeSaveRequestIdByPathRef.current.delete(path); + } + + setSavingPaths((current) => { + let changed = false; + const next = new Set(current); + for (const path of removedPaths) { + if (next.delete(path)) { + changed = true; + } + } + + return changed ? next : current; + }); + setSaveError((current) => (current && removedPaths.has(current.path) ? null : current)); + }, []); + + useEffect(() => { + const currentOpenFilePaths = Object.keys(openFiles); + if (previousOpenFilePathsRef.current === null) { + previousOpenFilePathsRef.current = currentOpenFilePaths; + return; + } + + const removedPaths = previousOpenFilePathsRef.current.filter((path) => !(path in openFiles)); + previousOpenFilePathsRef.current = currentOpenFilePaths; + invalidateSaveStateForPaths(removedPaths); + }, [invalidateSaveStateForPaths, openFiles]); + + useEffect(() => { + if (!workspaceId) { + pendingActivePathRef.current = null; + return; + } + + const nextPendingActivePath = + activeFilePath && !openFiles[activeFilePath] ? activeFilePath : null; + const previousPendingActivePath = pendingActivePathRef.current; + + if (previousPendingActivePath && previousPendingActivePath !== nextPendingActivePath) { + cancelPendingEditorLoad(workspaceId, previousPendingActivePath); + } + + pendingActivePathRef.current = nextPendingActivePath; + }, [activeFilePath, openFiles, workspaceId]); + const loadFile = useCallback( async (path: string, options?: { forceText?: boolean }) => { if (!workspaceId) { return; } + const requestId = beginPendingEditorLoad(workspaceId, path); setFileLoadError((current) => (current?.path === path ? null : current)); const result = await dispatch("file.read", { workspaceId, path, }); + if (shouldIgnorePendingEditorLoadResult(workspaceId, path, requestId)) { + return; + } + if (!result.ok || !result.data) { + finishPendingEditorLoad(workspaceId, path, requestId); const message = result.error?.message ?? "Failed to open file"; console.error("Failed to open file:", message); setFileLoadError({ path, message }); @@ -118,7 +198,12 @@ export function useCodeEditorActions() { if (options?.forceText && data.kind === "image" && data.isTextBacked) { try { const response = await fetch(data.url, { credentials: "include" }); + if (shouldIgnorePendingEditorLoadResult(workspaceId, path, requestId)) { + return; + } + if (!response.ok) { + finishPendingEditorLoad(workspaceId, path, requestId); const message = `Failed to fetch text-backed image bytes: ${response.status}`; console.error(message); setFileLoadError({ path, message }); @@ -126,6 +211,10 @@ export function useCodeEditorActions() { } const content = await response.text(); + if (shouldIgnorePendingEditorLoadResult(workspaceId, path, requestId)) { + return; + } + const newFile: OpenFile = { kind: "text", path, @@ -136,6 +225,7 @@ export function useCodeEditorActions() { viewingTextBackedImageAsText: true, }; + finishPendingEditorLoad(workspaceId, path, requestId); setOpenFiles((prev) => ({ ...prev, [path]: newFile })); if (workspaceRootPath) { monacoModelRegistry.updateFromDisk({ @@ -146,6 +236,7 @@ export function useCodeEditorActions() { } setFileLoadError((current) => (current?.path === path ? null : current)); } catch (error) { + finishPendingEditorLoad(workspaceId, path, requestId); const message = error instanceof Error ? error.message : "Failed to fetch text-backed image bytes"; console.error("Failed to fetch text-backed image bytes:", error); @@ -177,6 +268,7 @@ export function useCodeEditorActions() { externalState: undefined, }; + finishPendingEditorLoad(workspaceId, path, requestId); setOpenFiles((prev) => ({ ...prev, [path]: newFile })); if (workspaceRootPath && data.kind === "text") { monacoModelRegistry.updateFromDisk({ @@ -201,45 +293,61 @@ export function useCodeEditorActions() { }, []); const handleSave = useCallback(async () => { - if (!workspaceId || !currentFile || currentFile.kind !== "text" || isSaving) { + if (!workspaceId || !currentFile || currentFile.kind !== "text") { return; } - setIsSaving(true); - setSaveError(null); + const { path, content, baseHash } = currentFile; + if (savingPaths.has(path)) { + return; + } + + const requestId = ++nextSaveRequestIdRef.current; + activeSaveRequestIdByPathRef.current.set(path, requestId); + setSavingPaths((current) => new Set(current).add(path)); + setSaveError((current) => (current?.path === path ? null : current)); const result = await dispatch<{ newHash: string }>("file.write", { workspaceId, - path: currentFile.path, - content: currentFile.content, - baseHash: currentFile.baseHash || undefined, + path, + content, + baseHash: baseHash || undefined, }); + if (activeSaveRequestIdByPathRef.current.get(path) !== requestId) { + return; + } + if (result.ok && result.data) { setOpenFiles((prev) => { - const prevFile = prev[currentFile.path]; + const prevFile = prev[path]; if (!prevFile || prevFile.kind !== "text") { return prev; } return { ...prev, - [currentFile.path]: { + [path]: { ...prevFile, - savedContent: currentFile.content, + savedContent: content, baseHash: result.data!.newHash, isDirty: false, externalState: undefined, }, }; }); - setExternalStatus((current) => (current?.path === currentFile.path ? null : current)); + setExternalStatus((current) => (current?.path === path ? null : current)); } else { - setSaveError(result.error?.message ?? "Failed to save file"); + setSaveError({ path, message: result.error?.message ?? "Failed to save file" }); } - setIsSaving(false); - }, [currentFile, dispatch, isSaving, setOpenFiles, workspaceId]); + activeSaveRequestIdByPathRef.current.delete(path); + setSavingPaths((current) => { + const next = new Set(current); + next.delete(path); + return next; + }); + }, [currentFile, dispatch, savingPaths, setOpenFiles, workspaceId]); const handleContentChange = useCallback( (newContent: string) => { @@ -275,6 +383,10 @@ export function useCodeEditorActions() { return; } + if (hasPendingEditorLoad(workspaceId, activeFilePath)) { + return; + } + void loadFile(activeFilePath); }, [activeFilePath, loadFile, openFiles, workspaceId]); @@ -515,31 +627,23 @@ export function useCodeEditorActions() { ]); const handleClose = useCallback(() => { - if (!workspaceId) { + if (diffPreview?.source === "commit") { + setDiffPreview(null); + if (currentFile) { + const nextMode = deriveEditorModeForOpenFile(currentFile); + if (nextMode !== mode) { + setMode(nextMode); + } + } return; } - const currentPath = currentFile?.path; - setActiveFilePath(null); - - if (currentPath) { - setOpenFiles((prev) => { - if (!(currentPath in prev)) { - return prev; - } - - const next = { ...prev }; - delete next[currentPath]; - return next; - }); - if (workspaceRootPath && currentFile?.kind === "text") { - monacoModelRegistry.disposeFile(workspaceRootPath, currentPath); - } + if (currentFile?.path || activeFilePath) { + closePath(currentFile?.path ?? activeFilePath); } setSaveError(null); - setMode("edit"); - }, [currentFile, setActiveFilePath, setMode, setOpenFiles, workspaceId, workspaceRootPath]); + }, [activeFilePath, closePath, currentFile, diffPreview, mode, setDiffPreview, setMode]); const toggleSvgTextMode = useCallback(() => { if (!workspaceId || !currentFile) { @@ -624,11 +728,14 @@ export function useCodeEditorActions() { diffPreview.source === "commit") ? diffPreview : null; + const isSaving = Boolean(isTextFile && savingPaths.has(currentFile.path)); const canSave = Boolean(isTextFile && currentFile.isDirty && !isSaving); const activeLoadError = activeFilePath && fileLoadError?.path === activeFilePath ? fileLoadError.message : null; const activeExternalStatus = activeFilePath && externalStatus?.path === activeFilePath ? externalStatus.status : null; + const activeSaveError = + activeFilePath && saveError?.path === activeFilePath ? saveError.message : null; const documentPreviewKind = currentFile?.kind === "text" ? deriveDocumentPreviewKind(currentFile.path) : null; const documentPreview = usePreviewSession({ @@ -660,7 +767,7 @@ export function useCodeEditorActions() { isTextFile, mode, openInDiffMode, - saveError, + saveError: activeSaveError, setMode: (nextMode: WorkspaceEditorMode) => { setMode(nextMode); }, diff --git a/packages/web/src/features/code-editor/actions/use-open-location.ts b/packages/web/src/features/code-editor/actions/use-open-location.ts index 31a613ef..99c47374 100644 --- a/packages/web/src/features/code-editor/actions/use-open-location.ts +++ b/packages/web/src/features/code-editor/actions/use-open-location.ts @@ -1,19 +1,37 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useRef } from "react"; -import { activeFilePathAtomFamily, openFilesAtomFamily } from "../../workspace/atoms"; +import { + activeFilePathAtomFamily, + deriveEditorModeForOpenFile, + deriveEditorModeForPath, + editorModeAtomFamily, + gitDiffPreviewAtomFamily, + openFilesAtomFamily, +} from "../../workspace/atoms"; import { type PendingEditorNavigation, pendingEditorNavigationAtomFamily } from "../atoms"; export function useOpenLocation(workspaceId: string): { openLocation(input: PendingEditorNavigation): Promise; clearPendingNavigation(path: string): void; } { + const diffPreview = useAtomValue(gitDiffPreviewAtomFamily(workspaceId)); const openFiles = useAtomValue(openFilesAtomFamily(workspaceId)); const setActiveFilePath = useSetAtom(activeFilePathAtomFamily(workspaceId)); + const setEditorMode = useSetAtom(editorModeAtomFamily(workspaceId)); + const setDiffPreview = useSetAtom(gitDiffPreviewAtomFamily(workspaceId)); const setPendingNavigation = useSetAtom(pendingEditorNavigationAtomFamily(workspaceId)); const nextRequestIdRef = useRef(0); const openLocation = useCallback( async (input: PendingEditorNavigation) => { + if (diffPreview?.source === "commit") { + setDiffPreview(null); + const openFile = openFiles[input.path]; + setEditorMode( + openFile ? deriveEditorModeForOpenFile(openFile) : deriveEditorModeForPath(input.path) + ); + } + setActiveFilePath(input.path); if (!openFiles[input.path]) { @@ -26,7 +44,7 @@ export function useOpenLocation(workspaceId: string): { requestId: ++nextRequestIdRef.current, }); }, - [openFiles, setActiveFilePath, setPendingNavigation] + [diffPreview, openFiles, setActiveFilePath, setDiffPreview, setEditorMode, setPendingNavigation] ); const clearPendingNavigation = useCallback( diff --git a/packages/web/src/features/code-editor/index.test.tsx b/packages/web/src/features/code-editor/index.test.tsx index 97420c0c..dc522993 100644 --- a/packages/web/src/features/code-editor/index.test.tsx +++ b/packages/web/src/features/code-editor/index.test.tsx @@ -1,4 +1,12 @@ -import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + renderHook, + screen, + waitFor, + within, +} from "@testing-library/react"; import { createStore, Provider } from "jotai"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -16,7 +24,9 @@ import { type OpenFile, openFilesAtomFamily, } from "../workspace/atoms"; +import { OpenEditorsSection } from "../workspace/views/shared/open-editors-section"; import { useCodeEditorActions } from "./actions/use-code-editor-actions"; +import { useOpenLocation } from "./actions/use-open-location"; import { CodeEditorHost } from "./views/shared/code-editor-host"; const viewportMocks = vi.hoisted(() => ({ @@ -139,6 +149,16 @@ function wrapperFor(store: ReturnType) { }; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe("CodeEditorHost", () => { afterEach(() => { vi.restoreAllMocks(); @@ -188,20 +208,9 @@ describe("CodeEditorHost", () => { expect(screen.queryByText(/connecting/i)).not.toBeInTheDocument(); }); - it("does not re-fetch a file that is already open", async () => { - const { store, sendCommand } = setupStore({ - activePath: "src/b.ts", - openFiles: { - "src/b.ts": { - kind: "text", - path: "src/b.ts", - content: "cached", - savedContent: "cached", - baseHash: "h", - isDirty: false, - }, - }, - }); + it("closes the editor from the header when file.read fails before a buffer opens", async () => { + const sendCommand = vi.fn().mockRejectedValue(new Error("File not found")); + const { store } = setupStore({ activePath: "src/missing.ts", sendCommand }); render( @@ -209,24 +218,25 @@ describe("CodeEditorHost", () => { ); - expect(screen.getByTestId("monaco-host")).toHaveTextContent("cached"); - expect(sendCommand).not.toHaveBeenCalled(); + expect(await screen.findByRole("alert")).toHaveTextContent("File not found"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/missing.ts"); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); }); - it("clears the active file when the close button is clicked", async () => { - const { store } = setupStore({ - activePath: "src/c.ts", - openFiles: { - "src/c.ts": { - kind: "text", - path: "src/c.ts", - content: "content", - savedContent: "content", - baseHash: "h", - isDirty: false, - }, - }, - }); + it("ignores a late file.read success after the unloaded path is explicitly closed", async () => { + let resolveRead: + | ((value: { kind: "text"; content: string; baseHash: string; encoding: "utf-8" }) => void) + | null = null; + const sendCommand = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRead = resolve; + }) + ); + const { store } = setupStore({ activePath: "src/pending.ts", sendCommand }); render( @@ -234,157 +244,259 @@ describe("CodeEditorHost", () => { ); - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/c.ts"); + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.read", + { + workspaceId: "ws-1", + path: "src/pending.ts", + }, + undefined + ); + }); - const closeBtn = screen.getByRole("button", { name: "Close" }); - expect(closeBtn).not.toHaveAttribute("title"); + fireEvent.click(screen.getByRole("button", { name: "Close" })); - fireEvent.mouseEnter(closeBtn); - expect(screen.getByRole("tooltip")).toHaveTextContent("Close"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); - fireEvent.click(closeBtn); + await act(async () => { + resolveRead?.({ + kind: "text", + content: "late content", + baseHash: "late-hash", + encoding: "utf-8", + }); + }); - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); - expect(store.get(openFilesAtomFamily("ws-1"))["src/c.ts"]).toBeUndefined(); - expect(mockRegistryDisposeFile).toHaveBeenCalledWith("/tmp/ws", "src/c.ts"); + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))["src/pending.ts"]).toBeUndefined(); + }); }); - it("can render without the editor header for mobile content-only chrome", async () => { - const { store } = setupStore({ - activePath: "src/mobile.ts", - openFiles: { - "src/mobile.ts": { - kind: "text", - path: "src/mobile.ts", - content: "content", - savedContent: "content", - baseHash: "h", - isDirty: true, - }, - }, - }); + it("reopens the same path with a fresh load after closing an older pending load", async () => { + const firstRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + const secondRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + const sendCommand = vi + .fn() + .mockImplementationOnce(() => firstRead.promise) + .mockImplementationOnce(() => secondRead.promise); + const { store } = setupStore({ activePath: "src/foo.ts", sendCommand }); render( - + ); - expect(screen.getByTestId("monaco-host")).toHaveTextContent("content"); - expect(screen.queryByText("src/mobile.ts")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument(); - }); - - it("renders ImagePreview when file.read returns an image descriptor", async () => { - const sendCommand = vi.fn().mockImplementation(async (op: string) => { - if (op === "file.read") { - return { - kind: "image", - mime: "image/png", - url: "/api/file?workspaceId=ws-1&path=assets%2Flogo.png", - size: 1234, - isTextBacked: false, - version: "1", - }; - } - return null; + await waitFor(() => { + expect(sendCommand).toHaveBeenNthCalledWith( + 1, + "file.read", + { + workspaceId: "ws-1", + path: "src/foo.ts", + }, + undefined + ); }); - const { store } = setupStore({ activePath: "assets/logo.png", sendCommand }); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); - render( - - - - ); + act(() => { + store.set(activeFilePathAtomFamily("ws-1"), "src/foo.ts"); + }); await waitFor(() => { - expect(screen.getByTestId("image-preview")).toBeInTheDocument(); + expect(sendCommand).toHaveBeenNthCalledWith( + 2, + "file.read", + { + workspaceId: "ws-1", + path: "src/foo.ts", + }, + undefined + ); }); - const preview = screen.getByTestId("image-preview"); - expect(preview.getAttribute("data-mime")).toBe("image/png"); - expect(preview.getAttribute("data-url")).toContain("/api/file?"); - expect(preview.getAttribute("data-version")).toBe("1"); - expect(screen.queryByTestId("monaco-host")).not.toBeInTheDocument(); + await act(async () => { + firstRead.resolve({ + kind: "text", + content: "stale content", + baseHash: "stale-hash", + encoding: "utf-8", + }); + }); - // Save button must be disabled for images (nothing to write back). - const saveBtn = screen.getByRole("button", { name: "Save File" }); - expect(saveBtn).toBeDisabled(); - expect(saveBtn).not.toHaveAttribute("title"); + await act(async () => { + secondRead.resolve({ + kind: "text", + content: "fresh content", + baseHash: "fresh-hash", + encoding: "utf-8", + }); + }); - fireEvent.mouseEnter(saveBtn); - fireEvent.focus(saveBtn); - expect(screen.queryByRole("tooltip")).toBeNull(); + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/foo.ts"); + expect(store.get(openFilesAtomFamily("ws-1"))["src/foo.ts"]).toMatchObject({ + content: "fresh content", + savedContent: "fresh content", + baseHash: "fresh-hash", + }); + }); }); - it("defaults text files into edit mode and shows the text editor", async () => { + it("ignores a late file.read success after close all clears a different open editor", async () => { + const pendingRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + const sendCommand = vi.fn().mockImplementation(() => pendingRead.promise); const { store } = setupStore({ - activePath: "src/app.ts", + activePath: "src/pending.ts", openFiles: { - "src/app.ts": { + "src/open.ts": { kind: "text", - path: "src/app.ts", - content: "export const x = 1;", - savedContent: "export const x = 1;", - baseHash: "hash-text", + path: "src/open.ts", + content: "already open", + savedContent: "already open", + baseHash: "open-hash", isDirty: false, }, }, + sendCommand, }); render( + ); - expect(screen.getByTestId("monaco-host")).toHaveTextContent("export const x = 1;"); - expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.read", + { + workspaceId: "ws-1", + path: "src/pending.ts", + }, + undefined + ); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close all" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + + await act(async () => { + pendingRead.resolve({ + kind: "text", + content: "late content", + baseHash: "late-hash", + encoding: "utf-8", + }); + }); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + }); }); - it("keeps text files in preview mode after the user switches from edit to preview", async () => { + it("keeps pending-only active editors closable through shared close all and ignores the late load", async () => { + const pendingRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + const sendCommand = vi.fn().mockImplementation(() => pendingRead.promise); const { store } = setupStore({ - activePath: "src/preview.ts", - openFiles: { - "src/preview.ts": { - kind: "text", - path: "src/preview.ts", - content: "export const preview = true;", - savedContent: "export const preview = true;", - baseHash: "preview-hash", - isDirty: false, - }, - }, + activePath: "src/pending-only.ts", + openFiles: {}, + sendCommand, }); render( + ); - fireEvent.click(screen.getByRole("button", { name: "Preview" })); + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.read", + { + workspaceId: "ws-1", + path: "src/pending-only.ts", + }, + undefined + ); + }); + + const heading = screen.getByRole("heading", { level: 2, name: "Open Editors (1)" }); + const section = heading.closest("section") as HTMLElement; + const closeAll = within(section).getByRole("button", { name: "Close all" }); + expect(closeAll).toBeEnabled(); + + fireEvent.click(closeAll); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + + await act(async () => { + pendingRead.resolve({ + kind: "text", + content: "late content", + baseHash: "late-hash", + encoding: "utf-8", + }); + }); await waitFor(() => { - expect(store.get(editorModeAtomFamily("ws-1"))).toBe("preview"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); }); }); - it("defaults markdown files into preview mode after load", async () => { + it("closing a pending active file from the header reactivates the remaining loaded editor and ignores the late load", async () => { + const pendingRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + const sendCommand = vi.fn().mockImplementation(() => pendingRead.promise); const { store } = setupStore({ - activePath: "README.md", + activePath: "src/b.ts", openFiles: { - "README.md": { + "src/a.ts": { kind: "text", - path: "README.md", - content: "# Docs", - savedContent: "# Docs", - baseHash: "markdown-hash", + path: "src/a.ts", + content: "alpha", + savedContent: "alpha", + baseHash: "hash-a", isDirty: false, }, }, + sendCommand, }); render( @@ -394,22 +506,512 @@ describe("CodeEditorHost", () => { ); await waitFor(() => { - expect(store.get(editorModeAtomFamily("ws-1"))).toBe("preview"); + expect(sendCommand).toHaveBeenCalledWith( + "file.read", + { + workspaceId: "ws-1", + path: "src/b.ts", + }, + undefined + ); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(openFilesAtomFamily("ws-1"))).toMatchObject({ + "src/a.ts": expect.objectContaining({ content: "alpha" }), + }); + + await act(async () => { + pendingRead.resolve({ + kind: "text", + content: "late content", + baseHash: "late-hash", + encoding: "utf-8", + }); + }); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(openFilesAtomFamily("ws-1"))["src/b.ts"]).toBeUndefined(); }); }); - it("defaults image files into preview mode and keeps text-backed images editable as text when requested", async () => { + it("closing a pending active file from the shared open editors list reactivates the remaining loaded editor", async () => { + const pendingRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + const sendCommand = vi.fn().mockImplementation(() => pendingRead.promise); const { store } = setupStore({ - activePath: "assets/logo.svg", + activePath: "src/b.ts", openFiles: { - "assets/logo.svg": { - kind: "image", - path: "assets/logo.svg", - mime: "image/svg+xml", - url: "/api/file?workspaceId=ws-1&path=assets%2Flogo.svg", - size: 42, - version: "v1", - isTextBacked: true, + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "alpha", + savedContent: "alpha", + baseHash: "hash-a", + isDirty: false, + }, + }, + sendCommand, + }); + + render( + + + + + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.read", + { + workspaceId: "ws-1", + path: "src/b.ts", + }, + undefined + ); + }); + + const activeRow = screen + .getByRole("button", { name: "src/b.ts" }) + .closest(".workspace-open-editors__row") as HTMLElement; + fireEvent.click(within(activeRow).getByRole("button", { name: "Close src/b.ts" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + + await act(async () => { + pendingRead.resolve({ + kind: "text", + content: "late content", + baseHash: "late-hash", + encoding: "utf-8", + }); + }); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(openFilesAtomFamily("ws-1"))["src/b.ts"]).toBeUndefined(); + }); + }); + + it("closing the lexicographically last active editor from open editors reactivates the previous sorted editor", async () => { + const { store } = setupStore({ + activePath: "src/c.ts", + openFiles: { + "src/b.ts": { + kind: "text", + path: "src/b.ts", + content: "beta", + savedContent: "beta", + baseHash: "hash-b", + isDirty: false, + }, + "src/c.ts": { + kind: "text", + path: "src/c.ts", + content: "gamma", + savedContent: "gamma", + baseHash: "hash-c", + isDirty: false, + }, + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "alpha", + savedContent: "alpha", + baseHash: "hash-a", + isDirty: false, + }, + }, + }); + + render( + + + + + ); + + expect(screen.getByTestId("monaco-host")).toHaveTextContent("gamma"); + + const activeRow = screen + .getByRole("button", { name: "src/c.ts" }) + .closest(".workspace-open-editors__row") as HTMLElement; + fireEvent.click(within(activeRow).getByRole("button", { name: "Close src/c.ts" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/b.ts"); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("beta"); + }); + + it("cancels an older pending load when switching to a different path so it cannot resurrect after the newer path closes", async () => { + const firstRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + const secondRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + const sendCommand = vi + .fn() + .mockImplementationOnce(() => firstRead.promise) + .mockImplementationOnce(() => secondRead.promise); + const { store } = setupStore({ activePath: "src/a.ts", sendCommand }); + + render( + + + + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenNthCalledWith( + 1, + "file.read", + { + workspaceId: "ws-1", + path: "src/a.ts", + }, + undefined + ); + }); + + act(() => { + store.set(activeFilePathAtomFamily("ws-1"), "src/b.ts"); + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenNthCalledWith( + 2, + "file.read", + { + workspaceId: "ws-1", + path: "src/b.ts", + }, + undefined + ); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + + await act(async () => { + firstRead.resolve({ + kind: "text", + content: "late alpha", + baseHash: "hash-a", + encoding: "utf-8", + }); + }); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + }); + }); + + it("does not re-fetch a file that is already open", async () => { + const { store, sendCommand } = setupStore({ + activePath: "src/b.ts", + openFiles: { + "src/b.ts": { + kind: "text", + path: "src/b.ts", + content: "cached", + savedContent: "cached", + baseHash: "h", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + expect(screen.getByTestId("monaco-host")).toHaveTextContent("cached"); + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it("closing the active editor from the header switches to the next sorted open file", async () => { + const { store } = setupStore({ + activePath: "src/c.ts", + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "alpha", + savedContent: "alpha", + baseHash: "a", + isDirty: false, + }, + "src/c.ts": { + kind: "text", + path: "src/c.ts", + content: "content", + savedContent: "content", + baseHash: "h", + isDirty: false, + }, + "src/d.ts": { + kind: "text", + path: "src/d.ts", + content: "delta", + savedContent: "delta", + baseHash: "d", + isDirty: false, + }, + }, + }); + store.set(editorModeAtomFamily("ws-1"), "diff"); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "src/unrelated.ts", + diff: "diff --git a/src/unrelated.ts b/src/unrelated.ts", + staged: false, + source: "file", + }); + + render( + + + + ); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/c.ts"); + + const closeBtn = screen.getByRole("button", { name: "Close" }); + expect(closeBtn).not.toHaveAttribute("title"); + + fireEvent.mouseEnter(closeBtn); + expect(screen.getByRole("tooltip")).toHaveTextContent("Close"); + + fireEvent.click(closeBtn); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/d.ts"); + expect(store.get(openFilesAtomFamily("ws-1"))["src/c.ts"]).toBeUndefined(); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({ + path: "src/unrelated.ts", + diff: "diff --git a/src/unrelated.ts b/src/unrelated.ts", + staged: false, + source: "file", + }); + expect(mockRegistryDisposeFile).toHaveBeenCalledWith("/tmp/ws", "src/c.ts"); + }); + + it("closing the final remaining file from the header exits to the empty editor state", async () => { + const { store } = setupStore({ + activePath: "src/final.ts", + openFiles: { + "src/final.ts": { + kind: "text", + path: "src/final.ts", + content: "final", + savedContent: "final", + baseHash: "final-hash", + isDirty: false, + }, + }, + }); + store.set(editorModeAtomFamily("ws-1"), "diff"); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "src/final.ts", + diff: "diff --git a/src/final.ts b/src/final.ts", + staged: false, + source: "file", + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(mockRegistryDisposeFile).toHaveBeenCalledWith("/tmp/ws", "src/final.ts"); + }); + + it("can render without the editor header for mobile content-only chrome", async () => { + const { store } = setupStore({ + activePath: "src/mobile.ts", + openFiles: { + "src/mobile.ts": { + kind: "text", + path: "src/mobile.ts", + content: "content", + savedContent: "content", + baseHash: "h", + isDirty: true, + }, + }, + }); + + render( + + + + ); + + expect(screen.getByTestId("monaco-host")).toHaveTextContent("content"); + expect(screen.queryByText("src/mobile.ts")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument(); + }); + + it("renders ImagePreview when file.read returns an image descriptor", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "file.read") { + return { + kind: "image", + mime: "image/png", + url: "/api/file?workspaceId=ws-1&path=assets%2Flogo.png", + size: 1234, + isTextBacked: false, + version: "1", + }; + } + return null; + }); + + const { store } = setupStore({ activePath: "assets/logo.png", sendCommand }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("image-preview")).toBeInTheDocument(); + }); + + const preview = screen.getByTestId("image-preview"); + expect(preview.getAttribute("data-mime")).toBe("image/png"); + expect(preview.getAttribute("data-url")).toContain("/api/file?"); + expect(preview.getAttribute("data-version")).toBe("1"); + expect(screen.queryByTestId("monaco-host")).not.toBeInTheDocument(); + + // Save button must be disabled for images (nothing to write back). + const saveBtn = screen.getByRole("button", { name: "Save File" }); + expect(saveBtn).toBeDisabled(); + expect(saveBtn).not.toHaveAttribute("title"); + + fireEvent.mouseEnter(saveBtn); + fireEvent.focus(saveBtn); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("defaults text files into edit mode and shows the text editor", async () => { + const { store } = setupStore({ + activePath: "src/app.ts", + openFiles: { + "src/app.ts": { + kind: "text", + path: "src/app.ts", + content: "export const x = 1;", + savedContent: "export const x = 1;", + baseHash: "hash-text", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + expect(screen.getByTestId("monaco-host")).toHaveTextContent("export const x = 1;"); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + }); + + it("keeps text files in preview mode after the user switches from edit to preview", async () => { + const { store } = setupStore({ + activePath: "src/preview.ts", + openFiles: { + "src/preview.ts": { + kind: "text", + path: "src/preview.ts", + content: "export const preview = true;", + savedContent: "export const preview = true;", + baseHash: "preview-hash", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Preview" })); + + await waitFor(() => { + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("preview"); + }); + }); + + it("defaults markdown files into preview mode after load", async () => { + const { store } = setupStore({ + activePath: "README.md", + openFiles: { + "README.md": { + kind: "text", + path: "README.md", + content: "# Docs", + savedContent: "# Docs", + baseHash: "markdown-hash", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("preview"); + }); + }); + + it("defaults image files into preview mode and keeps text-backed images editable as text when requested", async () => { + const { store } = setupStore({ + activePath: "assets/logo.svg", + openFiles: { + "assets/logo.svg": { + kind: "image", + path: "assets/logo.svg", + mime: "image/svg+xml", + url: "/api/file?workspaceId=ws-1&path=assets%2Flogo.svg", + size: 42, + version: "v1", + isTextBacked: true, }, }, }); @@ -495,17 +1097,251 @@ describe("CodeEditorHost", () => { source: "commit", }); - render( - - - - ); + render( + + + + ); + + expect(screen.getByTestId("monaco-diff-host")).toHaveAttribute( + "data-modified", + "diff --git a/src/app.tsx b/src/app.tsx" + ); + expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + }); + + it("opens a normal file over an active commit-history preview", async () => { + const { store } = setupStore({ + activePath: "src/background.ts", + openFiles: { + "src/background.ts": { + kind: "text", + path: "src/background.ts", + content: "background", + savedContent: "background", + baseHash: "hash-bg", + isDirty: false, + }, + "src/target.ts": { + kind: "text", + path: "src/target.ts", + content: "target content", + savedContent: "target content", + baseHash: "hash-target", + isDirty: false, + }, + }, + }); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); + + render( + + + + ); + + expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + + const { result } = renderHook(() => useOpenLocation("ws-1"), { + wrapper: wrapperFor(store), + }); + + await act(async () => { + await result.current.openLocation({ + workspaceId: "ws-1", + path: "src/target.ts", + source: "manual", + }); + }); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/target.ts"); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("target content"); + expect(screen.queryByTestId("monaco-diff-host")).not.toBeInTheDocument(); + }); + }); + + it("closing a commit-history preview restores the background file to its normal mode", async () => { + const { store } = setupStore({ + activePath: "src/background.ts", + openFiles: { + "src/background.ts": { + kind: "text", + path: "src/background.ts", + content: "background", + savedContent: "background", + baseHash: "hash-bg", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("background"); + }); + + act(() => { + store.set(editorModeAtomFamily("ws-1"), "diff"); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("background"); + expect(screen.queryByTestId("monaco-diff-host")).not.toBeInTheDocument(); + }); + }); + + it("closing a commit-history preview restores the background file save error", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { + if (op === "file.write" && args?.path === "src/background.ts") { + throw new Error("Save failed on background"); + } + + if (op === "file.read") { + return { + kind: "text", + content: "hello world", + baseHash: "abc123", + encoding: "utf-8", + }; + } + + return null; + }); + const { store } = setupStore({ + activePath: "src/background.ts", + sendCommand, + openFiles: { + "src/background.ts": { + kind: "text", + path: "src/background.ts", + content: "changed background", + savedContent: "saved background", + baseHash: "hash-bg", + isDirty: true, + }, + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Save File" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Save failed on background"); + + act(() => { + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + expect(screen.queryByText("Save failed on background")).not.toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("changed background"); + expect(screen.getByRole("alert")).toHaveTextContent("Save failed on background"); + }); + }); + + it("openLocation normalizes editor mode when exiting a commit-history preview over a file-diff background", async () => { + const { store } = setupStore({ + activePath: "src/background.ts", + openFiles: { + "src/background.ts": { + kind: "text", + path: "src/background.ts", + content: "background", + savedContent: "background", + baseHash: "hash-bg", + isDirty: false, + }, + }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("background"); + }); + + act(() => { + store.set(editorModeAtomFamily("ws-1"), "diff"); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("diff"); + }); - expect(screen.getByTestId("monaco-diff-host")).toHaveAttribute( - "data-modified", - "diff --git a/src/app.tsx b/src/app.tsx" - ); - expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + const { result } = renderHook(() => useOpenLocation("ws-1"), { + wrapper: wrapperFor(store), + }); + + await act(async () => { + await result.current.openLocation({ + workspaceId: "ws-1", + path: "src/background.ts", + source: "manual", + }); + }); + + await waitFor(() => { + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/background.ts"); + expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("background"); + expect(screen.queryByTestId("monaco-diff-host")).not.toBeInTheDocument(); + }); }); it("derives diff enablement from git status for the active file", () => { @@ -571,23 +1407,315 @@ describe("CodeEditorHost", () => { source: "file", }); - const { result } = renderHook(() => useCodeEditorActions(), { - wrapper: wrapperFor(store), + const { result } = renderHook(() => useCodeEditorActions(), { + wrapper: wrapperFor(store), + }); + + expect(result.current.hasUnsavedChangesOutsideDiff).toBe(true); + }); + + it("shows the save tooltip on desktop for a text buffer", async () => { + const { store } = setupStore({ + activePath: "src/save.ts", + openFiles: { + "src/save.ts": { + kind: "text", + path: "src/save.ts", + content: "content", + savedContent: "content", + baseHash: "h", + isDirty: true, + }, + }, + }); + + render( + + + + ); + + const saveBtn = screen.getByRole("button", { name: "Save File" }); + expect(saveBtn).not.toHaveAttribute("title"); + + fireEvent.mouseEnter(saveBtn); + expect(screen.getByRole("tooltip")).toHaveTextContent("Save File"); + }); + + it("clears dirty state when text returns to the last saved content", async () => { + const { store } = setupStore({ activePath: "src/revert.ts" }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("monaco-host")).toHaveTextContent("hello world"); + }); + + const editor = screen.getByRole("textbox", { name: "Editor content" }); + + fireEvent.change(editor, { + target: { value: "hello world with edits" }, + }); + + await waitFor(() => { + expect(store.get(openFilesAtomFamily("ws-1"))["src/revert.ts"]).toMatchObject({ + content: "hello world with edits", + isDirty: true, + }); + }); + + fireEvent.change(editor, { + target: { value: "hello world" }, + }); + + await waitFor(() => { + expect(store.get(openFilesAtomFamily("ws-1"))["src/revert.ts"]).toMatchObject({ + content: "hello world", + isDirty: false, + }); + }); + + expect(screen.getByRole("button", { name: "Save File" })).toBeDisabled(); + }); + + it("reloads a clean text buffer after an external refresh signal changes the file on disk", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce({ + kind: "text", + content: "original", + baseHash: "hash-1", + encoding: "utf-8", + }) + .mockResolvedValueOnce({ + kind: "text", + content: "updated on disk", + baseHash: "hash-2", + encoding: "utf-8", + }); + + const { store } = setupStore({ + activePath: "src/live.ts", + sendCommand, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("monaco-host")).toHaveTextContent("original"); + }); + + act(() => { + store.set(editorRefreshTokenAtomFamily("ws-1"), 1); + }); + + await waitFor(() => { + expect(screen.getByTestId("monaco-host")).toHaveTextContent("updated on disk"); + }); + expect(mockRegistryUpdateFromDisk).toHaveBeenCalledWith({ + workspaceRootPath: "/tmp/ws", + path: "src/live.ts", + content: "updated on disk", + }); + expect(screen.queryByText(/changed on disk/i)).not.toBeInTheDocument(); + }); + + it("clears a stale save error after closing the failed file from the sidebar and switching active file", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { + if (op === "file.write" && args?.path === "src/a.ts") { + throw new Error("Save failed on A"); + } + + if (op === "file.read") { + return { + kind: "text", + content: "hello world", + baseHash: "abc123", + encoding: "utf-8", + }; + } + + return null; + }); + const { store } = setupStore({ + activePath: "src/a.ts", + sendCommand, + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "changed a", + savedContent: "saved a", + baseHash: "hash-a", + isDirty: true, + }, + "src/b.ts": { + kind: "text", + path: "src/b.ts", + content: "saved b", + savedContent: "saved b", + baseHash: "hash-b", + isDirty: false, + }, + }, + }); + + render( + + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Save File" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Save failed on A"); + + const activeRow = screen + .getByRole("button", { name: "src/a.ts" }) + .closest(".workspace-open-editors__row") as HTMLElement; + fireEvent.click(within(activeRow).getByRole("button", { name: "Close src/a.ts" })); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/b.ts"); + }); + + expect(screen.queryByText("Save failed on A")).not.toBeInTheDocument(); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("saved b"); + }); + + it("keeps save state scoped to the active file when switching during an in-flight save", async () => { + const saveADeferred = createDeferred<{ newHash: string }>(); + const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { + if (op === "file.write" && args?.path === "src/a.ts") { + return saveADeferred.promise; + } + + if (op === "file.write" && args?.path === "src/b.ts") { + return { newHash: "hash-b-2" }; + } + + if (op === "file.read") { + return { + kind: "text", + content: "hello world", + baseHash: "abc123", + encoding: "utf-8", + }; + } + + return null; + }); + const { store } = setupStore({ + activePath: "src/a.ts", + sendCommand, + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "changed a", + savedContent: "saved a", + baseHash: "hash-a", + isDirty: true, + }, + "src/b.ts": { + kind: "text", + path: "src/b.ts", + content: "changed b", + savedContent: "saved b", + baseHash: "hash-b", + isDirty: true, + }, + }, + }); + + render( + + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Save File" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.write", + { + workspaceId: "ws-1", + path: "src/a.ts", + content: "changed a", + baseHash: "hash-a", + }, + undefined + ); + }); + + fireEvent.click(screen.getByRole("button", { name: "src/b.ts" })); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/b.ts"); + }); + + expect(screen.getByTestId("monaco-host")).toHaveTextContent("changed b"); + expect(screen.queryByRole("button", { name: "Saving" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save File" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.write", + { + workspaceId: "ws-1", + path: "src/b.ts", + content: "changed b", + baseHash: "hash-b", + }, + undefined + ); + }); + + await act(async () => { + saveADeferred.resolve({ newHash: "hash-a-2" }); + }); + }); + + it("ignores a stale save success after close all preserves commit preview and the file is reopened", async () => { + const staleSave = createDeferred<{ newHash: string }>(); + const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { + if (op === "file.write" && args?.path === "src/a.ts") { + return staleSave.promise; + } + + if (op === "file.read" && args?.path === "src/a.ts") { + return { + kind: "text", + content: "reopened content", + baseHash: "reopen-hash", + encoding: "utf-8", + }; + } + + return null; }); - - expect(result.current.hasUnsavedChangesOutsideDiff).toBe(true); - }); - - it("shows the save tooltip on desktop for a text buffer", async () => { const { store } = setupStore({ - activePath: "src/save.ts", + activePath: "src/a.ts", + sendCommand, openFiles: { - "src/save.ts": { + "src/a.ts": { kind: "text", - path: "src/save.ts", - content: "content", - savedContent: "content", - baseHash: "h", + path: "src/a.ts", + content: "changed a", + savedContent: "saved a", + baseHash: "hash-a", isDirty: true, }, }, @@ -596,100 +1724,193 @@ describe("CodeEditorHost", () => { render( + ); - const saveBtn = screen.getByRole("button", { name: "Save File" }); - expect(saveBtn).not.toHaveAttribute("title"); + const { result } = renderHook(() => useOpenLocation("ws-1"), { + wrapper: wrapperFor(store), + }); - fireEvent.mouseEnter(saveBtn); - expect(screen.getByRole("tooltip")).toHaveTextContent("Save File"); - }); + fireEvent.click(screen.getByRole("button", { name: "Save File" })); - it("clears dirty state when text returns to the last saved content", async () => { - const { store } = setupStore({ activePath: "src/revert.ts" }); + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.write", + { + workspaceId: "ws-1", + path: "src/a.ts", + content: "changed a", + baseHash: "hash-a", + }, + undefined + ); + }); - render( - - - - ); + act(() => { + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); + }); await waitFor(() => { - expect(screen.getByTestId("monaco-host")).toHaveTextContent("hello world"); + expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); }); - const editor = screen.getByRole("textbox", { name: "Editor content" }); + fireEvent.click(screen.getByRole("button", { name: "Close all" })); - fireEvent.change(editor, { - target: { value: "hello world with edits" }, + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({ + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", }); - await waitFor(() => { - expect(store.get(openFilesAtomFamily("ws-1"))["src/revert.ts"]).toMatchObject({ - content: "hello world with edits", - isDirty: true, + await act(async () => { + await result.current.openLocation({ + workspaceId: "ws-1", + path: "src/a.ts", + source: "manual", }); }); - fireEvent.change(editor, { - target: { value: "hello world" }, + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("reopened content"); + }); + + expect(screen.queryByRole("button", { name: /Saving/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + await act(async () => { + staleSave.resolve({ newHash: "stale-hash" }); }); await waitFor(() => { - expect(store.get(openFilesAtomFamily("ws-1"))["src/revert.ts"]).toMatchObject({ - content: "hello world", + expect(store.get(openFilesAtomFamily("ws-1"))["src/a.ts"]).toMatchObject({ + content: "reopened content", + savedContent: "reopened content", + baseHash: "reopen-hash", isDirty: false, }); }); - expect(screen.getByRole("button", { name: "Save File" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: /Saving/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); - it("reloads a clean text buffer after an external refresh signal changes the file on disk", async () => { - const sendCommand = vi - .fn() - .mockResolvedValueOnce({ - kind: "text", - content: "original", - baseHash: "hash-1", - encoding: "utf-8", - }) - .mockResolvedValueOnce({ - kind: "text", - content: "updated on disk", - baseHash: "hash-2", - encoding: "utf-8", - }); + it("ignores a stale save failure after close all preserves commit preview and the file is reopened", async () => { + const staleSave = createDeferred<{ newHash: string }>(); + const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { + if (op === "file.write" && args?.path === "src/a.ts") { + return staleSave.promise; + } + + if (op === "file.read" && args?.path === "src/a.ts") { + return { + kind: "text", + content: "reopened content", + baseHash: "reopen-hash", + encoding: "utf-8", + }; + } + return null; + }); const { store } = setupStore({ - activePath: "src/live.ts", + activePath: "src/a.ts", sendCommand, + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "changed a", + savedContent: "saved a", + baseHash: "hash-a", + isDirty: true, + }, + }, }); render( + ); + const { result } = renderHook(() => useOpenLocation("ws-1"), { + wrapper: wrapperFor(store), + }); + + fireEvent.click(screen.getByRole("button", { name: "Save File" })); + await waitFor(() => { - expect(screen.getByTestId("monaco-host")).toHaveTextContent("original"); + expect(sendCommand).toHaveBeenCalledWith( + "file.write", + { + workspaceId: "ws-1", + path: "src/a.ts", + content: "changed a", + baseHash: "hash-a", + }, + undefined + ); }); act(() => { - store.set(editorRefreshTokenAtomFamily("ws-1"), 1); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); }); await waitFor(() => { - expect(screen.getByTestId("monaco-host")).toHaveTextContent("updated on disk"); + expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); }); - expect(mockRegistryUpdateFromDisk).toHaveBeenCalledWith({ - workspaceRootPath: "/tmp/ws", - path: "src/live.ts", - content: "updated on disk", + + fireEvent.click(screen.getByRole("button", { name: "Close all" })); + + await act(async () => { + await result.current.openLocation({ + workspaceId: "ws-1", + path: "src/a.ts", + source: "manual", + }); }); - expect(screen.queryByText(/changed on disk/i)).not.toBeInTheDocument(); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(screen.getByTestId("monaco-host")).toHaveTextContent("reopened content"); + }); + + expect(screen.queryByRole("button", { name: /Saving/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + await act(async () => { + staleSave.reject(new Error("Stale save failed")); + }); + + await waitFor(() => { + expect(store.get(openFilesAtomFamily("ws-1"))["src/a.ts"]).toMatchObject({ + content: "reopened content", + savedContent: "reopened content", + baseHash: "reopen-hash", + isDirty: false, + }); + }); + + expect(screen.queryByRole("button", { name: /Saving/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("marks a dirty text buffer as externally modified without overwriting local edits", async () => { @@ -784,6 +2005,102 @@ describe("CodeEditorHost", () => { }); }); + it("clears deleted-on-disk editor state after closing the final editor before reopening the path", async () => { + const pendingReopenRead = createDeferred<{ + kind: "text"; + content: string; + baseHash: string; + encoding: "utf-8"; + }>(); + let readCount = 0; + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op !== "file.read") { + return null; + } + + readCount += 1; + + if (readCount === 1) { + throw new CommandResultError({ + code: "not_found", + message: "Target not found", + }); + } + + return pendingReopenRead.promise; + }); + + const { store } = setupStore({ + activePath: "src/deleted.ts", + sendCommand, + openFiles: { + "src/deleted.ts": { + kind: "text", + path: "src/deleted.ts", + content: "stale buffer", + savedContent: "stale buffer", + baseHash: "hash-1", + isDirty: false, + }, + }, + }); + + const { result } = renderHook(() => useCodeEditorActions(), { + wrapper: wrapperFor(store), + }); + + act(() => { + store.set(editorRefreshTokenAtomFamily("ws-1"), 1); + }); + + await waitFor(() => { + expect(result.current.activeExternalStatus).toBe("deleted"); + }); + + act(() => { + result.current.handleClose(); + }); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({}); + expect(result.current.activeExternalStatus).toBeNull(); + + act(() => { + store.set(activeFilePathAtomFamily("ws-1"), "src/deleted.ts"); + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.read", + { + workspaceId: "ws-1", + path: "src/deleted.ts", + }, + undefined + ); + }); + + expect(result.current.activeExternalStatus).toBeNull(); + + await act(async () => { + pendingReopenRead.resolve({ + kind: "text", + content: "fresh content", + baseHash: "hash-2", + encoding: "utf-8", + }); + }); + + await waitFor(() => { + expect(result.current.currentFile).toMatchObject({ + kind: "text", + path: "src/deleted.ts", + content: "fresh content", + }); + expect(result.current.activeExternalStatus).toBeNull(); + }); + }); + it("refreshes an open image when version changes but url and size stay the same", async () => { const sendCommand = vi.fn().mockResolvedValue({ kind: "image", @@ -1150,5 +2467,75 @@ describe("CodeEditorHost", () => { expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); }); + + it("does not reopen a text-backed image as text when closed during the fetch stage", async () => { + const fetchDeferred = createDeferred<{ + ok: true; + text: () => Promise; + }>(); + const fetchMock = vi.fn(() => fetchDeferred.promise); + vi.stubGlobal("fetch", fetchMock); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "file.read") { + return { + kind: "image", + mime: "image/svg+xml", + url: "/api/file?workspaceId=ws-1&path=icon.svg", + size: 200, + isTextBacked: true, + version: "1", + }; + } + return null; + }); + + const { store } = setupStore({ + activePath: "icon.svg", + sendCommand, + openFiles: { + "icon.svg": { + kind: "image", + path: "icon.svg", + mime: "image/svg+xml", + url: "/api/file?workspaceId=ws-1&path=icon.svg", + size: 200, + isTextBacked: true, + version: "1", + }, + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("/api/file?workspaceId=ws-1&path=icon.svg", { + credentials: "include", + }); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))["icon.svg"]).toBeUndefined(); + + await act(async () => { + fetchDeferred.resolve({ + ok: true, + text: async () => '', + }); + }); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-1"))["icon.svg"]).toBeUndefined(); + }); + }); }); }); diff --git a/packages/web/src/features/code-editor/views/shared/code-editor-host.tsx b/packages/web/src/features/code-editor/views/shared/code-editor-host.tsx index 192771b2..c48f03c6 100644 --- a/packages/web/src/features/code-editor/views/shared/code-editor-host.tsx +++ b/packages/web/src/features/code-editor/views/shared/code-editor-host.tsx @@ -131,10 +131,13 @@ export const CodeEditorHeaderActions: FC = ({ }) => { const t = useTranslation(); const { + activeFilePath, + activeDiffChange, canDiff, canEdit, canPreview, canSave, + handleClose, handleSave, isImageFile, isSaving, @@ -148,11 +151,27 @@ export const CodeEditorHeaderActions: FC = ({ const toggleModeTitle = isImageFile ? t("code_editor.edit_as_text") : t("code_editor.preview_as_image"); + const isCommitPreview = activeDiffChange?.source === "commit"; if (variant !== "mobile") { return ; } + if (isCommitPreview) { + return ( +
+ +
+ ); + } + const handlePreviewMode = () => { if (isSvgTextBacked && !isImageFile) { toggleSvgTextMode(); @@ -223,6 +242,16 @@ export const CodeEditorHeaderActions: FC = ({ > {saveLabel} + {activeFilePath || activeDiffChange?.source === "commit" ? ( + + ) : null}
); }; diff --git a/packages/web/src/features/code-editor/views/shared/editor-surface.test.tsx b/packages/web/src/features/code-editor/views/shared/editor-surface.test.tsx index 054a86f6..fe7e44e7 100644 --- a/packages/web/src/features/code-editor/views/shared/editor-surface.test.tsx +++ b/packages/web/src/features/code-editor/views/shared/editor-surface.test.tsx @@ -239,4 +239,69 @@ describe("EditorSurface", () => { expect(buttonLabels).toEqual(["Diff", "预览", "编辑", "Save File", "Close"]); }); + + it("renders a working close action for commit-history previews", () => { + const state = createState({ + activeFilePath: "src/app.ts", + activeDiffChange: { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.ts b/src/app.ts", + source: "commit", + }, + }); + + render(); + + expect(screen.queryByRole("button", { name: "Diff" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(state.handleClose).toHaveBeenCalledTimes(1); + }); + + it("shows the commit preview title without leaking the background file dirty marker", () => { + const state = createState({ + currentFile: { + kind: "text", + path: "src/app.ts", + content: "export const app = 2;\n", + savedContent: "export const app = 1;\n", + baseHash: "hash-1", + isDirty: true, + }, + activeDiffChange: { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.ts b/src/app.ts", + source: "commit", + }, + }); + + render(); + + expect(screen.getByText("abc123 · commit subject")).toBeInTheDocument(); + expect(screen.queryByText("src/app.ts")).not.toBeInTheDocument(); + expect(document.querySelector(".dirty-indicator")).toBeNull(); + }); + + it("suppresses file-scoped alerts while a commit-history preview is active", () => { + const state = createState({ + activeExternalStatus: "modified", + activeDiffChange: { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.ts b/src/app.ts", + source: "commit", + }, + hasUnsavedChangesOutsideDiff: true, + saveError: "Failed to save file", + }); + + render(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("Failed to save file")).not.toBeInTheDocument(); + expect( + screen.queryByText("Diff preview is based on saved file contents.") + ).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/code-editor/views/shared/editor-surface.tsx b/packages/web/src/features/code-editor/views/shared/editor-surface.tsx index 788acefb..c924fd37 100644 --- a/packages/web/src/features/code-editor/views/shared/editor-surface.tsx +++ b/packages/web/src/features/code-editor/views/shared/editor-surface.tsx @@ -1,5 +1,6 @@ +import { X } from "lucide-react"; import type { FC } from "react"; -import { EmptyState, ThemedIcon } from "../../../../components/ui"; +import { EmptyState, IconButton, ThemedIcon, Tooltip } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; import { deriveDocumentPreviewKind } from "../../../workspace/atoms"; import { DocumentPreview } from "../../components/document-preview"; @@ -24,6 +25,7 @@ export const EditorSurface: FC = ({ state, chrome = "full" } activeLoadError, currentFile, documentPreview, + handleClose, handleContentChange, handleSave, hasUnsavedChangesOutsideDiff, @@ -50,11 +52,12 @@ export const EditorSurface: FC = ({ state, chrome = "full" } const currentTextFile = currentFile?.kind === "text" ? currentFile : null; const currentImageFile = currentFile?.kind === "image" ? currentFile : null; - const dirtyIndicator = currentTextFile?.isDirty ? ( - * - ) : null; const showHeader = chrome === "full"; const isCommitPreview = activeDiffChange?.source === "commit"; + const dirtyIndicator = + !isCommitPreview && currentTextFile?.isDirty ? ( + * + ) : null; const canRenderTextDiff = (mode === "diff" || isCommitPreview) && Boolean(activeDiffChange) && @@ -65,9 +68,11 @@ export const EditorSurface: FC = ({ state, chrome = "full" } mode === "preview" && currentTextFile !== null && deriveDocumentPreviewKind(currentTextFile.path) !== null; - const titleText = currentFile - ? currentFile.path - : (activeDiffChange?.title ?? activeFilePath ?? t("file.title")); + const titleText = isCommitPreview + ? (activeDiffChange.title ?? activeDiffChange.path) + : currentFile + ? currentFile.path + : (activeDiffChange?.title ?? activeFilePath ?? t("file.title")); return (
@@ -75,7 +80,7 @@ export const EditorSurface: FC = ({ state, chrome = "full" } {showHeader ? (
- {currentFile ? ( + {currentFile && !isCommitPreview ? ( <> {titleText} {dirtyIndicator} @@ -86,15 +91,15 @@ export const EditorSurface: FC = ({ state, chrome = "full" } {isCommitPreview ? (
- + + } + onClick={handleClose} + size="sm" + /> +
) : ( @@ -102,14 +107,14 @@ export const EditorSurface: FC = ({ state, chrome = "full" }
) : null} - {saveError ? ( + {!isCommitPreview && saveError ? (
{saveError}
) : null} - {activeExternalStatus ? ( + {!isCommitPreview && activeExternalStatus ? (
= ({ state, chrome = "full" }
) : null} - {hasUnsavedChangesOutsideDiff ? ( + {!isCommitPreview && hasUnsavedChangesOutsideDiff ? (
{t("code_editor.diff_saved_only")} diff --git a/packages/web/src/features/command-palette/components/command-palette.test.tsx b/packages/web/src/features/command-palette/components/command-palette.test.tsx index cbac67a7..15079f89 100644 --- a/packages/web/src/features/command-palette/components/command-palette.test.tsx +++ b/packages/web/src/features/command-palette/components/command-palette.test.tsx @@ -2,7 +2,7 @@ import type { Workspace } from "@coder-studio/core"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { commandPaletteOpenAtom, localeAtom } from "../../../atoms/app-ui"; +import { commandPaletteOpenAtom, localeAtom, quickOpenOpenAtom } from "../../../atoms/app-ui"; import { wsClientAtom } from "../../../atoms/connection"; import { activeWorkspaceIdAtom, @@ -233,6 +233,51 @@ describe("CommandPalette", () => { expect(store.get(terminalPanelVisibleAtom)).toBe(true); }); + it("opens Quick Open from the quick actions list on desktop", () => { + const store = createStore(); + store.set(localeAtom, "en"); + store.set(commandPaletteOpenAtom, true); + store.set(workspacesAtom, { + "ws-1": createWorkspace("ws-1", "/tmp/one"), + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(workspacesLoadStateAtom, "ready"); + store.set(activeWorkspaceIdAtom, "ws-1"); + + render( + + + + ); + + fireEvent.click(screen.getByText("Go to File...")); + + expect(store.get(quickOpenOpenAtom)).toBe(true); + expect(store.get(commandPaletteOpenAtom)).toBe(false); + }); + + it("hides Go to File on mobile", () => { + viewportMocks.viewport = "mobile"; + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(commandPaletteOpenAtom, true); + store.set(workspacesAtom, { + "ws-1": createWorkspace("ws-1", "/tmp/one"), + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(workspacesLoadStateAtom, "ready"); + store.set(activeWorkspaceIdAtom, "ws-1"); + + render( + + + + ); + + expect(screen.queryByText("Go to File...")).toBeNull(); + }); + it("keeps the desktop palette inside the shared workbench layer", () => { const store = createStore(); store.set(localeAtom, "en"); diff --git a/packages/web/src/features/command-palette/components/command-palette.tsx b/packages/web/src/features/command-palette/components/command-palette.tsx index b6989816..8459dc5d 100644 --- a/packages/web/src/features/command-palette/components/command-palette.tsx +++ b/packages/web/src/features/command-palette/components/command-palette.tsx @@ -8,7 +8,7 @@ import type { Workspace } from "@coder-studio/core"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { commandPaletteOpenAtom } from "../../../atoms/app-ui"; +import { commandPaletteOpenAtom, quickOpenOpenAtom } from "../../../atoms/app-ui"; import { activeWorkspaceIdAtom, orderedWorkspacesAtom, @@ -71,6 +71,7 @@ export function CommandPalette() { const [bottomPanelHeight, setBottomPanelHeight] = useAtom(bottomPanelHeightAtom); const activeWorkspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom); const setActiveWorkspaceId = useSetAtom(activeWorkspaceIdAtom); + const setQuickOpenOpen = useSetAtom(quickOpenOpenAtom); const selectWorkspaceTarget = useSelectWorkspaceTarget(); const workspaces = useAtomValue(orderedWorkspacesAtom); @@ -97,6 +98,7 @@ export function CommandPalette() { locationPathname: location.pathname, navigate, t, + setQuickOpenOpen, setShowWorkspaceLaunch: (nextValue) => { if (nextValue) { setIsOpen(false); @@ -295,6 +297,7 @@ function buildCommands(context: { locationPathname: string; navigate: (path: string) => void; t: (key: string) => string; + setQuickOpenOpen: (value: boolean) => void; setShowWorkspaceLaunch: (v: boolean) => void; }): Command[] { const { @@ -314,6 +317,7 @@ function buildCommands(context: { locationPathname, navigate, t, + setQuickOpenOpen, setShowWorkspaceLaunch, } = context; @@ -346,6 +350,17 @@ function buildCommands(context: { if (shellKind === "desktop") { commands.push( + ...(activeWorkspaceId + ? [ + { + id: "go-to-file", + label: t("quick_open.command_label"), + description: t("quick_open.command_description"), + shortcut: "Ctrl+P", + action: () => setQuickOpenOpen(true), + } satisfies Command, + ] + : []), { id: "toggle-focus-mode", label: t("tooltip.focus_mode"), diff --git a/packages/web/src/features/quick-open/components/quick-open.test.tsx b/packages/web/src/features/quick-open/components/quick-open.test.tsx new file mode 100644 index 00000000..a54c30f3 --- /dev/null +++ b/packages/web/src/features/quick-open/components/quick-open.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { quickOpenOpenAtom } from "../../../atoms/app-ui"; +import { wsClientAtom } from "../../../atoms/connection"; +import { + activeWorkspaceIdAtom, + workspaceOrderAtom, + workspacesAtom, + workspacesLoadStateAtom, +} from "../../../atoms/workspaces"; +import { activeFilePathAtomFamily } from "../../workspace/atoms/files"; +import { QuickOpen } from "./quick-open"; + +function seedWorkspace(store: ReturnType) { + store.set(workspacesAtom, { + "ws-test": { + id: "ws-test", + path: "/workspace", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + } as never); + store.set(workspaceOrderAtom, ["ws-test"]); + store.set(activeWorkspaceIdAtom, "ws-test"); + store.set(workspacesLoadStateAtom, "ready"); +} + +describe("QuickOpen", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("opens on Ctrl/Cmd+P, queries file.search, and renders file name with path", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspace(store); + + render( + + + + ); + + fireEvent.keyDown(window, { key: "p", ctrlKey: true }); + fireEvent.change(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + target: { value: "app" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "file.search", + { + workspaceId: "ws-test", + query: "app", + limit: 25, + }, + undefined + ); + + const result = screen.getByRole("option", { name: /app\.tsx/i }); + expect(within(result).getByText("app.tsx")).toHaveClass("quick-open__primary"); + expect(within(result).getByText("src/app.tsx")).toHaveClass("quick-open__secondary"); + }); + + it("moves the active row with keyboard and opens the selected file on Enter", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { path: "src/app.tsx", name: "app.tsx", kind: "file" }, + { path: "src/routes.ts", name: "routes.ts", kind: "file" }, + ], + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspace(store); + store.set(quickOpenOpenAtom, true); + + render( + + + + ); + + fireEvent.change(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + target: { value: "app" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + const input = screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }); + const firstResult = screen.getByRole("option", { name: /app\.tsx/i }); + const secondResult = screen.getByRole("option", { name: /routes\.ts/i }); + + expect(firstResult).toHaveAttribute("aria-selected", "true"); + expect(secondResult).toHaveAttribute("aria-selected", "false"); + + fireEvent.keyDown(input, { + key: "ArrowDown", + }); + + expect(firstResult).toHaveAttribute("aria-selected", "false"); + expect(secondResult).toHaveAttribute("aria-selected", "true"); + + fireEvent.keyDown(input, { + key: "Enter", + }); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/routes.ts"); + expect(store.get(quickOpenOpenAtom)).toBe(false); + }); +}); diff --git a/packages/web/src/features/quick-open/components/quick-open.tsx b/packages/web/src/features/quick-open/components/quick-open.tsx new file mode 100644 index 00000000..47790586 --- /dev/null +++ b/packages/web/src/features/quick-open/components/quick-open.tsx @@ -0,0 +1,222 @@ +import type { FileNode } from "@coder-studio/core"; +import { useAtom, useAtomValue } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { quickOpenOpenAtom } from "../../../atoms/app-ui"; +import { dispatchCommandAtom } from "../../../atoms/connection"; +import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces"; +import { EmptyState, ThemedIcon, WorkbenchLayer } from "../../../components/ui"; +import { useTranslation } from "../../../lib/i18n"; +import { useOpenLocation } from "../../code-editor/actions/use-open-location"; + +interface SearchFilesResult { + files: FileNode[]; +} + +export function QuickOpen() { + const t = useTranslation(); + const [open, setOpen] = useAtom(quickOpenOpenAtom); + const workspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom); + const dispatch = useAtomValue(dispatchCommandAtom); + const { openLocation } = useOpenLocation(workspaceId ?? "__workspace_placeholder__"); + const inputRef = useRef(null); + const dispatchRef = useRef(dispatch); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const failedLabel = t("quick_open.failed"); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "p") { + event.preventDefault(); + setOpen(true); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [setOpen]); + + useEffect(() => { + if (!open) { + return; + } + + inputRef.current?.focus(); + setQuery(""); + setSelectedIndex(0); + setResults([]); + setError(null); + }, [open]); + + useEffect(() => { + dispatchRef.current = dispatch; + }, [dispatch]); + + useEffect(() => { + if (!open || !workspaceId) { + return; + } + + const trimmed = query.trim(); + if (!trimmed) { + setResults([]); + setLoading(false); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + const timeout = window.setTimeout(() => { + void dispatchRef + .current("file.search", { + workspaceId, + query: trimmed, + limit: 25, + }) + .then((result) => { + if (cancelled) { + return; + } + + if (!result.ok || !result.data) { + setResults([]); + setError(failedLabel); + return; + } + + setResults(result.data.files); + setSelectedIndex(0); + }) + .catch(() => { + if (!cancelled) { + setResults([]); + setError(failedLabel); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + }, 150); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [failedLabel, open, query, workspaceId]); + + if (!open) { + return null; + } + + const activeResult = results[selectedIndex] ?? null; + + return ( + inputRef.current} + onOpenChange={setOpen} + open + > +
{ + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, Math.max(results.length - 1, 0))); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + return; + } + + if (event.key === "Enter" && activeResult && workspaceId) { + event.preventDefault(); + void openLocation({ + workspaceId, + path: activeResult.path, + source: "manual", + }); + setOpen(false); + } + }} + > +
+ + setQuery(event.target.value)} + /> +
+ +
+ {!workspaceId ? ( + {t("workspace.no_workspace")}

} + /> + ) : error ? ( +

{error}

+ ) : !query.trim() ? ( +

{t("quick_open.empty")}

+ ) : loading ? ( +

{t("common.loading")}

+ ) : results.length === 0 ? ( +

{t("quick_open.no_results")}

+ ) : ( +
+ {results.map((file, index) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/features/quick-open/index.tsx b/packages/web/src/features/quick-open/index.tsx new file mode 100644 index 00000000..66647ad7 --- /dev/null +++ b/packages/web/src/features/quick-open/index.tsx @@ -0,0 +1 @@ +export { QuickOpen } from "./components/quick-open"; diff --git a/packages/web/src/features/settings/components/provider-settings.tsx b/packages/web/src/features/settings/components/provider-settings.tsx index 165fea3f..9b8f3165 100644 --- a/packages/web/src/features/settings/components/provider-settings.tsx +++ b/packages/web/src/features/settings/components/provider-settings.tsx @@ -2,11 +2,12 @@ import type { ProviderRuntimeStatusEntry, ProviderRuntimeStatusResponse } from " import { useAtomValue } from "jotai"; import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { connectionStatusAtom, dispatchCommandAtom } from "../../../atoms/connection"; +import { connectionStatusAtom } from "../../../atoms/connection"; import { Button, Notice, SegmentedControl, Textarea } from "../../../components/ui"; import { useTranslation } from "../../../lib/i18n"; import { buildDiagnosticsPath } from "../../diagnostics"; import { ConfigEditor, type ConfigType } from "./config-editor"; +import { useSessionGateDispatch } from "./use-session-gate-dispatch"; export interface ProviderInfo { id: "claude" | "codex"; @@ -48,7 +49,7 @@ export function ProviderSettings({ }: ProviderSettingsProps) { const t = useTranslation(); const navigate = useNavigate(); - const dispatch = useAtomValue(dispatchCommandAtom); + const dispatch = useSessionGateDispatch(); const connectionStatus = useAtomValue(connectionStatusAtom); const commandPreviewTitle = t("settings.provider.command_preview_title"); const commandPreviewHint = t("settings.provider.command_preview_hint"); @@ -170,7 +171,7 @@ export function ProviderSettings({ const loadRuntimeStatus = async () => { const result = await dispatch("provider.runtimeStatus", {}); - if (cancelled || !result.ok || !result.data) { + if (cancelled || result === null || !result.ok || !result.data) { return; } const providersData = result.data.providers ?? {}; @@ -212,7 +213,7 @@ export function ProviderSettings({ config: { additionalArgs }, }); - if (cancelled) { + if (cancelled || result === null) { return; } diff --git a/packages/web/src/features/settings/components/settings-page.test.tsx b/packages/web/src/features/settings/components/settings-page.test.tsx index 20465170..9e524a47 100644 --- a/packages/web/src/features/settings/components/settings-page.test.tsx +++ b/packages/web/src/features/settings/components/settings-page.test.tsx @@ -213,6 +213,25 @@ describe("SettingsPage", () => { }); }); + it("redirects to session gate instead of showing an inline load error when activation is required", async () => { + const sendCommand = vi.fn().mockRejectedValue( + new CommandResultError({ + code: "activation_required", + message: "This tab is no longer the active session", + }) + ); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + + await waitFor(() => { + expect(routerMocks.navigate).toHaveBeenCalledWith("/session-gate", { replace: true }); + }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("This tab is no longer the active session")).not.toBeInTheDocument(); + }); + it("renders the footer version from server metadata", () => { const store = createConnectedStore(vi.fn().mockResolvedValue({})); store.set(serverInfoAtom, { diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx index 6d28a30e..663e9dc5 100644 --- a/packages/web/src/features/settings/components/settings-page.tsx +++ b/packages/web/src/features/settings/components/settings-page.tsx @@ -30,11 +30,7 @@ import { Check, ChevronRight } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { localeAtom, themeAtom } from "../../../atoms/app-ui"; -import { - connectionStatusAtom, - dispatchCommandAtom, - serverInfoAtom, -} from "../../../atoms/connection"; +import { connectionStatusAtom, serverInfoAtom } from "../../../atoms/connection"; import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces"; import { Button, Input, Notice, Pill, Select, Switch, ThemedIcon } from "../../../components/ui"; import { useViewport } from "../../../hooks/use-viewport"; @@ -64,6 +60,7 @@ import { type SettingsSection, } from "./settings-sections"; import { ShortcutsSettings } from "./shortcuts-settings"; +import { useSessionGateDispatch } from "./use-session-gate-dispatch"; type NotificationCapabilityStatus = "available" | "limited" | "unsupported"; type NotificationPermissionState = NotificationPermission | "unavailable"; @@ -212,7 +209,7 @@ export function SettingsPage() { const navigate = useNavigate(); const viewport = useViewport(); const isMobile = viewport === "mobile"; - const dispatch = useAtomValue(dispatchCommandAtom); + const dispatch = useSessionGateDispatch(); const connectionStatus = useAtomValue(connectionStatusAtom); const serverInfo = useAtomValue(serverInfoAtom); const resolvedActiveWorkspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom); @@ -354,6 +351,10 @@ export function SettingsPage() { ...updateSelectionVersionRef.current, }; const result = await dispatch>("settings.get", {}); + if (result === null) { + return; + } + if (!result.ok || !result.data) { if (!cancelled) { setSettingsLoadError(result.error?.message ?? settingsLoadFailedUnknownRef.current); @@ -564,12 +565,12 @@ export function SettingsPage() { }, }, }); - if (!persistResult.ok) { + if (persistResult === null || !persistResult.ok) { return; } const runtimeResult = await dispatch("lsp.setMode", { mode: nextMode }); - if (!runtimeResult.ok) { + if (runtimeResult === null || !runtimeResult.ok) { return; } @@ -592,6 +593,9 @@ export function SettingsPage() { updateSelectionVersionRef.current.autoCheckEnabled += 1; setUpdateAutoCheckEnabled(value); const result = await saveUpdateSettings({ autoCheckEnabled: value }); + if (result === null) { + return; + } if (!result.ok) { setUpdateAutoCheckEnabled((current) => !value); } @@ -605,6 +609,9 @@ export function SettingsPage() { updateSelectionVersionRef.current.checkIntervalSec += 1; setUpdateCheckIntervalSec(value); const result = await saveUpdateSettings({ checkIntervalSec: value }); + if (result === null) { + return; + } if (!result.ok) { setUpdateCheckIntervalSec(previous); } @@ -977,7 +984,7 @@ function GeneralSettings({ const lspRuntimeModeDescId = useId(); const copyOnSelectLabelId = useId(); const copyOnSelectDescId = useId(); - const dispatch = useAtomValue(dispatchCommandAtom); + const dispatch = useSessionGateDispatch(); const setNotificationPreferences = useSetAtom(notificationPreferencesAtom); const [notificationPermission, setNotificationPermission] = useState("unavailable"); @@ -1072,6 +1079,10 @@ function GeneralSettings({ }, }); + if (result === null) { + return; + } + if (!result.ok) { setSupervisorTimeoutDraft(String(supervisorEvaluationTimeoutSec)); setSupervisorTimeoutError(result.error?.message || t("settings.config_files.save_failed")); @@ -1107,6 +1118,10 @@ function GeneralSettings({ }, }); + if (result === null) { + return; + } + if (!result.ok) { setSupervisorRetryMaxCountDraft(String(supervisorRetryMaxCount)); setSupervisorRetryMaxCountError( @@ -1144,6 +1159,10 @@ function GeneralSettings({ }, }); + if (result === null) { + return; + } + if (!result.ok) { setSupervisorRetryDelayDraft(String(supervisorRetryDelaySec)); setSupervisorRetryDelayError(result.error?.message || t("settings.config_files.save_failed")); @@ -1605,7 +1624,7 @@ function AppearanceSettings({ const desktopTerminalFontSizeDescId = useId(); const mobileTerminalFontSizeLabelId = useId(); const mobileTerminalFontSizeDescId = useId(); - const dispatch = useAtomValue(dispatchCommandAtom); + const dispatch = useSessionGateDispatch(); const currentThemeId = resolveStoredThemeId(theme); const themeOptions = THEMES.map((registeredTheme) => ({ value: registeredTheme.id, @@ -1631,7 +1650,7 @@ function AppearanceSettings({ }); const saveSettings = async (settings: Record) => { - await dispatch("settings.update", { settings }); + return await dispatch("settings.update", { settings }); }; useEffect(() => { @@ -1704,6 +1723,10 @@ function AppearanceSettings({ }, }); + if (result === null) { + return; + } + if (!result.ok) { setDraft(String(currentValue)); setError(result.error?.message || t("settings.config_files.save_failed")); diff --git a/packages/web/src/features/settings/components/use-session-gate-dispatch.ts b/packages/web/src/features/settings/components/use-session-gate-dispatch.ts new file mode 100644 index 00000000..6237d115 --- /dev/null +++ b/packages/web/src/features/settings/components/use-session-gate-dispatch.ts @@ -0,0 +1,30 @@ +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { + type CommandResult, + type DispatchCommandOptions, + dispatchCommandAtom, +} from "../../../atoms/connection"; + +export function useSessionGateDispatch() { + const dispatch = useAtomValue(dispatchCommandAtom); + const navigate = useNavigate(); + + return useCallback( + async function dispatchWithSessionGate( + op: string, + args: unknown, + options?: DispatchCommandOptions + ): Promise | null> { + const result = await dispatch(op, args, options); + if (!result.ok && result.error?.code === "activation_required") { + navigate("/session-gate", { replace: true }); + return null; + } + + return result; + }, + [dispatch, navigate] + ); +} diff --git a/packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts b/packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts index 3a3f25f2..e3ee34a6 100644 --- a/packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts +++ b/packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts @@ -279,7 +279,43 @@ describe("RecoveryCoordinator", () => { await coordinator.notifyReason("seq_gap", "term-1"); expect(markClosed).toHaveBeenCalledWith({ exitCode: 7 }); - expect(setUiMode).toHaveBeenCalledWith("silent"); + expect(setUiMode).toHaveBeenCalledWith("closed"); + }); + + it("surfaces directly closed terminals as closed UI state", async () => { + const setUiMode = vi.fn(); + const markClosed = vi.fn(); + const sendCommand = vi.fn().mockResolvedValueOnce({ + ok: true, + data: { + terminals: [{ terminalId: "term-1", action: "closed", headSeq: 30, exitCode: 9 }], + }, + }); + + const coordinator = createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection: vi.fn().mockResolvedValue({ ok: true }), + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }); + + coordinator.registerTerminal({ + terminalId: "term-1", + workspaceId: "ws-1", + getRenderedSeq: () => 20, + setUiMode, + markClosed, + }); + + await coordinator.notifyReason("initial_mount", "term-1"); + + expect(markClosed).toHaveBeenCalledWith({ exitCode: 9 }); + expect(setUiMode).toHaveBeenCalledWith("closed"); }); it("executes snapshot as blocking rebuild", async () => { @@ -346,4 +382,103 @@ describe("RecoveryCoordinator", () => { }) ); }); + + it("surfaces closed UI after snapshot recovery completes a closed session", async () => { + const setUiMode = vi.fn(); + const markClosed = vi.fn(); + const applySnapshot = vi.fn(); + const sendCommand = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + data: { + terminals: [ + { + terminalId: "term-1", + action: "snapshot", + headSeq: 30, + closed: { exitCode: 5 }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + ok: true, + data: { + status: "ok", + transport: "binary", + streamId: 1, + size: 3, + seq: 30, + rows: 24, + cols: 80, + source: "headless", + bytes: new Uint8Array([1, 2, 3]), + }, + }); + + const coordinator = createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection: vi.fn().mockResolvedValue({ ok: true }), + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand, + applyReplay: vi.fn(), + applySnapshot, + }); + + coordinator.registerTerminal({ + terminalId: "term-1", + workspaceId: "ws-1", + getRenderedSeq: () => 0, + setUiMode, + markClosed, + }); + + await coordinator.notifyReason("initial_mount", "term-1"); + + expect(markClosed).toHaveBeenCalledWith({ exitCode: 5 }); + expect(setUiMode).toHaveBeenCalledWith("closed"); + }); + + it("passes through unrecoverable reasons so terminals can render scenario-specific recovery UI", async () => { + const setUiMode = vi.fn(); + const sendCommand = vi.fn().mockResolvedValueOnce({ + ok: true, + data: { + terminals: [ + { + terminalId: "term-1", + action: "unrecoverable", + reason: "too_old_no_snapshot", + }, + ], + }, + }); + + const coordinator = createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection: vi.fn().mockResolvedValue({ ok: true }), + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }); + + coordinator.registerTerminal({ + terminalId: "term-1", + workspaceId: "ws-1", + getRenderedSeq: () => 20, + setUiMode, + }); + + await coordinator.notifyReason("seq_gap", "term-1"); + + expect(setUiMode).toHaveBeenCalledWith("error", { reason: "too_old_no_snapshot" }); + }); }); diff --git a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx index 509ec909..257e220d 100644 --- a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +++ b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx @@ -81,6 +81,10 @@ const uploadHookMocks = vi.hoisted(() => ({ handleFiles: vi.fn().mockResolvedValue(undefined), })); +const baseRequestAnimationFrame = global.requestAnimationFrame; +const baseCancelAnimationFrame = global.cancelAnimationFrame; +const baseResizeObserver = global.ResizeObserver; + const clipboardHelperMocks = vi.hoisted(() => ({ copyTextWithFallback: vi.fn(), })); @@ -371,6 +375,10 @@ describe("XtermHost", () => { afterEach(() => { resetGlobalRecoveryCoordinator(); + vi.useRealTimers(); + global.requestAnimationFrame = baseRequestAnimationFrame; + global.cancelAnimationFrame = baseCancelAnimationFrame; + global.ResizeObserver = baseResizeObserver; vi.restoreAllMocks(); }); @@ -913,19 +921,20 @@ describe("XtermHost", () => { ); }); - it("shows a restoring overlay while the initial replay is in flight", async () => { + it("shows a restoring overlay only after the initial recovery exceeds the grace delay", async () => { const store = createStore(); const sendCommand = vi.fn().mockImplementation((op: string) => { - if (op === "terminal.replay") { + if (op === "terminal.snapshot") { return new Promise(() => {}); } - return Promise.resolve({ ok: true, data: { status: "ok" } }); + return Promise.resolve({ status: "ok" }); }); const subscribe = vi.fn(() => vi.fn()); const rafCallbacks: FrameRequestCallback[] = []; const originalRequestAnimationFrame = global.requestAnimationFrame; const originalCancelAnimationFrame = global.cancelAnimationFrame; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); mockTerminal.cols = 132; mockTerminal.rows = 36; @@ -958,7 +967,19 @@ describe("XtermHost", () => { await Promise.resolve(); }); - expect(await screen.findByText("正在恢复终端内容…")).toBeInTheDocument(); + expect(screen.queryByText("正在恢复终端内容…")).not.toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(1199); + }); + + expect(screen.queryByText("正在恢复终端内容…")).not.toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(screen.getByText("正在恢复终端内容…")).toBeInTheDocument(); expect( screen.getByText( "恢复期间暂时无法使用当前终端;请耐心等待,历史内容恢复完成后再继续。内容较多时可能需要更久。" @@ -969,6 +990,85 @@ describe("XtermHost", () => { global.requestAnimationFrame = originalRequestAnimationFrame; global.cancelAnimationFrame = originalCancelAnimationFrame; + vi.useRealTimers(); + }); + + it("does not show a restoring overlay when recovery finishes within the grace delay", async () => { + const store = createStore(); + const snapshotBytes = new TextEncoder().encode("hello"); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.snapshot") { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + status: "ok", + transport: "binary", + streamId: 7, + size: 5, + seq: 5, + rows: 24, + cols: 80, + bytes: snapshotBytes, + }); + }, 800); + }); + } + + return Promise.resolve({ status: "ok" }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe, + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + await act(async () => { + const callback = rafCallbacks.shift(); + callback?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.queryByText("正在恢复终端内容…")).not.toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(800); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expectTerminalWriteData(snapshotBytes); + expect(screen.queryByText("正在恢复终端内容…")).not.toBeInTheDocument(); + expect(document.querySelector(".xterm-replay-overlay")).toBeFalsy(); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + vi.useRealTimers(); }); it("queues desktop hydration before creating xterm and shows queue placeholder copy", async () => { @@ -1188,7 +1288,7 @@ describe("XtermHost", () => { }); expect(hydrationCoordinatorMocks.request).not.toHaveBeenCalled(); - expect(await screen.findByText("Restoring terminal output...")).toBeInTheDocument(); + expect(screen.queryByText("Restoring terminal output...")).not.toBeInTheDocument(); expect(sendCommand).toHaveBeenCalledWith( "terminal.replay", { @@ -1204,7 +1304,7 @@ describe("XtermHost", () => { global.cancelAnimationFrame = originalCancelAnimationFrame; }); - it("shows a degraded overlay message when replay fails so the terminal remains usable", async () => { + it("shows a retryable recovery notice instead of a blocking overlay when replay fails", async () => { const store = createStore(); const sendCommand = vi.fn().mockImplementation((op: string) => { if (op === "terminal.replay") { @@ -1250,24 +1350,32 @@ describe("XtermHost", () => { }); await waitFor(() => { - expect(screen.getByText("历史内容恢复失败")).toBeInTheDocument(); + expect(screen.getByText("终端历史暂未恢复")).toBeInTheDocument(); }); expect( - screen.getByText("新输出仍会继续显示;如果需要完整历史,再手动刷新页面。") + screen.getByText( + "当前终端可以继续使用,但较早输出这次没有补齐。你可以重试恢复;如果服务端仍保留历史,稍后或刷新页面后仍可能找回。" + ) ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重试恢复" })).toBeInTheDocument(); + expect(document.querySelector(".xterm-replay-overlay")).toBeFalsy(); global.requestAnimationFrame = originalRequestAnimationFrame; global.cancelAnimationFrame = originalCancelAnimationFrame; }); - it("shows a degraded overlay when replay returns unknown so unavailable terminals do not stay loading", async () => { + it("retries local recovery when the retry action is clicked", async () => { const store = createStore(); const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.snapshot") { + return Promise.resolve({ status: "unsupported" }); + } + if (op === "terminal.replay") { - return Promise.resolve({ status: "unknown" }); + return Promise.reject(new Error("Command timeout: terminal.replay")); } - return Promise.resolve({ ok: true, data: { status: "ok" } }); + return Promise.resolve({ status: "ok" }); }); const subscribe = vi.fn(() => vi.fn()); const rafCallbacks: FrameRequestCallback[] = []; @@ -1293,7 +1401,7 @@ describe("XtermHost", () => { render( - + ); @@ -1306,30 +1414,64 @@ describe("XtermHost", () => { }); await waitFor(() => { - expect(screen.getByText("当前会话已结束")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重试恢复" })).toBeInTheDocument(); }); - expect(screen.getByText("是否重新打开一个新会话继续。")).toBeInTheDocument(); - expect(screen.queryByText("正在恢复终端内容…")).not.toBeInTheDocument(); + + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.snapshot")).toHaveLength(1); + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.replay")).toHaveLength(1); + expect( + sendCommand.mock.calls.filter(([op]) => op === "terminal.replay").map(([, args]) => args) + ).toEqual([{ terminalId: "retry-local-terminal", lastSeq: 0 }]); + + fireEvent.click(screen.getByRole("button", { name: "重试恢复" })); + + await waitFor(() => { + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.snapshot")).toHaveLength(2); + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.replay")).toHaveLength(2); + }); + expect( + sendCommand.mock.calls.filter(([op]) => op === "terminal.replay").map(([, args]) => args) + ).toEqual([ + { terminalId: "retry-local-terminal", lastSeq: 0 }, + { terminalId: "retry-local-terminal", lastSeq: 0 }, + ]); global.requestAnimationFrame = originalRequestAnimationFrame; global.cancelAnimationFrame = originalCancelAnimationFrame; }); - it("shows provider-specific recovery actions for closed agent sessions", async () => { + it("retries local gap recovery from the original missing-history seq", async () => { const store = createStore(); - const onContinue = vi.fn(); - const onClose = vi.fn(); + const initialReplayChunk = new TextEncoder().encode("snapshot\n"); + const gapChunk = new TextEncoder().encode("tail\n"); + let subscriptionHandler: ((topic: string, payload: unknown, seq: number) => void) | undefined; + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + let replayCount = 0; const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.snapshot") { + return Promise.resolve({ status: "unsupported" }); + } + if (op === "terminal.replay") { - return Promise.resolve({ status: "unknown" }); + replayCount += 1; + if (replayCount === 1) { + return Promise.resolve({ + status: "ok", + transport: "binary", + streamId: 1, + size: initialReplayChunk.byteLength, + seq: 100, + bytes: initialReplayChunk, + } satisfies TerminalReplayPayload); + } + + return Promise.reject(new Error("Command timeout: terminal.replay")); } - return Promise.resolve({ ok: true, data: { status: "ok" } }); + return Promise.resolve({ status: "ok" }); }); - const subscribe = vi.fn(() => vi.fn()); - const rafCallbacks: FrameRequestCallback[] = []; - const originalRequestAnimationFrame = global.requestAnimationFrame; - const originalCancelAnimationFrame = global.cancelAnimationFrame; mockTerminal.cols = 132; mockTerminal.rows = 36; @@ -1343,22 +1485,17 @@ describe("XtermHost", () => { store.set(localeAtom, "zh"); store.set(wsClientAtom, { sendCommand, - subscribe, + subscribe: vi.fn((_topics, handler) => { + subscriptionHandler = handler; + return vi.fn(); + }), getStatus: vi.fn(() => "connected"), onStatus: vi.fn(() => () => {}), } as never); render( - + ); @@ -1371,49 +1508,79 @@ describe("XtermHost", () => { }); await waitFor(() => { - expect(screen.getByText("当前会话已结束")).toBeInTheDocument(); + expectReplayCall(sendCommand, "gap-retry-terminal", 0); + expectTerminalWriteData(initialReplayChunk); }); - expect(screen.getByText("是否重新打开一个 Codex 会话继续。")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "确认" })); - fireEvent.click(screen.getByRole("button", { name: "关闭" })); + mockTerminal.write.mockClear(); - expect(onContinue).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); + await act(async () => { + subscriptionHandler?.( + Topics.terminalOutput("test-workspace", "gap-retry-terminal"), + { transport: "binary", streamId: 2, size: gapChunk.byteLength, bytes: gapChunk }, + 112 + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "重试恢复" })).toBeInTheDocument(); + }); + expect( + sendCommand.mock.calls.filter(([op]) => op === "terminal.replay").map(([, args]) => args) + ).toEqual([ + { terminalId: "gap-retry-terminal", lastSeq: 0 }, + { terminalId: "gap-retry-terminal", lastSeq: 100 }, + ]); + + fireEvent.click(screen.getByRole("button", { name: "重试恢复" })); + + await waitFor(() => { + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.replay")).toHaveLength(3); + }); + expect( + sendCommand.mock.calls.filter(([op]) => op === "terminal.replay").map(([, args]) => args) + ).toEqual([ + { terminalId: "gap-retry-terminal", lastSeq: 0 }, + { terminalId: "gap-retry-terminal", lastSeq: 100 }, + { terminalId: "gap-retry-terminal", lastSeq: 100 }, + ]); global.requestAnimationFrame = originalRequestAnimationFrame; global.cancelAnimationFrame = originalCancelAnimationFrame; }); - it("does not trigger replay on successful foreground probe when continuity is intact", async () => { - const initialSnapshot = new TextEncoder().encode("init"); + it("routes retry through recovery.reconcile when a coordinator is installed", async () => { const probeConnection = vi.fn().mockResolvedValue({ ok: true }); const sendCommand = vi.fn(async (op: string) => { if (op === "recovery.reconcile") { return { - terminals: [{ terminalId: "term-1", action: "snapshot", headSeq: 12 }], + terminals: [ + { + terminalId: "retry-coordinator-terminal", + action: "replay", + fromSeq: 0, + headSeq: 10, + }, + ], }; } - if (op === "terminal.snapshot") { - return { - status: "ok", - transport: "binary", - streamId: 1, - size: initialSnapshot.byteLength, - seq: 12, - rows: 24, - cols: 80, - source: "headless", - bytes: initialSnapshot, - }; + if (op === "terminal.replay") { + throw new Error("Command timeout: terminal.replay"); + } + + if (op === "terminal.resize") { + return { status: "ok" }; } throw new Error(`Unexpected op ${op}`); }); const store = createStore(); - store.set(localeAtom, "en"); + store.set(localeAtom, "zh"); store.set(wsClientAtom, { sendCommand, subscribe: vi.fn(() => () => {}), @@ -1451,48 +1618,579 @@ describe("XtermHost", () => { render( - + ); + await waitFor(() => { + expect(screen.getByRole("button", { name: "重试恢复" })).toBeInTheDocument(); + }); + + sendCommand.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "重试恢复" })); + await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( "recovery.reconcile", { - reason: "initial_mount", - terminals: [{ terminalId: "term-1", renderedSeq: 0 }], + reason: "foreground_resume", + terminals: [{ terminalId: "retry-coordinator-terminal", renderedSeq: 0 }], }, undefined ); }); - await waitFor(() => { - expect(sendCommand).toHaveBeenCalledWith( - "terminal.snapshot", - { terminalId: "term-1" }, - { timeoutMs: TERMINAL_REPLAY_TIMEOUT_MS } - ); - }); + }); - sendCommand.mockClear(); + it("preserves the original recovery anchor when coordinator retry follows a failed gap recovery", async () => { + const initialReplayChunk = new TextEncoder().encode("snapshot\n"); + const gapChunk = new TextEncoder().encode("tail\n"); + let subscriptionHandler: ((topic: string, payload: unknown, seq: number) => void) | undefined; + const probeConnection = vi.fn().mockResolvedValue({ ok: true }); + const sendCommand = vi.fn( + async (op: string, args?: { terminals?: Array<{ renderedSeq: number }> }) => { + if (op === "recovery.reconcile") { + return { + terminals: [ + { + terminalId: "retry-coordinator-gap-terminal", + action: "replay", + fromSeq: args?.terminals?.[0]?.renderedSeq ?? 0, + headSeq: 130, + }, + ], + }; + } - await act(async () => { - await getGlobalRecoveryCoordinator()?.notifyReason("foreground_resume", "term-1"); - }); + if (op === "terminal.replay") { + if (args?.terminals) { + throw new Error( + `Unexpected reconcile-shaped args for terminal.replay: ${JSON.stringify(args)}` + ); + } - expect(sendCommand).toHaveBeenCalledWith( - "recovery.reconcile", - { - reason: "foreground_resume", - terminals: [{ terminalId: "term-1", renderedSeq: 12 }], - }, - undefined - ); - expect(sendCommand.mock.calls.some(([op]) => op === "terminal.replay")).toBe(false); - }); + if ( + (sendCommand.mock.calls.filter(([name]) => name === "terminal.replay").length ?? 0) === + 1 + ) { + return { + status: "ok", + transport: "binary", + streamId: 1, + size: initialReplayChunk.byteLength, + seq: 100, + bytes: initialReplayChunk, + } satisfies TerminalReplayPayload; + } - it("routes live seq gaps through recovery.reconcile before replay", async () => { - const initialSnapshot = new TextEncoder().encode("hello"); - const missedTail = new TextEncoder().encode("missed\ntail\n"); + throw new Error("Command timeout: terminal.replay"); + } + + if (op === "terminal.resize") { + return { status: "ok" }; + } + + throw new Error(`Unexpected op ${op}`); + } + ); + + const store = createStore(); + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe: vi.fn((_topics, handler) => { + subscriptionHandler = handler; + return vi.fn(); + }), + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + } as never); + + setGlobalRecoveryCoordinator( + createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand: async (op, innerArgs, options) => { + try { + const data = await sendCommand(op, innerArgs as never, options); + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + code: "command_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }) + ); + + render( + + + + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "recovery.reconcile", + { + reason: "initial_mount", + terminals: [{ terminalId: "retry-coordinator-gap-terminal", renderedSeq: 0 }], + }, + undefined + ); + expectTerminalWriteData(initialReplayChunk); + }); + + await act(async () => { + subscriptionHandler?.( + Topics.terminalOutput("test-workspace", "retry-coordinator-gap-terminal"), + { transport: "binary", streamId: 2, size: gapChunk.byteLength, bytes: gapChunk }, + 112 + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "重试恢复" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "重试恢复" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "recovery.reconcile", + { + reason: "foreground_resume", + terminals: [{ terminalId: "retry-coordinator-gap-terminal", renderedSeq: 100 }], + }, + undefined + ); + }); + }); + + it("shows a dedicated notice when earlier history is no longer recoverable", async () => { + const initialSnapshot = new TextEncoder().encode("init"); + const probeConnection = vi.fn().mockResolvedValue({ ok: true }); + const sendCommand = vi.fn(async (op: string) => { + if (op === "recovery.reconcile") { + return { + terminals: [ + { + terminalId: "too-old-terminal", + action: "unrecoverable", + reason: "too_old_no_snapshot", + }, + ], + }; + } + + if (op === "terminal.snapshot") { + return { + status: "ok", + transport: "binary", + streamId: 1, + size: initialSnapshot.byteLength, + seq: 12, + rows: 24, + cols: 80, + source: "headless", + bytes: initialSnapshot, + }; + } + + throw new Error(`Unexpected op ${op}`); + }); + + const store = createStore(); + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + } as never); + + setGlobalRecoveryCoordinator( + createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand: async (op, args, options) => { + try { + const data = await sendCommand(op, args, options); + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + code: "command_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }) + ); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("较早历史已无法恢复")).toBeInTheDocument(); + }); + expect( + screen.getByText( + "较早的终端输出已经从回放缓冲区中淘汰,而且当前也没有可用快照。现在只能继续查看后续输出。" + ) + ).toBeInTheDocument(); + expect(document.querySelector(".xterm-replay-overlay")).toBeFalsy(); + }); + + it("shows an unavailable terminal overlay when the coordinator reports unknown_terminal", async () => { + const probeConnection = vi.fn().mockResolvedValue({ ok: true }); + const sendCommand = vi.fn(async (op: string) => { + if (op === "recovery.reconcile") { + return { + terminals: [ + { + terminalId: "unknown-terminal", + action: "unrecoverable", + reason: "unknown_terminal", + }, + ], + }; + } + + if (op === "terminal.resize") { + return { status: "ok" }; + } + + throw new Error(`Unexpected op ${op}`); + }); + + const store = createStore(); + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + } as never); + + setGlobalRecoveryCoordinator( + createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand: async (op, args, options) => { + try { + const data = await sendCommand(op, args, options); + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + code: "command_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }) + ); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("当前终端已不可恢复")).toBeInTheDocument(); + }); + expect( + screen.getByText("这个终端会话已经不在服务端,历史输出无法再补回。请重新打开一个新终端继续。") + ).toBeInTheDocument(); + expect(document.querySelector(".xterm-replay-overlay")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "重试恢复" })).not.toBeInTheDocument(); + expect(mockTerminal.options).toEqual( + expect.objectContaining({ + disableStdin: true, + cursorBlink: false, + }) + ); + }); + + it("shows a degraded overlay when replay returns unknown so unavailable terminals do not stay loading", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.replay") { + return Promise.resolve({ status: "unknown" }); + } + + return Promise.resolve({ ok: true, data: { status: "ok" } }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe, + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + await act(async () => { + const callback = rafCallbacks.shift(); + callback?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText("当前终端已不可恢复")).toBeInTheDocument(); + }); + expect( + screen.getByText("这个终端会话已经不在服务端,历史输出无法再补回。请重新打开一个新终端继续。") + ).toBeInTheDocument(); + expect(screen.queryByText("正在恢复终端内容…")).not.toBeInTheDocument(); + expect(mockTerminal.options).toEqual( + expect.objectContaining({ + disableStdin: true, + cursorBlink: false, + }) + ); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + it("shows provider-specific recovery actions for closed agent sessions", async () => { + const store = createStore(); + const onContinue = vi.fn(); + const onClose = vi.fn(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.replay") { + return Promise.resolve({ status: "unknown" }); + } + + return Promise.resolve({ ok: true, data: { status: "ok" } }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe, + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + await act(async () => { + const callback = rafCallbacks.shift(); + callback?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText("当前终端已不可恢复")).toBeInTheDocument(); + }); + expect( + screen.getByText("这个终端会话已经不在服务端。是否重新打开一个 Codex 会话继续?") + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "确认" })); + fireEvent.click(screen.getByRole("button", { name: "关闭" })); + + expect(onContinue).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + it("does not trigger replay on successful foreground probe when continuity is intact", async () => { + const initialSnapshot = new TextEncoder().encode("init"); + const probeConnection = vi.fn().mockResolvedValue({ ok: true }); + const sendCommand = vi.fn(async (op: string) => { + if (op === "recovery.reconcile") { + return { + terminals: [{ terminalId: "term-1", action: "snapshot", headSeq: 12 }], + }; + } + + if (op === "terminal.snapshot") { + return { + status: "ok", + transport: "binary", + streamId: 1, + size: initialSnapshot.byteLength, + seq: 12, + rows: 24, + cols: 80, + source: "headless", + bytes: initialSnapshot, + }; + } + + throw new Error(`Unexpected op ${op}`); + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand, + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + } as never); + + setGlobalRecoveryCoordinator( + createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand: async (op, args, options) => { + try { + const data = await sendCommand(op, args, options); + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + code: "command_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }) + ); + + render( + + + + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "recovery.reconcile", + { + reason: "initial_mount", + terminals: [{ terminalId: "term-1", renderedSeq: 0 }], + }, + undefined + ); + }); + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "terminal.snapshot", + { terminalId: "term-1" }, + { timeoutMs: TERMINAL_REPLAY_TIMEOUT_MS } + ); + }); + + sendCommand.mockClear(); + + await act(async () => { + await getGlobalRecoveryCoordinator()?.notifyReason("foreground_resume", "term-1"); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "recovery.reconcile", + { + reason: "foreground_resume", + terminals: [{ terminalId: "term-1", renderedSeq: 12 }], + }, + undefined + ); + expect(sendCommand.mock.calls.some(([op]) => op === "terminal.replay")).toBe(false); + }); + + it("routes live seq gaps through recovery.reconcile before replay", async () => { + const initialSnapshot = new TextEncoder().encode("hello"); + const missedTail = new TextEncoder().encode("missed\ntail\n"); const probeConnection = vi.fn().mockResolvedValue({ ok: true }); let eventHandler: ((topic: string, payload: unknown, seq: number) => void) | undefined; let reconcileCount = 0; @@ -1725,6 +2423,101 @@ describe("XtermHost", () => { }); }); + it("renders live output immediately after an unrecoverable recovery decision", async () => { + let eventHandler: ((topic: string, payload: unknown, seq: number) => void) | undefined; + const liveChunk = new TextEncoder().encode("later output"); + const sendCommand = vi.fn(async (op: string) => { + if (op === "terminal.resize") { + return { status: "ok" }; + } + + if (op === "recovery.reconcile") { + return { + terminals: [ + { + terminalId: "too-old-live-terminal", + action: "unrecoverable", + reason: "too_old_no_snapshot", + }, + ], + }; + } + + throw new Error(`Unexpected op ${op}`); + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand, + subscribe: vi.fn((_topics, handler) => { + eventHandler = handler; + return () => { + eventHandler = undefined; + }; + }), + getStatus: vi.fn(() => "connected"), + probeConnection: vi.fn().mockResolvedValue({ ok: true }), + onStatus: vi.fn(() => () => {}), + } as never); + + setGlobalRecoveryCoordinator( + createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection: vi.fn().mockResolvedValue({ ok: true }), + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand: async (op, args, options) => { + try { + const data = await sendCommand(op, args, options); + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + code: "command_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }) + ); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Earlier history can no longer be restored")).toBeInTheDocument(); + }); + + mockTerminal.write.mockClear(); + + act(() => { + eventHandler?.( + Topics.terminalOutput("test-workspace", "too-old-live-terminal"), + { + transport: "binary", + streamId: 12, + size: liveChunk.byteLength, + bytes: liveChunk, + }, + liveChunk.byteLength + ); + }); + + await waitFor(() => { + expectTerminalWriteData(liveChunk); + }); + }); + it("marks terminal closed after recovery reconcile returns closed state", async () => { const sendCommand = vi.fn(async (op: string) => { if (op === "terminal.resize") { @@ -1809,6 +2602,17 @@ describe("XtermHost", () => { undefined ); }); + await waitFor(() => { + expect(screen.getByText("This session has ended")).toBeInTheDocument(); + }); + expect(screen.getByText("Reopen a new session to continue?")).toBeInTheDocument(); + expect(document.querySelector(".xterm-replay-overlay")).toBeTruthy(); + expect(mockTerminal.options).toEqual( + expect.objectContaining({ + disableStdin: true, + cursorBlink: false, + }) + ); expect(store.get(terminalMetaAtomFamily("closed-terminal"))).toMatchObject({ alive: false, exitCode: 3, diff --git a/packages/web/src/features/terminal-panel/recovery-coordinator.ts b/packages/web/src/features/terminal-panel/recovery-coordinator.ts index 54771ba2..686245c7 100644 --- a/packages/web/src/features/terminal-panel/recovery-coordinator.ts +++ b/packages/web/src/features/terminal-panel/recovery-coordinator.ts @@ -12,13 +12,17 @@ import type { TerminalReplayPayload, TerminalSnapshotPayload, } from "../../ws/client"; -import { type RecoveryUiMode, TERMINAL_REPLAY_TIMEOUT_MS } from "./replay-state"; +import { + type RecoveryUiMode, + type RecoveryUiModeDetail, + TERMINAL_REPLAY_TIMEOUT_MS, +} from "./replay-state"; interface RegisteredTerminal { terminalId: string; workspaceId: string; getRenderedSeq: () => number; - setUiMode: (mode: RecoveryUiMode) => void; + setUiMode: (mode: RecoveryUiMode, detail?: RecoveryUiModeDetail) => void; markClosed?: (state: RecoveryClosedTerminalState) => Promise | void; completeRecovery?: ( headSeq: number, @@ -285,7 +289,7 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove await deps.applySnapshot(terminalId, snapshotResult.data); } await applyClosedState(terminal, closed); - terminal.setUiMode("silent"); + terminal.setUiMode(closed ? "closed" : "silent"); }; const applyClosedState = async ( @@ -323,12 +327,12 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove } else { await applyClosedState(terminal, { exitCode: decision.exitCode }); } - terminal.setUiMode("silent"); + terminal.setUiMode("closed"); return; } if (decision.action === "unrecoverable") { - terminal.setUiMode("error"); + terminal.setUiMode("error", { reason: decision.reason }); return; } @@ -371,7 +375,7 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove await deps.applyReplay(decision.terminalId, replayResult.data); } await applyClosedState(terminal, decision.closed); - terminal.setUiMode("silent"); + terminal.setUiMode(decision.closed ? "closed" : "silent"); return; } diff --git a/packages/web/src/features/terminal-panel/replay-state.ts b/packages/web/src/features/terminal-panel/replay-state.ts index 245c8489..3d46f3a6 100644 --- a/packages/web/src/features/terminal-panel/replay-state.ts +++ b/packages/web/src/features/terminal-panel/replay-state.ts @@ -2,15 +2,24 @@ export const TERMINAL_REPLAY_TIMEOUT_MS = 120_000; export type RecoveryUiMode = | "silent" + | "closed" | "checking" | "non_blocking_recovering" | "blocking_rebuild" | "error"; +export interface RecoveryUiModeDetail { + reason?: "too_old_no_snapshot" | "unknown_terminal"; +} + export type TerminalReplayUiState = | { kind: "loading" } | { kind: "ready" } - | { kind: "degraded"; reason: "timeout" | "failed" | "truncated" | "closed" }; + | { kind: "closed" } + | { kind: "unavailable" } + | { kind: "truncated" } + | { kind: "retryable_failure"; reason: "timeout" | "failed" } + | { kind: "unrecoverable_history"; reason: "too_old_no_snapshot" }; export function classifyReplayFailure(error: unknown): "timeout" | "failed" { if (error instanceof Error && error.message.includes("Command timeout: terminal.replay")) { diff --git a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx index 4371ffba..1f879ec0 100644 --- a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +++ b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx @@ -18,6 +18,7 @@ import { Terminal } from "@xterm/xterm"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { type ChangeEvent as ReactChangeEvent, + type ReactNode, useCallback, useEffect, useLayoutEffect, @@ -26,7 +27,7 @@ import { } from "react"; import { themeAtom } from "../../../../atoms/app-ui"; import { dispatchCommandAtom, wsClientAtom } from "../../../../atoms/connection"; -import { Button, LocalOverlay } from "../../../../components/ui"; +import { Button, LocalOverlay, Notice } from "../../../../components/ui"; import { useViewport } from "../../../../hooks/use-viewport"; import { copyTextWithFallback } from "../../../../lib/clipboard"; import { useTranslation } from "../../../../lib/i18n"; @@ -71,6 +72,7 @@ const MOBILE_COPY_MODE_LONG_PRESS_MS = 500; const MOBILE_COPY_MODE_MOVE_TOLERANCE_PX = 10; const TERMINAL_FOCUS_REPORTING_BYTES = new Set(["\x1b[I", "\x1b[O"]); const TERMINAL_COPY_ON_SELECT_ERROR_THROTTLE_MS = 3_000; +const TERMINAL_RECOVERY_LOADING_OVERLAY_DELAY_MS = 1_200; interface TerminalInputDraftState { nextDraft: string; @@ -410,7 +412,7 @@ export function XtermHost({ const meta = useAtomValue(terminalMetaAtomFamily(terminalId)); const terminalMetaRef = useRef(meta); const terminalKind = terminalKindProp ?? meta?.kind ?? "shell"; - const isInteractive = !readOnly && meta?.alive !== false; + const baseIsInteractive = !readOnly && meta?.alive !== false; const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); @@ -431,13 +433,21 @@ export function XtermHost({ const pendingReplayChunksRef = useRef>([]); const replayCompletedRef = useRef(false); const replayedSeqRef = useRef(0); - const applyReplayPayloadRef = useRef<((payload: ReplayPayload) => Promise) | null>(null); + const recoveryReplayAnchorSeqRef = useRef(null); + const applyReplayPayloadRef = useRef< + | ((payload: ReplayPayload, options?: { resetTerminalBeforeWrite?: boolean }) => Promise) + | null + >(null); const applySnapshotPayloadRef = useRef<((payload: SnapshotPayload) => Promise) | null>( null ); const completeHistoricalRecoveryRef = useRef< ((coveredSeq: number, closed?: RecoveryClosedTerminalState) => Promise) | null >(null); + const failHistoricalRecoveryRef = useRef<((error: unknown) => Promise) | null>(null); + const showUnrecoverableHistoryRef = useRef<(() => Promise) | null>(null); + const showUnavailableTerminalRef = useRef<(() => Promise) | null>(null); + const retryHistoricalRecoveryRef = useRef<(() => void) | null>(null); const coldStartStateRef = useRef<"idle" | "in-flight" | "done">("idle"); const activeHistoricalRecoveryModeRef = useRef<"initial" | "reconnect" | null>(null); const latestRenderedSeqRef = useRef(0); @@ -481,6 +491,9 @@ export function XtermHost({ }); const [replayUiState, setReplayUiState] = useState({ kind: "loading" }); + const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false); + const isInteractive = + baseIsInteractive && replayUiState.kind !== "closed" && replayUiState.kind !== "unavailable"; const activeRecoveryUiModeRef = useRef("blocking_rebuild"); const [hydrationState, setHydrationState] = useState< { kind: "idle" } | { kind: "queued"; queuePosition: number } | { kind: "granted" } @@ -607,6 +620,22 @@ export function XtermHost({ } }, [uiTheme]); + useEffect(() => { + if (replayUiState.kind !== "loading") { + setLoadingOverlayVisible(false); + return; + } + + setLoadingOverlayVisible(false); + const timeoutId = setTimeout(() => { + setLoadingOverlayVisible(true); + }, TERMINAL_RECOVERY_LOADING_OVERLAY_DELAY_MS); + + return () => { + clearTimeout(timeoutId); + }; + }, [replayUiState]); + useEffect(() => { const container = containerRef.current; if (!container || typeof window === "undefined" || typeof window.matchMedia !== "function") { @@ -1204,19 +1233,60 @@ export function XtermHost({ return recoveryCoordinator.registerTerminal({ terminalId, workspaceId, - getRenderedSeq: () => latestRenderedSeqRef.current, - setUiMode: (mode) => { + getRenderedSeq: () => recoveryReplayAnchorSeqRef.current ?? latestRenderedSeqRef.current, + setUiMode: (mode, detail) => { activeRecoveryUiModeRef.current = mode; if (mode === "silent") { + recoveryReplayAnchorSeqRef.current = null; setReplayUiState({ kind: "ready" }); return; } + if (mode === "closed") { + recoveryReplayAnchorSeqRef.current = null; + setReplayUiState({ kind: "closed" }); + return; + } + if (mode === "error") { - setReplayUiState({ kind: "degraded", reason: "failed" }); + if (detail?.reason === "too_old_no_snapshot") { + if (showUnrecoverableHistoryRef.current) { + void showUnrecoverableHistoryRef.current(); + return; + } + + recoveryReplayAnchorSeqRef.current = null; + setReplayUiState({ kind: "unrecoverable_history", reason: "too_old_no_snapshot" }); + return; + } + + if (detail?.reason === "unknown_terminal") { + if (showUnavailableTerminalRef.current) { + void showUnavailableTerminalRef.current(); + return; + } + + recoveryReplayAnchorSeqRef.current = null; + setReplayUiState({ kind: "unavailable" }); + return; + } + + if (failHistoricalRecoveryRef.current) { + void failHistoricalRecoveryRef.current(new Error("terminal recovery failed")); + return; + } + + setReplayUiState({ kind: "retryable_failure", reason: "failed" }); return; } + if ( + (mode === "non_blocking_recovering" || mode === "blocking_rebuild") && + recoveryReplayAnchorSeqRef.current === null + ) { + recoveryReplayAnchorSeqRef.current = latestRenderedSeqRef.current; + } + setReplayUiState({ kind: "loading" }); }, markClosed: ({ exitCode }) => { @@ -1587,6 +1657,7 @@ export function XtermHost({ releaseHydration(); await flushHistoricalRecovery({ coveredSeq }); + recoveryReplayAnchorSeqRef.current = null; if (closed) { markTerminalClosed(closed.exitCode); } @@ -1599,24 +1670,57 @@ export function XtermHost({ return; } - setReplayUiState({ kind: "degraded", reason: classifyReplayFailure(error) }); + activeRecoveryUiModeRef.current = "error"; + setReplayUiState({ kind: "retryable_failure", reason: classifyReplayFailure(error) }); releaseHydration(); await flushHistoricalRecovery(); }; + failHistoricalRecoveryRef.current = failHistoricalRecovery; - applyReplayPayloadRef.current = async (payload) => { + const showUnrecoverableHistory = async () => { if (!mountedRef.current || !terminalRef.current) { return; } + activeRecoveryUiModeRef.current = "error"; + recoveryReplayAnchorSeqRef.current = null; + setReplayUiState({ kind: "unrecoverable_history", reason: "too_old_no_snapshot" }); + releaseHydration(); + await flushHistoricalRecovery(); + }; + showUnrecoverableHistoryRef.current = showUnrecoverableHistory; + + const showUnavailableTerminal = async () => { + if (!mountedRef.current || !terminalRef.current) { + return; + } + + activeRecoveryUiModeRef.current = "error"; + recoveryReplayAnchorSeqRef.current = null; + setReplayUiState({ kind: "unavailable" }); + releaseHydration(); + await flushHistoricalRecovery(); + }; + showUnavailableTerminalRef.current = showUnavailableTerminal; + + applyReplayPayloadRef.current = async (payload, options) => { + if (!mountedRef.current || !terminalRef.current) { + return; + } + + const resetTerminalBeforeWrite = + options?.resetTerminalBeforeWrite ?? recoveryReplayAnchorSeqRef.current === 0; coldStartStateRef.current = "in-flight"; activeHistoricalRecoveryModeRef.current = "reconnect"; + activeRecoveryUiModeRef.current = "silent"; setReplayUiState({ kind: "ready" }); releaseHydration(); await flushHistoricalRecovery({ bytes: payload.bytes, coveredSeq: payload.seq, + resetTerminalBeforeWrite, }); + recoveryReplayAnchorSeqRef.current = null; }; applySnapshotPayloadRef.current = async (payload) => { @@ -1626,6 +1730,7 @@ export function XtermHost({ coldStartStateRef.current = "in-flight"; activeHistoricalRecoveryModeRef.current = "initial"; + activeRecoveryUiModeRef.current = "silent"; setReplayUiState({ kind: "ready" }); releaseHydration(); await flushHistoricalRecovery({ @@ -1633,6 +1738,7 @@ export function XtermHost({ coveredSeq: payload.seq, resetTerminalBeforeWrite: true, }); + recoveryReplayAnchorSeqRef.current = null; }; const requestSnapshot = (options?: { @@ -1643,6 +1749,9 @@ export function XtermHost({ return; } + if (recoveryReplayAnchorSeqRef.current === null) { + recoveryReplayAnchorSeqRef.current = latestRenderedSeqRef.current; + } coldStartStateRef.current = "in-flight"; replayCompletedRef.current = false; setReplayUiState({ kind: "loading" }); @@ -1692,11 +1801,13 @@ export function XtermHost({ options?: { onTooOld?: () => void; onError?: (error: unknown) => void; + resetTerminalBeforeWrite?: boolean; } ) => { if (!wsClient) { return; } + recoveryReplayAnchorSeqRef.current = lastSeq; coldStartStateRef.current = "in-flight"; replayCompletedRef.current = false; if (lastSeq === 0) { @@ -1720,16 +1831,18 @@ export function XtermHost({ } if (result.ok && result.data?.status === "ok" && result.data.bytes) { - void applyReplayPayloadRef.current?.({ - ...result.data, - bytes: result.data.bytes, - }); + void applyReplayPayloadRef.current?.( + { + ...result.data, + bytes: result.data.bytes, + }, + { resetTerminalBeforeWrite: options?.resetTerminalBeforeWrite ?? lastSeq === 0 } + ); return; } if (result.ok && result.data?.status === "unknown") { - setReplayUiState({ kind: "degraded", reason: "closed" }); - releaseHydration(); + void showUnavailableTerminal(); return; } @@ -1794,6 +1907,11 @@ export function XtermHost({ return; } + if (reason === "too_old" && result.ok && result.data?.status === "unsupported") { + void showUnrecoverableHistory(); + return; + } + void failHistoricalRecovery( result.ok ? new Error(`terminal.snapshot returned status ${result.data?.status ?? "unknown"}`) @@ -1803,26 +1921,38 @@ export function XtermHost({ }); }; + const requestReconnectRecovery = (fromSeq: number) => { + activeHistoricalRecoveryModeRef.current = "reconnect"; + activeRecoveryUiModeRef.current = "non_blocking_recovering"; + setReplayUiState({ kind: "loading" }); + retryHistoricalRecoveryRef.current = () => { + requestReconnectRecovery(fromSeq); + }; + requestReplay(fromSeq, { + onTooOld: () => { + requestReconnectSnapshotFallback("too_old"); + }, + onError: (error) => { + requestReconnectSnapshotFallback("error", error); + }, + }); + }; + const requestHistoricalRecovery = (mode: "initial" | "reconnect") => { if (!wsClient) { return; } - activeHistoricalRecoveryModeRef.current = mode; - if (mode === "reconnect") { - setReplayUiState({ kind: "loading" }); - requestReplay(latestRenderedSeqRef.current, { - onTooOld: () => { - requestReconnectSnapshotFallback("too_old"); - }, - onError: (error) => { - requestReconnectSnapshotFallback("error", error); - }, - }); + requestReconnectRecovery(latestRenderedSeqRef.current); return; } + activeHistoricalRecoveryModeRef.current = "initial"; + activeRecoveryUiModeRef.current = "blocking_rebuild"; + retryHistoricalRecoveryRef.current = () => { + requestHistoricalRecovery("initial"); + }; requestSnapshot({ onUnavailable: (result) => { const connectionStatus = getConnectionStatus(); @@ -1842,6 +1972,16 @@ export function XtermHost({ traceTerminal(terminalId, "snapshot.fallback", { reason: result.ok ? (result.data?.status ?? "unsupported") : String(result.error), }); + if (result.ok && result.data?.status === "unsupported") { + requestReplay(0, { + resetTerminalBeforeWrite: true, + onTooOld: () => { + void showUnrecoverableHistory(); + }, + }); + return; + } + requestReplay(0); }, }); @@ -1915,7 +2055,7 @@ export function XtermHost({ void failHistoricalRecovery(error); }); } else { - requestReplay(replayedSeqRef.current); + requestReconnectRecovery(replayedSeqRef.current); } return; } @@ -1960,6 +2100,11 @@ export function XtermHost({ applyReplayPayloadRef.current = null; applySnapshotPayloadRef.current = null; completeHistoricalRecoveryRef.current = null; + failHistoricalRecoveryRef.current = null; + showUnrecoverableHistoryRef.current = null; + showUnavailableTerminalRef.current = null; + retryHistoricalRecoveryRef.current = null; + recoveryReplayAnchorSeqRef.current = null; if (replayWriteGenerationRef.current === replayWriteGeneration) { replayWriteGenerationRef.current += 1; replayWriteDepthRef.current = 0; @@ -2181,12 +2326,12 @@ export function XtermHost({ if ( viewport !== "mobile" && hydrationState.kind === "granted" && - meta?.alive && + isInteractive && terminalRef.current ) { terminalRef.current.focus(); } - }, [hydrationState.kind, meta?.alive, viewport]); + }, [hydrationState.kind, isInteractive, viewport]); const showMobileInputBar = viewport === "mobile" && isInteractive; const mobileInputDisabled = !isInteractive || uploadBusy || connectionStatus !== "connected"; @@ -2278,6 +2423,19 @@ export function XtermHost({ fileInputRef.current?.click(); }, []); + const handleRetryRecovery = useCallback(() => { + setReplayUiState({ kind: "loading" }); + + if (recoveryCoordinator) { + void recoveryCoordinator.notifyReason("foreground_resume", terminalId).catch((error) => { + void failHistoricalRecoveryRef.current?.(error); + }); + return; + } + + retryHistoricalRecoveryRef.current?.(); + }, [recoveryCoordinator, terminalId]); + const handleFileInputChange = useCallback( async (event: ReactChangeEvent) => { const files = Array.from(event.currentTarget.files ?? []); @@ -2293,43 +2451,69 @@ export function XtermHost({ const shouldBlockTerminal = replayUiState.kind === "loading" && activeRecoveryUiModeRef.current === "blocking_rebuild"; + const canShowRecoverySurface = viewport === "mobile" || hydrationState.kind === "granted"; const showReplayOverlay = - replayUiState.kind !== "ready" && - (viewport === "mobile" || - hydrationState.kind === "granted" || - activeRecoveryUiModeRef.current === "non_blocking_recovering"); + ((replayUiState.kind === "loading" && shouldBlockTerminal && loadingOverlayVisible) || + replayUiState.kind === "closed" || + replayUiState.kind === "unavailable") && + canShowRecoverySurface; + const showInlineRecoveryNotice = + replayUiState.kind === "retryable_failure" || + replayUiState.kind === "unrecoverable_history" || + replayUiState.kind === "truncated"; let replayTitle = ""; let replayBody = ""; let replayClassName = "xterm-replay-overlay"; - const showClosedSessionActions = - replayUiState.kind === "degraded" && - replayUiState.reason === "closed" && + const showRecoveryActions = + (replayUiState.kind === "closed" || replayUiState.kind === "unavailable") && terminalKind === "agent" && Boolean(onClosedSessionContinue) && Boolean(onClosedSessionClose); + let noticeTitle = ""; + let noticeBody = ""; + let noticeAction: ReactNode = null; + let noticeTone: "warning" | "error" = "warning"; if (replayUiState.kind === "loading") { replayTitle = t("terminal.replay.loading_title"); replayBody = t("terminal.replay.loading_body"); - } else if (replayUiState.kind === "degraded") { + } else if (replayUiState.kind === "closed") { replayClassName += " xterm-replay-overlay--degraded"; - replayTitle = - replayUiState.reason === "truncated" - ? t("terminal.replay.truncated_title") - : replayUiState.reason === "closed" - ? t("terminal.replay.closed_title") - : t("terminal.replay.failed_title"); - replayBody = - replayUiState.reason === "truncated" - ? t("terminal.replay.truncated_body") - : replayUiState.reason === "closed" - ? closedSessionProviderLabel - ? t("terminal.replay.closed_body_with_provider", { - provider: closedSessionProviderLabel, - }) - : t("terminal.replay.closed_body") - : t("terminal.replay.failed_body"); + replayBody = closedSessionProviderLabel + ? t("terminal.replay.closed_body_with_provider", { + provider: closedSessionProviderLabel, + }) + : t("terminal.replay.closed_body"); + replayTitle = t("terminal.replay.closed_title"); + } else if (replayUiState.kind === "unavailable") { + replayClassName += " xterm-replay-overlay--degraded"; + replayBody = closedSessionProviderLabel + ? t("terminal.replay.unknown_body_with_provider", { + provider: closedSessionProviderLabel, + }) + : t("terminal.replay.unknown_body"); + replayTitle = t("terminal.replay.unknown_title"); + } else if (replayUiState.kind === "retryable_failure") { + noticeTitle = t("terminal.replay.retryable_title"); + noticeBody = t("terminal.replay.retryable_body"); + noticeAction = ( + + ); + } else if (replayUiState.kind === "unrecoverable_history") { + noticeTitle = t("terminal.replay.unrecoverable_title"); + noticeBody = t("terminal.replay.unrecoverable_body"); + } else if (replayUiState.kind === "truncated") { + noticeTitle = t("terminal.replay.truncated_title"); + noticeBody = t("terminal.replay.truncated_body"); } return ( @@ -2362,6 +2546,9 @@ export function XtermHost({ void handleFileInputChange(event); }} /> + {showInlineRecoveryNotice ? ( + + ) : null}
@@ -2446,7 +2633,7 @@ export function XtermHost({ ) : null}
{replayTitle}
{replayBody ?
{replayBody}
: null} - {showClosedSessionActions ? ( + {showRecoveryActions ? (
+ ); + } + + function WorkspaceRouteControls() { + const navigate = useNavigate(); + + return ( + <> + + + + ); + } + const store = createStore(); store.set(connectionStatusAtom, "connected"); store.set(wsClientAtom, { sendCommand } as never); seedReadyWorkspaceState(store, { - "ws-test": { - id: "ws-test", - path: "/home/spencer/workspace/coder-studio", + "ws-a": { + id: "ws-a", + path: "/workspace-a", targetRuntime: "native", openedAt: 1, lastActiveAt: 1, @@ -432,25 +1062,44 @@ describe("WorkspacePage", () => { focusMode: false, }, }, + "ws-b": { + id: "ws-b", + path: "/workspace-b", + targetRuntime: "native", + openedAt: 2, + lastActiveAt: 2, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, }); + store.set(activeWorkspaceIdAtom, "ws-b"); - const { unmount } = render( + render( - } /> + } /> + } /> ); - await waitFor(() => { - expect(store.get(activeWorkspaceIdAtom)).toBe("ws-test"); - }); + await screen.findByTestId("file-tree-panel"); + expect(store.get(activeWorkspaceIdAtom)).toBe("ws-b"); - unmount(); + fireEvent.click(screen.getByRole("button", { name: "打开设置" })); + + await screen.findByRole("button", { name: "返回工作区" }); + expect(store.get(activeWorkspaceIdAtom)).toBe("ws-b"); - expect(store.get(activeWorkspaceIdAtom)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "返回工作区" })); + + await screen.findByTestId("file-tree-panel"); + expect(store.get(activeWorkspaceIdAtom)).toBe("ws-b"); }); it("shows the empty state when rendered without an active workspace", async () => { @@ -795,6 +1444,79 @@ describe("WorkspacePage", () => { expect(screen.queryByTestId("git-diff-viewer")).not.toBeInTheDocument(); }); + it("returns from editor mode to the agent session view when close all closes the last desktop editor", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + store.set(editorModeAtomFamily("ws-test"), "edit"); + store.set(openFilesAtomFamily("ws-test"), { + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "const app = 1;", + savedContent: "const app = 1;", + baseHash: "hash-app", + isDirty: false, + }, + }); + + render( + + + + } /> + + + + ); + + await screen.findByTestId("code-editor-host"); + expect(screen.queryByTestId("agent-panes")).not.toBeInTheDocument(); + + const heading = screen.getByRole("heading", { level: 2, name: /(Open Editors|打开的编辑器)/i }); + expect(heading).toHaveTextContent(/(Open Editors|打开的编辑器)\s*\(1\)/i); + + const section = heading.closest("section") as HTMLElement; + fireEvent.click(within(section).getByRole("button", { name: /Close all|全部关闭/i })); + + await screen.findByTestId("agent-panes"); + expect(heading).toHaveTextContent(/(Open Editors|打开的编辑器)\s*\(0\)/i); + expect(screen.queryByTestId("code-editor-host")).not.toBeInTheDocument(); + expect(store.get(openFilesAtomFamily("ws-test"))).toEqual({}); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBeNull(); + }); + it("keeps commit-history diff previews reachable on desktop without an active file", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "git.status") { @@ -850,6 +1572,167 @@ describe("WorkspacePage", () => { expect(screen.queryByTestId("agent-panes")).not.toBeInTheDocument(); }); + it("keeps commit-history diff previews reachable after close all clears open editors", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + store.set(editorModeAtomFamily("ws-test"), "edit"); + store.set(openFilesAtomFamily("ws-test"), { + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "const app = 1;", + savedContent: "const app = 1;", + baseHash: "hash-app", + isDirty: false, + }, + }); + store.set(gitDiffPreviewAtomFamily("ws-test"), { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); + + render( + + + + } /> + + + + ); + + await screen.findByTestId("code-editor-host"); + + const heading = screen.getByRole("heading", { level: 2, name: /(Open Editors|打开的编辑器)/i }); + const section = heading.closest("section") as HTMLElement; + fireEvent.click(within(section).getByRole("button", { name: /Close all|全部关闭/i })); + + await screen.findByTestId("code-editor-host"); + expect(screen.queryByTestId("agent-panes")).not.toBeInTheDocument(); + expect(store.get(openFilesAtomFamily("ws-test"))).toEqual({}); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBeNull(); + expect(store.get(gitDiffPreviewAtomFamily("ws-test"))).toEqual({ + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); + }); + + it("clearing the final open editor from Open Editors also clears an active commit preview", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + store.set(editorModeAtomFamily("ws-test"), "edit"); + store.set(openFilesAtomFamily("ws-test"), { + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "const app = 1;", + savedContent: "const app = 1;", + baseHash: "hash-app", + isDirty: false, + }, + }); + store.set(gitDiffPreviewAtomFamily("ws-test"), { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }); + + render( + + + + } /> + + + + ); + + await screen.findByTestId("code-editor-host"); + expect(screen.queryByTestId("agent-panes")).not.toBeInTheDocument(); + + const activeRow = screen + .getByRole("button", { name: "src/app.tsx" }) + .closest(".workspace-open-editors__row") as HTMLElement; + fireEvent.click( + within(activeRow).getByRole("button", { name: /^(Close|关闭) src\/app\.tsx$/ }) + ); + + await screen.findByTestId("agent-panes"); + expect(screen.queryByTestId("code-editor-host")).not.toBeInTheDocument(); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBeNull(); + expect(store.get(openFilesAtomFamily("ws-test"))).toEqual({}); + expect(store.get(gitDiffPreviewAtomFamily("ws-test"))).toBeNull(); + }); + it("keeps the resized desktop file panel width after dragging the left separator", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "git.status") { diff --git a/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx b/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx index 072f4916..1aa19ec4 100644 --- a/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx +++ b/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx @@ -1,15 +1,7 @@ -import { useSetAtom } from "jotai"; -import { ChevronsUp } from "lucide-react"; -import { type FC, useEffect, useRef, useState } from "react"; -import { - EmptyState, - IconButton, - Tab, - TabList, - Tabs, - ThemedIcon, - Tooltip, -} from "../../../../components/ui"; +import { useAtomValue, useSetAtom } from "jotai"; +import { type FC, useEffect, useRef } from "react"; +import { activeWorkspaceAtom } from "../../../../atoms/workspaces"; +import { EmptyState } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; import { AgentPanes } from "../../../agent-panes"; import { CodeEditorHost } from "../../../code-editor/views/shared/code-editor-host"; @@ -19,17 +11,32 @@ import { TopBar } from "../../../topbar"; import { useWorkspaceFullscreen } from "../../actions/use-workspace-fullscreen"; import { useWorkspaceScreenModel } from "../../actions/use-workspace-screen-model"; import { sidebarCollapsedAtom } from "../../atoms"; -import { FileTreePanel } from "../shared/file-tree-panel"; +import { sanitizeDesktopSidebarView } from "../../atoms/layout"; +import { ExplorerPanel } from "../shared/explorer-panel"; import { GitPanel } from "../shared/git-panel"; +import { SearchPanel } from "../shared/search-panel"; +import { WorkspaceActivityBar } from "../shared/workspace-activity-bar"; import { WorkspaceStatusBar } from "../shared/workspace-status-bar"; -export const WorkspaceDesktopView: FC = () => { +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (target.isContentEditable || target.closest('[contenteditable="true"]')) { + return true; + } + + return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName); +} + +const WorkspaceDesktopScene: FC = () => { const fullscreenRootRef = useRef(null); const fullscreenController = useWorkspaceFullscreen(fullscreenRootRef); - const [fileTreeCollapseVersion, setFileTreeCollapseVersion] = useState(0); const t = useTranslation(); const { createRequest, + desktopSidebarView, focusMode, gitState, handleBottomMouseDown, @@ -41,17 +48,22 @@ export const WorkspaceDesktopView: FC = () => { leftPanelWidth, leftPanelRef, mainAreaMode, - setSidebarTab, + setDesktopSidebarView, sidebarCollapsed, - sidebarTab, terminalPanelVisible, workspace, bottomPanelHeight, bottomPanelRef, } = useWorkspaceScreenModel(); const setSidebarCollapsed = useSetAtom(sidebarCollapsedAtom); + const activeSidebarView = sanitizeDesktopSidebarView(desktopSidebarView); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || isEditableTarget(event.target)) { + return; + } + if (!(event.metaKey || event.ctrlKey)) { return; } @@ -64,34 +76,25 @@ export const WorkspaceDesktopView: FC = () => { if (event.key === "1") { event.preventDefault(); - setSidebarTab("files"); + setDesktopSidebarView("explorer"); return; } if (event.key === "2") { event.preventDefault(); - setSidebarTab("git"); + setDesktopSidebarView("search"); + return; + } + + if (event.key === "3") { + event.preventDefault(); + setDesktopSidebarView("source-control"); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [setSidebarCollapsed, setSidebarTab]); - - if (!workspace) { - return ( -
-
-
- {t("workspace.no_workspace")}

} - /> -
-
-
- ); - } + }, [setDesktopSidebarView, setSidebarCollapsed]); return (
@@ -106,73 +109,34 @@ export const WorkspaceDesktopView: FC = () => { style={{ width: `${leftPanelWidth}px` }} >
- - - - {t("file.title")} - - - {t("label.git")} - - - - } - actions={ -
- {sidebarTab === "files" ? ( - <> - - } - onClick={handleOpenFileCreate} - size="sm" - /> - - - } - onClick={handleOpenFolderCreate} - size="sm" - /> - - - } - onClick={() => setFileTreeCollapseVersion((value) => value + 1)} - size="sm" - /> - - - ) : null} -
- } + -
- {sidebarTab === "files" ? ( - + {activeSidebarView === "explorer" ? ( + - ) : ( - - )} + ) : null} + + {activeSidebarView === "search" ? ( + + ) : null} + + {activeSidebarView === "source-control" ? ( +
+ +
+ +
+
+ ) : null}
@@ -230,5 +194,27 @@ export const WorkspaceDesktopView: FC = () => { ); }; +export const WorkspaceDesktopView: FC = () => { + const workspace = useAtomValue(activeWorkspaceAtom); + const t = useTranslation(); + + if (!workspace) { + return ( +
+
+
+ {t("workspace.no_workspace")}

} + /> +
+
+
+ ); + } + + return ; +}; + export { WorkspaceDesktopView as WorkspacePage }; export default WorkspaceDesktopView; diff --git a/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx b/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx new file mode 100644 index 00000000..aa6f3b2e --- /dev/null +++ b/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx @@ -0,0 +1,280 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { wsClientAtom } from "../../../../atoms/connection"; +import { activeFilePathAtomFamily, fileTreeAtomFamily, openFilesAtomFamily } from "../../atoms"; +import { MobileExplorerPanel } from "./mobile-explorer-panel"; + +const fileTreePanelSpy = vi.fn(); + +vi.mock("../../../../lib/i18n", () => ({ + useTranslation: () => (key: string, params?: Record) => { + const translations: Record = { + "workspace.quick_jump.title": "Quick Jump", + "workspace.quick_jump.placeholder": "Type a filename or path", + "workspace.quick_jump.no_results": "No results", + "workspace.quick_jump.failed": "Search failed", + "workspace.sidebar.workspace": "Workspace", + "workspace.sidebar.open_editors": "Open Editors", + "file.new_file": "New File", + "file.new_folder": "New Folder", + "file.collapse_all": "Collapse All", + "action.close": "Close", + "action.close_all": "Close all", + "common.loading": "Loading", + }; + + if (key === "workspace.open_editors.title_with_count") { + return `${params?.title ?? "Open Editors"} (${params?.count ?? 0})`; + } + + if (key === "workspace.open_editors.expand_label") { + return "Expand Open Editors"; + } + + if (key === "workspace.open_editors.collapse_label") { + return "Collapse Open Editors"; + } + + if (key === "workspace.open_editors.close_path") { + return `Close ${params?.path ?? ""}`.trim(); + } + + return translations[key] ?? key; + }, +})); + +vi.mock("../shared/file-tree-panel", () => ({ + FileTreePanel: (props: unknown) => { + fileTreePanelSpy(props); + return
; + }, +})); + +describe("MobileExplorerPanel", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + fileTreePanelSpy.mockReset(); + }); + + it("renders quick jump above open editors and a file tree without the embedded tree search", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args: { query?: string }) => { + if (op === "file.search") { + return { + files: [ + { path: "README.md", name: "README.md", kind: "file" }, + { + path: "src/mobile-files-sheet.tsx", + name: "mobile-files-sheet.tsx", + kind: "file", + }, + ].filter((file) => file.path.toLowerCase().includes((args.query ?? "").toLowerCase())), + }; + } + + return { ok: true }; + }); + + const onSelectFile = vi.fn(); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + store.set(activeFilePathAtomFamily("ws-test"), "src/mobile-files-sheet.tsx"); + store.set(openFilesAtomFamily("ws-test"), { + "README.md": { + kind: "text", + path: "README.md", + content: "# docs", + savedContent: "# docs", + baseHash: "base-readme", + isDirty: false, + }, + "src/mobile-files-sheet.tsx": { + kind: "text", + path: "src/mobile-files-sheet.tsx", + content: "export function MobileFilesSheet() {}\n", + savedContent: "export function MobileFilesSheet() {}\n", + baseHash: "base-mobile-files-sheet", + isDirty: false, + }, + }); + + render( + + + + ); + + const headings = screen.getAllByRole("heading", { level: 2 }); + expect(headings[0]).toHaveTextContent(/Quick Jump|快速跳转/i); + expect(headings[1]).toHaveTextContent(/Open Editors|打开的编辑器/i); + expect(headings[2]).toHaveTextContent(/Workspace|工作区/i); + + expect(screen.getByRole("button", { name: "README.md" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "src/mobile-files-sheet.tsx" })).toHaveClass( + "workspace-open-editors__item--active" + ); + expect(screen.getByRole("searchbox", { name: /Quick Jump|快速跳转/i })).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(/Type a filename or path|输入文件名或路径/i) + ).toBeInTheDocument(); + expect(screen.queryByRole("searchbox", { name: /Search Files|搜索文件/i })).toBeNull(); + expect(fileTreePanelSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "mobile", + showSearch: false, + }) + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Quick Jump|快速跳转/i }), { + target: { value: "read" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "file.search", + { + workspaceId: "ws-test", + query: "read", + limit: 10, + }, + undefined + ); + + fireEvent.click( + within(screen.getByText(/Quick Jump|快速跳转/i).closest("section") as HTMLElement).getByRole( + "button", + { + name: /README\.md/i, + } + ) + ); + + expect(onSelectFile).toHaveBeenCalledWith("README.md"); + }); + + it("renders shared open editor controls on mobile and closing the active row selects the next file", () => { + const onSelectFile = vi.fn(); + const store = createStore(); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + store.set(activeFilePathAtomFamily("ws-test"), "src/alpha.tsx"); + store.set(openFilesAtomFamily("ws-test"), { + "src/alpha.tsx": { + kind: "text", + path: "src/alpha.tsx", + content: "export const alpha = 1;\n", + savedContent: "export const alpha = 1;\n", + baseHash: "base-alpha", + isDirty: false, + }, + "src/beta.tsx": { + kind: "text", + path: "src/beta.tsx", + content: "export const beta = 2;\n", + savedContent: "export const beta = 2;\n", + baseHash: "base-beta", + isDirty: false, + }, + }); + + render( + + + + ); + + const heading = screen.getByRole("heading", { level: 2, name: /(Open Editors|打开的编辑器)/i }); + expect(heading).toHaveTextContent(/(Open Editors|打开的编辑器)\s*\(2\)/i); + + const section = heading.closest("section") as HTMLElement; + + expect( + within(section).getByRole("button", { + name: /Collapse Open Editors|Expand Open Editors|收起打开的编辑器|展开打开的编辑器/i, + }) + ).toHaveAttribute("aria-expanded", "true"); + expect( + within(section).getByRole("button", { name: /Close all|全部关闭/i }) + ).toBeInTheDocument(); + expect(within(section).getByRole("button", { name: "src/alpha.tsx" })).toHaveClass( + "workspace-open-editors__item--active" + ); + + const activeRow = within(section) + .getByRole("button", { name: "src/alpha.tsx" }) + .closest(".workspace-open-editors__row") as HTMLElement; + fireEvent.click( + within(activeRow).getByRole("button", { + name: /Close src\/alpha\.tsx|关闭 src\/alpha\.tsx/i, + }) + ); + + expect(within(section).getByRole("button", { name: "src/beta.tsx" })).toHaveClass( + "workspace-open-editors__item--active" + ); + expect(within(section).queryByRole("button", { name: "src/alpha.tsx" })).toBeNull(); + expect(heading).toHaveTextContent(/(Open Editors|打开的编辑器)\s*\(1\)/i); + expect(Object.keys(store.get(openFilesAtomFamily("ws-test")))).toEqual(["src/beta.tsx"]); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/beta.tsx"); + }); + + it("renders workspace actions inside the Workspace section and wires mobile callbacks", () => { + const onOpenFileCreate = vi.fn(); + const onOpenFolderCreate = vi.fn(); + const onCollapseAll = vi.fn(); + const store = createStore(); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + store.set(openFilesAtomFamily("ws-test"), {}); + + render( + + + + ); + + expect(fileTreePanelSpy).toHaveBeenCalledWith( + expect.objectContaining({ + collapseVersion: 3, + showSearch: false, + variant: "mobile", + }) + ); + + const workspaceSection = screen + .getByRole("heading", { level: 2, name: "Workspace" }) + .closest("section") as HTMLElement; + + fireEvent.click(within(workspaceSection).getByRole("button", { name: "New File" })); + fireEvent.click(within(workspaceSection).getByRole("button", { name: "New Folder" })); + fireEvent.click(within(workspaceSection).getByRole("button", { name: "Collapse All" })); + + expect(onOpenFileCreate).toHaveBeenCalledTimes(1); + expect(onOpenFolderCreate).toHaveBeenCalledTimes(1); + expect(onCollapseAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx b/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx new file mode 100644 index 00000000..51091170 --- /dev/null +++ b/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx @@ -0,0 +1,50 @@ +import type { CreateRequest } from "../../actions/use-file-actions"; +import { FileTreePanel } from "../shared/file-tree-panel"; +import { OpenEditorsSection } from "../shared/open-editors-section"; +import { QuickJumpSection } from "../shared/quick-jump-section"; +import { WorkspaceSectionHeader } from "../shared/workspace-section-header"; + +interface MobileExplorerPanelProps { + workspaceId: string; + createRequest?: CreateRequest | null; + onCreateRequestConsumed?: () => void; + collapseVersion?: number; + onOpenFileCreate?: () => void; + onOpenFolderCreate?: () => void; + onCollapseAll?: () => void; + routeToDetail: (path: string) => void; +} + +export function MobileExplorerPanel({ + workspaceId, + createRequest = null, + onCreateRequestConsumed, + collapseVersion = 0, + onOpenFileCreate, + onOpenFolderCreate, + onCollapseAll, + routeToDetail, +}: MobileExplorerPanelProps) { + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx b/packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx index 8c4a26fd..a0ad6262 100644 --- a/packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx +++ b/packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx @@ -5,13 +5,15 @@ import { wsClientAtom } from "../../../../atoms/connection"; import type { GitDiffPreview } from "../../atoms"; import { MobileFilesSheet } from "./mobile-files-sheet"; +const mobileExplorerPanelSpy = vi.fn(); + vi.mock("../../../../lib/i18n", () => ({ useTranslation: () => (key: string) => { const translations: Record = { "mobile.files.tabs": "Files tabs", - "file.title": "Files", - "label.git": "Git", - "action.search_files": "Search files", + "workspace.sidebar.explorer": "Explorer", + "workspace.sidebar.search": "Search", + "workspace.sidebar.source_control": "Source Control", "file.new_file": "New File", "file.new_folder": "New Folder", "file.collapse_all": "Collapse All", @@ -26,11 +28,16 @@ vi.mock("../../../code-editor/views/shared/code-editor-host", () => ({ CodeEditorHost: () =>
, })); -vi.mock("../shared/file-tree-panel", () => ({ - FileTreePanel: () => ( -
- -
+vi.mock("./mobile-explorer-panel", () => ({ + MobileExplorerPanel: (props: unknown) => { + mobileExplorerPanelSpy(props); + return
; + }, +})); + +vi.mock("../shared/search-panel", () => ({ + SearchPanel: ({ variant }: { variant?: string }) => ( +
), })); @@ -56,49 +63,65 @@ vi.mock("../shared/git-panel", () => ({ describe("MobileFilesSheet", () => { beforeEach(() => { vi.restoreAllMocks(); + mobileExplorerPanelSpy.mockReset(); }); - it("renders file actions in the tab row instead of a separate dock", async () => { - const sendCommand = vi.fn().mockResolvedValue({ - path: "/workspace", - children: [], - }); + it("renders three icon tabs and keeps explorer actions inside the explorer content", () => { + render( + + + + ); - const store = createStore(); - store.set(wsClientAtom, { sendCommand } as never); + expect(screen.getByRole("tab", { name: "Explorer" })).toHaveClass( + "mobile-files-sheet__segment", + "active" + ); + expect(screen.getByRole("tab", { name: "Search" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Source Control" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "New File" })).toBeNull(); + expect(screen.getByTestId("mobile-explorer-panel")).toBeInTheDocument(); + }); + it("renders the mobile search panel without explorer actions when Search is active", () => { render( - - + + ); - const newFileButton = await screen.findByRole("button", { name: "New File" }); - const searchInput = await screen.findByRole("searchbox", { name: "Search files" }); - - expect(document.querySelector(".mobile-files-sheet__dock")).toBeNull(); - expect(document.querySelector(".file-tree-mobile-actions")).toBeNull(); - expect(newFileButton.closest(".mobile-files-sheet__tab-actions")).not.toBeNull(); - expect( - newFileButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy(); - expect(searchInput).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "New File" })).toBeNull(); + expect(screen.getByTestId("search-panel")).toHaveAttribute("data-variant", "mobile"); }); - it("uses the mobile segmented tab styling without legacy panel-tab classes", () => { + it("passes workspace action callbacks through to the explorer content", () => { + const onCreateFile = vi.fn(); + const onCreateFolder = vi.fn(); + const onCollapseAll = vi.fn(); + render( - + ); - const filesTab = screen.getByRole("tab", { name: "Files" }); - const gitTab = screen.getByRole("tab", { name: "Git" }); - - expect(filesTab).toHaveClass("mobile-files-sheet__segment"); - expect(filesTab).not.toHaveClass("panel-tab"); - expect(gitTab).toHaveClass("mobile-files-sheet__segment", "active"); - expect(gitTab).not.toHaveClass("panel-tab"); + expect(mobileExplorerPanelSpy).toHaveBeenCalledWith( + expect.objectContaining({ + collapseVersion: 7, + onCollapseAll, + onOpenFileCreate: onCreateFile, + onOpenFolderCreate: onCreateFolder, + workspaceId: "ws-test", + }) + ); }); it("uses one file detail surface for preview edit and diff instead of separate editor and diff pages", () => { @@ -107,7 +130,7 @@ describe("MobileFilesSheet", () => { ); @@ -124,7 +147,7 @@ describe("MobileFilesSheet", () => { diff --git a/packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx b/packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx index f37e1247..46680781 100644 --- a/packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx +++ b/packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx @@ -1,20 +1,24 @@ -import { ChevronsUp } from "lucide-react"; -import { IconButton, Tab, TabList, Tabs, ThemedIcon, Tooltip } from "../../../../components/ui"; +import { FolderTree, GitBranch, Search } from "lucide-react"; +import { Tab, TabList, Tabs } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; import { CodeEditorHost, type CodeEditorState, } from "../../../code-editor/views/shared/code-editor-host"; import type { CreateRequest } from "../../actions/use-file-actions"; -import type { MobileFilesRoute } from "../../actions/use-workspace-screen-model"; +import type { + MobileFilesRoute, + MobileWorkspaceSidebarView, +} from "../../actions/use-workspace-screen-model"; import type { GitDiffPreview } from "../../atoms"; -import { FileTreePanel } from "../shared/file-tree-panel"; import { GitPanel } from "../shared/git-panel"; +import { SearchPanel } from "../shared/search-panel"; +import { MobileExplorerPanel } from "./mobile-explorer-panel"; interface MobileFilesSheetProps { workspaceId: string; route: MobileFilesRoute; - activeTab: "files" | "git"; + activeView: MobileWorkspaceSidebarView; createRequest?: CreateRequest | null; onCreateRequestConsumed?: () => void; collapseVersion?: number; @@ -22,15 +26,14 @@ interface MobileFilesSheetProps { onCreateFolder?: () => void; onCollapseAll?: () => void; onRouteChange?: (route: MobileFilesRoute) => void; - onTabChange?: (tab: "files" | "git") => void; - onCloseSheet?: () => void; + onTabChange?: (view: MobileWorkspaceSidebarView) => void; editorState?: CodeEditorState; } export function MobileFilesSheet({ workspaceId, route, - activeTab, + activeView, createRequest = null, onCreateRequestConsumed, collapseVersion = 0, @@ -39,7 +42,6 @@ export function MobileFilesSheet({ onCollapseAll, onRouteChange, onTabChange, - onCloseSheet, editorState, }: MobileFilesSheetProps) { const t = useTranslation(); @@ -66,61 +68,58 @@ export function MobileFilesSheet({
onTabChange?.(tab as "files" | "git")} - value={activeTab} + onValueChange={(view) => onTabChange?.(view as MobileWorkspaceSidebarView)} + value={activeView} > - - {t("file.title")} + + - - {t("label.git")} + + + + + - - {activeTab === "files" ? ( -
- - } - onClick={onCreateFile} - size="sm" - /> - - - } - onClick={onCreateFolder} - size="sm" - /> - - - } - onClick={onCollapseAll} - size="sm" - /> - -
- ) : null}
- {activeTab === "files" ? ( - onRouteChange?.({ kind: "detail", path })} + onOpenFileCreate={onCreateFile} + onOpenFolderCreate={onCreateFolder} + onCollapseAll={onCollapseAll} + routeToDetail={(path) => onRouteChange?.({ kind: "detail", path })} collapseVersion={collapseVersion} + /> + ) : activeView === "search" ? ( + onRouteChange?.({ kind: "detail", path })} /> ) : ( diff --git a/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx b/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx new file mode 100644 index 00000000..172cc022 --- /dev/null +++ b/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx @@ -0,0 +1,406 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { connectionStatusAtom, wsClientAtom } from "../../../../atoms/connection"; +import { activeWorkspaceIdAtom } from "../../../../atoms/workspaces"; +import { seedReadyWorkspaceState } from "../../../../test-utils/workspace-state"; +import { + activeFilePathAtomFamily, + gitDiffPreviewAtomFamily, + type OpenFile, + openFilesAtomFamily, +} from "../../atoms"; +import { OpenEditorsSection } from "../shared/open-editors-section"; +import { WorkspaceMobileView } from "./workspace-mobile-view"; + +vi.mock("../../../../lib/i18n", () => ({ + useTranslation: () => (key: string, params?: Record) => { + const translations: Record = { + "action.back": "Back", + "action.close": "Close", + "action.create_session": "Create session", + "action.save_file": "Save File", + "code_editor.mode_diff": "Diff", + "code_editor.mode_edit": "Edit", + "code_editor.mode_preview": "Preview", + "code_editor.edit_as_text": "Edit as text", + "code_editor.preview_as_image": "Preview as image", + "code_editor.saving": "Saving", + "file.title": "File", + "mobile.empty.files_terminal_hint": "Use files or terminal to get started.", + "mobile.empty.start_session": "Start session", + "mobile.files.editor_fallback": "Editor", + "mobile.sheet.dismiss": "Dismiss sheet", + "action.close_all": "Close all", + "workspace.sidebar.explorer": "Explorer", + "workspace.sidebar.open_editors": "Open Editors", + "workspace.open_editors.collapse_label": "Collapse Open Editors", + "workspace.open_editors.expand_label": "Expand Open Editors", + "workspace.sidebar.search": "Search", + "workspace.sidebar.source_control": "Source Control", + }; + + if (key === "mobile.sheet.region") { + return `Sheet ${params?.title ?? ""}`.trim(); + } + + if (key === "workspace.open_editors.title_with_count") { + return `${params?.title ?? "Open Editors"} (${params?.count ?? "0"})`; + } + + if (key === "workspace.open_editors.close_path") { + return `Close ${params?.path ?? ""}`.trim(); + } + + return translations[key] ?? key; + }, +})); + +vi.mock("../../../agent-panes/views/shared/session-card", () => ({ + SessionCard: () =>
, +})); + +vi.mock("../../../supervisor/views/mobile/mobile-supervisor-sheet", () => ({ + MobileSupervisorSheet: () =>
, +})); + +vi.mock("../../../terminal-panel", () => ({ + TerminalPanel: () =>
, +})); + +vi.mock("../shared/workspace-launch-modal", () => ({ + WorkspaceLaunchModal: () =>
, +})); + +vi.mock("../shared/workspace-status-bar", () => ({ + WorkspaceStatusBar: () =>
, +})); + +vi.mock("./hooks/use-mobile-layout-mode", () => ({ + useMobileLayoutMode: () => "compact", +})); + +vi.mock("./hooks/use-mobile-motion-mode", () => ({ + useMobileMotionMode: () => "full", +})); + +vi.mock("./hooks/use-visual-viewport-inset", () => ({ + useVisualViewportInset: () => 0, +})); + +vi.mock("../../actions/use-workspace-fullscreen", () => ({ + useWorkspaceFullscreen: () => ({ + isSupported: false, + isFullscreen: false, + enter: vi.fn(), + exit: vi.fn(), + toggle: vi.fn(), + }), +})); + +vi.mock("../../actions/use-workspace-ui-state-persistence", () => ({ + useWorkspaceUiStatePersistence: () => ({ + persistUiState: vi.fn().mockResolvedValue(true), + }), +})); + +vi.mock("./mobile-agent-sheet", () => ({ + MobileAgentSheet: () =>
, +})); + +vi.mock("./mobile-dock", () => ({ + MobileDock: ({ + onSelectItem, + }: { + onSelectItem: (item: "agent" | "files" | "terminal") => void; + }) => ( +
+ +
+ ), +})); + +vi.mock("./mobile-files-sheet", () => ({ + MobileFilesSheet: ({ + workspaceId, + route, + onRouteChange, + }: { + workspaceId: string; + route: { kind: "root" } | { kind: "detail"; path?: string; title?: string }; + onRouteChange?: ( + route: { kind: "root" } | { kind: "detail"; path?: string; title?: string } + ) => void; + }) => + route.kind === "root" ? ( +
+ + + +
+ ) : ( +
{route.title ?? route.path}
+ ), +})); + +vi.mock("./mobile-topbar", () => ({ + MobileTopBar: () =>
, +})); + +vi.mock("./mobile-workspace-drawer", () => ({ + MobileWorkspaceDrawer: () => null, +})); + +function createSendCommandMock() { + return vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + if (op === "session.list") { + return []; + } + + return null; + }); +} + +function renderMobileView(options: { + activePath: string | null; + openFiles: Record; + diffPreview?: { + path: string; + title?: string; + diff: string; + source: "commit"; + } | null; +}) { + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand: createSendCommandMock() } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/tmp/ws-test", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(activeWorkspaceIdAtom, "ws-test"); + store.set(activeFilePathAtomFamily("ws-test"), options.activePath); + store.set(openFilesAtomFamily("ws-test"), options.openFiles as never); + if (options.diffPreview !== undefined) { + store.set(gitDiffPreviewAtomFamily("ws-test"), options.diffPreview as never); + } + + render( + + + + + + ); + + return store; +} + +describe("WorkspaceMobileView", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rebinds the mobile files detail route to the next active editor when the mobile header closes the current file", async () => { + const store = renderMobileView({ + activePath: "src/a.ts", + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "alpha", + savedContent: "alpha", + baseHash: "hash-a", + isDirty: false, + }, + "src/b.ts": { + kind: "text", + path: "src/b.ts", + content: "beta", + savedContent: "beta", + baseHash: "hash-b", + isDirty: false, + }, + }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Files" })); + fireEvent.click(screen.getByRole("button", { name: "Open a.ts" })); + + expect(screen.getByRole("heading", { level: 2, name: "a.ts" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/b.ts"); + expect(screen.getByRole("heading", { level: 2, name: "b.ts" })).toBeInTheDocument(); + }); + }); + + it("closes the mobile files sheet when the mobile header closes the final open editor", async () => { + const store = renderMobileView({ + activePath: "src/a.ts", + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "alpha", + savedContent: "alpha", + baseHash: "hash-a", + isDirty: false, + }, + }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Files" })); + fireEvent.click(screen.getByRole("button", { name: "Open a.ts" })); + + expect(screen.getByRole("heading", { level: 2, name: "a.ts" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBeNull(); + expect(screen.queryByTestId("mobile-files-sheet-root")).not.toBeInTheDocument(); + expect(screen.queryByTestId("mobile-files-sheet-detail")).not.toBeInTheDocument(); + }); + }); + + it("keeps commit preview detail bound to the commit route and closes the preview without closing the background file", async () => { + const store = renderMobileView({ + activePath: "src/a.ts", + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "alpha", + savedContent: "alpha", + baseHash: "hash-a", + isDirty: false, + }, + }, + diffPreview: { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit", + }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Files" })); + fireEvent.click(screen.getByRole("button", { name: "Open commit preview" })); + + await waitFor(() => { + expect( + screen.getByRole("heading", { level: 2, name: "abc123 · commit subject" }) + ).toBeInTheDocument(); + }); + + expect(screen.queryByRole("button", { name: "Diff" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Preview" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/a.ts"); + expect(store.get(gitDiffPreviewAtomFamily("ws-test"))).toBeNull(); + expect(screen.getByRole("heading", { level: 2, name: "a.ts" })).toBeInTheDocument(); + }); + }); + + it("preserves an active commit preview when close all clears open editors", async () => { + const diffPreview = { + path: "abc123", + title: "abc123 · commit subject", + diff: "diff --git a/src/app.tsx b/src/app.tsx", + source: "commit" as const, + }; + const store = renderMobileView({ + activePath: "src/a.ts", + openFiles: { + "src/a.ts": { + kind: "text", + path: "src/a.ts", + content: "alpha", + savedContent: "alpha", + baseHash: "hash-a", + isDirty: false, + }, + }, + diffPreview, + }); + + fireEvent.click(screen.getByRole("button", { name: "Files" })); + fireEvent.click(screen.getByRole("button", { name: "Open commit preview" })); + + await waitFor(() => { + expect( + screen.getByRole("heading", { level: 2, name: "abc123 · commit subject" }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Back" })); + const openEditorsSection = screen + .getByRole("heading", { level: 2, name: "Open Editors (1)" }) + .closest("section") as HTMLElement; + fireEvent.click(within(openEditorsSection).getByRole("button", { name: "Close all" })); + + await waitFor(() => { + expect(store.get(openFilesAtomFamily("ws-test"))).toEqual({}); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBeNull(); + expect(store.get(gitDiffPreviewAtomFamily("ws-test"))).toEqual(diffPreview); + }); + }); +}); diff --git a/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx b/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx index 2abcfcbe..3fc35706 100644 --- a/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx +++ b/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx @@ -16,7 +16,10 @@ import { MobileSupervisorSheet } from "../../../supervisor/views/mobile/mobile-s import { TerminalPanel } from "../../../terminal-panel"; import type { CreateRequest } from "../../actions/use-file-actions"; import { useWorkspaceFullscreen } from "../../actions/use-workspace-fullscreen"; -import { useWorkspaceScreenModel } from "../../actions/use-workspace-screen-model"; +import { + type MobileWorkspaceSidebarView, + useWorkspaceScreenModel, +} from "../../actions/use-workspace-screen-model"; import { useWorkspaceUiStatePersistence } from "../../actions/use-workspace-ui-state-persistence"; import { WorkspaceLaunchModal } from "../shared/workspace-launch-modal"; import { WorkspaceStatusBar } from "../shared/workspace-status-bar"; @@ -81,6 +84,7 @@ export function WorkspaceMobileView() { activeWorkspaceId, closeMobileSession, closeMobileSheet, + diffPreview, handleMobileSessionCreated, handleOpenBranchSwitcher, gitState, @@ -101,7 +105,7 @@ export function WorkspaceMobileView() { ); const [drawerOpen, setDrawerOpen] = useState(false); const [agentSheetOpen, setAgentSheetOpen] = useState(false); - const [mobileFilesTab, setMobileFilesTab] = useState<"files" | "git">("files"); + const [mobileFilesView, setMobileFilesView] = useState("explorer"); const [mobileFileCreateRequest, setMobileFileCreateRequest] = useState( null ); @@ -235,6 +239,57 @@ export function WorkspaceMobileView() { supervisorDialog.sessionId, ]); + useEffect(() => { + if (mobileSheet !== "files" || mobileFilesRoute.kind !== "detail") { + return; + } + + const isCommitDetailRoute = + diffPreview?.source === "commit" && + mobileFilesRoute.path === diffPreview.path && + mobileFilesRoute.title === diffPreview.title; + + if (isCommitDetailRoute) { + return; + } + + if (mobileEditorState.activeFilePath) { + if ( + mobileFilesRoute.path !== mobileEditorState.activeFilePath || + mobileFilesRoute.title !== undefined + ) { + updateMobileFilesRoute({ + kind: "detail", + path: mobileEditorState.activeFilePath, + }); + } + return; + } + + if (diffPreview?.source === "commit") { + if ( + mobileFilesRoute.path !== diffPreview.path || + mobileFilesRoute.title !== diffPreview.title + ) { + updateMobileFilesRoute({ + kind: "detail", + path: diffPreview.path, + title: diffPreview.title, + }); + } + return; + } + + closeMobileSheet(); + }, [ + closeMobileSheet, + diffPreview, + mobileEditorState.activeFilePath, + mobileFilesRoute, + mobileSheet, + updateMobileFilesRoute, + ]); + const filesSheetKicker = mobileFilesRoute.kind === "detail" ? t("file.title") : null; const handleMobileCreateRequest = (mode: "file" | "folder") => { @@ -258,14 +313,16 @@ export function WorkspaceMobileView() { ? (mobileFilesRoute.title ?? mobileFilesRoute.path?.split("/").pop() ?? t("mobile.files.editor_fallback")) - : mobileFilesTab === "files" - ? t("file.title") - : t("label.git"), + : mobileFilesView === "explorer" + ? t("workspace.sidebar.explorer") + : mobileFilesView === "search" + ? t("workspace.sidebar.search") + : t("workspace.sidebar.source_control"), body: activeWorkspaceId ? ( setMobileFileCreateRequest(null)} collapseVersion={mobileFileCollapseVersion} @@ -273,7 +330,7 @@ export function WorkspaceMobileView() { onCreateFolder={() => handleMobileCreateRequest("folder")} onCollapseAll={() => setMobileFileCollapseVersion((value) => value + 1)} onRouteChange={updateMobileFilesRoute} - onTabChange={setMobileFilesTab} + onTabChange={setMobileFilesView} onCloseSheet={closeMobileSheet} editorState={mobileEditorState} /> diff --git a/packages/web/src/features/workspace/views/shared/explorer-panel.test.tsx b/packages/web/src/features/workspace/views/shared/explorer-panel.test.tsx new file mode 100644 index 00000000..1c9cbf37 --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/explorer-panel.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { describe, expect, it, vi } from "vitest"; +import { fileTreeAtomFamily, openFilesAtomFamily } from "../../atoms"; +import { ExplorerPanel } from "./explorer-panel"; + +const fileTreePanelSpy = vi.fn(); + +vi.mock("../../../../lib/i18n", () => ({ + useTranslation: () => (key: string, params?: Record) => { + const translations: Record = { + "workspace.sidebar.explorer": "Explorer", + "workspace.sidebar.workspace": "Workspace", + "workspace.sidebar.open_editors": "Open Editors", + "file.new_file": "New File", + "file.new_folder": "New Folder", + "file.collapse_all": "Collapse All", + "action.close": "Close", + "action.close_all": "Close all", + }; + + if (key === "workspace.open_editors.title_with_count") { + return `${params?.title ?? "Open Editors"} (${params?.count ?? 0})`; + } + + if (key === "workspace.open_editors.expand_label") { + return "Expand Open Editors"; + } + + if (key === "workspace.open_editors.collapse_label") { + return "Collapse Open Editors"; + } + + if (key === "workspace.open_editors.close_path") { + return `Close ${params?.path ?? ""}`.trim(); + } + + return translations[key] ?? key; + }, +})); + +vi.mock("../shared/file-tree-panel", () => ({ + FileTreePanel: (props: unknown) => { + fileTreePanelSpy(props); + return
; + }, +})); + +describe("ExplorerPanel", () => { + it("keeps the panel header for Explorer and moves file actions into the Workspace section", () => { + const onOpenFileCreate = vi.fn(); + const onOpenFolderCreate = vi.fn(); + const store = createStore(); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + store.set(openFilesAtomFamily("ws-test"), {}); + + const { container } = render( + + + + ); + + expect(fileTreePanelSpy).toHaveBeenCalledWith( + expect.objectContaining({ + collapseVersion: 0, + showSearch: false, + variant: "desktop", + }) + ); + + const explorerHeader = screen.getByText("Explorer").closest(".panel-header") as HTMLElement; + expect(within(explorerHeader).queryByRole("button", { name: "New File" })).toBeNull(); + expect(container.querySelector(".workspace-sidebar-panel__actions")).not.toBe( + explorerHeader.querySelector(".workspace-sidebar-panel__actions") + ); + + const workspaceSection = screen + .getByRole("heading", { level: 2, name: "Workspace" }) + .closest("section") as HTMLElement; + + fireEvent.click(within(workspaceSection).getByRole("button", { name: "New File" })); + fireEvent.click(within(workspaceSection).getByRole("button", { name: "New Folder" })); + fireEvent.click(within(workspaceSection).getByRole("button", { name: "Collapse All" })); + + expect(onOpenFileCreate).toHaveBeenCalledTimes(1); + expect(onOpenFolderCreate).toHaveBeenCalledTimes(1); + expect(fileTreePanelSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + collapseVersion: 1, + }) + ); + }); +}); diff --git a/packages/web/src/features/workspace/views/shared/explorer-panel.tsx b/packages/web/src/features/workspace/views/shared/explorer-panel.tsx new file mode 100644 index 00000000..ae58ab66 --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/explorer-panel.tsx @@ -0,0 +1,53 @@ +import type { FC } from "react"; +import { useState } from "react"; +import { useTranslation } from "../../../../lib/i18n"; +import { PanelHeader } from "../../../shared/components/panel-header"; +import type { WorkspaceCreateRequest } from "../../actions/use-workspace-screen-model"; +import { FileTreePanel } from "./file-tree-panel"; +import { OpenEditorsSection } from "./open-editors-section"; +import { WorkspaceSectionHeader } from "./workspace-section-header"; + +interface ExplorerPanelProps { + workspaceId: string; + createRequest: WorkspaceCreateRequest | null; + onCreateRequestConsumed: () => void; + onOpenFileCreate: () => void; + onOpenFolderCreate: () => void; +} + +export const ExplorerPanel: FC = ({ + workspaceId, + createRequest, + onCreateRequestConsumed, + onOpenFileCreate, + onOpenFolderCreate, +}) => { + const t = useTranslation(); + const [collapseVersion, setCollapseVersion] = useState(0); + + return ( +
+ + +
+ + +
+ setCollapseVersion((value) => value + 1)} + /> + +
+
+
+ ); +}; diff --git a/packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx b/packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx index 271051a5..a3f6ee92 100644 --- a/packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx @@ -1151,6 +1151,63 @@ describe("FileTreePanel", () => { expect(screen.queryByText("AppController.tsx")).not.toBeInTheDocument(); }); + it("restores file search state per workspace tab instance", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args: { query?: string }) => { + if (op === "file.search") { + const query = args.query?.toLowerCase() ?? ""; + const files = [ + { path: "README.md", name: "README.md", kind: "file" }, + { path: "src/AppController.tsx", name: "AppController.tsx", kind: "file" }, + { path: "src/button.tsx", name: "button.tsx", kind: "file" }, + ].filter((item) => item.name.toLowerCase().includes(query)); + + return { files }; + } + + return { ok: true }; + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + const { rerender } = render( + + + + ); + + fireEvent.change(screen.getByPlaceholderText("action.search_files"), { + target: { value: "app" }, + }); + + expect(await screen.findByText("AppController.tsx")).toBeInTheDocument(); + + rerender( + + + + ); + + expect(await screen.findByPlaceholderText("action.search_files")).toHaveValue(""); + expect(screen.queryByText("AppController.tsx")).toBeNull(); + + fireEvent.change(screen.getByPlaceholderText("action.search_files"), { + target: { value: "read" }, + }); + + expect(await screen.findByText("README.md")).toBeInTheDocument(); + expect(screen.queryByText("AppController.tsx")).toBeNull(); + + rerender( + + + + ); + + expect(await screen.findByPlaceholderText("action.search_files")).toHaveValue("app"); + expect(await screen.findByText("AppController.tsx")).toBeInTheDocument(); + expect(screen.queryByText("README.md")).toBeNull(); + }); + it("renders the compact shared empty shell for search misses", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string, args: { query?: string }) => { if (op === "file.search") { @@ -2012,6 +2069,21 @@ describe("FileTreePanel", () => { expect(document.querySelector(".tree-item-actions")).toBeNull(); }); + it("omits the desktop filename search input when showSearch is false", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + + render( + + + + ); + + expect(screen.queryByLabelText("action.search_files")).toBeNull(); + expect(document.querySelector(".file-tree-search")).toBeNull(); + }); + it("opens the mobile action sheet on long press but not on ordinary tap", async () => { const sendCommand = vi.fn().mockResolvedValue({ ok: true }); const store = createStore(); diff --git a/packages/web/src/features/workspace/views/shared/file-tree-panel.tsx b/packages/web/src/features/workspace/views/shared/file-tree-panel.tsx index d06f523c..2d6030e3 100644 --- a/packages/web/src/features/workspace/views/shared/file-tree-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/file-tree-panel.tsx @@ -1,8 +1,9 @@ import type { FileNode } from "@coder-studio/core"; -import { useAtomValue, useSetAtom } from "jotai"; +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { atomFamily } from "jotai-family"; import { ChevronDown, ChevronRight, X } from "lucide-react"; import type { FC, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { workspaceByIdAtomFamily } from "../../../../atoms/workspaces"; import { Button, @@ -104,8 +105,39 @@ interface FileTreePanelProps { onVisibleCountChange?: (count: number, loading: boolean) => void; collapseVersion?: number; variant?: "desktop" | "mobile"; + showSearch?: boolean; } +interface FileTreePanelState { + searchValue: string; + resolvedQuery: string; + searchResults: FileNode[]; + searchLoading: boolean; + contextTargetPath: string | null; +} + +function createInitialFileTreePanelState(): FileTreePanelState { + return { + searchValue: "", + resolvedQuery: "", + searchResults: [], + searchLoading: false, + contextTargetPath: null, + }; +} + +function getFileTreePanelStateKey( + workspaceId: string, + variant: "desktop" | "mobile", + showSearch: boolean +): string { + return `${workspaceId}::${variant}::${showSearch ? "search" : "plain"}`; +} + +const fileTreePanelStateAtomFamily = atomFamily((_stateKey: string) => + atom(createInitialFileTreePanelState()) +); + function normalizeExpandedDirs(paths: Iterable): string[] { return [...new Set([...paths].map(normalizeDirPath).filter(Boolean))] .sort((a, b) => a.localeCompare(b)) @@ -121,7 +153,11 @@ export const FileTreePanel: FC = ({ onVisibleCountChange, collapseVersion = 0, variant = "desktop", + showSearch = true, }) => { + const [panelState, setPanelState] = useAtom( + fileTreePanelStateAtomFamily(getFileTreePanelStateKey(workspaceId, variant, showSearch)) + ); const t = useTranslation(); const workspace = useAtomValue(workspaceByIdAtomFamily(workspaceId)); const expandedDirs = useAtomValue(expandedDirsAtomFamily(workspaceId)); @@ -157,15 +193,13 @@ export const FileTreePanel: FC = ({ onCreateRequestConsumed, onSelectFile, }); - const [searchValue, setSearchValue] = useState(""); + const { contextTargetPath, resolvedQuery, searchLoading, searchResults, searchValue } = + panelState; const searchQuery = searchValue.trim(); const treeNodes = useMemo( () => (fileTree ? sortNodes(buildNestedTree(fileTree)) : []), [fileTree] ); - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); - const [contextTargetPath, setContextTargetPath] = useState(null); const searchRequestIdRef = useRef(0); const { contextTarget, @@ -224,14 +258,30 @@ export const FileTreePanel: FC = ({ let cancelled = false; if (!hasSearch) { - setSearchResults([]); - setSearchLoading(false); + setPanelState((current) => + current.searchResults.length > 0 || current.resolvedQuery || current.searchLoading + ? { + ...current, + resolvedQuery: "", + searchResults: [], + searchLoading: false, + } + : current + ); + searchRequestIdRef.current += 1; return () => { cancelled = true; }; } - setSearchLoading(true); + if (resolvedQuery === searchQuery && !searchLoading) { + return; + } + + setPanelState((current) => ({ + ...current, + searchLoading: true, + })); const requestId = ++searchRequestIdRef.current; const timeout = setTimeout(() => { @@ -242,8 +292,12 @@ export const FileTreePanel: FC = ({ return; } - setSearchResults(result); - setSearchLoading(false); + setPanelState((current) => ({ + ...current, + resolvedQuery: searchQuery, + searchResults: result, + searchLoading: false, + })); })(); }, 150); @@ -251,7 +305,7 @@ export const FileTreePanel: FC = ({ cancelled = true; clearTimeout(timeout); }; - }, [hasSearch, loadSearchResults, searchQuery]); + }, [hasSearch, loadSearchResults, resolvedQuery, searchLoading, searchQuery, setPanelState]); useEffect(() => { onVisibleCountChange?.(visibleFileCount, searchLoading || isLoading); @@ -270,13 +324,27 @@ export const FileTreePanel: FC = ({ return; } - setContextTargetPath(contextTarget.node.path); - }, [contextTarget]); + setPanelState((current) => + current.contextTargetPath === contextTarget.node.path + ? current + : { + ...current, + contextTargetPath: contextTarget.node.path, + } + ); + }, [contextTarget, setPanelState]); const closeContextMenu = useCallback(() => { - setContextTargetPath(null); + setPanelState((current) => + current.contextTargetPath === null + ? current + : { + ...current, + contextTargetPath: null, + } + ); closeMenu(); - }, [closeMenu]); + }, [closeMenu, setPanelState]); const openRowContextMenu = useCallback( ( @@ -288,14 +356,17 @@ export const FileTreePanel: FC = ({ handleSelectFile(node.path); } - setContextTargetPath(node.path); + setPanelState((current) => ({ + ...current, + contextTargetPath: node.path, + })); openDesktopMenu(event, { node, surface, triggerElement: event.currentTarget, }); }, - [handleSelectFile, openDesktopMenu] + [handleSelectFile, openDesktopMenu, setPanelState] ); const beginRowLongPress = useCallback( @@ -316,26 +387,33 @@ export const FileTreePanel: FC = ({ return ( <>
- + {showSearch ? ( + + ) : null}
{hasSearch ? ( diff --git a/packages/web/src/features/workspace/views/shared/git-panel.test.tsx b/packages/web/src/features/workspace/views/shared/git-panel.test.tsx index 9e3381a9..e180210c 100644 --- a/packages/web/src/features/workspace/views/shared/git-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/git-panel.test.tsx @@ -2215,6 +2215,112 @@ describe("GitPanel", () => { expect(otherTextarea?.value).toBe(""); }); + it("restores git panel instance state per workspace tab instance", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return status; + } + + if (op === "git.branches") { + return { current: "feature/ai-agent", branches: [] }; + } + + if (op === "worktree.list") { + return { + worktrees, + }; + } + + if (op === "git.log") { + return { + entries: historyEntries, + }; + } + + return {}; + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + store.set(workspacesAtom, { + "ws-a": { + id: "ws-a", + path: "/repo/a", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + "ws-b": { + id: "ws-b", + path: "/repo/b", + targetRuntime: "native", + openedAt: 2, + lastActiveAt: 2, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + } as never); + + const { rerender } = render( + + + + ); + + fireEvent.click(await screen.findByRole("button", { name: /Worktrees/ })); + fireEvent.click(screen.getByRole("button", { name: "History" })); + fireEvent.click(screen.getByRole("button", { name: "New" })); + + expect(await screen.findByText("pr/123-fix-auth")).toBeInTheDocument(); + expect(await screen.findByText("feat: refresh source control surface")).toBeInTheDocument(); + expect(await screen.findByLabelText("Branch")).toBeInTheDocument(); + + rerender( + + + + ); + + expect(await screen.findByRole("button", { name: /Worktrees/ })).toHaveAttribute( + "aria-expanded", + "false" + ); + expect(screen.getByRole("button", { name: "History" })).toHaveAttribute( + "aria-expanded", + "false" + ); + expect(screen.queryByText("pr/123-fix-auth")).toBeNull(); + expect(screen.queryByText("feat: refresh source control surface")).toBeNull(); + expect(screen.queryByLabelText("Branch")).toBeNull(); + + rerender( + + + + ); + + expect(await screen.findByRole("button", { name: /Worktrees/ })).toHaveAttribute( + "aria-expanded", + "true" + ); + expect(screen.getByRole("button", { name: "History" })).toHaveAttribute( + "aria-expanded", + "true" + ); + expect(await screen.findByText("pr/123-fix-auth")).toBeInTheDocument(); + expect(await screen.findByText("feat: refresh source control surface")).toBeInTheDocument(); + expect(await screen.findByLabelText("Branch")).toBeInTheDocument(); + }); + it("clears the persisted commit draft for the workspace after a successful commit", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "git.status") { diff --git a/packages/web/src/features/workspace/views/shared/git-panel.tsx b/packages/web/src/features/workspace/views/shared/git-panel.tsx index 9a7d0209..d90095d9 100644 --- a/packages/web/src/features/workspace/views/shared/git-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/git-panel.tsx @@ -1,8 +1,9 @@ import type { GitCommitSummary, GitFileChange, WorktreeInfo } from "@coder-studio/core"; -import { useAtomValue } from "jotai"; +import { atom, useAtom, useAtomValue } from "jotai"; +import { atomFamily } from "jotai-family"; import { ChevronDown, Minus, Plus, RotateCcw } from "lucide-react"; import type { FC, MouseEvent, ReactNode } from "react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { localeAtom } from "../../../../atoms/app-ui"; import { ConfirmDialog, @@ -58,6 +59,42 @@ interface GitPanelProps { variant?: "desktop" | "mobile"; } +interface GitPanelState { + worktreeSurfaceView: "list" | "create" | null; + worktreesExpanded: boolean; + historyExpanded: boolean; + collapsedGroups: Record; +} + +function createInitialCollapsedGroups(isMobile: boolean): Record { + return isMobile + ? { + staged: false, + changes: true, + } + : { + staged: false, + changes: false, + }; +} + +function createInitialGitPanelState(isMobile: boolean): GitPanelState { + return { + worktreeSurfaceView: null, + worktreesExpanded: false, + historyExpanded: false, + collapsedGroups: createInitialCollapsedGroups(isMobile), + }; +} + +function getGitPanelStateKey(workspaceId: string, variant: "desktop" | "mobile"): string { + return `${workspaceId}::${variant}`; +} + +const gitPanelStateAtomFamily = atomFamily((stateKey: string) => + atom(createInitialGitPanelState(stateKey.endsWith("::mobile"))) +); + export const GitPanel: FC = ({ workspaceId, refreshToken = 0, @@ -65,6 +102,9 @@ export const GitPanel: FC = ({ variant = "desktop", }) => { const isMobile = variant === "mobile"; + const [panelState, setPanelState] = useAtom( + gitPanelStateAtomFamily(getGitPanelStateKey(workspaceId, variant)) + ); const locale = useAtomValue(localeAtom) === "zh" ? "zh" : "en"; const t = useTranslation(); const { @@ -95,21 +135,8 @@ export const GitPanel: FC = ({ }); const { currentWorktree, hasWorkspace, list, loadWorktrees, openWorktree } = useWorktreeManagementActions(workspaceId); - const [worktreeSurfaceView, setWorktreeSurfaceView] = useState<"list" | "create" | null>(null); - const [worktreesExpanded, setWorktreesExpanded] = useState(false); - const [historyExpanded, setHistoryExpanded] = useState(false); const worktreeAutoLoadAttemptedRef = useRef(false); - const [collapsedGroups, setCollapsedGroups] = useState>(() => - isMobile - ? { - staged: false, - changes: true, - } - : { - staged: false, - changes: false, - } - ); + const { collapsedGroups, historyExpanded, worktreeSurfaceView, worktreesExpanded } = panelState; useEffect(() => { worktreeAutoLoadAttemptedRef.current = false; @@ -134,7 +161,10 @@ export const GitPanel: FC = ({ const handleWorktreeOpen = async (worktree: WorktreeInfo) => { if (currentWorktree?.path === worktree.path) { - setWorktreeSurfaceView("list"); + setPanelState((current) => ({ + ...current, + worktreeSurfaceView: "list", + })); return; } @@ -180,7 +210,12 @@ export const GitPanel: FC = ({ +
+ {isExpanded ? ( +
+ {openEditorPaths.map((path) => ( +
+ + + } + size="sm" + onClick={() => closePath(path)} + /> + +
+ ))} +
+ ) : null} + + ); +} diff --git a/packages/web/src/features/workspace/views/shared/quick-jump-section.tsx b/packages/web/src/features/workspace/views/shared/quick-jump-section.tsx new file mode 100644 index 00000000..2db09ab0 --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/quick-jump-section.tsx @@ -0,0 +1,133 @@ +import type { FileNode } from "@coder-studio/core"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { dispatchCommandAtom } from "../../../../atoms/connection"; +import { ThemedIcon } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { deriveEditorModeForPath, editorModeAtomFamily } from "../../atoms"; + +interface SearchFilesResult { + files: FileNode[]; +} + +interface QuickJumpSectionProps { + workspaceId: string; + onSelectFile?: (path: string) => void; +} + +export function QuickJumpSection({ workspaceId, onSelectFile }: QuickJumpSectionProps) { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const setEditorMode = useSetAtom(editorModeAtomFamily(workspaceId)); + const { openLocation } = useOpenLocation(workspaceId); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + const requestIdRef = useRef(0); + const hasQuery = query.trim().length > 0; + + useEffect(() => { + const trimmed = query.trim(); + if (!trimmed) { + setResults([]); + setLoading(false); + setFailed(false); + return; + } + + let cancelled = false; + setLoading(true); + setFailed(false); + const requestId = ++requestIdRef.current; + + const timeout = window.setTimeout(() => { + void dispatch("file.search", { + workspaceId, + query: trimmed, + limit: 10, + }) + .then((result) => { + if (cancelled || requestId !== requestIdRef.current) { + return; + } + + if (!result.ok || !result.data) { + setResults([]); + setFailed(true); + return; + } + + setResults(result.data.files); + }) + .catch(() => { + if (!cancelled) { + setResults([]); + setFailed(true); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + }, 150); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [dispatch, query, workspaceId]); + + return ( +
+

{t("workspace.quick_jump.title")}

+ + + {hasQuery ? ( +
+ {loading ? ( +

{t("common.loading")}

+ ) : failed ? ( +

{t("workspace.quick_jump.failed")}

+ ) : results.length === 0 ? ( +

{t("workspace.quick_jump.no_results")}

+ ) : ( + results.map((file) => ( + + )) + )} +
+ ) : null} +
+ ); +} diff --git a/packages/web/src/features/workspace/views/shared/search-panel.test.tsx b/packages/web/src/features/workspace/views/shared/search-panel.test.tsx new file mode 100644 index 00000000..eb03a378 --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/search-panel.test.tsx @@ -0,0 +1,509 @@ +// @vitest-environment jsdom + +import type { SearchContentResult } from "@coder-studio/core"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { wsClientAtom } from "../../../../atoms/connection"; +import { pendingEditorNavigationAtomFamily } from "../../../code-editor/atoms"; +import { activeFilePathAtomFamily } from "../../atoms/files"; +import { SearchPanel } from "./search-panel"; + +describe("SearchPanel", () => { + const singleMatchCountPattern = /1.*(?:matches|条匹配)/i; + + function renderSearchPanel(sendCommand: ReturnType) { + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + return { store }; + } + + async function searchFor(query: string) { + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: query }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("shows the empty hint only once before a query is entered", () => { + const sendCommand = vi.fn(); + renderSearchPanel(sendCommand); + + expect( + screen.getAllByText(/Type to search across file contents|输入关键词以搜索文件内容/i) + ).toHaveLength(1); + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it("debounces content queries, renders grouped results, and highlights matches", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 2, + hasMoreMatches: true, + matches: [ + { + line: 3, + column: 7, + endColumn: 18, + preview: "const needleValue = searchState;", + previewColumnStart: 7, + previewColumnEnd: 18, + }, + { + line: 8, + column: 8, + endColumn: 19, + preview: "return needleValue;", + previewColumnStart: 8, + previewColumnEnd: 19, + }, + ], + }, + ], + totalMatchCount: 2, + hasMoreFiles: true, + truncatedMatchFileCount: 1, + } satisfies SearchContentResult); + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + expect(sendCommand).toHaveBeenCalledWith( + "file.searchContent", + { + workspaceId: "ws-test", + query: "needle", + maxFiles: 50, + maxMatchesPerFile: 20, + }, + undefined + ); + + expect(screen.getByText("app.tsx")).toBeInTheDocument(); + expect(screen.getByText("src/app.tsx")).toBeInTheDocument(); + expect(screen.getAllByText("needleValue")[0]?.tagName).toBe("MARK"); + expect(screen.getByText(/Results limited|结果已截断/i)).toBeInTheDocument(); + }); + + it("expands file groups by default after results load", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + const groupHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + + expect(groupHeader).toHaveAttribute("aria-expanded", "true"); + expect(groupHeader).toHaveAttribute("aria-controls"); + expect(screen.getByRole("button", { name: /12.*needle/i })).toBeInTheDocument(); + }); + + it("collapses and re-expands file matches when the group header is clicked", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + const groupHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + + fireEvent.click(groupHeader); + + expect(groupHeader).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("button", { name: /12.*needle/i })).not.toBeInTheDocument(); + + fireEvent.click(groupHeader); + + expect(groupHeader).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /12.*needle/i })).toBeInTheDocument(); + }); + + it("resets returned file groups to expanded on a new successful query", async () => { + const sendCommand = vi.fn().mockImplementation(async (_op: string, args: { query: string }) => { + if (args.query === "needle") { + return { + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult; + } + + return { + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 21, + column: 3, + endColumn: 9, + preview: "startThread(worker);", + previewColumnStart: 6, + previewColumnEnd: 12, + }, + ], + }, + { + path: "src/worker.ts", + name: "worker.ts", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 4, + column: 10, + endColumn: 16, + preview: "threadPool.run(job);", + previewColumnStart: 1, + previewColumnEnd: 7, + }, + ], + }, + ], + totalMatchCount: 2, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult; + }); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + const firstHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + + fireEvent.click(firstHeader); + expect(firstHeader).toHaveAttribute("aria-expanded", "false"); + + await searchFor("thread"); + + const appHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + const workerHeader = screen.getByRole("button", { + name: new RegExp(`worker\\.ts.*src/worker\\.ts.*${singleMatchCountPattern.source}`, "i"), + }); + + expect(appHeader).toHaveAttribute("aria-expanded", "true"); + expect(workerHeader).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /21.*startThread/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /4.*threadPool/i })).toBeInTheDocument(); + }); + + it("clears collapsed group state when the query is cleared", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + const groupHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + + fireEvent.click(groupHeader); + expect(groupHeader).toHaveAttribute("aria-expanded", "false"); + + await searchFor(""); + + expect( + screen.queryByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }) + ).not.toBeInTheDocument(); + + await searchFor("needle"); + + const nextHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + + expect(nextHeader).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /12.*needle/i })).toBeInTheDocument(); + }); + + it("opens the file at the selected match location", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + const { store } = renderSearchPanel(sendCommand); + + await searchFor("needle"); + + fireEvent.click(screen.getByRole("button", { name: /12.*needle/i })); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(store.get(pendingEditorNavigationAtomFamily("ws-test"))).toMatchObject({ + workspaceId: "ws-test", + path: "src/app.tsx", + line: 12, + column: 5, + source: "search", + }); + }); + + it("renders a mobile variant without the desktop header and still opens the selected match", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + const onSelectFile = vi.fn(); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + expect(screen.queryByRole("heading", { name: /Search|搜索/i })).toBeNull(); + + await searchFor("needle"); + fireEvent.click(screen.getByRole("button", { name: /12.*needle/i })); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(onSelectFile).toHaveBeenCalledWith("src/app.tsx"); + }); + + it("shows retry when the search command fails", async () => { + const sendCommand = vi.fn().mockRejectedValue(new Error("boom")); + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + expect(screen.getByRole("button", { name: /Retry|重试/i })).toBeInTheDocument(); + }); + + it("re-expands file groups after a failed search is retried successfully", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ + files: [ + { + path: "src/thread.ts", + name: "thread.ts", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 4, + column: 10, + endColumn: 16, + preview: "threadPool.run(job);", + previewColumnStart: 1, + previewColumnEnd: 7, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + fireEvent.click( + screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }) + ); + + await searchFor("thread"); + + fireEvent.click(screen.getByRole("button", { name: /Retry|重试/i })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + const retryHeader = screen.getByRole("button", { + name: new RegExp(`thread\\.ts.*src/thread\\.ts.*${singleMatchCountPattern.source}`, "i"), + }); + + expect(retryHeader).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /4.*threadPool/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/features/workspace/views/shared/search-panel.tsx b/packages/web/src/features/workspace/views/shared/search-panel.tsx new file mode 100644 index 00000000..8b432d26 --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/search-panel.tsx @@ -0,0 +1,327 @@ +import type { SearchContentMatch, SearchContentResult } from "@coder-studio/core"; +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { atomFamily } from "jotai-family"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { useEffect, useId, useRef } from "react"; +import { dispatchCommandAtom } from "../../../../atoms/connection"; +import { Button } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { PanelHeader } from "../../../shared/components/panel-header"; +import { deriveEditorModeForPath, editorModeAtomFamily } from "../../atoms"; + +interface SearchPanelProps { + workspaceId: string; + variant?: "desktop" | "mobile"; + onSelectFile?: (path: string) => void; +} + +interface SearchPanelState { + query: string; + retryNonce: number; + resolvedQuery: string; + resolvedRetryNonce: number; + results: SearchContentResult | null; + expandedFiles: Record; + loading: boolean; + error: boolean; +} + +const searchPanelStateAtomFamily = atomFamily((workspaceId: string) => + atom({ + query: "", + retryNonce: 0, + resolvedQuery: "", + resolvedRetryNonce: 0, + results: null, + expandedFiles: {}, + loading: false, + error: false, + }) +); + +function renderPreview(match: SearchContentMatch): ReactNode { + const start = Math.max(0, match.previewColumnStart - 1); + const end = Math.max(start, match.previewColumnEnd - 1); + + return ( + <> + {match.preview.slice(0, start)} + {match.preview.slice(start, end)} + {match.preview.slice(end)} + + ); +} + +function buildExpandedFileMap(results: SearchContentResult): Record { + return Object.fromEntries(results.files.map((file) => [file.path, true])); +} + +export const SearchPanel: FC = ({ + workspaceId, + variant = "desktop", + onSelectFile, +}) => { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const { openLocation } = useOpenLocation(workspaceId); + const setEditorMode = useSetAtom(editorModeAtomFamily(workspaceId)); + const inputRef = useRef(null); + const dispatchRef = useRef(dispatch); + const groupIdPrefix = useId(); + const [state, setState] = useAtom(searchPanelStateAtomFamily(workspaceId)); + const { + error, + expandedFiles, + loading, + query, + resolvedQuery, + resolvedRetryNonce, + results, + retryNonce, + } = state; + + useEffect(() => { + inputRef.current?.focus(); + }, [workspaceId]); + + useEffect(() => { + dispatchRef.current = dispatch; + }, [dispatch]); + + useEffect(() => { + const trimmed = query.trim(); + if (!trimmed) { + setState((current) => + current.results !== null || + current.resolvedQuery || + Object.keys(current.expandedFiles).length > 0 || + current.loading || + current.error + ? { + ...current, + resolvedQuery: "", + resolvedRetryNonce: current.retryNonce, + results: null, + expandedFiles: {}, + loading: false, + error: false, + } + : current + ); + return; + } + + if (results && !error && resolvedQuery === trimmed && resolvedRetryNonce === retryNonce) { + return; + } + + let cancelled = false; + setState((current) => ({ + ...current, + loading: true, + error: false, + })); + + const timeout = window.setTimeout(() => { + void dispatchRef + .current("file.searchContent", { + workspaceId, + query: trimmed, + maxFiles: 50, + maxMatchesPerFile: 20, + }) + .then((result) => { + if (cancelled) { + return; + } + + if (!result.ok || !result.data) { + setState((current) => ({ + ...current, + results: null, + expandedFiles: {}, + error: true, + })); + return; + } + + setState((current) => ({ + ...current, + resolvedQuery: trimmed, + resolvedRetryNonce: retryNonce, + results: result.data, + expandedFiles: buildExpandedFileMap(result.data), + })); + }) + .catch(() => { + if (!cancelled) { + setState((current) => ({ + ...current, + results: null, + expandedFiles: {}, + error: true, + })); + } + }) + .finally(() => { + if (!cancelled) { + setState((current) => ({ + ...current, + loading: false, + })); + } + }); + }, 250); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [query, retryNonce, setState, workspaceId]); + + const openMatch = (path: string, line: number, column: number, endColumn: number) => { + setEditorMode(deriveEditorModeForPath(path)); + void openLocation({ + workspaceId, + path, + line, + column, + endColumn, + source: "search", + }); + onSelectFile?.(path); + }; + + return ( +
+ {variant === "desktop" ? : null} + +
+ + setState((current) => ({ + ...current, + query: event.target.value, + })) + } + placeholder={t("workspace.search.placeholder")} + /> + +
+ {loading + ? t("common.loading") + : query.trim() + ? t("workspace.search.results_count", { + count: results?.totalMatchCount ?? 0, + files: results?.files.length ?? 0, + }) + : null} +
+ + {results && (results.hasMoreFiles || results.truncatedMatchFileCount > 0) ? ( +
+ {t("workspace.search.truncated")} +
+ ) : null} +
+ +
+ {error ? ( +
+

{t("workspace.search.failed")}

+ +
+ ) : !query.trim() ? ( +

{t("workspace.search.empty")}

+ ) : loading ? ( +

{t("common.loading")}

+ ) : !results || results.files.length === 0 ? ( +

{t("workspace.search.no_results")}

+ ) : ( + results.files.map((file, index) => { + const matchesId = `${groupIdPrefix}-group-${index}`; + const isExpanded = expandedFiles[file.path] ?? true; + + return ( +
+ + + +
+ ); + }) + )} +
+
+ ); +}; diff --git a/packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx b/packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx new file mode 100644 index 00000000..5c28b87a --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx @@ -0,0 +1,49 @@ +import { FolderTree, GitBranch, Search } from "lucide-react"; +import type { FC } from "react"; +import { useTranslation } from "../../../../lib/i18n"; +import type { DesktopSidebarView } from "../../atoms/layout"; + +interface WorkspaceActivityBarProps { + activeView: DesktopSidebarView; + onSelectView: (view: DesktopSidebarView) => void; +} + +export const WorkspaceActivityBar: FC = ({ + activeView, + onSelectView, +}) => { + const t = useTranslation(); + const items: Array<{ + view: DesktopSidebarView; + label: string; + icon: typeof FolderTree; + }> = [ + { view: "explorer", label: t("workspace.sidebar.explorer"), icon: FolderTree }, + { view: "search", label: t("workspace.sidebar.search"), icon: Search }, + { + view: "source-control", + label: t("workspace.sidebar.source_control"), + icon: GitBranch, + }, + ]; + + return ( + + ); +}; diff --git a/packages/web/src/features/workspace/views/shared/workspace-section-header.tsx b/packages/web/src/features/workspace/views/shared/workspace-section-header.tsx new file mode 100644 index 00000000..ddf68fd5 --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/workspace-section-header.tsx @@ -0,0 +1,52 @@ +import { ChevronsUp } from "lucide-react"; +import { IconButton, ThemedIcon, Tooltip } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; + +interface WorkspaceSectionHeaderProps { + onOpenFileCreate?: () => void; + onOpenFolderCreate?: () => void; + onCollapseAll?: () => void; +} + +export function WorkspaceSectionHeader({ + onOpenFileCreate, + onOpenFolderCreate, + onCollapseAll, +}: WorkspaceSectionHeaderProps) { + const t = useTranslation(); + + return ( +
+

{t("workspace.sidebar.workspace")}

+
+ + } + onClick={onOpenFileCreate} + size="sm" + /> + + + } + onClick={onOpenFolderCreate} + size="sm" + /> + + + } + onClick={onCollapseAll} + size="sm" + /> + +
+
+ ); +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 6510d478..6521764a 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -81,7 +81,8 @@ "search_commands": "Search Commands", "back": "Back", "expand": "Expand", - "collapse": "Collapse" + "collapse": "Collapse", + "close_all": "Close all" }, "label": { "workspace": "Workspace", @@ -154,6 +155,36 @@ "loading_description": "Preparing your workspace list and restoring the last active session.", "load_failed_title": "Failed to load workspaces", "load_failed_description": "Failed to fetch workspace list", + "sidebar": { + "label": "Workspace activity bar", + "explorer": "Explorer", + "search": "Search", + "source_control": "Source Control", + "open_editors": "Open Editors", + "workspace": "Workspace" + }, + "quick_jump": { + "title": "Quick Jump", + "placeholder": "Type a filename or path", + "no_results": "No matching files found.", + "failed": "File search failed. Try again." + }, + "open_editors": { + "title_with_count": "{title} ({count})", + "close_path": "Close {path}", + "expand_label": "Expand Open Editors", + "collapse_label": "Collapse Open Editors" + }, + "search": { + "empty": "Type to search across file contents.", + "placeholder": "Search across file contents", + "results_count": "{count} results in {files} files", + "no_results": "No content matches found.", + "truncated": "Results limited to keep search responsive.", + "file_match_count": "{count}{suffix} matches", + "failed": "Search failed. Try again.", + "retry": "Retry" + }, "launch": { "kicker": "START WORKSPACE", "title": "Open Workspace", @@ -181,6 +212,15 @@ "missing_provider": "{provider} CLI not found" } }, + "quick_open": { + "title": "Go to File", + "placeholder": "Type a filename or path", + "empty": "Type to search by filename or path.", + "no_results": "No matching files found.", + "failed": "File search failed. Try again.", + "command_label": "Go to File...", + "command_description": "Quickly open a file in the current workspace" + }, "mobile": { "dock": { "aria_label": "Mobile dock", @@ -301,8 +341,16 @@ "up_next": "Up next...", "loading_title": "Restoring terminal output...", "loading_body": "This terminal is unavailable while history is being restored. Please wait until recovery finishes before continuing. Larger histories may take longer to restore.", - "failed_title": "Terminal history could not be restored", - "failed_body": "New output will keep streaming. Refresh manually only if you need the missing history.", + "failed_title": "Terminal history was not restored yet", + "failed_body": "The terminal is still usable, but older output was not filled back in this time. You can retry recovery; if the server still retains the history, it may still come back later or after a refresh.", + "retryable_title": "Terminal history was not restored yet", + "retryable_body": "The terminal is still usable, but older output was not filled back in this time. You can retry recovery; if the server still retains the history, it may still come back later or after a refresh.", + "retry_action": "Retry recovery", + "unrecoverable_title": "Earlier history can no longer be restored", + "unrecoverable_body": "Older terminal output has already fallen out of the replay buffer and there is no usable snapshot available now. Only later output can still be viewed.", + "unknown_title": "This terminal can no longer be restored", + "unknown_body": "This terminal session is no longer present on the server, so its history cannot be recovered. Open a new terminal to continue.", + "unknown_body_with_provider": "This terminal session is no longer present on the server. Reopen a {provider} session to continue?", "closed_title": "This session has ended", "closed_body": "Reopen a new session to continue?", "closed_body_with_provider": "Reopen a {provider} session to continue?", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index 8fefbdb5..cf99b546 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -81,7 +81,8 @@ "search_commands": "搜索命令", "back": "返回", "expand": "展开", - "collapse": "收起" + "collapse": "收起", + "close_all": "全部关闭" }, "label": { "workspace": "工作区", @@ -154,6 +155,36 @@ "loading_description": "正在准备工作区列表并恢复上次活跃的会话。", "load_failed_title": "工作区加载失败", "load_failed_description": "获取工作区列表失败", + "sidebar": { + "label": "工作区活动栏", + "explorer": "资源管理器", + "search": "搜索", + "source_control": "源代码管理", + "open_editors": "打开的编辑器", + "workspace": "工作区" + }, + "quick_jump": { + "title": "快速跳转", + "placeholder": "输入文件名或路径", + "no_results": "未找到匹配文件。", + "failed": "文件搜索失败,请重试。" + }, + "open_editors": { + "title_with_count": "{title} ({count})", + "close_path": "关闭 {path}", + "expand_label": "展开打开的编辑器", + "collapse_label": "收起打开的编辑器" + }, + "search": { + "empty": "输入关键词以搜索文件内容。", + "placeholder": "搜索文件内容", + "results_count": "在 {files} 个文件中找到 {count} 条结果", + "no_results": "未找到内容匹配。", + "truncated": "结果已截断,以保持搜索响应速度。", + "file_match_count": "{count}{suffix} 条匹配", + "failed": "搜索失败,请重试。", + "retry": "重试" + }, "launch": { "kicker": "启动工作区", "title": "打开工作区", @@ -181,6 +212,15 @@ "missing_provider": "未找到 {provider} CLI" } }, + "quick_open": { + "title": "跳转到文件", + "placeholder": "输入文件名或路径", + "empty": "输入内容以按文件名或路径搜索。", + "no_results": "未找到匹配文件。", + "failed": "文件搜索失败,请重试。", + "command_label": "跳转到文件...", + "command_description": "在当前工作区中快速打开文件" + }, "mobile": { "dock": { "aria_label": "移动底栏", @@ -301,8 +341,16 @@ "up_next": "即将开始…", "loading_title": "正在恢复终端内容…", "loading_body": "恢复期间暂时无法使用当前终端;请耐心等待,历史内容恢复完成后再继续。内容较多时可能需要更久。", - "failed_title": "历史内容恢复失败", - "failed_body": "新输出仍会继续显示;如果需要完整历史,再手动刷新页面。", + "failed_title": "终端历史暂未恢复", + "failed_body": "当前终端可以继续使用,但较早输出这次没有补齐。你可以重试恢复;如果服务端仍保留历史,稍后或刷新页面后仍可能找回。", + "retryable_title": "终端历史暂未恢复", + "retryable_body": "当前终端可以继续使用,但较早输出这次没有补齐。你可以重试恢复;如果服务端仍保留历史,稍后或刷新页面后仍可能找回。", + "retry_action": "重试恢复", + "unrecoverable_title": "较早历史已无法恢复", + "unrecoverable_body": "较早的终端输出已经从回放缓冲区中淘汰,而且当前也没有可用快照。现在只能继续查看后续输出。", + "unknown_title": "当前终端已不可恢复", + "unknown_body": "这个终端会话已经不在服务端,历史输出无法再补回。请重新打开一个新终端继续。", + "unknown_body_with_provider": "这个终端会话已经不在服务端。是否重新打开一个 {provider} 会话继续?", "closed_title": "当前会话已结束", "closed_body": "是否重新打开一个新会话继续。", "closed_body_with_provider": "是否重新打开一个 {provider} 会话继续。", diff --git a/packages/web/src/shells/desktop-shell.test.tsx b/packages/web/src/shells/desktop-shell.test.tsx index 8deebc40..9a2d79e6 100644 --- a/packages/web/src/shells/desktop-shell.test.tsx +++ b/packages/web/src/shells/desktop-shell.test.tsx @@ -33,6 +33,10 @@ vi.mock("../features/command-palette", () => ({ CommandPalette: () => null, })); +vi.mock("../features/quick-open", () => ({ + QuickOpen: () =>
QuickOpen
, +})); + vi.mock("../features/workspace/views/shared/branch-quick-pick", () => ({ BranchQuickPick: () => null, })); @@ -206,6 +210,36 @@ describe("DesktopShell auth gating", () => { expect(screen.getByText("WorkspacePage")).toBeInTheDocument(); }); + it("mounts QuickOpen beside CommandPalette on desktop", () => { + window.history.replaceState({}, "", "/workspace"); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, true); + store.set(workspacesAtom, { + "ws-1": { + id: "ws-1", + path: "/tmp/ws-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(activeWorkspaceIdAtom, "ws-1"); + store.set(workspacesLoadStateAtom, "ready"); + + renderShell(store); + + expect(screen.getByText("QuickOpen")).toBeInTheDocument(); + }); + it("shows the shared workspace gate on desktop while /workspace is unresolved", () => { window.history.replaceState({}, "", "/workspace"); diff --git a/packages/web/src/shells/desktop-shell.tsx b/packages/web/src/shells/desktop-shell.tsx index effa35ca..b4f5332d 100644 --- a/packages/web/src/shells/desktop-shell.tsx +++ b/packages/web/src/shells/desktop-shell.tsx @@ -15,6 +15,7 @@ import { CommandPalette } from "../features/command-palette"; import { DiagnosticsPage } from "../features/diagnostics"; import { NotFoundPage } from "../features/not-found"; import { ToastContainer } from "../features/notifications"; +import { QuickOpen } from "../features/quick-open"; import { SettingsPage } from "../features/settings"; import { WelcomePage } from "../features/welcome"; import { WorkspaceDesktopView } from "../features/workspace/views/desktop/workspace-desktop-view"; @@ -85,6 +86,7 @@ export function DesktopShell() { )} +
diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx index 71a2bd4c..bd7ac654 100644 --- a/packages/web/src/shells/mobile-shell/index.test.tsx +++ b/packages/web/src/shells/mobile-shell/index.test.tsx @@ -776,12 +776,12 @@ describe("MobileShell Phase 2 workspace", () => { await user.click(screen.getByRole("button", { name: "Open Files sheet" })); expect(screen.getByRole("tablist", { name: "Files sheet tabs" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Files" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Explorer" })).toHaveAttribute("aria-selected", "true"); expect(screen.queryByRole("button", { name: "Close current sheet" })).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /back|返回/i })); - expect(screen.queryByRole("tab", { name: "Files" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Explorer" })).not.toBeInTheDocument(); }); it("shows the current branch name in the shared workspace footer", async () => { @@ -3085,7 +3085,7 @@ describe("MobileShell Phase 2 workspace", () => { await user.click(screen.getByRole("button", { name: "打开文件面板" })); - expect(screen.getByRole("region", { name: "文件面板" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "资源管理器面板" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "关闭当前面板" })).toBeInTheDocument(); }); @@ -3161,10 +3161,10 @@ describe("MobileShell Phase 2 workspace", () => { renderMobileShell(); await user.click(screen.getByRole("button", { name: "Open Files sheet" })); - expect(screen.getByRole("region", { name: "Files sheet" })).toHaveClass( + expect(screen.getByRole("region", { name: "Explorer sheet" })).toHaveClass( "mobile-sheet--fullscreen" ); - expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^new file$|^新建文件$/i })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "mock-file-tree" })); expect(screen.getByTestId("mobile-code-editor")).toBeInTheDocument(); @@ -3183,8 +3183,11 @@ describe("MobileShell Phase 2 workspace", () => { renderMobileShell(); await user.click(screen.getByRole("button", { name: "Open Files sheet" })); - await user.click(screen.getByRole("tab", { name: "Git" })); - expect(screen.getByRole("tab", { name: "Git" })).toHaveAttribute("aria-selected", "true"); + await user.click(screen.getByRole("tab", { name: /Source Control|源代码管理/i })); + expect(screen.getByRole("tab", { name: /Source Control|源代码管理/i })).toHaveAttribute( + "aria-selected", + "true" + ); await user.click(screen.getByRole("button", { name: "mock-git-panel" })); expect(screen.getByTestId("mobile-code-editor")).toBeInTheDocument(); @@ -3195,11 +3198,11 @@ describe("MobileShell Phase 2 workspace", () => { renderMobileShell(); await user.click(screen.getByRole("button", { name: "Open Files sheet" })); - await user.click(screen.getByRole("tab", { name: "Git" })); + await user.click(screen.getByRole("tab", { name: /Source Control|源代码管理/i })); await user.click(screen.getByRole("button", { name: "mock-git-history" })); expect(screen.getByTestId("mobile-code-editor")).toBeInTheDocument(); - expect(screen.getByText("abc123 · commit subject")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /back|返回/i })).toBeInTheDocument(); }); it("shows mobile diff preview and edit mode actions in the unified detail header", async () => { @@ -3219,15 +3222,20 @@ describe("MobileShell Phase 2 workspace", () => { expect(mockMobileEditorSetMode).toHaveBeenCalledWith("edit"); }); - it("shows file actions in the tab row only on the files tab", async () => { + it("shows file actions inside the workspace section only on the explorer tab", async () => { const user = userEvent.setup(); renderMobileShell(); await user.click(screen.getByRole("button", { name: "Open Files sheet" })); - expect(screen.getByRole("button", { name: /^new file$|^新建文件$/i })).toBeInTheDocument(); + const workspaceSection = screen + .getByRole("heading", { level: 2, name: /workspace|工作区/i }) + .closest("section") as HTMLElement; + expect( + within(workspaceSection).getByRole("button", { name: /^new file$|^新建文件$/i }) + ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /refresh|刷新/i })).toBeNull(); - await user.click(screen.getByRole("tab", { name: "Git" })); + await user.click(screen.getByRole("tab", { name: /Source Control|源代码管理/i })); expect(screen.queryByRole("button", { name: /^new file$|^新建文件$/i })).toBeNull(); }); @@ -3237,8 +3245,11 @@ describe("MobileShell Phase 2 workspace", () => { renderMobileShell(); await user.click(screen.getByRole("button", { name: "Open Files sheet" })); - await user.click(screen.getByRole("tab", { name: "Git" })); - expect(screen.getByRole("tab", { name: "Git" })).toHaveAttribute("aria-selected", "true"); + await user.click(screen.getByRole("tab", { name: /Source Control|源代码管理/i })); + expect(screen.getByRole("tab", { name: /Source Control|源代码管理/i })).toHaveAttribute( + "aria-selected", + "true" + ); await user.click(screen.getByRole("button", { name: "mock-git-panel" })); expect(screen.getByTestId("mobile-code-editor")).toBeInTheDocument(); @@ -3256,8 +3267,11 @@ describe("MobileShell Phase 2 workspace", () => { const { store } = renderMobileShell(); await user.click(screen.getByRole("button", { name: "Open Files sheet" })); - await user.click(screen.getByRole("tab", { name: "Git" })); - expect(screen.getByRole("tab", { name: "Git" })).toHaveAttribute("aria-selected", "true"); + await user.click(screen.getByRole("tab", { name: /Source Control|源代码管理/i })); + expect(screen.getByRole("tab", { name: /Source Control|源代码管理/i })).toHaveAttribute( + "aria-selected", + "true" + ); store.set(gitDiffPreviewAtomFamily("ws-1"), { path: "src/app.tsx", diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 9e9403f6..f364becf 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -187,6 +187,129 @@ min-height: min(82dvh, 700px); } +.quick-open { + display: flex; + width: min(640px, calc(100vw - var(--sp-6))); + max-height: min(72vh, 520px); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--surface-overlay-border); + border-radius: var(--radius-overlay); + background: var(--surface-overlay-bg); + box-shadow: var(--surface-overlay-shadow); +} + +.quick-open__search { + display: flex; + align-items: center; + gap: var(--gap-default); + padding: calc(var(--sp-3) - var(--gap-compact)) var(--sp-4); + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--bg-surface) 66%, transparent); +} + +.quick-open__icon { + flex-shrink: 0; + color: var(--text-tertiary); +} + +.quick-open__input { + flex: 1; + min-width: 0; + min-height: 30px; + border: none; + background: transparent; + color: var(--text-primary); + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); + outline: none; +} + +.quick-open__input::placeholder { + color: color-mix(in srgb, var(--text-tertiary) 92%, var(--bg-panel)); +} + +.quick-open__list { + display: flex; + min-height: 0; + flex-direction: column; + overflow-y: auto; + padding: var(--sp-1) 0 var(--sp-2); +} + +.quick-open__state, +.quick-open__empty { + padding: var(--sp-5) var(--sp-4); + color: var(--text-tertiary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.quick-open__item { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--gap-compact); + width: 100%; + padding: calc(var(--sp-2) + var(--gap-hairline)) var(--sp-4) + calc(var(--sp-3) - var(--gap-compact)); + border: none; + background: transparent; + color: var(--text-primary); + text-align: left; + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--border) 96%, transparent); + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.quick-open__item:hover, +.quick-open__item--active, +.quick-open__item[aria-selected="true"] { + background: color-mix(in srgb, var(--accent-blue) 12%, var(--bg-panel)); +} + +.quick-open__item:focus-visible { + outline: none; + background: color-mix(in srgb, var(--accent-blue) 14%, var(--bg-panel)); + box-shadow: + inset 0 0 0 var(--state-focus-ring-width) + color-mix(in srgb, var(--border-focus) 64%, transparent), + inset 0 -1px 0 color-mix(in srgb, var(--border) 96%, transparent); +} + +.quick-open__item:last-child { + box-shadow: none; +} + +.quick-open__primary { + min-width: 0; + overflow: hidden; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); +} + +.quick-open__secondary { + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.quick-open__item--active .quick-open__secondary, +.quick-open__item[aria-selected="true"] .quick-open__secondary { + color: color-mix(in srgb, var(--accent-blue) 52%, var(--text-secondary)); +} + @media (prefers-reduced-motion: reduce) { .command-palette, .mobile-sheet-layer__backdrop, @@ -12320,50 +12443,71 @@ textarea.input { display: flex; min-height: 0; height: 100%; - flex-direction: column; + flex-direction: row; background: var(--bg-panel); } -.workspace-sidebar-panel__tabs { +.workspace-activity-bar { display: flex; + width: 52px; + flex-direction: column; align-items: center; - gap: var(--gap-default); - min-width: 0; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-2); + border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-panel) 88%, var(--bg-page)); } -.workspace-sidebar-panel__tab { - position: relative; +.workspace-activity-bar__button { display: inline-flex; + width: 100%; + min-height: 40px; align-items: center; - gap: var(--gap-control); - min-height: 24px; - padding: 0; + justify-content: center; border: none; + border-radius: var(--radius-lg); background: transparent; color: var(--text-tertiary); - font-size: var(--type-body-6-size); - line-height: var(--type-body-6-line-height); - font-weight: var(--type-body-6-weight); - transition: color var(--duration-fast) var(--ease-out); + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); } -.workspace-sidebar-panel__tab:hover { +.workspace-activity-bar__button:hover { + background: var(--bg-hover); color: var(--text-secondary); } -.workspace-sidebar-panel__tab.active { +.workspace-activity-bar__button--active { + background: color-mix(in srgb, var(--accent-blue) 14%, transparent); color: var(--text-primary); } -.workspace-sidebar-panel__tab.active::after { - content: ""; +.workspace-activity-bar__label { position: absolute; - right: 0; - bottom: -8px; - left: 0; - height: 1.5px; - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--accent-blue) 90%, white 10%); + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.workspace-sidebar-panel__content { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; +} + +.workspace-sidebar-view { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; } .workspace-sidebar-panel__actions { @@ -12380,116 +12524,369 @@ textarea.input { flex: 1; } -.workspace-sidebar-panel .panel-toolbar-btn { - width: 24px; - height: 24px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--text-tertiary); -} - -.workspace-sidebar-panel .panel-toolbar-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); +.workspace-sidebar-panel__body--stacked { + flex-direction: column; } -.file-tree-shell { +.workspace-sidebar-section { display: flex; min-height: 0; - flex: 1; flex-direction: column; + padding: var(--sp-3) var(--sp-3) 0; } -.file-tree-shell .file-tree-search, -.file-tree-shell .file-tree-search--desktop { - display: flex; - align-items: center; - gap: var(--gap-default); - margin: var(--space-default) var(--inset-control-inline); - padding-inline: var(--inset-control-inline); - min-height: var(--control-height-md); - border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); - border-radius: var(--radius-panel); - background: color-mix(in srgb, var(--bg-surface) 90%, var(--bg-page)); -} - -.file-tree-shell .file-tree-search:focus-within, -.file-tree-shell .file-tree-search--desktop:focus-within { - border-color: color-mix(in srgb, var(--accent-blue) 70%, transparent); +.workspace-sidebar-section--fill { + flex: 1; + padding-bottom: var(--sp-3); } -.file-tree-shell .file-tree-search-input { - min-height: 30px; - font-size: var(--type-body-3-size); - line-height: var(--type-body-3-line-height); - font-weight: var(--type-body-3-weight); +.workspace-sidebar-section__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-2); + margin-bottom: var(--sp-2); } -.file-tree-shell .file-tree { - padding: 0 0 6px; +.workspace-sidebar-section__actions { + margin-left: auto; } -.file-tree-shell .tree-item { - position: relative; - gap: var(--gap-tight); - min-height: 26px; - margin: 0 var(--gap-tight); - padding: 3px var(--inset-control-block) 3px var(--inset-row-inline); - border-radius: var(--radius-panel); - color: var(--text-secondary); - transition: - background-color var(--duration-fast) var(--ease-out), - color var(--duration-fast) var(--ease-out); +.workspace-sidebar-section__title { + margin: 0; + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + text-transform: uppercase; + letter-spacing: 0.06em; } -.file-tree-shell .tree-item:hover { - background: color-mix(in srgb, var(--bg-hover) 92%, transparent); +.workspace-open-editors { + display: flex; + flex-direction: column; + gap: var(--gap-micro); } -.file-tree-shell .tree-item.selected { - padding-left: calc(var(--inset-row-inline) - var(--state-focus-ring-width)); - border-left: var(--state-focus-ring-width) solid var(--state-selected-border); - background: var(--state-selected-bg); +.workspace-open-editors__header { + display: flex; + align-items: center; + gap: var(--gap-compact); + margin-bottom: var(--sp-2); } -.file-tree-shell .tree-item--context-target { - background: color-mix(in srgb, var(--accent-blue) 16%, transparent); +.workspace-open-editors__header-main { + display: flex; + min-width: 0; + align-items: center; + gap: var(--gap-compact); + flex: 1 1 auto; } -.file-tree-shell .tree-chevron { - width: 14px; - height: 14px; +.workspace-open-editors__toggle { color: var(--text-tertiary); } -.file-tree-shell .tree-icon.file { - color: var(--icon-file-default); +.workspace-open-editors__title { + display: inline-flex; + align-items: baseline; + justify-content: flex-start; + gap: var(--gap-compact); + margin: 0; + min-width: 0; } -.file-tree-shell .tree-label { - min-width: 0; +.workspace-open-editors__title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--text-primary); - font-size: var(--type-body-3-size); - line-height: var(--type-body-3-line-height); - font-weight: var(--type-body-3-weight); -} - -.file-tree-shell .tree-search-labels { - gap: var(--gap-hairline); } -.file-tree-shell .tree-search-path, -.file-tree-shell .tree-empty-hint, -.file-tree-shell .tree-loading { - color: var(--text-tertiary); - font-family: var(--font-mono); - font-size: var(--type-body-5-size); - line-height: var(--type-body-5-line-height); - font-weight: var(--type-body-5-weight); +.workspace-open-editors__count { + color: var(--text-secondary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); +} + +.workspace-open-editors__close-all { + margin-left: auto; + min-height: 28px; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.workspace-open-editors__close-all:hover:not(:disabled) { + color: var(--text-primary); +} + +.workspace-open-editors__close-all:disabled { + color: var(--text-muted); +} + +.workspace-open-editors__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--gap-compact); +} + +.workspace-open-editors__item { + display: block; + width: 100%; + min-width: 0; + min-height: 28px; + padding: 0 var(--sp-2); + border: none; + border-radius: var(--radius-md); + background: transparent; + overflow: hidden; + color: var(--text-secondary); + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-open-editors__item-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-open-editors__item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.workspace-open-editors__item--active { + background: var(--state-selected-bg); + color: var(--text-primary); +} + +.workspace-open-editors__item-close { + flex: 0 0 auto; + color: var(--text-tertiary); +} + +.workspace-quick-jump { + padding-bottom: var(--sp-3); +} + +.workspace-quick-jump__search { + display: flex; + align-items: center; + gap: var(--gap-default); + min-height: 34px; + padding: 0 calc(var(--sp-3) - var(--gap-compact)); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg-surface) 92%, var(--bg-page)); +} + +.workspace-quick-jump__search:focus-within { + border-color: color-mix(in srgb, var(--accent-blue) 70%, transparent); +} + +.workspace-quick-jump__input { + min-width: 0; + min-height: 32px; + flex: 1; + border: none; + background: transparent; + color: var(--text-primary); + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); +} + +.workspace-quick-jump__input::placeholder { + color: color-mix(in srgb, var(--text-tertiary) 92%, var(--bg-panel)); +} + +.workspace-quick-jump__input:focus { + outline: none; +} + +.workspace-quick-jump__results { + display: flex; + flex-direction: column; + padding-top: var(--sp-2); +} + +.workspace-quick-jump__item { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--gap-compact); + width: 100%; + min-height: 40px; + padding: var(--sp-2) 0; + border: none; + background: transparent; + color: inherit; + text-align: left; + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.workspace-quick-jump__item:hover { + color: var(--text-primary); +} + +.workspace-quick-jump__item:focus-visible { + outline: none; + border-radius: var(--radius-md); + box-shadow: inset 0 0 0 var(--state-focus-ring-width) + color-mix(in srgb, var(--border-focus) 64%, transparent); +} + +.workspace-quick-jump__primary { + min-width: 0; + overflow: hidden; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); +} + +.workspace-quick-jump__secondary { + min-width: 0; + overflow: hidden; + color: var(--text-tertiary); + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); +} + +.workspace-quick-jump__state { + margin: 0; + padding-top: var(--sp-2); + color: var(--text-tertiary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.workspace-sidebar-panel .panel-toolbar-btn { + width: 24px; + height: 24px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-tertiary); +} + +.workspace-sidebar-panel .panel-toolbar-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.file-tree-shell { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.file-tree-shell .file-tree-search, +.file-tree-shell .file-tree-search--desktop { + display: flex; + align-items: center; + gap: var(--gap-default); + margin: var(--space-default) var(--inset-control-inline); + padding-inline: var(--inset-control-inline); + min-height: var(--control-height-md); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: var(--radius-panel); + background: color-mix(in srgb, var(--bg-surface) 90%, var(--bg-page)); +} + +.file-tree-shell .file-tree-search:focus-within, +.file-tree-shell .file-tree-search--desktop:focus-within { + border-color: color-mix(in srgb, var(--accent-blue) 70%, transparent); +} + +.file-tree-shell .file-tree-search-input { + min-height: 30px; + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); +} + +.file-tree-shell .file-tree { + padding: 0 0 6px; +} + +.file-tree-shell .tree-item { + position: relative; + gap: var(--gap-tight); + min-height: 26px; + margin: 0 var(--gap-tight); + padding: 3px var(--inset-control-block) 3px var(--inset-row-inline); + border-radius: var(--radius-panel); + color: var(--text-secondary); + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.file-tree-shell .tree-item:hover { + background: color-mix(in srgb, var(--bg-hover) 92%, transparent); +} + +.file-tree-shell .tree-item.selected { + padding-left: calc(var(--inset-row-inline) - var(--state-focus-ring-width)); + border-left: var(--state-focus-ring-width) solid var(--state-selected-border); + background: var(--state-selected-bg); +} + +.file-tree-shell .tree-item--context-target { + background: color-mix(in srgb, var(--accent-blue) 16%, transparent); +} + +.file-tree-shell .tree-chevron { + width: 14px; + height: 14px; + color: var(--text-tertiary); +} + +.file-tree-shell .tree-icon.file { + color: var(--icon-file-default); +} + +.file-tree-shell .tree-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); +} + +.file-tree-shell .tree-search-labels { + gap: var(--gap-hairline); +} + +.file-tree-shell .tree-search-path, +.file-tree-shell .tree-empty-hint, +.file-tree-shell .tree-loading { + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); } .file-tree-shell .tree-item-actions { @@ -12522,6 +12919,232 @@ textarea.input { opacity: 1; } +.workspace-search-panel__controls { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: calc(var(--sp-2) - var(--gap-compact)) var(--sp-3) var(--sp-2); + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--bg-panel) 82%, transparent); +} + +.workspace-search-panel__input { + min-height: 34px; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--border) 64%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--bg-page) 82%, var(--bg-surface) 18%); + color: var(--text-primary); + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); + box-shadow: none; +} + +.workspace-search-panel__input::placeholder { + color: color-mix(in srgb, var(--text-tertiary) 92%, var(--bg-panel)); +} + +.workspace-search-panel__input:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--accent-blue) 72%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-blue) 44%, transparent); +} + +.workspace-search-panel__summary { + color: var(--text-secondary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.workspace-search-panel__truncate-note { + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); +} + +.workspace-search-panel__results { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + overflow-y: auto; + padding: var(--sp-1) 0 var(--sp-2); +} + +.workspace-search-panel__state { + margin: 0; + padding: var(--sp-4) var(--sp-3); + color: var(--text-tertiary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.workspace-search-panel__state-block { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--gap-default); + padding: var(--sp-4) var(--sp-3); +} + +.workspace-search-panel__group { + display: flex; + flex-direction: column; +} + +.workspace-search-panel__group-header { + display: grid; + grid-template-columns: 14px minmax(0, 1fr) auto; + align-items: start; + gap: 0 var(--sp-2); + width: 100%; + padding: calc(var(--sp-2) - var(--gap-hairline)) var(--sp-3) var(--gap-tight) + calc(var(--sp-3) - var(--gap-compact)); + border: none; + background: transparent; + color: var(--text-secondary); + text-align: left; + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--border) 96%, transparent); + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.workspace-search-panel__group-header:hover { + background: color-mix(in srgb, var(--bg-hover) 88%, transparent); +} + +.workspace-search-panel__group-header:focus-visible { + outline: none; + background: color-mix(in srgb, var(--state-selected-bg) 76%, transparent); + box-shadow: + inset 0 0 0 var(--state-focus-ring-width) + color-mix(in srgb, var(--border-focus) 64%, transparent), + inset 0 -1px 0 color-mix(in srgb, var(--border) 96%, transparent); +} + +.workspace-search-panel__group-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 18px; + color: var(--text-tertiary); +} + +.workspace-search-panel__group-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--gap-compact); +} + +.workspace-search-panel__group-name { + min-width: 0; + overflow: hidden; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); +} + +.workspace-search-panel__group-path { + min-width: 0; + overflow: hidden; + color: var(--text-tertiary); + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); +} + +.workspace-search-panel__group-count { + align-self: start; + padding-top: var(--gap-hairline); + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); +} + +.workspace-search-panel__matches { + display: flex; + flex-direction: column; + padding-bottom: var(--sp-1); +} + +.workspace-search-panel__match { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + align-items: baseline; + gap: var(--sp-2); + width: 100%; + padding: var(--sp-1) var(--sp-3) var(--sp-1) calc(var(--control-height-md) + var(--gap-compact)); + border: none; + background: transparent; + color: var(--text-primary); + text-align: left; + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.workspace-search-panel__match:hover { + background: color-mix(in srgb, var(--bg-hover) 88%, transparent); +} + +.workspace-search-panel__match:focus-visible { + outline: none; + background: color-mix(in srgb, var(--state-selected-bg) 76%, transparent); + box-shadow: inset 0 0 0 var(--state-focus-ring-width) + color-mix(in srgb, var(--border-focus) 64%, transparent); +} + +.workspace-search-panel__line { + color: var(--text-tertiary); + text-align: right; + font-family: var(--font-mono); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); +} + +.workspace-search-panel__preview { + min-width: 0; + overflow: hidden; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--type-body-3-size); + line-height: var(--type-body-3-line-height); + font-weight: var(--type-body-3-weight); +} + +.workspace-search-panel__preview mark { + padding: 0; + border-radius: 2px; + background: color-mix(in srgb, var(--accent-blue) 18%, transparent); + color: inherit; +} + +.workspace-search-panel--mobile { + background: transparent; +} + +.workspace-search-panel--mobile .workspace-search-panel__controls { + padding-top: 0; + background: transparent; +} + .mobile-sheet--files .file-tree-shell--mobile .file-tree-search { margin: 0; border-right: none; @@ -13142,8 +13765,8 @@ textarea.input { position: relative; display: inline-flex; align-items: center; - justify-content: flex-start; - gap: 6px; + justify-content: center; + min-width: 32px; min-height: 32px; padding: 0; border: none; @@ -13156,6 +13779,12 @@ textarea.input { transition: color var(--duration-fast) var(--ease-out); } +.mobile-files-sheet__segment-icon { + display: block; + line-height: 0; + flex-shrink: 0; +} + .mobile-files-sheet__segment:hover { color: var(--text-secondary); } @@ -13176,35 +13805,6 @@ textarea.input { background: color-mix(in srgb, var(--accent-blue) 90%, white 10%); } -.mobile-files-sheet__tab-actions { - display: inline-flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - margin-left: auto; -} - -.mobile-files-sheet__tab-action { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - border: none; - border-radius: 6px; - background: transparent; - color: var(--text-tertiary); - transition: - background-color var(--duration-fast) var(--ease-out), - color var(--duration-fast) var(--ease-out); -} - -.mobile-files-sheet__tab-action:hover { - background: color-mix(in srgb, var(--bg-hover) 82%, var(--accent-blue) 10%); - color: var(--text-primary); -} - .mobile-files-sheet__content, .mobile-files-sheet__detail { display: flex; @@ -13220,6 +13820,13 @@ textarea.input { flex: 1; } +.mobile-explorer-panel { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + .workspace-page > .workspace-status-bar { width: 100%; } diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index c774e503..0358c3f0 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -893,9 +893,10 @@ describe("components.css theme-sensitive surfaces", () => { const statusBar = getLastRuleBlock(".workspace-status-bar"); const agentPanes = getLastRuleBlock(".workspace-main-stage > .agent-panes"); const bottomPanel = getLastRuleBlock(".workspace-bottom-panel"); - const sidebarTabs = getLastRuleBlock(".workspace-sidebar-panel__tabs"); - const sidebarTab = getLastRuleBlock(".workspace-sidebar-panel__tab"); - const sidebarTabActiveAfter = getLastRuleBlock(".workspace-sidebar-panel__tab.active::after"); + const activityBar = getLastRuleBlock(".workspace-activity-bar"); + const activityBarButton = getLastRuleBlock(".workspace-activity-bar__button"); + const activityBarButtonHover = getLastRuleBlock(".workspace-activity-bar__button:hover"); + const activityBarButtonActive = getLastRuleBlock(".workspace-activity-bar__button--active"); const sidebarActions = getLastRuleBlock(".workspace-sidebar-panel__actions"); const verticalDividerRules = getRuleBlocksFrom(stylesheet, ".split-divider-v").join("\n"); const horizontalDividerRules = getRuleBlocksFrom(stylesheet, ".split-divider-h").join("\n"); @@ -989,9 +990,16 @@ describe("components.css theme-sensitive surfaces", () => { ); expect(resolvingStrongLine).toContain("border: 1px solid var(--state-info-border)"); expect(resolvingStrongLine).toContain("background: var(--state-info-bg)"); - expect(sidebarTabs).toContain("gap: var(--gap-default)"); - expect(sidebarTab).toContain("gap: var(--gap-control)"); - expect(sidebarTabActiveAfter).toContain("border-radius: var(--radius-pill)"); + expect(activityBar).toContain("border-right: 1px solid var(--border)"); + expect(activityBar).toContain( + "background: color-mix(in srgb, var(--bg-panel) 88%, var(--bg-page))" + ); + expect(activityBarButton).toContain("border-radius: var(--radius-lg)"); + expect(activityBarButton).toContain("background: transparent"); + expect(activityBarButtonHover).toContain("background: var(--bg-hover)"); + expect(activityBarButtonActive).toContain( + "background: color-mix(in srgb, var(--accent-blue) 14%, transparent)" + ); expect(sidebarActions).toContain("gap: var(--gap-control)"); expect(verticalDividerRules).toContain("width: 10px"); expect(verticalDividerRules).not.toContain("width: 8px"); @@ -2138,11 +2146,17 @@ describe("components.css theme-sensitive surfaces", () => { const mobileFilesGitSurface = getLastRuleBlock(".mobile-sheet--files .git-panel--mobile"); const mobileFilesSegmented = getLastRuleBlock(".mobile-files-sheet__segmented"); const mobileFilesSegment = getLastRuleBlock(".mobile-files-sheet__segment"); + const mobileFilesSegmentIcon = getLastRuleBlock(".mobile-files-sheet__segment-icon"); const mobileFilesSegmentActive = getLastRuleBlock(".mobile-files-sheet__segment.active"); const mobileFilesSegmentIndicator = getLastRuleBlock( ".mobile-files-sheet__segment.active::after" ); - const mobileFilesTabAction = getLastRuleBlock(".mobile-files-sheet__tab-action"); + const workspaceSectionHeader = getLastRuleBlock(".workspace-sidebar-section__header"); + const workspaceSectionActions = getLastRuleBlock(".workspace-sidebar-section__actions"); + const mobileExplorerPanel = getLastRuleBlock(".mobile-explorer-panel"); + const mobileQuickJumpSearch = getLastRuleBlock(".workspace-quick-jump__search"); + const mobileQuickJumpItem = getLastRuleBlock(".workspace-quick-jump__item"); + const mobileSearchPanel = getLastRuleBlock(".workspace-search-panel--mobile"); const mobileFileSearch = getLastRuleBlock( ".mobile-sheet--files .file-tree-shell--mobile .file-tree-search" ); @@ -2175,13 +2189,21 @@ describe("components.css theme-sensitive surfaces", () => { expect(mobileFilesSegmented).toContain("border-radius: 0"); expect(mobileFilesSegmented).not.toContain("linear-gradient("); expect(mobileFilesSegmented).toContain("box-shadow: none"); + expect(mobileExplorerPanel).toContain("display: flex"); + expect(mobileExplorerPanel).toContain("flex-direction: column"); expect(mobileFilesSegment).toContain("padding: 0"); + expect(mobileFilesSegment).toContain("justify-content: center"); + expect(mobileFilesSegment).toContain("min-width: 32px"); expect(mobileFilesSegment).toContain("font-weight: var(--type-body-6-weight)"); + expect(mobileFilesSegmentIcon).toContain("display: block"); expect(mobileFilesSegmentActive).toContain("background: transparent"); expect(mobileFilesSegmentIndicator).toContain("height: 1.5px"); - expect(mobileFilesTabAction).toContain("border: none"); - expect(mobileFilesTabAction).toContain("border-radius: 6px"); - expect(mobileFilesTabAction).toContain("background: transparent"); + expect(workspaceSectionHeader).toContain("justify-content: space-between"); + expect(workspaceSectionHeader).toContain("margin-bottom: var(--sp-2)"); + expect(workspaceSectionActions).toContain("margin-left: auto"); + expect(mobileQuickJumpSearch).toContain("border: 1px solid"); + expect(mobileQuickJumpItem).toContain("grid-template-columns: minmax(0, 1fr)"); + expect(mobileSearchPanel).toContain("background: transparent"); expect(mobileFilesSurface).toContain( "border: 1px solid color-mix(in srgb, var(--border) 80%, transparent)" ); @@ -2609,6 +2631,81 @@ describe("components.css theme-sensitive surfaces", () => { expect(rowActionsDesktop).toContain("opacity: 0"); }); + it("keeps workspace search and quick open on compact editor-search chrome", () => { + const openEditorsHeader = getLastRuleBlock(".workspace-open-editors__header"); + const openEditorsHeaderMain = getLastRuleBlock(".workspace-open-editors__header-main"); + const openEditorsTitle = getLastRuleBlock(".workspace-open-editors__title"); + const openEditorsTitleText = getLastRuleBlock(".workspace-open-editors__title-text"); + const openEditorsCloseAll = getLastRuleBlock(".workspace-open-editors__close-all"); + const openEditorsRow = getLastRuleBlock(".workspace-open-editors__row"); + const searchControls = getLastRuleBlock(".workspace-search-panel__controls"); + const searchInput = getLastRuleBlock(".workspace-search-panel__input"); + const openEditorsItem = getLastRuleBlock(".workspace-open-editors__item"); + const openEditorsItemLabel = getLastRuleBlock(".workspace-open-editors__item-label"); + const searchGroupHeader = getLastRuleBlock(".workspace-search-panel__group-header"); + const searchGroupPath = getLastRuleBlock(".workspace-search-panel__group-path"); + const searchMatch = getLastRuleBlock(".workspace-search-panel__match"); + const searchLine = getLastRuleBlock(".workspace-search-panel__line"); + const quickOpen = getLastRuleBlock(".quick-open"); + const quickOpenSearch = getLastRuleBlock(".quick-open__search"); + const quickOpenItem = getLastRuleBlock(".quick-open__item"); + const quickOpenItemActive = getLastRuleBlock(".quick-open__item--active"); + const quickOpenItemSelected = getLastRuleBlock('.quick-open__item[aria-selected="true"]'); + const quickOpenPrimary = getLastRuleBlock(".quick-open__primary"); + const quickOpenSecondary = getLastRuleBlock(".quick-open__secondary"); + const quickOpenSelectedSecondary = getLastRuleBlock( + '.quick-open__item[aria-selected="true"] .quick-open__secondary' + ); + + expect(openEditorsHeader).toContain("display: flex"); + expect(openEditorsHeaderMain).toContain("flex: 1 1 auto"); + expect(openEditorsHeaderMain).toContain("min-width: 0"); + expect(openEditorsTitle).toContain("justify-content: flex-start"); + expect(openEditorsTitle).toContain("min-width: 0"); + expect(openEditorsTitleText).toContain("text-overflow: ellipsis"); + expect(openEditorsTitleText).toContain("white-space: nowrap"); + expect(openEditorsCloseAll).toContain("margin-left: auto"); + expect(openEditorsCloseAll).toContain("background: transparent"); + expect(openEditorsCloseAll).toContain("color: var(--text-secondary)"); + expect(openEditorsRow).toContain("grid-template-columns: minmax(0, 1fr) auto"); + expect(searchControls).toContain("border-bottom: 1px solid color-mix("); + expect(searchControls).toContain("background: color-mix("); + expect(searchInput).toContain("min-height: 34px"); + expect(searchInput).toContain("border-radius: 4px"); + expect(searchInput).toContain("box-shadow: none"); + expect(openEditorsItem).toContain("overflow: hidden"); + expect(openEditorsItem).toContain("text-overflow: ellipsis"); + expect(openEditorsItem).toContain("white-space: nowrap"); + expect(openEditorsItemLabel).toContain("text-overflow: ellipsis"); + expect(openEditorsItemLabel).toContain("white-space: nowrap"); + expect(searchGroupHeader).toContain("grid-template-columns: 14px minmax(0, 1fr) auto"); + expect(searchGroupHeader).toContain("box-shadow: inset 0 -1px 0 color-mix("); + expect(searchGroupPath).toContain("font-family: var(--font-mono)"); + expect(searchGroupPath).toContain("font-size: var(--type-body-6-size)"); + expect(searchMatch).toContain("grid-template-columns: 40px minmax(0, 1fr)"); + expect(searchLine).toContain("text-align: right"); + + expect(quickOpen).toContain("border: 1px solid var(--surface-overlay-border)"); + expect(quickOpen).toContain("border-radius: var(--radius-overlay)"); + expect(quickOpen).toContain("background: var(--surface-overlay-bg)"); + expect(quickOpenSearch).toContain("border-bottom: 1px solid color-mix("); + expect(quickOpenSearch).toContain("background: color-mix("); + expect(quickOpenItem).toContain("gap: var(--gap-compact)"); + expect(quickOpenItem).toContain("box-shadow: inset 0 -1px 0 color-mix("); + expect(quickOpenItemActive).toContain( + "background: color-mix(in srgb, var(--accent-blue) 12%, var(--bg-panel))" + ); + expect(quickOpenItemSelected).toContain( + "background: color-mix(in srgb, var(--accent-blue) 12%, var(--bg-panel))" + ); + expect(quickOpenPrimary).toContain("font-size: var(--type-body-3-size)"); + expect(quickOpenSecondary).toContain("font-size: var(--type-body-5-size)"); + expect(quickOpenSecondary).toContain("color: var(--text-secondary)"); + expect(quickOpenSelectedSecondary).toContain( + "color: color-mix(in srgb, var(--accent-blue) 52%, var(--text-secondary))" + ); + }); + it("keeps the desktop git panel and command palette on tighter tool-surface chrome", () => { const gitScroll = getLastRuleBlock(".git-panel-scroll"); const gitCommitBlock = getLastRuleBlock(".git-commit-block"); diff --git a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx index ec305fdd..c6323492 100644 --- a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx +++ b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx @@ -1284,7 +1284,7 @@ export function createShowcaseScenes(): UiPreviewSceneDefinition[] { }), render: () => ( } />