From 6c8fc261cc6fbc7c2ea495c51518b3bc04d3cbd3 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:21:26 +0800 Subject: [PATCH 01/26] docs: tighten desktop file tree drag-to-terminal plan --- ...5-24-desktop-file-tree-drag-to-terminal.md | 200 ++++++++++++++++-- 1 file changed, 186 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md b/docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md index c89d94a3..10395bb4 100644 --- a/docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md +++ b/docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md @@ -4,7 +4,7 @@ **Goal:** Allow desktop users to drag file-tree rows into the active terminal to insert shell-quoted workspace-relative paths without uploading anything. -**Architecture:** Define one shared drag payload contract in `packages/web/src/lib`, then teach the terminal drop hook to recognize that payload before the existing file-upload path, and finally make desktop `FileTreeNode` rows emit the payload on `dragstart`. Keep the change web-only, leave search/mobile/open-editors untouched, and reuse the existing `quoteShellSingle()` plus `sendTextToTerminal()` path so terminal input stays consistent. +**Architecture:** Define one shared drag payload contract in `packages/web/src/lib`, then teach the terminal drop hook to recognize that payload before the existing file-upload path, and finally make desktop `FileTreeNode` rows emit the payload on `dragstart`. Keep the change web-only, leave search/mobile/open-editors untouched, and reuse the existing `quoteShellSingle()` plus terminal insertion sequencing so internal path drops stay ordered relative to uploads without incorrectly entering the upload-busy state. **Tech Stack:** TypeScript, React 19, Vitest, Testing Library, Jotai, DOM Drag and Drop APIs @@ -303,6 +303,57 @@ it("prevents default for internal workspace drags and inserts a quoted relative expect(result.current.busy).toBe(false); }); +it("keeps internal path insertion ordered behind earlier uploads", async () => { + const store = createStore(); + let resolveUpload: ((value: Response) => void) | undefined; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve as (value: Response) => void; + }) + ) + ); + + renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + await act(async () => { + fireDrop(container, [makeFile("upload.txt")]); + fireWorkspacePathDrop(container, { + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + await Promise.resolve(); + }); + + expect(sendInput).not.toHaveBeenCalled(); + + await act(async () => { + resolveUpload?.({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + files: [{ path: "/abs/upload.txt", originalName: "upload.txt", size: 1 }], + }), + } as Response); + await flushAsyncWork(); + }); + + expect(sendInput.mock.calls).toEqual([["'/abs/upload.txt' "], ["'src/app.tsx' "]]); +}); + it("rejects internal workspace drops from another workspace", async () => { const store = createStore(); const { result } = renderHook( @@ -334,6 +385,45 @@ it("rejects internal workspace drops from another workspace", async () => { ); expect(result.current.busy).toBe(false); }); + +it("toasts when the internal workspace payload is invalid", async () => { + const store = createStore(); + + renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + await act(async () => { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { + value: { + files: [], + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + items: [], + getData: (type: string) => + type === WORKSPACE_PATH_DRAG_MIME ? "{bad json" : "src/app.tsx", + }, + }); + container.dispatchEvent(event); + await flushAsyncWork(); + }); + + expect(sendInput).not.toHaveBeenCalled(); + expect(store.get(toastsAtom)).toContainEqual( + expect.objectContaining({ + kind: "error", + title: "Drop failed", + body: "Could not read the dragged workspace path.", + }) + ); +}); ``` - [ ] **Step 2: Run the terminal drop-hook tests to verify they fail** @@ -346,7 +436,7 @@ pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/uplo Expected: FAIL on the new workspace-path drag tests because the hook still ignores the custom MIME payload, so `defaultPrevented` stays `false` and `sendTextToTerminal()` is never called. -- [ ] **Step 3: Implement internal workspace-path drop parsing without touching upload flow** +- [ ] **Step 3: Implement internal workspace-path drop parsing while preserving terminal insertion order** Update the imports and helpers in `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts`: @@ -357,6 +447,53 @@ import { } from "../../../lib/workspace-path-drag"; ``` +Before wiring the new callback, refactor `runSequence()` so uploads and internal path drops share the same output queue while only uploads toggle `busy` and use the default upload toast: + +```ts +interface RunSequenceOptions { + trackBusy?: boolean; + onError?: (error: unknown) => void; +} + + const runSequence = useCallback( + async (task: () => Promise, options?: RunSequenceOptions) => { + const { trackBusy = true, onError } = options ?? {}; + const sequence = nextSequenceRef.current; + nextSequenceRef.current += 1; + + if (trackBusy) { + inFlightCountRef.current += 1; + setBusy(true); + } + + try { + const text = await task(); + await settleSequence(sequence, text); + } catch (error) { + await settleSequence(sequence, null); + if (onError) { + onError(error); + return; + } + + const code = error instanceof UploadError ? error.code : "unknown"; + pushToast({ + kind: "error", + title: "Upload failed", + body: `Could not upload file(s): ${code}`, + duration: 5_000, + }); + } finally { + if (trackBusy) { + inFlightCountRef.current = Math.max(0, inFlightCountRef.current - 1); + setBusy(inFlightCountRef.current > 0); + } + } + }, + [pushToast, settleSequence] + ); +``` + Add this callback next to the existing `handleText()` callback: ```ts @@ -383,19 +520,20 @@ Add this callback next to the existing `handleText()` callback: return; } - try { - await sendTextToTerminal(`${quoteShellSingle(payload.path)} `); - } catch (error) { - console.debug("Workspace path drop failed:", error); - pushToast({ - kind: "error", - title: "Drop failed", - body: "Could not insert the dragged path into the terminal.", - duration: 3_000, - }); - } + await runSequence(async () => `${quoteShellSingle(payload.path)} `, { + trackBusy: false, + onError: (error) => { + console.debug("Workspace path drop failed:", error); + pushToast({ + kind: "error", + title: "Drop failed", + body: "Could not insert the dragged path into the terminal.", + duration: 3_000, + }); + }, + }); }, - [pushToast, sendTextToTerminal, workspaceId] + [pushToast, runSequence, workspaceId] ); ``` @@ -529,6 +667,40 @@ it("marks desktop tree rows draggable and writes workspace path drag data on dra expect(values.get("text/plain")).toBe("README.md"); }); +it("writes workspace drag data for nested desktop nodes too", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [".", [{ path: "src", name: "src", kind: "dir", children: [] }]], + ["src", [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }]], + ]) + ); + store.set(expandedDirsAtomFamily("ws-test"), new Set(["src"])); + + render( + + + + ); + + const nestedRow = screen.getByText("app.tsx").closest(".tree-item"); + expect(nestedRow).toHaveAttribute("draggable", "true"); + + const { dataTransfer, values } = createDragDataTransfer(); + fireEvent.dragStart(nestedRow!, { dataTransfer }); + + expect(values.get(WORKSPACE_PATH_DRAG_MIME)).toBe( + JSON.stringify({ + workspaceId: "ws-test", + path: "src/app.tsx", + kind: "file", + }) + ); + expect(values.get("text/plain")).toBe("src/app.tsx"); +}); + it("keeps mobile tree rows non-draggable", () => { const store = createStore(); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); From 5e2b061dd2f9f279a6134a5c8b888ad091fbc1be Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:25:49 +0800 Subject: [PATCH 02/26] feat: add workspace path drag payload helper --- .../web/src/lib/workspace-path-drag.test.ts | 80 +++++++++++++++++++ packages/web/src/lib/workspace-path-drag.ts | 54 +++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 packages/web/src/lib/workspace-path-drag.test.ts create mode 100644 packages/web/src/lib/workspace-path-drag.ts diff --git a/packages/web/src/lib/workspace-path-drag.test.ts b/packages/web/src/lib/workspace-path-drag.test.ts new file mode 100644 index 00000000..eef19b4a --- /dev/null +++ b/packages/web/src/lib/workspace-path-drag.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { + getWorkspacePathDragPayload, + hasWorkspacePathDragType, + setWorkspacePathDragData, + WORKSPACE_PATH_DRAG_MIME, +} from "./workspace-path-drag"; + +describe("workspace-path-drag", () => { + it("writes the custom mime payload and plain text path", () => { + const setData = vi.fn(); + const dataTransfer = { + effectAllowed: "none", + setData, + } as unknown as DataTransfer; + + setWorkspacePathDragData(dataTransfer, { + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + + expect(dataTransfer.effectAllowed).toBe("copy"); + expect(setData).toHaveBeenNthCalledWith( + 1, + WORKSPACE_PATH_DRAG_MIME, + JSON.stringify({ + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }) + ); + expect(setData).toHaveBeenNthCalledWith(2, "text/plain", "src/app.tsx"); + }); + + it("reads a valid payload only when the custom mime type is present", () => { + const dataTransfer = { + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + getData: vi.fn((type: string) => + type === WORKSPACE_PATH_DRAG_MIME + ? JSON.stringify({ + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }) + : "src/app.tsx" + ), + } as unknown as DataTransfer; + + expect(hasWorkspacePathDragType(dataTransfer)).toBe(true); + expect(getWorkspacePathDragPayload(dataTransfer)).toEqual({ + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + }); + + it("returns null for invalid payloads", () => { + expect( + getWorkspacePathDragPayload({ + types: [WORKSPACE_PATH_DRAG_MIME], + getData: () => "{bad json", + } as unknown as DataTransfer) + ).toBeNull(); + + expect( + getWorkspacePathDragPayload({ + types: [WORKSPACE_PATH_DRAG_MIME], + getData: () => JSON.stringify({ workspaceId: "ws-1", path: "", kind: "file" }), + } as unknown as DataTransfer) + ).toBeNull(); + + expect( + getWorkspacePathDragPayload({ + types: ["text/plain"], + getData: () => "src/app.tsx", + } as unknown as DataTransfer) + ).toBeNull(); + }); +}); diff --git a/packages/web/src/lib/workspace-path-drag.ts b/packages/web/src/lib/workspace-path-drag.ts new file mode 100644 index 00000000..6560a721 --- /dev/null +++ b/packages/web/src/lib/workspace-path-drag.ts @@ -0,0 +1,54 @@ +export const WORKSPACE_PATH_DRAG_MIME = "application/x-coder-studio-workspace-path"; + +export interface WorkspacePathDragPayload { + workspaceId: string; + path: string; + kind: "file" | "dir"; +} + +function isWorkspacePathDragPayload(value: unknown): value is WorkspacePathDragPayload { + if (!value || typeof value !== "object") { + return false; + } + + const payload = value as Record; + + return ( + typeof payload.workspaceId === "string" && + payload.workspaceId.length > 0 && + typeof payload.path === "string" && + payload.path.length > 0 && + (payload.kind === "file" || payload.kind === "dir") + ); +} + +export function hasWorkspacePathDragType( + dataTransfer: Pick | null | undefined +): boolean { + return Array.from(dataTransfer?.types ?? []).includes(WORKSPACE_PATH_DRAG_MIME); +} + +export function setWorkspacePathDragData( + dataTransfer: Pick, + payload: WorkspacePathDragPayload +): void { + dataTransfer.effectAllowed = "copy"; + dataTransfer.setData(WORKSPACE_PATH_DRAG_MIME, JSON.stringify(payload)); + dataTransfer.setData("text/plain", payload.path); +} + +export function getWorkspacePathDragPayload( + dataTransfer: Pick | null | undefined +): WorkspacePathDragPayload | null { + if (!hasWorkspacePathDragType(dataTransfer)) { + return null; + } + + try { + const raw = dataTransfer?.getData(WORKSPACE_PATH_DRAG_MIME) ?? ""; + const parsed: unknown = JSON.parse(raw); + return isWorkspacePathDragPayload(parsed) ? parsed : null; + } catch { + return null; + } +} From d99a4b1dbcc5a5d42ddcf7521ae29370eda6df31 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:32:07 +0800 Subject: [PATCH 03/26] feat: support workspace path drops in terminal --- .../uploads/use-paste-drop-upload.test.tsx | 194 ++++++++++++++++++ .../uploads/use-paste-drop-upload.ts | 87 +++++++- 2 files changed, 273 insertions(+), 8 deletions(-) diff --git a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx index bf739597..9cd24c07 100644 --- a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx +++ b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WORKSPACE_PATH_DRAG_MIME } from "../../../lib/workspace-path-drag"; import { toastsAtom } from "../../notifications/atoms"; import { usePasteDropUpload } from "./use-paste-drop-upload.js"; @@ -44,6 +45,42 @@ function fireDrop(target: HTMLElement, files: File[]) { return event; } +function fireWorkspacePathDragOver( + target: HTMLElement, + payload: { workspaceId: string; path: string; kind: "file" | "dir" } +) { + const event = new Event("dragover", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { + value: { + files: [], + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + items: [], + getData: (type: string) => + type === WORKSPACE_PATH_DRAG_MIME ? JSON.stringify(payload) : payload.path, + }, + }); + target.dispatchEvent(event); + return event; +} + +function fireWorkspacePathDrop( + target: HTMLElement, + payload: { workspaceId: string; path: string; kind: "file" | "dir" } +) { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { + value: { + files: [], + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + items: [], + getData: (type: string) => + type === WORKSPACE_PATH_DRAG_MIME ? JSON.stringify(payload) : payload.path, + }, + }); + target.dispatchEvent(event); + return event; +} + function fireTextDrop(target: HTMLElement) { const event = new Event("drop", { bubbles: true, cancelable: true }); Object.defineProperty(event, "dataTransfer", { @@ -264,6 +301,163 @@ describe("usePasteDropUpload", () => { expect(sendInput).not.toHaveBeenCalled(); }); + it("prevents default for internal workspace drags and inserts a quoted relative path", async () => { + const store = createStore(); + const { result } = renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + const dragOver = fireWorkspacePathDragOver(container, { + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + expect(dragOver.defaultPrevented).toBe(true); + + await act(async () => { + const drop = fireWorkspacePathDrop(container, { + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + expect(drop.defaultPrevented).toBe(true); + await flushAsyncWork(); + }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(sendInput).toHaveBeenCalledWith("'src/app.tsx' "); + expect(result.current.busy).toBe(false); + }); + + it("keeps internal path insertion ordered behind earlier uploads", async () => { + const store = createStore(); + let resolveUpload: ((value: Response) => void) | undefined; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve as (value: Response) => void; + }) + ) + ); + + renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + await act(async () => { + fireDrop(container, [makeFile("upload.txt")]); + fireWorkspacePathDrop(container, { + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + await Promise.resolve(); + }); + + expect(sendInput).not.toHaveBeenCalled(); + + await act(async () => { + resolveUpload?.({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + files: [{ path: "/abs/upload.txt", originalName: "upload.txt", size: 1 }], + }), + } as Response); + await flushAsyncWork(); + }); + + expect(sendInput.mock.calls).toEqual([["'/abs/upload.txt' "], ["'src/app.tsx' "]]); + }); + + it("rejects internal workspace drops from another workspace", async () => { + const store = createStore(); + const { result } = renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + await act(async () => { + fireWorkspacePathDrop(container, { + workspaceId: "ws-2", + path: "src/app.tsx", + kind: "file", + }); + await flushAsyncWork(); + }); + + expect(sendInput).not.toHaveBeenCalled(); + expect(store.get(toastsAtom)).toContainEqual( + expect.objectContaining({ + kind: "error", + title: "Drop failed", + }) + ); + expect(result.current.busy).toBe(false); + }); + + it("toasts when the internal workspace payload is invalid", async () => { + const store = createStore(); + + renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + await act(async () => { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { + value: { + files: [], + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + items: [], + getData: (type: string) => + type === WORKSPACE_PATH_DRAG_MIME ? "{bad json" : "src/app.tsx", + }, + }); + container.dispatchEvent(event); + await flushAsyncWork(); + }); + + expect(sendInput).not.toHaveBeenCalled(); + expect(store.get(toastsAtom)).toContainEqual( + expect.objectContaining({ + kind: "error", + title: "Drop failed", + body: "Could not read the dragged workspace path.", + }) + ); + }); + it("keeps busy true until overlapping uploads both finish", async () => { const store = createStore(); let resolveFirst: ((value: Response) => void) | undefined; diff --git a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts index 25b8dfe9..4e946b10 100644 --- a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts +++ b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts @@ -1,5 +1,9 @@ import { useSetAtom } from "jotai"; import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { + getWorkspacePathDragPayload, + hasWorkspacePathDragType, +} from "../../../lib/workspace-path-drag"; import { pushToastAtom } from "../../notifications/atoms"; import { quoteShellSingle } from "./quote-shell.js"; import { UploadError, uploadFiles } from "./upload-files.js"; @@ -28,6 +32,11 @@ export interface PasteDropUploadActions { busy: boolean; } +interface RunSequenceOptions { + trackBusy?: boolean; + onError?: (error: unknown) => void; +} + export function usePasteDropUpload(opts: Options): PasteDropUploadActions { const { containerRef, workspaceId, sendTextToTerminal, enabled } = opts; const [busy, setBusy] = useState(false); @@ -59,17 +68,26 @@ export function usePasteDropUpload(opts: Options): PasteDropUploadActions { ); const runSequence = useCallback( - async (task: () => Promise) => { + async (task: () => Promise, options?: RunSequenceOptions) => { + const { trackBusy = true, onError } = options ?? {}; const sequence = nextSequenceRef.current; nextSequenceRef.current += 1; - inFlightCountRef.current += 1; - setBusy(true); + + if (trackBusy) { + inFlightCountRef.current += 1; + setBusy(true); + } try { const text = await task(); await settleSequence(sequence, text); } catch (error) { await settleSequence(sequence, null); + if (onError) { + onError(error); + return; + } + const code = error instanceof UploadError ? error.code : "unknown"; pushToast({ kind: "error", @@ -78,8 +96,10 @@ export function usePasteDropUpload(opts: Options): PasteDropUploadActions { duration: 5_000, }); } finally { - inFlightCountRef.current = Math.max(0, inFlightCountRef.current - 1); - setBusy(inFlightCountRef.current > 0); + if (trackBusy) { + inFlightCountRef.current = Math.max(0, inFlightCountRef.current - 1); + setBusy(inFlightCountRef.current > 0); + } } }, [pushToast, settleSequence] @@ -114,6 +134,45 @@ export function usePasteDropUpload(opts: Options): PasteDropUploadActions { [runSequence] ); + const handleWorkspacePathDrop = useCallback( + async (dataTransfer: DataTransfer | null | undefined) => { + const payload = getWorkspacePathDragPayload(dataTransfer); + if (!payload) { + pushToast({ + kind: "error", + title: "Drop failed", + body: "Could not read the dragged workspace path.", + duration: 3_000, + }); + return; + } + + if (payload.workspaceId !== workspaceId) { + pushToast({ + kind: "error", + title: "Drop failed", + body: "You can only drop paths from the current workspace.", + duration: 3_000, + }); + return; + } + + await runSequence(async () => `${quoteShellSingle(payload.path)} `, { + trackBusy: false, + onError: (error) => { + console.debug("Workspace path drop failed:", error); + pushToast({ + kind: "error", + title: "Drop failed", + body: "Could not insert the dragged path into the terminal.", + duration: 3_000, + }); + }, + }); + }, + [pushToast, runSequence, workspaceId] + ); + const handleClipboardPaste = useCallback(async () => { if (!enabled) { return; @@ -199,16 +258,28 @@ export function usePasteDropUpload(opts: Options): PasteDropUploadActions { const onDrop = (event: DragEvent) => { const files = event.dataTransfer?.files; - if (!files || files.length === 0) { + if (files && files.length > 0) { + event.preventDefault(); + event.stopPropagation(); + void handleFiles(Array.from(files)); + return; + } + + if (!hasWorkspacePathDragType(event.dataTransfer)) { return; } event.preventDefault(); event.stopPropagation(); - void handleFiles(Array.from(files)); + void handleWorkspacePathDrop(event.dataTransfer); }; const onDragOver = (event: DragEvent) => { + if (hasWorkspacePathDragType(event.dataTransfer)) { + event.preventDefault(); + return; + } + const types = Array.from(event.dataTransfer?.types ?? []); if (types.includes("Files")) { event.preventDefault(); @@ -224,7 +295,7 @@ export function usePasteDropUpload(opts: Options): PasteDropUploadActions { element.removeEventListener("drop", onDrop, { capture: true }); element.removeEventListener("dragover", onDragOver, { capture: true }); }; - }, [containerRef, enabled, handleFiles]); + }, [containerRef, enabled, handleFiles, handleWorkspacePathDrop]); return { busy, From e768e011b83567faa20c71308a2806b228d5bd18 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:35:49 +0800 Subject: [PATCH 04/26] fix: avoid upload busy state for terminal text inserts --- .../uploads/use-paste-drop-upload.test.tsx | 43 +++++++++++++++++++ .../uploads/use-paste-drop-upload.ts | 4 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx index 9cd24c07..9a9e381f 100644 --- a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx +++ b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx @@ -263,6 +263,49 @@ describe("usePasteDropUpload", () => { expect(sendInput).toHaveBeenCalledWith("ls -la"); }); + it("keeps plain text insertion out of upload busy handling while pending", async () => { + const store = createStore(); + const clipboardRead = vi.fn().mockResolvedValue([]); + const clipboardReadText = vi.fn().mockResolvedValue("ls -la"); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + read: clipboardRead, + readText: clipboardReadText, + } satisfies Pick & { read: () => Promise }, + }); + let resolveSend: (() => void) | undefined; + sendInput.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSend = resolve; + }) + ); + + const { result } = renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + const pastePromise = result.current.handleClipboardPaste(); + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.busy).toBe(false); + + await act(async () => { + resolveSend?.(); + await pastePromise; + }); + }); + it("uploads files passed directly to the explicit file handler", async () => { const store = createStore(); const { result } = renderHook( diff --git a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts index 4e946b10..8d937429 100644 --- a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts +++ b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts @@ -129,7 +129,9 @@ export function usePasteDropUpload(opts: Options): PasteDropUploadActions { return; } - await runSequence(async () => text); + await runSequence(async () => text, { + trackBusy: false, + }); }, [runSequence] ); From 0def44ca4ce7b1186fe55e236287a6c94710e135 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:36:53 +0800 Subject: [PATCH 05/26] feat(web): forward xterm binary mouse input --- .../__tests__/xterm-host.test.tsx | 72 +++++++++++++++++++ .../views/shared/xterm-host.tsx | 68 ++++++++++++++---- 2 files changed, 128 insertions(+), 12 deletions(-) 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 201b08f4..2b47e201 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 @@ -277,6 +277,7 @@ function stubRowsGeometry( const mockTerminal = { open: vi.fn(), onData: vi.fn(() => vi.fn()), // Return dispose function + onBinary: vi.fn(() => vi.fn()), onResize: vi.fn(() => vi.fn()), onRender: vi.fn(() => vi.fn()), onSelectionChange: vi.fn(() => vi.fn()), @@ -3252,6 +3253,77 @@ describe("XtermHost", () => { global.cancelAnimationFrame = originalCancelAnimationFrame; }); + it("dispatches binary terminal input bytes without UTF-8 re-encoding", async () => { + const store = createStore(); + const sendTerminalInput = vi.fn().mockResolvedValue(undefined); + + store.set(wsClientAtom, { + sendTerminalInput, + subscribe: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + const onBinaryCallback = mockTerminal.onBinary.mock.calls[0]?.[0]; + expect(onBinaryCallback).toBeTypeOf("function"); + + await onBinaryCallback?.("\x1b[M\u00ffA"); + + expect(sendTerminalInput).toHaveBeenCalledWith( + "binary-terminal", + Uint8Array.from([0x1b, 0x5b, 0x4d, 0xff, 0x41]), + "control", + undefined + ); + }); + + it("does not send binary terminal input when rendered in read-only mode", async () => { + const store = createStore(); + const sendTerminalInput = vi.fn().mockResolvedValue(undefined); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + 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, { + sendTerminalInput, + subscribe: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + const onBinaryCallback = mockTerminal.onBinary.mock.calls[0]?.[0]; + expect(onBinaryCallback).toBeTypeOf("function"); + + await onBinaryCallback?.("\x1b[M !!"); + + await act(async () => { + const callback = rafCallbacks.shift(); + callback?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(sendTerminalInput).not.toHaveBeenCalled(); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + }); + it("encodes Chinese terminal input as UTF-8 bytes before dispatching", async () => { const store = createStore(); const sendTerminalInput = vi.fn().mockResolvedValue(undefined); 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 a889b41f..78860ef7 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 @@ -189,6 +189,16 @@ function consumeTerminalInputDraft( return { nextDraft, submittedText }; } +function binaryStringToBytes(data: string): Uint8Array { + const bytes = new Uint8Array(data.length); + + for (let index = 0; index < data.length; index += 1) { + bytes[index] = data.charCodeAt(index) & 0xff; + } + + return bytes; +} + function getTouchAt( list: TouchList | ArrayLike | undefined | null, index: number @@ -689,6 +699,7 @@ export function XtermHost({ // Latest copies of callback identities used inside the mount effect, exposed // via refs so the effect's cleanup/re-creation is not tied to their churn. const handleInputRef = useRef<(data: string) => void | Promise>(() => {}); + const handleBinaryInputRef = useRef<(data: string) => void | Promise>(() => {}); const handleResizeRef = useRef<(size: { cols: number; rows: number }) => void | Promise>( () => {} ); @@ -1256,6 +1267,22 @@ export function XtermHost({ } }, [pushCopyOnSelectFailureToast, terminalPreferences.copyOnSelect, viewport]); + const dispatchTerminalInput = useCallback( + async (bytes: Uint8Array, activity: TerminalInputActivity, submittedText?: string) => { + if (!interactiveRef.current) { + return; + } + + if (!wsClient) { + console.error("Cannot send terminal input: WebSocket not connected"); + return; + } + + await wsClient.sendTerminalInput(terminalId, bytes, activity, submittedText); + }, + [terminalId, wsClient] + ); + /** * Handle user input - dispatch to server */ @@ -1268,15 +1295,6 @@ export function XtermHost({ return; } - if (!interactiveRef.current) { - return; - } - - if (!wsClient) { - console.error("Cannot send terminal input: WebSocket not connected"); - return; - } - const inputRevision = inputRevisionRef.current + 1; inputRevisionRef.current = inputRevision; const previousCtrlMode = ctrlModeRef.current; @@ -1301,8 +1319,7 @@ export function XtermHost({ }); inputDraftRef.current = nextDraft; - await wsClient.sendTerminalInput( - terminalId, + await dispatchTerminalInput( terminalInputEncoder.encode(normalized.data), activity, submittedText @@ -1320,7 +1337,25 @@ export function XtermHost({ console.error("Failed to send terminal input:", error); } }, - [terminalId, updateCtrlMode, wsClient] + [dispatchTerminalInput, terminalId, updateCtrlMode] + ); + + const handleBinaryInput = useCallback( + async (data: string) => { + const bytes = binaryStringToBytes(data); + + traceTerminal(terminalId, "input.binary", { + activity: "control", + summary: summarizeTerminalData(bytes), + }); + + try { + await dispatchTerminalInput(bytes, "control"); + } catch (error) { + console.error("Failed to send terminal input:", error); + } + }, + [dispatchTerminalInput, terminalId] ); const handleResize = useCallback( @@ -1498,6 +1533,10 @@ export function XtermHost({ handleInputRef.current = handleInput; }, [handleInput]); + useEffect(() => { + handleBinaryInputRef.current = handleBinaryInput; + }, [handleBinaryInput]); + useEffect(() => { handleResizeRef.current = handleResize; }, [handleResize]); @@ -1590,6 +1629,11 @@ export function XtermHost({ terminal.onData((data) => { void handleInputRef.current(data); }); + if (typeof terminal.onBinary === "function") { + terminal.onBinary((data) => { + void handleBinaryInputRef.current(data); + }); + } const renderDisposable = typeof terminal.onRender === "function" ? terminal.onRender(({ start, end }) => { From b9f94a4914a42f5bc6bf992fe052fd5b83ca9aa2 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:39:31 +0800 Subject: [PATCH 06/26] fix: avoid upload toasts for terminal text inserts --- .../uploads/use-paste-drop-upload.test.tsx | 43 +++++++++++++++++++ .../uploads/use-paste-drop-upload.ts | 3 ++ 2 files changed, 46 insertions(+) diff --git a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx index 9a9e381f..ea70b252 100644 --- a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx +++ b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx @@ -306,6 +306,49 @@ describe("usePasteDropUpload", () => { }); }); + it("surfaces plain text send failures as paste errors, not upload errors", async () => { + const store = createStore(); + const clipboardRead = vi.fn().mockResolvedValue([]); + const clipboardReadText = vi.fn().mockResolvedValue("ls -la"); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + read: clipboardRead, + readText: clipboardReadText, + } satisfies Pick & { read: () => Promise }, + }); + sendInput.mockRejectedValueOnce(new Error("terminal write failed")); + + const { result } = renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + await act(async () => { + await expect(result.current.handleClipboardPaste()).rejects.toThrow("terminal write failed"); + await flushAsyncWork(); + }); + + expect(store.get(toastsAtom)).toContainEqual( + expect.objectContaining({ + kind: "error", + title: "Paste failed", + }) + ); + expect(store.get(toastsAtom)).not.toContainEqual( + expect.objectContaining({ + kind: "error", + title: "Upload failed", + }) + ); + }); + it("uploads files passed directly to the explicit file handler", async () => { const store = createStore(); const { result } = renderHook( diff --git a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts index 8d937429..7bac4e59 100644 --- a/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts +++ b/packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts @@ -131,6 +131,9 @@ export function usePasteDropUpload(opts: Options): PasteDropUploadActions { await runSequence(async () => text, { trackBusy: false, + onError: (error) => { + throw error; + }, }); }, [runSequence] From e654adab583e72ab783b568c40310c5931a9b40a Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:41:22 +0800 Subject: [PATCH 07/26] docs: add semantic color system redesign spec --- ...4-semantic-color-system-big-bang-design.md | 639 ++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-semantic-color-system-big-bang-design.md diff --git a/docs/superpowers/specs/2026-05-24-semantic-color-system-big-bang-design.md b/docs/superpowers/specs/2026-05-24-semantic-color-system-big-bang-design.md new file mode 100644 index 00000000..67aaf0a0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-semantic-color-system-big-bang-design.md @@ -0,0 +1,639 @@ +# Semantic Color System Big-Bang Design + +Date: 2026-05-24 +Status: Draft +Owner: Codex + +## Problem + +当前 `packages/web` 已经具备主题 token、glass/runtime appearance,以及一部分 workspace material token,但颜色系统仍然存在三类并行入口: + +1. 主题基础色与 surface token,例如 `--bg-*`、`--text-*`、`--accent-*`、`--surface-*` +2. runtime appearance 输入,例如 `--app-surface-opacity`、`--app-surface-backdrop-filter` +3. 组件侧局部颜色实现,例如硬编码 `#hex`、`rgba(...)`、`color-mix(...)` 和直接写死的 `blur(...)` + +这导致以下问题: + +- 组件可以绕过体系直接选色,颜色规范无法真正收口。 +- glass 和 surface opacity 虽然已经支持动态变化,但很多组件仍然在本地重复计算透明背景。 +- 状态色、git、diff、badge、icon tone 等领域存在独立配色逻辑,无法保证语义一致。 +- 多主题虽然存在,但组件使用的是“颜色实现细节”,而不是统一的语义接口。 +- 测试目前能验证局部 token 使用,但不能强约束“组件侧不得定义颜色”。 + +用户希望建立一套新的颜色体系,满足以下要求: + +- 保留现有多主题结构。 +- 保留现有 glass / transparency runtime 输入。 +- 定义几组固定基础前景色和背景色。 +- 组件不能直接接触基础色盘。 +- 组件只能使用统一的语义颜色 token。 +- 状态色、图标色、git/diff 等领域色也必须收口到同一体系。 + +## Goals + +- 在 `packages/web` 内建立一套新的分层颜色系统。 +- 保留现有主题族:`mint`、`graphite`、`nord`、`hc`。 +- 保留现有外观运行时输入:`glassEnabled`、`glassIntensity`、`surfaceOpacity`、背景 dim/blur。 +- 把基础颜色限制在 token 定义层,组件只能消费语义 token。 +- 把动态透明度和 glass blur 收敛到 token/material 层,不允许组件自己计算 alpha 或 blur。 +- 把 `git / diff / badge / notice / pill / icon tone` 等领域颜色纳入统一语义体系。 +- 最终合入结果不保留旧公开颜色接口作为长期兼容层。 +- 增加自动化校验,阻止未来重新引入硬编码颜色或越权 token 使用。 + +## Non-Goals + +- 本次不重新设计 `mint / graphite / nord / hc` 的视觉风格方向。 +- 本次不更改 Monaco、xterm 等外部协议所需的配色数据结构。 +- 本次不开放用户自定义任意颜色、任意 CSS 或脚本化主题。 +- 本次不扩展到 server、CLI 或非 `packages/web` UI 栈。 +- 本次不重新设计 spacing、radius、typography 等非颜色体系。 +- 本次不改变现有 appearance personalization 的产品能力边界。 + +## User Decisions Captured + +- 保留当前多主题结构,不降级为单主题。 +- 组件层采用严格语义消费模式,不允许直接接触基础色盘。 +- glass 与透明度属于体系一部分,透明度变化由 token/material 层承接,而不是组件局部计算。 +- 透明度控制保持全局统一输入,而不是按区域拆分控制。 +- 状态色、图标色、git/diff 等领域色必须收进同一套语义体系。 +- 最终方案选择 big-bang rewrite,不保留旧公开颜色接口的长期兼容别名。 + +## Approaches Considered + +### Option A: 在现有 token 上直接修补 + +继续沿用当前 `--bg-* / --accent-* / --state-* / --ws-*` 结构,仅做硬编码替换与规则加固。 + +优点: + +- 改动最小。 +- 迁移速度最快。 + +缺点: + +- 现有 token 命名边界并不干净,基础色与语义色混杂。 +- 很难彻底禁止组件直接依赖实现细节。 +- 长期仍容易回到“局部补丁式配色”。 + +### Option B: 新建语义体系并保留兼容 alias + +定义新体系,同时用旧变量作为过渡兼容层逐步替换。 + +优点: + +- 风险较低。 +- 渐进迁移更容易控制。 + +缺点: + +- 迁移周期更长。 +- 旧接口会在一段时间内继续存在,降低体系约束强度。 +- 与用户要求的“统一用一套体系、不靠兼容层延续旧接口”不完全一致。 + +### Option C: Big-bang Rewrite(最终选择) + +直接建立新的语义颜色体系,并在一次迁移中替换组件使用入口。最终合入结果不保留旧公开颜色接口。 + +优点: + +- 能得到边界最清晰、最强约束的最终结果。 +- 新体系可以从一开始就按“reference 私有、semantic 公开”组织。 +- 最符合用户对“所有地方都只能用这套体系颜色”的要求。 + +缺点: + +- 回归风险最高。 +- 对 workspace/material、shared shell、state domains 的联动要求最高。 +- 必须依赖严格的迁移顺序和自动化校验,不能只靠人工 review。 + +## Final Choice + +采用 Option C。 + +需要额外明确一个执行约束: + +- 最终合入到主分支的结果不保留旧公开颜色接口。 +- 实现分支内允许存在极短暂的过渡映射或中间状态,以支持分步骤提交和验证。 +- 但在 merge 前,所有旧公开颜色接口、组件侧原始颜色实现、组件侧 material math 都必须清零。 + +这使得 big-bang rewrite 在工程上可执行,同时保持最终边界足够硬。 + +## Design Principles + +### 1. 组件只消费语义,不消费颜色实现细节 + +组件层只能引用语义 token,不能引用基础色盘,不能引用 runtime appearance 输入,也不能自行生成颜色。 + +### 2. 透明度变化是系统能力,不是组件能力 + +glass、surface opacity、blur 都属于 material 层能力。组件只消费最终 surface/material token。 + +### 3. 状态色属于颜色系统的一等公民 + +`success / warning / danger / info` 不再只是辅助色,而是统一语义体系的一部分。所有 badge、notice、git、diff、icon tone 都从该层派生。 + +### 4. 主题只负责 reference 层 + +不同主题之间只切换基础色值与协议级颜色。组件语义接口必须在所有主题下保持稳定。 + +### 5. 协议例外隔离 + +Monaco、xterm、浏览器协议兼容这类特殊场景可以保留颜色实现细节,但必须局限在协议适配层,不能泄漏到组件配色接口。 + +## Final Design + +### 1. Token Layering + +新的颜色系统分为四层。 + +#### 1.1 Reference Layer(私有基础色盘) + +Reference layer 只允许定义在 [tokens.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/tokens.css:1) 中,供 semantic/material/domain layers 派生使用。 + +建议采用固定阶梯形式,至少包含: + +- 前景色阶梯 + - `--ref-fg-0` + - `--ref-fg-1` + - `--ref-fg-2` + - `--ref-fg-3` + - `--ref-fg-inverse` +- 背景色阶梯 + - `--ref-bg-0` + - `--ref-bg-1` + - `--ref-bg-2` + - `--ref-bg-3` + - `--ref-bg-4` + - `--ref-bg-5` + - `--ref-bg-6` +- 边框色阶梯 + - `--ref-border-0` + - `--ref-border-1` + - `--ref-border-2` + - `--ref-border-focus` + - `--ref-border-danger` +- 状态基色 + - `--ref-status-success` + - `--ref-status-warning` + - `--ref-status-danger` + - `--ref-status-info` + +职责: + +- 提供每个主题下稳定的基础颜色素材。 +- 承担最终 semantic/material/domain token 的唯一颜色输入。 + +非职责: + +- 组件不得直接使用 `--ref-*`。 + +#### 1.2 Semantic Layer(唯一公开颜色接口) + +Semantic layer 是组件唯一允许消费的通用颜色接口。 + +建议至少包含: + +- 文本 + - `--text-primary` + - `--text-secondary` + - `--text-tertiary` + - `--text-disabled` + - `--text-inverse` +- Surface + - `--surface-page` + - `--surface-panel` + - `--surface-elevated` + - `--surface-input` + - `--surface-muted` + - `--surface-hover` + - `--surface-active` + - `--surface-disabled` +- Border + - `--border-default` + - `--border-subtle` + - `--border-strong` + - `--border-focus` + - `--border-danger` +- Status + - `--status-success-fg` + - `--status-success-bg` + - `--status-success-border` + - `--status-success-icon` + - `--status-warning-fg` + - `--status-warning-bg` + - `--status-warning-border` + - `--status-warning-icon` + - `--status-danger-fg` + - `--status-danger-bg` + - `--status-danger-border` + - `--status-danger-icon` + - `--status-info-fg` + - `--status-info-bg` + - `--status-info-border` + - `--status-info-icon` + +职责: + +- 作为普通组件的唯一公开颜色 API。 +- 统一文本、surface、border、status 的语义定义。 + +#### 1.3 Material Layer(透明与 glass 输出层) + +Material layer 接收 runtime appearance 输入,并产出组件可消费的最终材质 token。 + +运行时输入保持现有形态: + +- `data-appearance-glass` +- `--app-surface-opacity` +- `--app-surface-backdrop-filter` + +但这些输入只允许在 token/material 定义层使用,不允许组件直接引用。 + +建议至少产出: + +- 通用 material + - `--material-panel` + - `--material-elevated` + - `--material-overlay` + - `--material-local-overlay` + - `--material-backdrop-filter` +- workspace shell surfaces + - `--workspace-sidebar-surface` + - `--workspace-activitybar-surface` + - `--workspace-statusbar-surface` + - `--workspace-session-surface` + - `--workspace-session-active-surface` + - `--workspace-session-header-surface` + - `--workspace-terminal-shell-surface` + - `--workspace-terminal-toolbar-surface` + - `--workspace-terminal-tabs-surface` + - `--workspace-editor-shell-surface` + - `--workspace-editor-toolbar-surface` + +职责: + +- 统一承接 glass on/off、surface opacity、blur 的变化。 +- 统一输出 workspace 和 global shells 最终使用的材质颜色。 + +非职责: + +- 组件不得在本地用 `color-mix(...)` 或 alpha 计算 material。 + +#### 1.4 Domain-Derived Layer(领域派生语义) + +这层负责把通用状态与 semantic 色映射为具体业务领域颜色,供特定组件消费。 + +至少包括: + +- Git + - `--git-status-added-fg/bg/border` + - `--git-status-modified-fg/bg/border` + - `--git-status-deleted-fg/bg/border` + - `--git-status-untracked-fg/bg/border` + - `--git-status-renamed-fg/bg/border` +- Diff + - `--diff-added-fg/bg/border` + - `--diff-modified-fg/bg/border` + - `--diff-deleted-fg/bg/border` +- Icon tones + - `--icon-primary` + - `--icon-secondary` + - `--icon-muted` + - `--icon-success` + - `--icon-warning` + - `--icon-danger` + - `--icon-info` + - 文件类型 tone 与 git tone + +职责: + +- 防止业务领域组件各自挑色。 +- 保持 git/diff/badge/icon 等系统内部语义一致。 + +### 2. Theme Responsibilities + +主题切换仍保留在现有 theme system 中,但职责边界改为: + +- `tokens.css` 中按 `data-theme` 为 `--ref-*` 赋值。 +- semantic/material/domain-derived tokens 从 `--ref-*` 派生。 +- [theme/registry.ts](/home/spencer/workspace/coder-studio/packages/web/src/theme/registry.ts:1) 继续负责: + - terminal theme + - Monaco theme + - icon theme registry + +这些协议级颜色不是组件配色入口,不参与组件颜色消费约束。 + +### 3. Appearance Runtime Integration + +现有 appearance personalization 与 document application 链路保留: + +- [personalization.ts](/home/spencer/workspace/coder-studio/packages/web/src/appearance/personalization.ts:1) +- [document.ts](/home/spencer/workspace/coder-studio/packages/web/src/appearance/document.ts:17) +- [settings-page.tsx](/home/spencer/workspace/coder-studio/packages/web/src/features/settings/components/settings-page.tsx:2238) + +保留原因: + +- `surfaceOpacity` 已经是用户可配置能力。 +- `glassIntensity` 已经是用户可配置能力。 +- high-contrast 主题已经具备 glass disable 逻辑。 + +新体系只改变一件事: + +- runtime appearance 只负责写输入变量。 +- 最终 surface/material 颜色全部由 token 层解析。 + +组件不再直接使用: + +- `--app-surface-opacity` +- `--app-surface-backdrop-filter` +- `data-appearance-glass` 的局部判断 + +### 4. Allowed Definition Boundaries + +只有以下文件或区域允许定义颜色实现细节: + +- [tokens.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/tokens.css:1) + - reference layer + - semantic mappings + - material formulas + - domain-derived mappings +- [theme/registry.ts](/home/spencer/workspace/coder-studio/packages/web/src/theme/registry.ts:1) + - Monaco/xterm/icon protocol colors +- [document.ts](/home/spencer/workspace/coder-studio/packages/web/src/appearance/document.ts:17) + - runtime appearance inputs only + +其他组件样式与 UI 文件均不得定义颜色实现细节。 + +### 5. Consumer Rules + +以下文件或区域只能消费 semantic/material/domain-derived tokens: + +- [base.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/base.css:1) +- [components.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/components.css:1) +- `packages/web/src/components/ui/**/*.module.css` +- `packages/web/src/features/**/*.css` +- `packages/web/src/**/*.tsx` 内的 inline style + +这些位置禁止: + +- `#hex` +- `rgb()` / `rgba()` +- `hsl()` / `hsla()` +- `oklch()` +- `color-mix()` +- 直接使用 `--ref-*` +- 直接使用 `--app-surface-opacity` +- 直接使用 `--app-surface-backdrop-filter` +- 局部写死 `blur(...)` + +允许保留的非颜色关键字: + +- `transparent` +- `currentColor` +- `inherit` +- `none` + +### 6. Big-Bang Scope of Replacement + +本次替换不是“替换掉少量硬编码”,而是“替换整个组件侧公开颜色接口”。 + +最终合入结果中,不应继续存在作为组件接口使用的旧公开颜色体系,例如: + +- `--bg-*` +- `--accent-*` +- `--color-*` +- 旧的 `--ws-*` 简写 material surfaces + +如果某个现有 token 名称本身已经符合新语义边界,例如 `--text-primary`,可以保留其名称,但必须重新纳入新 semantic layer 定义,而不是作为历史体系的例外。 + +## Migration Strategy + +### Phase 1: Rebuild the token system + +在 [tokens.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/tokens.css:1) 中重建新层级: + +- reference layer +- semantic layer +- material layer +- domain-derived layer + +先让所有主题完成 `--ref-*` 映射,再由这些 reference tokens 推出 semantic/material/domain tokens。 + +### Phase 2: Centralize material outputs + +保留 runtime appearance 输入链路,但把最终面色与 blur 输出全部集中到 token 层。 + +目标: + +- [base.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/base.css:1) 不再直接引用 `--app-surface-opacity` +- [components.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/components.css:1) 不再直接引用 `--app-surface-opacity` +- [components.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/components.css:1) 不再直接引用 `--app-surface-backdrop-filter` +- glass enabled/disabled/high-contrast 都通过 material tokens 统一反映 + +### Phase 3: Replace shared shells first + +优先迁移回归风险最高、影响面最大的共享 shell: + +- app loading shell +- settings shell +- modal / drawer / sheet / local overlay +- workspace shell +- terminal shell +- editor shell + +原因: + +- 这些区域最容易直接写 transparency math +- 这些区域与 glass/material 强相关 +- 如果不先迁这层,后续组件迁移会持续依赖旧 surface 接口 + +### Phase 4: Replace workspace material consumers + +把最近新增或正在演进的 workspace material surface 一次性切换到新 material/workspace semantic tokens。 + +包括: + +- sidebar +- activity bar +- status bar +- session cards +- session headers +- terminal toolbar / tabs / shells +- editor toolbar / shells + +重点目标: + +- 清理当前 `components.css` 中仍然残留的 component-local material formulas +- 清理直接写死的 blur 值 + +### Phase 5: Replace state/domain consumers + +统一替换所有状态与领域色使用方: + +- git status chips / rows / badges +- diff lines / panes +- notice / toast / badge / pill / tag +- file and git icon tones +- empty states / notices / warning banners + +目标: + +- 组件不再自己决定“warning 到底该用哪个黄” +- renamed/untracked 等当前无统一语义来源的颜色必须有明确 domain-derived token + +### Phase 6: Replace module CSS and TSX inline colors + +迁移所有 `components/ui/**/*.module.css` 与 `features/**` 内联样式。 + +目标: + +- 不留下“全局样式收口了,但局部模块还在自己写色”的尾巴 + +### Phase 7: Remove old public color interfaces + +最终删除或停止暴露旧公开颜色接口,并确保组件侧引用全部切换完成。 + +这一步完成后,仓库不应再依赖历史颜色 API。 + +## Validation and Enforcement + +这次迁移必须引入硬性校验,不能只靠人工 code review。 + +### 1. Consumer-side raw color guard + +对 production UI 样式与组件文件做扫描: + +- 禁止 `#hex` +- 禁止 `rgb/rgba` +- 禁止 `hsl/hsla` +- 禁止 `oklch` +- 禁止 `color-mix` + +白名单仅限: + +- `tokens.css` +- `theme/registry.ts` +- 协议级例外文件 + +### 2. Reference token guard + +除 `tokens.css` 外,禁止任何文件直接使用 `--ref-*`。 + +### 3. Runtime appearance input guard + +除 `tokens.css`、`document.ts` 和协议级确有必要的极少数适配层外,禁止引用: + +- `--app-surface-opacity` +- `--app-surface-backdrop-filter` + +### 4. Legacy interface guard + +在迁移完成后,增加检查确保旧公开颜色接口不再被组件消费。 + +### 5. Theme and appearance regression coverage + +至少验证以下矩阵: + +- `mint-dark` +- `mint-light` +- `graphite-dark` +- `graphite-light` +- `nord-dark` +- `nord-light` +- `hc-dark` +- `hc-light` + +以及以下外观状态: + +- glass off +- glass on +- surface opacity 低值 +- surface opacity 高值 +- high-contrast 主题下 glass 自动绕过 + +### 6. Shared stylesheet tests + +扩展现有主题测试: + +- [base.theme.test.ts](/home/spencer/workspace/coder-studio/packages/web/src/styles/base.theme.test.ts:1) +- [components.theme.test.ts](/home/spencer/workspace/coder-studio/packages/web/src/styles/components.theme.test.ts:1) + +并新增 guard 级测试,验证: + +- 组件侧无原始颜色 +- 组件侧无越权 token +- 组件侧无 material math + +## Risks and Mitigations + +### Risk 1: Workspace material regressions + +原因: + +- workspace/material 最近仍在演进 +- surface、blur、background-image pass-through 高耦合 + +缓解: + +- 先迁共享 shell,再迁 workspace consumers +- 保留 workspace preview coverage +- 强制验证 glass on/off 和 high-contrast + +### Risk 2: Big-bang rename churn + +原因: + +- token 改名会带来大范围替换 + +缓解: + +- reference/semantic/material/domain layering 先落定,再做消费替换 +- 迁移顺序固定,不允许一边设计一边随处替换 +- 合并前统一执行全仓扫描 + +### Risk 3: Protocol layers accidentally pulled into UI rules + +原因: + +- Monaco/xterm 的色值需求和 UI semantic API 不同 + +缓解: + +- 在 spec 中明确协议例外边界 +- `theme/registry.ts` 继续作为协议配色容器,不把它当组件颜色入口 + +### Risk 4: Temporary transition code leaking into mainline + +原因: + +- big-bang 过程中实现分支可能出现短暂过渡映射 + +缓解: + +- 明确要求最终 merge 结果清零 +- 用 legacy interface guard 阻止遗留接口残存 + +## Acceptance Criteria + +迁移完成后,以下条件必须全部成立: + +- 所有 production UI 组件只消费 semantic/material/domain-derived tokens。 +- 组件侧不存在原始颜色值和颜色公式。 +- glass 与 surface opacity 的变化只在 token/material 层计算。 +- 所有主题都可以通过同一套组件语义接口渲染。 +- `git / diff / badge / notice / icon tone` 都有统一语义来源。 +- 高对比主题仍然能绕过 glass 行为。 +- 最终合入树中不存在旧公开颜色接口的组件消费。 + +## Open Questions + +无。 + +本次设计已经确认以下关键选择: + +- 多主题保留 +- glass/runtime appearance 保留 +- 基础前景/背景色盘只作为私有 reference 层 +- 组件只允许消费语义 token +- 状态色与领域色纳入统一体系 +- 最终合入结果采用 big-bang rewrite,不保留旧公开颜色接口 From 186ebd733e2a290087d98ba09c55ae1d069ff7eb Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:44:08 +0800 Subject: [PATCH 08/26] feat: add desktop file tree drag-to-terminal source --- .../views/shared/file-tree-panel.test.tsx | 107 ++++++++++++++++++ .../views/shared/file-tree-panel.tsx | 26 ++++- 2 files changed, 132 insertions(+), 1 deletion(-) 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 a3f6ee92..50e5205d 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 @@ -3,6 +3,7 @@ import { createStore, Provider } from "jotai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { wsClientAtom } from "../../../../atoms/connection"; import { workspacesAtom } from "../../../../atoms/workspaces"; +import { WORKSPACE_PATH_DRAG_MIME } from "../../../../lib/workspace-path-drag"; import { pendingEditorNavigationAtomFamily } from "../../../code-editor/atoms"; import { activeFilePathAtomFamily, @@ -36,6 +37,18 @@ vi.mock("../../../../lib/i18n", () => ({ }, })); +function createDragDataTransfer() { + const values = new Map(); + const dataTransfer = { + effectAllowed: "none", + setData: vi.fn((type: string, value: string) => { + values.set(type, value); + }), + } as unknown as DataTransfer; + + return { dataTransfer, values }; +} + describe("FileTreePanel", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -2084,6 +2097,100 @@ describe("FileTreePanel", () => { expect(document.querySelector(".file-tree-search")).toBeNull(); }); + it("marks desktop tree rows draggable and writes workspace path drag data on dragstart", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { path: "README.md", name: "README.md", kind: "file" }, + { path: "src", name: "src", kind: "dir", children: [] }, + ], + ], + ]) + ); + + render( + + + + ); + + const fileRow = screen.getByText("README.md").closest(".tree-item"); + const folderRow = screen.getByText("src").closest(".tree-item"); + expect(fileRow).toHaveAttribute("draggable", "true"); + expect(folderRow).toHaveAttribute("draggable", "true"); + + const { dataTransfer, values } = createDragDataTransfer(); + fireEvent.dragStart(fileRow!, { dataTransfer }); + + expect(values.get(WORKSPACE_PATH_DRAG_MIME)).toBe( + JSON.stringify({ + workspaceId: "ws-test", + path: "README.md", + kind: "file", + }) + ); + expect(values.get("text/plain")).toBe("README.md"); + }); + + it("writes workspace drag data for nested desktop nodes too", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [".", [{ path: "src", name: "src", kind: "dir", children: [] }]], + ["src", [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }]], + ]) + ); + store.set(expandedDirsAtomFamily("ws-test"), new Set(["src"])); + + render( + + + + ); + + const nestedRow = screen.getByText("app.tsx").closest(".tree-item"); + expect(nestedRow).toHaveAttribute("draggable", "true"); + + const { dataTransfer, values } = createDragDataTransfer(); + fireEvent.dragStart(nestedRow!, { dataTransfer }); + + expect(values.get(WORKSPACE_PATH_DRAG_MIME)).toBe( + JSON.stringify({ + workspaceId: "ws-test", + path: "src/app.tsx", + kind: "file", + }) + ); + expect(values.get("text/plain")).toBe("src/app.tsx"); + }); + + it("keeps mobile tree rows non-draggable", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([[".", [{ path: "README.md", name: "README.md", kind: "file" }]]]) + ); + + render( + + + + ); + + expect(screen.getByText("README.md").closest(".tree-item")).not.toHaveAttribute( + "draggable", + "true" + ); + }); + 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 2d6030e3..e6d2c05a 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 @@ -2,7 +2,12 @@ import type { FileNode } from "@coder-studio/core"; 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 type { + FC, + DragEvent as ReactDragEvent, + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, +} from "react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { workspaceByIdAtomFamily } from "../../../../atoms/workspaces"; import { @@ -20,6 +25,7 @@ import { Tooltip, } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; +import { setWorkspacePathDragData } from "../../../../lib/workspace-path-drag"; import { useCreateShellTerminal } from "../../../terminal-panel/actions/use-create-shell-terminal"; import { type CreateDialogState, @@ -442,6 +448,7 @@ export const FileTreePanel: FC = ({ treeNodes.map((node) => ( = ({ }; interface FileTreeNodeProps { + workspaceId: string; node: FileNode; depth: number; variant: "desktop" | "mobile"; @@ -600,6 +608,7 @@ interface FileTreeNodeProps { } const FileTreeNode: FC = ({ + workspaceId, node, depth, variant, @@ -664,6 +673,18 @@ const FileTreeNode: FC = ({ onToggleDirs(nextExpanded); }; + const handleDragStart = (event: ReactDragEvent) => { + if (variant !== "desktop" || !event.dataTransfer) { + return; + } + + setWorkspacePathDragData(event.dataTransfer, { + workspaceId, + path: node.path, + kind: node.kind, + }); + }; + const paddingLeft = depth * 14 + 16; return ( @@ -672,6 +693,8 @@ const FileTreeNode: FC = ({ className={`tree-item tree-item--${node.kind} ${ selectedPath === node.path ? "selected" : "" } ${contextTargetPath === node.path ? "tree-item--context-target" : ""}`} + draggable={variant === "desktop" ? true : undefined} + onDragStart={variant === "desktop" ? handleDragStart : undefined} onClick={handleClick} onContextMenu={ variant === "desktop" ? (event) => onOpenContextMenu(event, node, "tree") : undefined @@ -737,6 +760,7 @@ const FileTreeNode: FC = ({ {sortNodes(node.children).map((child) => ( Date: Sun, 24 May 2026 23:47:47 +0800 Subject: [PATCH 09/26] docs: add seasonal themes design spec --- .../2026-05-24-seasonal-themes-design.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-seasonal-themes-design.md diff --git a/docs/superpowers/specs/2026-05-24-seasonal-themes-design.md b/docs/superpowers/specs/2026-05-24-seasonal-themes-design.md new file mode 100644 index 00000000..4a7a47c7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-seasonal-themes-design.md @@ -0,0 +1,351 @@ +# Seasonal Themes Design + +Date: 2026-05-24 +Status: Draft +Owner: Codex + +## Problem + +当前产品已经有成熟的主题系统:`appearance.themeId` 贯通 Web UI、Monaco、terminal 和 icon tone,用户可以在现有内建主题之间切换。但现有主题更多是通用产品风格,还没有一套能明显传达“春夏秋冬”情绪变化的主题组。 + +这次需求不是做单纯的背景皮肤,也不是额外引入一套并行的季节系统,而是在现有主题模型内新增一组完整、可长期使用的四季主题。目标是让使用者在切换主题时能感受到春天的灿烂、夏天的生命力、秋天的寂寥和冬天的静谧,同时保持企业级产品应有的可读性、稳定性和系统感。 + +## Goals + +- 新增 `8` 个内建四季主题:春、夏、秋、冬各自提供 `light` 和 `dark` 两个版本。 +- 继续复用现有主题切换与解析规则,不新增第二套季节主题机制。 +- 让四季主题在 Web UI、Monaco、terminal、icon tone 上保持统一体验,而不是只改网页背景色。 +- 采用偏企业级的 `Enterprise-Balanced` 方向:季节感明确,但不让界面大面积高饱和染色。 +- 让同季节的 `light / dark` 一眼看出属于同一组,同时保留各自适合明暗模式的层级与对比。 +- 保持设置页中的主题选择结构清晰,可直观看出季节和明暗对应关系。 + +## Non-Goals + +- 本期不改主题切换机制,不增加“按季节自动切换”。 +- 本期不增加背景图片、动态天气、粒子、动画等附加季节效果。 +- 本期不引入新的“主题包”或任意自定义 token 注入能力。 +- 本期不改变现有 `success / warning / error / info` 的语义职责。 +- 本期不重做 icon glyph 系统,不为四季主题单独设计一套图标造型语言。 +- 本期不改写外观个性化设置模型,不和背景个性化功能耦合。 + +## User Decisions Captured + +- 季节主题不是轻量皮肤,而是完整应用主题。 +- 优先方向是“平衡型”:允许春夏更张扬,秋冬更克制,但整体仍要适合长期使用。 +- 春天主色逻辑为“花的红色”,强调灿烂感,但背景不应被大面积红色铺满。 +- 夏天主色逻辑为生命的绿色。 +- 秋天主色逻辑为黄色系,但应避免廉价高亮黄,更偏麦黄、琥珀、深金。 +- 冬天主色逻辑为白色系与冷灰蓝,强调静谧、雪雾、冷空气感。 +- 每个季节都要提供 `light` 和 `dark` 两个版本。 +- 不改变当前主题系统规则,只是在现有模型里新增一组主题。 +- 最终方向选定为更企业级的 `Enterprise-Balanced`,而不是纯情绪板式或极度克制的方案。 + +## Approaches Considered + +### Option A: Enterprise-Soft + +四季主色主要进入强调层,页面和面板大体保持中性,季节感更多体现在按钮、选中态、标签、图标和焦点。 + +优点: + +- 风险最低,最接近典型企业产品。 +- 最不容易影响正文区、代码区和表单控件的可读性。 + +缺点: + +- 四季差异会偏弱,可能无法达到“明显感受到四季变化”的目标。 + +### Option B: Enterprise-Balanced(推荐) + +保留成熟产品的中性层级结构,但允许不同季节在 page、surface、sidebar 等表面层注入低饱和色温,同时让强调层更明确地体现季节主色。 + +优点: + +- 四季感清楚,但整体仍像稳定的产品主题系统。 +- 最容易兼顾“春夏更鲜明、秋冬更收束”的情绪目标。 +- 最适合扩展到 `light / dark` 双套并保持配对关系。 + +缺点: + +- 设计和实现上比 `Enterprise-Soft` 更需要精细校准表面、边框、选中态、Monaco 和 terminal 色阶。 + +### Option C: Enterprise-Expressive + +将更多季节色温带入侧栏、面板和 overlay,整体主题切换时能更强烈地感受到季节变化。 + +优点: + +- 辨识度最高,最容易让用户感到“换了一个季节”。 + +缺点: + +- 长时间使用风险更高。 +- 最容易让正文区、代码区和功能性控件的对比失衡。 + +## Final Choice + +采用 Option B。 + +这组四季主题将沿用现有主题系统的结构,通过稳定的语义 token 层把季节气质注入到 Web UI、Monaco、terminal 和 icon tone 中。整体方向是企业级、可长期使用的季节主题,而不是海报式或背景皮肤式表达。 + +## Final Design + +### 1. 主题模型与命名 + +在现有主题注册表基础上新增 `8` 个内建主题: + +- `spring-light` +- `spring-dark` +- `summer-light` +- `summer-dark` +- `autumn-light` +- `autumn-dark` +- `winter-light` +- `winter-dark` + +继续沿用现有 `AppThemeDefinition` 模型,不新增新的季节主题类型。每个主题仍然包含: + +- `id` +- `family` +- `kind` +- `labelKey` +- `pairedThemeId` +- `isHighContrast` +- `documentThemeAttr` +- `terminalTheme` +- `monaco` +- `iconTheme` + +`ThemeFamily` 扩展为包含四个新 family: + +- `spring` +- `summer` +- `autumn` +- `winter` + +成对关系固定如下: + +- `spring-light` ↔ `spring-dark` +- `summer-light` ↔ `summer-dark` +- `autumn-light` ↔ `autumn-dark` +- `winter-light` ↔ `winter-dark` + +这样可以保持与现有 `mint / graphite / nord / hc` 完全一致的解析与切换逻辑。 + +### 2. 整体视觉原则 + +四季主题遵循以下全局原则: + +- 大面积页面和面板优先使用低饱和表面色,不用主色直接铺满整个界面。 +- 季节主色主要进入强调层:主按钮、选中态、焦点、重要标签、部分图标 tone、局部高光区。 +- `light` 版主要通过空气感、色温和背景透气度来区分季节。 +- `dark` 版主要通过更深的色温和更清楚的强调色来区分季节。 +- 同季节的 `light / dark` 必须一眼看出属于同一组,不能做成两个不相关的主题。 + +### 3. 四季语义色板策略 + +#### Spring + +目标气质是“花的红色”和“灿烂”,但不是告警红,也不是大面积粉色界面。 + +原则: + +- 主强调色使用花瓣红、玫瑰珊瑚、胭脂红一类的暖红系。 +- 表面层使用浅暖白、微粉雾灰作为基底,保持明亮和通透。 +- 少量嫩叶绿可作为辅助点缀,但不抢夺主强调角色。 +- `spring-dark` 使用莓红、深玫瑰、花影红作为强调,底色偏暖黑灰或轻微紫灰。 + +#### Summer + +目标气质是生命力、生长和饱满,但不是荧光绿,也不是环保海报式满屏绿。 + +原则: + +- 主强调色使用叶绿、草木绿、生命力绿。 +- 表面层只做极轻的灰绿倾向,正文和主要容器保持接近中性。 +- `summer-dark` 使用深林绿、湿润绿,体现沉稳的生命力,而不是霓虹科技绿。 + +#### Autumn + +目标气质是黄色系、成熟、日照和寂寥,但避免节庆金或廉价高亮黄。 + +原则: + +- 主强调色使用麦黄、琥珀、深金、枯叶黄。 +- 表面层使用纸张暖黄、谷物米色和干燥暖灰。 +- `autumn-dark` 使用深琥珀、焦糖棕金、暮色黄褐,传达成熟收束感。 + +#### Winter + +目标气质是白色系、冷空气、雪雾和静谧,是四季中最克制的一组。 + +原则: + +- 主强调色使用冷灰蓝、雾蓝、雪光蓝,避免强饱和亮蓝。 +- 表面层使用雪白、雾白、冷灰和淡蓝灰。 +- `winter-dark` 使用夜雪蓝灰、月光钢蓝和冷静深灰,整体存在感比其他季节更轻。 + +### 4. 语义状态与季节主色的边界 + +状态语义继续保持稳定,不让季节主色直接吞并系统状态色。 + +必须明确的边界: + +- 春的主强调色不等于 `error` +- 夏的主强调色不等于 `success` +- 秋的主强调色不等于 `warning` +- 冬的主强调色可以邻近 `info`,但要比系统信息蓝更安静 + +实现原则: + +- `success / warning / error / info` 仍保留稳定语义职责。 +- 季节主色与状态色可以在色相上相近,但不能让用户在交互上混淆。 +- 因此,季节色更多接管 `accent`、`focus`、`selection`、`icon accent`,而不是直接替换所有状态色。 + +### 5. CSS Token 设计 + +在 [packages/web/src/styles/tokens.css](/home/spencer/workspace/coder-studio/packages/web/src/styles/tokens.css) 中新增 `8` 个 `[data-theme="..."]` block。 + +继续沿用现有 token 命名和结构,不引入第二套季节 token 命名空间。 + +优先需要校准的角色包括: + +- 页面和表面: + - `--bg-page` + - `--bg-surface` + - `--bg-sidebar` + - `--bg-terminal` + - `--bg-hover` + - `--bg-active` + - `--bg-input` +- 边框与对比: + - `--border` + - `--border-light` + - `--border-focus` + - `--border-error` +- 文本: + - `--text-primary` + - `--text-secondary` + - `--text-tertiary` + - `--text-disabled` +- 强调与语义: + - `--accent-blue` + - `--accent-green` + - `--accent-amber` + - `--accent-pink` + - `--accent-purple` + - `--color-success` + - `--color-warning` + - `--color-error` + - `--color-info` +- 状态与 overlay: + - `--state-*` + - `--overlay-*` + - `--surface-*` + - `--shadow-glow` +- 图标: + - `--icon-*` + +这里的关键不是机械换色,而是保证: + +- 表面层级能被清楚分辨 +- 控件 hover / active / selected 状态仍然明显 +- 各季节的焦点色和选中态具有各自气质 + +### 6. Monaco 与 Terminal + +四季主题必须提供完整的 `terminalTheme` 和 `monaco` 定义,不能只改网页 token。 + +#### Monaco + +原则: + +- `editor.background` 仍围绕主题基底控制,不使用过强情绪色。 +- `comment` 维持中性偏灰,避免语义噪音。 +- `keyword` 使用季节主强调色。 +- `string` 使用与季节主强调相协调的辅助色。 +- `selectionBackground`、`editorCursor.foreground`、`editorLineNumber.foreground` 跟随季节气质微调。 + +这样用户会感知到“整个产品都进入了同一个季节”,但写代码时不至于疲劳。 + +#### Terminal + +原则: + +- 背景和前景对比优先稳定性。 +- ANSI palette 保持语义可辨,不为了追求季节感而破坏红绿黄蓝的角色。 +- 可让 `cursor`、`selectionBackground` 和个别亮色通道体现季节主色倾向。 + +### 7. Icon Theme + +第一阶段不改变 icon glyph 选择逻辑,只调整各季节的 tone 和 surface 倾向。 + +策略: + +- Spring:accent 更偏花红系 +- Summer:accent 更偏叶绿系 +- Autumn:accent 更偏琥珀黄系 +- Winter:accent 更偏冷灰蓝系 + +文件、状态和导航图标仍维持稳定的语义结构,避免为四季主题引入新的图标复杂度。 + +### 8. 设置页与文案 + +设置页继续使用当前主题选择逻辑,不引入新的切换规则。 + +建议将四季主题作为一个独立分组加入现有主题列表,顺序固定为: + +- `Spring Light` +- `Spring Dark` +- `Summer Light` +- `Summer Dark` +- `Autumn Light` +- `Autumn Dark` +- `Winter Light` +- `Winter Dark` + +需要同步补充: + +- `labelKey` +- 中英文翻译文案 +- 主题选择器内的排序与分组文案 + +这样用户在设置页中能清楚理解这是“四季主题组”,并看出每个季节的明暗对应关系。 + +### 9. 测试与验证 + +至少需要更新以下测试面: + +- [packages/web/src/theme/registry.test.ts](/home/spencer/workspace/coder-studio/packages/web/src/theme/registry.test.ts) + - 主题总数 + - family 覆盖 + - paired theme 对称性 + - `labelKey` 可翻译性 + - `documentThemeAttr` 与 `id` 对齐 +- `resolve` 类测试 + - 新主题 `themeId` 可被正确解析 + - 未知值仍回退默认主题 +- 样式主题测试 + - 新主题的 token 覆盖完整 + - 共享组件在四季主题下仍有合理状态对比 +- 如测试范围允许,补充设置页或视觉测试 + - 确认主题列表中新增四季项 + - 确认 light / dark 对应关系显示正确 + +### 10. Implementation Notes + +建议按以下顺序实现: + +1. 扩展 `theme registry`、`ThemeFamily`、`THEME_IDS` 和 `pairedThemeId` +2. 补齐 `tokens.css` 中 `8` 个新主题 block +3. 为新主题补齐 `terminalTheme`、`monaco`、`iconTheme` +4. 更新设置页主题列表与文案 +5. 补齐测试与必要视觉回归 + +这样能先把主题定义层打稳,再逐步连通 UI 和测试。 + +## Open Questions + +当前没有阻塞实现的开放问题。 + +唯一的主观校准点是:在正式实现时,`Enterprise-Balanced` 方向可以根据实际预览结果,在“更克制一点”和“再多一点季节感”之间做一次小幅微调,但不改变本设计的结构和边界。 From b469da6a62d59926b36ca2a3b034190796e414bc Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:50:11 +0800 Subject: [PATCH 10/26] test: cover desktop draggable row clicks --- .../views/shared/file-tree-panel.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 50e5205d..7fbac50e 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 @@ -2171,6 +2171,48 @@ describe("FileTreePanel", () => { expect(values.get("text/plain")).toBe("src/app.tsx"); }); + it("keeps desktop draggable file rows clickable for shared editor navigation", async () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn().mockResolvedValue({}) } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { + path: "src/app.tsx", + name: "app.tsx", + kind: "file", + }, + ], + ], + ]) + ); + + render( + + + + ); + + const row = screen.getByText("app.tsx").closest(".tree-item"); + expect(row).toHaveAttribute("draggable", "true"); + + fireEvent.click(row!); + + await waitFor(() => { + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + }); + + expect(store.get(pendingEditorNavigationAtomFamily("ws-test"))).toMatchObject({ + workspaceId: "ws-test", + path: "src/app.tsx", + source: "file-tree", + requestId: expect.any(Number), + }); + }); + it("keeps mobile tree rows non-draggable", () => { const store = createStore(); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); From 87cb77631a7602d65ffe621a23d5ea648efcccb4 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:54:53 +0800 Subject: [PATCH 11/26] fix: enable desktop search result path drags --- .../views/shared/file-tree-panel.test.tsx | 42 +++++++++++++++++++ .../views/shared/file-tree-panel.tsx | 16 +++++++ 2 files changed, 58 insertions(+) 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 7fbac50e..e55a68cd 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 @@ -1369,6 +1369,48 @@ describe("FileTreePanel", () => { ).toBeInTheDocument(); }); + it("marks desktop search result rows draggable and writes workspace path drag data", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args: { query?: string }) => { + if (op === "file.search") { + const query = args.query?.toLowerCase() ?? ""; + const files = [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }].filter((item) => + item.name.toLowerCase().includes(query) + ); + + return { files }; + } + + return { ok: true }; + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByPlaceholderText("action.search_files"), { + target: { value: "app" }, + }); + + const searchRow = (await screen.findByText("app.tsx")).closest(".tree-item"); + expect(searchRow).toHaveAttribute("draggable", "true"); + + const { dataTransfer, values } = createDragDataTransfer(); + fireEvent.dragStart(searchRow!, { dataTransfer }); + + expect(values.get(WORKSPACE_PATH_DRAG_MIME)).toBe( + JSON.stringify({ + workspaceId: "ws-test", + path: "src/app.tsx", + kind: "file", + }) + ); + expect(values.get("text/plain")).toBe("src/app.tsx"); + }); + it("opens the rename modal from the context menu and submits file.rename", async () => { const sendCommand = vi .fn() 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 e6d2c05a..577cec29 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 @@ -429,6 +429,7 @@ export const FileTreePanel: FC = ({ searchResults.map((node) => ( = ({ }; interface FileSearchResultRowProps { + workspaceId: string; node: FileNode; variant: "desktop" | "mobile"; selectedPath: string | null; @@ -529,6 +531,7 @@ interface FileSearchResultRowProps { } const FileSearchResultRow: FC = ({ + workspaceId, node, variant, selectedPath, @@ -542,12 +545,25 @@ const FileSearchResultRow: FC = ({ }) => { const dirName = node.path.includes("/") ? node.path.slice(0, node.path.lastIndexOf("/")) : ""; const surface = variant === "mobile" ? "mobile" : "search"; + const handleDragStart = (event: ReactDragEvent) => { + if (variant !== "desktop" || !event.dataTransfer) { + return; + } + + setWorkspacePathDragData(event.dataTransfer, { + workspaceId, + path: node.path, + kind: node.kind, + }); + }; return (
{ if (consumeSuppressedClick()) { return; From d360eb7ab20df813ddd02201f96e8ab46e0534a5 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:58:09 +0800 Subject: [PATCH 12/26] docs: add session pane drag reorder design --- ...-05-24-session-pane-drag-reorder-design.md | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-session-pane-drag-reorder-design.md diff --git a/docs/superpowers/specs/2026-05-24-session-pane-drag-reorder-design.md b/docs/superpowers/specs/2026-05-24-session-pane-drag-reorder-design.md new file mode 100644 index 00000000..268a99a5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-session-pane-drag-reorder-design.md @@ -0,0 +1,468 @@ +# Session Pane Drag Reorder Design + +Date: 2026-05-24 +Status: Draft +Owner: codex + +## Problem + +当前 workspace 里的 session pane 支持 split、close、replace 和持久化布局,但不支持直接拖拽 pane 改变位置。用户如果想把一个 session 挪到另一块区域,只能先关闭、再重建 split,或者新建 pane 后手动重排。这对多 session 并行工作的场景很笨重。 + +现有实现已经具备这项能力所需的大部分基础: + +- `PaneNode` 已经是明确的 split tree,`leaf` 代表 pane,`split` 代表布局关系。 +- pane 结构已经通过 `workspace.uiState.paneLayout` 做服务端持久化。 +- split ratio 已经是本地状态,不需要为这次变更引入多端同步。 +- `pane-layout-tree.ts` 已经负责 split、close、remove、collapse 等树变换。 + +缺的是一条桌面端的拖拽交互链路:用户能抓住一个 session pane,把它拖到另一个 pane 的左、右、上、下或中间,并让 pane tree 稳定地重排。 + +## Goals + +- 桌面端支持拖拽 session pane 改变布局位置。 +- 支持五个 drop 语义: + - `left` + - `right` + - `top` + - `bottom` + - `center` +- 拖到 `left/right/top/bottom` 时,把 source pane 插入到 target pane 对应方向。 +- 拖到 `center` 时: + - target 为 session pane:交换两个 pane 的 session 内容 + - target 为 draft pane:把 session 移动到这个空槽位 +- 所有布局变更继续复用现有 `paneLayout` 持久化链路。 +- 保持现有二叉 split tree 模型,不引入自由布局或多叉容器。 + +## Non-Goals + +- 不覆盖移动端。 +- 不支持跨 workspace 拖拽。 +- 不支持多选拖拽。 +- 不支持 touch drag。 +- 不引入拖拽中实时改树预览;只显示 hover feedback,drop 后再提交布局。 +- 不做实时多客户端协同拖拽。 +- 不做完整键盘拖拽可访问性模型。 +- 不允许 draft pane 作为拖拽源。 +- 不允许对 draft pane 使用边缘插入;draft pane 只接受 `center` drop。 + +## Desired User Behavior + +### Drag Source + +- 只有带真实 `sessionId` 的 pane 可拖拽。 +- 拖拽必须从 `SessionCard` header 内一个显式 drag handle 开始。 +- terminal 区域、整个 pane body、draft pane 均不能作为 drag start 区域。 + +### Drop Target + +- session pane 可作为五向 drop target:`left/right/top/bottom/center` +- draft pane 可作为单向 drop target:`center` + +### Drop Semantics + +#### 1. Drop Center On Session Pane + +不新建 split,仅交换两个 leaf 上承载的 `sessionId`。 + +- `paneId` 保持不变 +- `sessionId` 互换 +- 任何 split 结构和 ratio 都不改变 + +#### 2. Drop Center On Draft Pane + +把 source session 移入 target draft leaf: + +- target draft leaf 获得 source 的 `sessionId` +- source 原 leaf 被移除 +- source 原路径上的单子节点 split 继续按现有规则 collapse + +#### 3. Drop Left / Right / Top / Bottom On Session Pane + +采用“包裹目标 leaf”的插入规则: + +1. 先把 source leaf 从旧位置移除 +2. 旧树若出现只剩一个 child 的 split,则沿用现有 collapse 规则 +3. 在 target leaf 原位置创建一个新的二叉 split +4. 按 drop 方向决定 child 顺序: + - `left` => `split(horizontal, [source, target])` + - `right` => `split(horizontal, [target, source])` + - `top` => `split(vertical, [source, target])` + - `bottom` => `split(vertical, [target, source])` + +#### 4. Invalid Drops + +以下情况直接 no-op: + +- source 拖到自己 +- target 不存在 +- drop 区域无效 +- source 已在 target draft center 的等价位置 +- 会导致无结构变化的重复提交 + +## Architecture + +本次设计继续沿用现有 `agent-panes` 的分层: + +1. 视图层:负责 drag handle、hover overlay、drop surface +2. 控制层:负责当前拖拽状态与 drop intent 决策 +3. 树操作层:负责纯函数变换 `PaneNode` + +### View Layer + +主要文件: + +- `packages/web/src/features/agent-panes/index.tsx` +- `packages/web/src/features/agent-panes/views/shared/session-card.tsx` +- `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx` + +职责: + +- 为 session pane 暴露 drag handle +- 为 leaf pane 暴露 drop surface +- 根据 controller 的 hover state 绘制五向或单向 overlay +- 不直接改 pane tree + +### Controller Layer + +新增一个 workspace-scoped drag controller hook,例如: + +- `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts` + +职责: + +- 维护拖拽过程中的瞬时 UI 状态 +- 记录 source 与 hover target +- 根据 pointer 位置计算 drop placement +- 在 `pointerup` 时生成最终 `dropIntent` +- 调用 `usePaneActions` 暴露的新 mutation + +推荐状态: + +```ts +interface PaneDragState { + isDragging: boolean; + sourcePaneId: string | null; + sourceSessionId: string | null; + hoverTargetPaneId: string | null; + hoverPlacement: "left" | "right" | "top" | "bottom" | "center" | null; +} +``` + +### Tree Mutation Layer + +主要文件: + +- `packages/web/src/features/agent-panes/pane-layout-tree.ts` +- `packages/web/src/features/agent-panes/actions/use-pane-actions.ts` + +职责: + +- 对 `PaneNode` 做纯函数变换 +- 返回新的 pane tree +- 继续通过 `applyLayout()` 写入 jotai + `persistUiState({ paneLayout })` + +## Tree Identity Rules + +拖拽逻辑内部一律以 `paneId` 定位 leaf,不以 `sessionId` 定位位置。 + +原因: + +- draft pane 没有 `sessionId` +- 移动和交换后 `sessionId` 会变化 +- `paneId` 更适合作为“位置身份” +- `center swap` 语义更适合交换 `sessionId`,而不是交换节点身份 + +结论: + +- `paneId` 表示布局中的位置 +- `sessionId` 表示这个位置当前承载的 session 内容 + +## Tree Mutation Rules + +### Recommended Pure Helpers + +建议在 `pane-layout-tree.ts` 中新增以下纯函数: + +- `extractLeafByPaneId(node, paneId)` +- `swapPaneSessionsByPaneId(node, sourcePaneId, targetPaneId)` +- `moveSessionToDraftPane(node, sourcePaneId, targetPaneId)` +- `insertPaneAtEdge(node, sourcePaneId, targetPaneId, placement)` +- `wrapLeafWithSplit(node, targetPaneId, direction, order, incomingSessionId)` + +这些函数的输入输出只处理 `PaneNode`,不接触 DOM 或 React state。 + +### Source Extraction + +`extractLeafByPaneId()` 负责: + +- 找到 source leaf +- 返回一个去掉 source 的新 tree +- 如果某个 split 删除 child 后只剩一个 child,则 collapse 为该 child +- 如果整棵树被删空,则返回一个空 root leaf + +### Center Swap + +`swapPaneSessionsByPaneId()` 只交换两个 leaf 的 `sessionId`: + +- 不交换 `paneId` +- 不改 child 顺序 +- 不新建 split +- 不影响 ratio + +### Move To Draft Center + +`moveSessionToDraftPane()` 分两步: + +1. 从 source 位置提取 session +2. 把这个 `sessionId` 填入 target draft leaf + +效果: + +- target draft 变成 session pane +- source 原位置被移除并触发必要 collapse + +### Edge Insert + +`insertPaneAtEdge()` 分三步: + +1. `extractLeafByPaneId(sourcePaneId)` +2. 用 `targetPaneId` 在新树里重新定位 target leaf +3. 调用 `wrapLeafWithSplit()` 生成新的二叉 split + +新 split 的方向和 child 顺序: + +- `left` => `direction = "horizontal"`, children = `[source, target]` +- `right` => `direction = "horizontal"`, children = `[target, source]` +- `top` => `direction = "vertical"`, children = `[source, target]` +- `bottom` => `direction = "vertical"`, children = `[target, source]` + +### Draft Edge Drops + +V1 明确不支持 draft pane 的边缘插入: + +- draft pane 只接受 `center` +- 这样避免“空槽位再包一层 split”的交互歧义 +- 同时减少 helper 分叉和 hover UI 复杂度 + +## Split IDs And Ratios + +### Existing Rule + +- pane 结构持久化到服务端 +- split ratio 通过 `readPaneRatio()` / `writePaneRatio()` 保存在本地 + +### New Rule + +#### Center Swap + +- 不新建 split +- ratio 全部保留 + +#### Move To Draft Center + +- 不新建 split +- ratio 只会因 source 原路径上的 split 被 collapse 而自然消失 + +#### Edge Insert + +- 创建新的 `splitId` +- 新 split 默认 `ratio = 0.5` + +### No Ratio Inheritance + +V1 不尝试继承 source 旧 split 的 ratio。 + +原因: + +- source 旧空间关系已经失效 +- 强行继承比例会让新布局结果难以预测 +- 默认 `0.5` 更稳定,也符合现有 split 初始行为 + +### Local Ratio Cleanup + +V1 不主动清理 localStorage 中失效的旧 split ratio key。 + +原因: + +- 这些 key 变成无引用数据后不会影响渲染 +- 清理逻辑可以独立作为后续维护项 +- 当前阶段应优先降低拖拽功能的实现复杂度 + +## Interaction Model + +### Drag Handle + +`SessionCard` header 增加一个显式 drag handle: + +- 仅 handle 响应 `pointerdown` +- split / close 等按钮维持原有点击职责 +- 不从 terminal 区域开启拖拽 + +### Global Dragging State + +开始拖拽后给 `document.body` 添加全局 class,例如: + +- `is-dragging-pane` + +用途: + +- 统一 cursor +- `user-select: none` +- 控制 overlay、hover 和交互禁用样式 + +### Drag Preview + +V1 使用自绘轻量浮层,而不是浏览器原生 drag image。 + +预览内容只需包含: + +- session title +- provider +- state dot / badge + +不渲染真实 terminal 内容。 + +### Hit Testing + +drop target 只挂在 leaf pane 上,不挂在 split container 上。 + +对 session pane: + +- 读取 target pane 的 `DOMRect` +- 切成五块: + - left strip + - right strip + - top strip + - bottom strip + - center rect + +边缘带宽建议: + +- 默认取 pane 宽/高的 `22%` +- 最小 `48px` +- 最大 `96px` + +命中优先级: + +1. 先判定四边 +2. 四边都不命中时落到 center + +对 draft pane: + +- 整块 pane 都视为 `center` target +- 不显示四边命中区 + +### Hover Feedback + +session pane hover 时显示五向 overlay: + +- 当前命中的方向高亮 +- 其它方向可弱提示或不显示 + +draft pane hover 时: + +- 整块 center 高亮 +- 可显示轻量文案,例如 `Move here` + +### Invalid Target Handling + +以下 target 不显示可 drop 样式: + +- source pane 自己 +- 被判断为 no-op 的无效投放点 +- controller 无法解析 placement 的区域 + +## Event Handling Rules + +### Start + +- drag handle 上的 `pointerdown` 必须 `stopPropagation()` +- 避免触发 session card 当前已有的 focus / active click 逻辑 + +### During Drag + +- 浮层预览必须 `pointer-events: none` +- hover 命中计算不依赖 `event.target` +- 命中统一通过 pane ref map + pointer 坐标计算 + +这样可以避免以下干扰: + +- overlay 自己挡住事件 +- terminal 内部子元素影响命中 +- header / badge / button 嵌套结构影响 target 判断 + +### End + +- `pointerup` 时由 controller 生成 `dropIntent` +- 仅当 `dropIntent` 有效时调用 pane action +- 无效 intent 直接清空 drag state + +## Persistence + +拖拽完成后的布局变更继续走现有持久化链路: + +- `setPaneLayout(next)` +- `persistUiState({ paneLayout: next })` + +不新增后端接口,不修改 workspace 数据结构。 + +## Recommended Pane Actions + +在 `use-pane-actions.ts` 中新增面向拖拽的 mutation,例如: + +- `swapPaneSessions(sourcePaneId, targetPaneId)` +- `moveSessionToDraftPane(sourcePaneId, targetPaneId)` +- `insertPaneAtEdge(sourcePaneId, targetPaneId, placement)` + +这些 action 继续复用现有 `applyLayout()`。 + +## Testing + +### Tree Unit Tests + +重点覆盖 `pane-layout-tree.ts` 的纯函数: + +1. `center` 交换两个 session pane,只互换 `sessionId` +2. source 移动到 draft center +3. `left/right/top/bottom` 插入后创建新 split +4. source 移除后旧父 split 正确 collapse +5. 拖到自己 no-op +6. target 不存在 no-op +7. draft pane 只接受 center 的数据层约束 + +### Component Tests + +重点覆盖视图与 controller: + +1. 只有 header handle 能触发 drag start +2. session pane 会根据 pointer 位置算出正确 placement +3. draft pane 只返回 `center` +4. 有效 drop 会调用正确的 pane action +5. 无效 target 不触发 mutation + +### E2E + +V1 至少覆盖两条高价值路径: + +1. 两个 session pane 互换位置 +2. 一个 session pane 移动到 draft pane + +## Rollout Plan + +建议按以下顺序实现: + +1. 先新增 tree helpers 和单测 +2. 再扩展 `usePaneActions` +3. 再接入 `SessionCard` drag handle、controller 和 overlay +4. 最后补组件测试和最小 e2e + +## Risks + +- 命中区如果抖动,会让 hover feedback 和 drop 结果不稳定 +- terminal 区域若被错误纳入 drag start,会破坏文本选择和输入体验 +- source 抽取后再定位 target 的逻辑如果不严格依赖 `paneId`,容易产生错误重排 +- 新增 overlay 如果参与命中,会污染 target 判断 + +## Open Questions + +无。v1 的交互语义、draft pane 规则、数据边界和桌面端范围都已确认。 From dec50c47eb10e7962c34d8db695025a17de61ecc Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:02:26 +0800 Subject: [PATCH 13/26] docs: refine workspace navigation shortcuts design and plan --- ...26-05-24-workspace-navigation-shortcuts.md | 220 +++++++++++++----- ...4-workspace-navigation-shortcuts-design.md | 6 +- 2 files changed, 163 insertions(+), 63 deletions(-) diff --git a/docs/superpowers/plans/2026-05-24-workspace-navigation-shortcuts.md b/docs/superpowers/plans/2026-05-24-workspace-navigation-shortcuts.md index 131fb55e..869ccfbd 100644 --- a/docs/superpowers/plans/2026-05-24-workspace-navigation-shortcuts.md +++ b/docs/superpowers/plans/2026-05-24-workspace-navigation-shortcuts.md @@ -4,7 +4,7 @@ **Goal:** Add desktop keyboard shortcuts for spatial session navigation with `Ctrl+Arrow` and workspace tab navigation with `Ctrl+Shift+ArrowLeft/ArrowRight`. -**Architecture:** Extend the existing shortcut registry so the new bindings are first-class and visible in settings. Add a desktop workspace navigation hook that resolves configured bindings, uses a pure pane-neighbor helper to find adjacent session targets from the server-backed pane layout tree, and reuses existing workspace/session persistence actions for state updates. +**Architecture:** Extend the existing shortcut registry so the new session bindings are first-class and visible in settings while rebinding the existing workspace-tab actions to directional `Ctrl+Shift+Arrow` defaults. Add a desktop workspace navigation hook that resolves configured bindings, uses a pure pane-neighbor helper to find adjacent session targets from the server-backed pane layout tree, and reuses existing workspace/session persistence actions for state updates. **Tech Stack:** React 19, Jotai, Vitest, Testing Library, shared shortcut settings UI, existing workspace/session persistence hooks, and the server-backed agent pane layout tree. @@ -23,7 +23,9 @@ **Modify:** - `packages/web/src/lib/shortcuts.ts` — register new shortcut actions and support explicit `Ctrl` plus arrow-key parsing/formatting/matching +- `packages/web/src/features/settings/components/shortcuts-settings.tsx` — preserve explicit `Ctrl+Arrow*` bindings when recording workspace navigation shortcuts - `packages/web/src/features/settings/components/shortcuts-settings.test.tsx` — ensure the new bindings appear in settings and render with expected text +- `packages/web/src/features/workspace/index.test.tsx` — cover the desktop view mounting path for workspace navigation shortcuts - `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` — mount the navigation shortcut hook alongside existing desktop workspace listeners **Existing files reused without structural changes:** @@ -46,6 +48,7 @@ **Files:** - Create: `packages/web/src/lib/shortcuts.test.ts` - Modify: `packages/web/src/lib/shortcuts.ts` +- Modify: `packages/web/src/features/settings/components/shortcuts-settings.tsx` - Modify: `packages/web/src/features/settings/components/shortcuts-settings.test.tsx` - [ ] **Step 1: Write the failing shortcut utility tests** @@ -83,6 +86,21 @@ describe("shortcuts", () => { ).toBe(false); }); + it("does not match extra modifiers against a narrower binding", () => { + expect( + matchesShortcut( + new KeyboardEvent("keydown", { key: "ArrowRight", ctrlKey: true, shiftKey: true }), + "Ctrl+ArrowRight" + ) + ).toBe(false); + expect( + matchesShortcut( + new KeyboardEvent("keydown", { key: "ArrowRight", ctrlKey: true, shiftKey: true }), + "Ctrl+Shift+ArrowRight" + ) + ).toBe(true); + }); + it("formats arrow bindings for display", () => { vi.stubGlobal("navigator", { platform: "Linux x86_64" }); expect(formatShortcut("Ctrl+Shift+ArrowRight")).toBe("Ctrl+⇧+→"); @@ -94,7 +112,7 @@ describe("shortcuts", () => { expect.arrayContaining([ expect.objectContaining({ id: "session.navigate.left", defaultBinding: "Ctrl+ArrowLeft" }), expect.objectContaining({ - id: "workspace.navigate.next", + id: "workspace.next", defaultBinding: "Ctrl+Shift+ArrowRight", }), ]) @@ -103,20 +121,48 @@ describe("shortcuts", () => { }); ``` -- [ ] **Step 2: Write the failing settings rendering test** +- [ ] **Step 2: Write the failing settings tests** -Add this case to `packages/web/src/features/settings/components/shortcuts-settings.test.tsx`: +Add these cases to `packages/web/src/features/settings/components/shortcuts-settings.test.tsx`: ```ts it("renders the new workspace navigation shortcuts in the settings list", async () => { renderShortcutsSettings(); expect(await screen.findByText("命令面板")).toBeInTheDocument(); - expect(screen.getByText("切换到左侧会话")).toBeInTheDocument(); - expect(screen.getByText("切换到右侧工作区")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "工作区" })); + + expect(await screen.findByText("切换到左侧会话")).toBeInTheDocument(); + expect(screen.getByText("下一个工作区")).toBeInTheDocument(); expect(screen.getByText("Ctrl+←")).toBeInTheDocument(); expect(screen.getByText("Ctrl+⇧+→")).toBeInTheDocument(); }); + +it("captures explicit ctrl arrow bindings for workspace shortcuts", async () => { + const sendCommand = vi.fn().mockResolvedValue({}); + const { store } = renderShortcutsSettings(sendCommand); + + fireEvent.click(screen.getByRole("tab", { name: "工作区" })); + fireEvent.click(await screen.findByText("Ctrl+←")); + fireEvent.keyDown(screen.getByRole("textbox", { name: "切换到左侧会话" }), { + key: "ArrowDown", + ctrlKey: true, + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { shortcuts: { "session.navigate.left": "Ctrl+ArrowDown" } }, + }, + undefined + ); + }); + + expect(store.get(customShortcutsAtom)).toMatchObject({ + "session.navigate.left": "Ctrl+ArrowDown", + }); +}); ``` - [ ] **Step 3: Run the targeted tests to verify failure** @@ -130,7 +176,7 @@ pnpm --filter @coder-studio/web exec vitest run \ ``` Expected: -- FAIL because `shortcuts.ts` does not yet define the navigation actions or format arrow keys +- FAIL because `shortcuts.ts` does not yet define the navigation actions, `matchesShortcut` still accepts extra modifiers, and `shortcuts-settings.tsx` does not yet preserve explicit `Ctrl+Arrow*` captures - [ ] **Step 4: Implement the minimal shortcut registry changes** @@ -168,15 +214,15 @@ Update `packages/web/src/lib/shortcuts.ts` so it: category: "workspace", }, { - id: "workspace.navigate.previous", - name: "切换到左侧工作区", + id: "workspace.previous", + name: "上一个工作区", description: "切换到上一个工作区标签", defaultBinding: "Ctrl+Shift+ArrowLeft", category: "workspace", }, { - id: "workspace.navigate.next", - name: "切换到右侧工作区", + id: "workspace.next", + name: "下一个工作区", description: "切换到下一个工作区标签", defaultBinding: "Ctrl+Shift+ArrowRight", category: "workspace", @@ -193,23 +239,41 @@ const formatted = binding .replace("ArrowDown", "↓"); ``` -- supports explicit modifier matching in `matchesShortcut`: +- tightens `matchesShortcut` so it requires an exact modifier set: ```ts const ctrlPressed = event.ctrlKey; const metaPressed = event.metaKey; - -for (const modifier of modifiers) { - if (modifier === "Mod" && !(isMac ? metaPressed : ctrlPressed)) return false; - if (modifier === "Ctrl" && !ctrlPressed) return false; - if (modifier === "Meta" && !metaPressed) return false; - if (modifier === "Shift" && !shiftPressed) return false; - if (modifier === "Alt" && !altPressed) return false; -} +const expectedCtrl = modifiers.includes("Ctrl") || (!isMac && modifiers.includes("Mod")); +const expectedMeta = modifiers.includes("Meta") || (isMac && modifiers.includes("Mod")); +const expectedShift = modifiers.includes("Shift"); +const expectedAlt = modifiers.includes("Alt"); + +if (ctrlPressed !== expectedCtrl) return false; +if (metaPressed !== expectedMeta) return false; +if (shiftPressed !== expectedShift) return false; +if (altPressed !== expectedAlt) return false; ``` - keeps `parseShortcut` unchanged except for preserving arrow-key names as the `key` +- updates `packages/web/src/features/settings/components/shortcuts-settings.tsx` so shortcut capture stores `Ctrl+Arrow*` for explicit arrow-navigation bindings while keeping legacy `Mod+letter` capture behavior for the existing shortcuts: + +```ts +const isArrowKey = event.key.startsWith("Arrow"); + +if (isMac) { + if (event.metaKey) { + parts.push("Mod"); + } + if (event.ctrlKey) { + parts.push("Ctrl"); + } +} else if (event.ctrlKey) { + parts.push(isArrowKey ? "Ctrl" : "Mod"); +} +``` + - [ ] **Step 5: Run the targeted tests to verify they pass** Run: @@ -231,6 +295,7 @@ Run: git add \ packages/web/src/lib/shortcuts.ts \ packages/web/src/lib/shortcuts.test.ts \ + packages/web/src/features/settings/components/shortcuts-settings.tsx \ packages/web/src/features/settings/components/shortcuts-settings.test.tsx git commit -m "feat: register workspace navigation shortcuts" ``` @@ -512,6 +577,7 @@ git commit -m "feat: add spatial pane navigation helper" **Files:** - Create: `packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.ts` - Create: `packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx` +- Modify: `packages/web/src/features/workspace/index.test.tsx` - Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` - [ ] **Step 1: Write the failing hook tests** @@ -527,6 +593,7 @@ import { wsClientAtom } from "../../../atoms/connection"; import { activeWorkspaceIdAtom, workspaceOrderAtom, workspacesAtom } from "../../../atoms/workspaces"; import { lastViewedTargetAtom } from "../../../atoms/app-ui"; import { paneLayoutAtomFamily } from "../../agent-panes/atoms/pane-layout"; +import { seedReadyWorkspaceState } from "../../../test-utils/workspace-state"; import { useWorkspaceNavigationShortcuts } from "./use-workspace-navigation-shortcuts"; function Harness() { @@ -556,8 +623,7 @@ describe("useWorkspaceNavigationShortcuts", () => { store.set(wsClientAtom, { sendCommand, subscribe: vi.fn(() => () => {}) } as never); store.set(customShortcutsAtom, {}); store.set(activeWorkspaceIdAtom, "ws-1"); - store.set(workspaceOrderAtom, ["ws-1", "ws-2"]); - store.set(workspacesAtom, { + seedReadyWorkspaceState(store, { "ws-1": { id: "ws-1", path: "/tmp/one", @@ -575,6 +641,7 @@ describe("useWorkspaceNavigationShortcuts", () => { uiState: { leftPanelWidth: 280, bottomPanelHeight: 200, focusMode: false }, }, }); + store.set(workspaceOrderAtom, ["ws-1", "ws-2"]); store.set(paneLayoutAtomFamily("ws-1"), { id: "root", type: "split", @@ -600,16 +667,21 @@ describe("useWorkspaceNavigationShortcuts", () => { sessionId: "sess_2", }); }); + expect(store.get(workspacesAtom)["ws-1"]?.uiState.activeSessionId).toBe("sess_2"); }); - it("switches to the next workspace on ctrl shift right", async () => { - const sendCommand = vi.fn().mockResolvedValue({ workspaceId: "ws-2", updatedAt: 10 }); + it("switches to the next workspace on ctrl shift right without matching session navigation", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args: unknown) => { + if (op === "workspace.lastViewedTarget.set") { + return { ...(args as object), updatedAt: 10 }; + } + return {}; + }); const store = createStore(); store.set(wsClientAtom, { sendCommand, subscribe: vi.fn(() => () => {}) } as never); store.set(customShortcutsAtom, {}); store.set(activeWorkspaceIdAtom, "ws-1"); - store.set(workspaceOrderAtom, ["ws-1", "ws-2"]); - store.set(workspacesAtom, { + seedReadyWorkspaceState(store, { "ws-1": { id: "ws-1", path: "/tmp/one", @@ -627,6 +699,7 @@ describe("useWorkspaceNavigationShortcuts", () => { uiState: { leftPanelWidth: 280, bottomPanelHeight: 200, focusMode: false }, }, }); + store.set(workspaceOrderAtom, ["ws-1", "ws-2"]); store.set(paneLayoutAtomFamily("ws-1"), { id: "root", type: "leaf", sessionId: "sess_1" }); render( @@ -640,6 +713,12 @@ describe("useWorkspaceNavigationShortcuts", () => { await waitFor(() => { expect(store.get(activeWorkspaceIdAtom)).toBe("ws-2"); }); + await waitFor(() => { + expect(store.get(lastViewedTargetAtom)).toMatchObject({ + workspaceId: "ws-2", + }); + }); + expect(store.get(workspacesAtom)["ws-1"]?.uiState.activeSessionId).toBeUndefined(); }); }); ``` @@ -650,11 +729,30 @@ Add this case to `packages/web/src/features/workspace/index.test.tsx`: ```ts it("uses workspace navigation shortcuts to switch the active workspace", async () => { - const sendCommand = vi.fn().mockResolvedValue({ workspaceId: "ws-b", updatedAt: 10 }); + const sendCommand = vi.fn().mockImplementation(async (op: string, args: unknown) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + if (op === "session.list") { + return []; + } + if (op === "workspace.lastViewedTarget.set") { + return { ...(args as object), updatedAt: 10 }; + } + return {}; + }); const store = createStore(); store.set(connectionStatusAtom, "connected"); store.set(wsClientAtom, { sendCommand, subscribe: vi.fn(() => () => {}) } as never); - store.set(workspacesAtom, { + seedReadyWorkspaceState(store, { "ws-a": { id: "ws-a", path: "/tmp/a", @@ -690,6 +788,11 @@ it("uses workspace navigation shortcuts to switch the active workspace", async ( await waitFor(() => { expect(store.get(activeWorkspaceIdAtom)).toBe("ws-b"); }); + await waitFor(() => { + expect(store.get(lastViewedTargetAtom)).toMatchObject({ + workspaceId: "ws-b", + }); + }); }); ``` @@ -705,6 +808,7 @@ pnpm --filter @coder-studio/web exec vitest run \ Expected: - FAIL because the navigation hook is not implemented or mounted yet +- FAIL because the navigation hook is not implemented or mounted yet and the desktop view does not attach it - [ ] **Step 4: Implement the desktop navigation hook** @@ -743,6 +847,31 @@ export function useWorkspaceNavigationShortcuts(workspaceId: string) { } const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + + const currentIndex = workspaceOrder.indexOf(workspaceId); + const previousBinding = getEffectiveBinding("workspace.previous", customBindings); + const nextBinding = getEffectiveBinding("workspace.next", customBindings); + + if (previousBinding && matchesShortcut(event, previousBinding) && currentIndex > 0) { + event.preventDefault(); + void selectWorkspaceTarget(workspaceOrder[currentIndex - 1]!); + return; + } + + if (nextBinding && matchesShortcut(event, nextBinding) && currentIndex >= 0) { + const nextWorkspaceId = workspaceOrder[currentIndex + 1]; + if (!nextWorkspaceId) { + event.preventDefault(); + return; + } + event.preventDefault(); + void selectWorkspaceTarget(nextWorkspaceId); + return; + } + for (const [shortcutId, direction] of Object.entries(SESSION_DIRECTION_BY_SHORTCUT)) { const binding = getEffectiveBinding(shortcutId, customBindings); if (!binding || !matchesShortcut(event, binding)) { @@ -765,26 +894,6 @@ export function useWorkspaceNavigationShortcuts(workspaceId: string) { void persistUiState({ activeSessionId: nextSessionId }); return; } - - const previousBinding = getEffectiveBinding("workspace.navigate.previous", customBindings); - const nextBinding = getEffectiveBinding("workspace.navigate.next", customBindings); - const currentIndex = workspaceOrder.indexOf(workspaceId); - - if (previousBinding && matchesShortcut(event, previousBinding) && currentIndex > 0) { - event.preventDefault(); - void selectWorkspaceTarget(workspaceOrder[currentIndex - 1]!); - return; - } - - if (nextBinding && matchesShortcut(event, nextBinding) && currentIndex >= 0) { - const nextWorkspaceId = workspaceOrder[currentIndex + 1]; - if (!nextWorkspaceId) { - event.preventDefault(); - return; - } - event.preventDefault(); - void selectWorkspaceTarget(nextWorkspaceId); - } }; window.addEventListener("keydown", handleKeyDown); @@ -875,22 +984,11 @@ Confirm the run shows: - workspace navigation shortcut hook passing - desktop workspace integration passing -- [ ] **Step 3: Commit the plan artifact** - -Run: - -```bash -git add docs/superpowers/plans/2026-05-24-workspace-navigation-shortcuts.md -git commit -m "docs: add workspace navigation shortcuts implementation plan" -``` - ---- - ## Self-Review ### Spec coverage -- shortcut registry and settings visibility: covered in Task 1 +- shortcut registry and settings visibility, including explicit `Ctrl+Arrow*` capture: covered in Task 1 - spatial session navigation based on pane geometry: covered in Task 2 - desktop workspace runtime shortcut handling: covered in Task 3 - session state persistence and workspace switching semantics: covered in Task 3 @@ -904,4 +1002,4 @@ The plan avoids `TODO`, `TBD`, and “write tests for the above” style placeho - `findAdjacentSessionId(layout, activeSessionId, direction)` is introduced in Task 2 and consumed with the same signature in Task 3 - `useWorkspaceNavigationShortcuts(workspaceId)` is defined in Task 3 and mounted with the same single-argument signature in `workspace-desktop-view.tsx` -- shortcut IDs used in tests and runtime match the IDs introduced in Task 1 +- workspace-tab runtime bindings continue to use the existing `workspace.previous` and `workspace.next` IDs while the new directional session bindings use the `session.navigate.*` IDs introduced in Task 1 diff --git a/docs/superpowers/specs/2026-05-24-workspace-navigation-shortcuts-design.md b/docs/superpowers/specs/2026-05-24-workspace-navigation-shortcuts-design.md index 17457298..fa2ab34c 100644 --- a/docs/superpowers/specs/2026-05-24-workspace-navigation-shortcuts-design.md +++ b/docs/superpowers/specs/2026-05-24-workspace-navigation-shortcuts-design.md @@ -171,8 +171,8 @@ Add shortcut definitions for: - `session.navigate.right` - `session.navigate.up` - `session.navigate.down` -- `workspace.navigate.previous` -- `workspace.navigate.next` +- existing `workspace.previous` +- existing `workspace.next` Default bindings: @@ -187,8 +187,10 @@ The shortcut utility layer must be extended so it can: - parse explicit modifiers beyond `Mod` - distinguish `Ctrl` from `Mod` +- reject extra modifiers so `Ctrl+Shift+ArrowRight` does not also match `Ctrl+ArrowRight` - correctly match arrow keys - format arrow bindings for the settings UI +- preserve explicit `Ctrl+Arrow*` bindings when the settings UI records a shortcut while keeping existing `Mod+letter` behavior intact This is a targeted extension, not a full keyboard abstraction rewrite. From 729028ec0bb9fb6e158c2d5a55a68026e606a01d Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:08:08 +0800 Subject: [PATCH 14/26] feat: register workspace navigation shortcuts --- .../components/shortcuts-settings.test.tsx | 44 ++++++++++++- .../components/shortcuts-settings.tsx | 8 ++- packages/web/src/lib/shortcuts.test.ts | 66 +++++++++++++++++++ packages/web/src/lib/shortcuts.ts | 61 ++++++++++++----- 4 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 packages/web/src/lib/shortcuts.test.ts diff --git a/packages/web/src/features/settings/components/shortcuts-settings.test.tsx b/packages/web/src/features/settings/components/shortcuts-settings.test.tsx index f6b4a9a5..22d41d22 100644 --- a/packages/web/src/features/settings/components/shortcuts-settings.test.tsx +++ b/packages/web/src/features/settings/components/shortcuts-settings.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { wsClientAtom } from "../../../atoms/connection"; @@ -31,6 +31,7 @@ function renderShortcutsSettings( describe("ShortcutsSettings", () => { beforeEach(() => { vi.restoreAllMocks(); + vi.spyOn(window.navigator, "platform", "get").mockReturnValue("Win32"); }); afterEach(() => { @@ -105,4 +106,45 @@ describe("ShortcutsSettings", () => { }); expect(screen.getByText("Ctrl+⇧+K")).toBeInTheDocument(); }); + + it("shows workspace navigation shortcuts in the 工作区 tab", async () => { + renderShortcutsSettings(); + + fireEvent.click(screen.getByRole("tab", { name: "工作区" })); + + expect(await screen.findByText("切换到左侧会话")).toBeInTheDocument(); + expect(screen.getByText("下一个工作区")).toBeInTheDocument(); + expect(screen.getByText("Ctrl+←")).toBeInTheDocument(); + expect(screen.getByText("Ctrl+⇧+→")).toBeInTheDocument(); + }); + + it("captures Ctrl+ArrowDown for session.navigate.left and persists it", async () => { + const sendCommand = vi.fn().mockResolvedValue({}); + const { store } = renderShortcutsSettings(sendCommand); + + fireEvent.click(screen.getByRole("tab", { name: "工作区" })); + fireEvent.click(await screen.findByText("Ctrl+←")); + fireEvent.keyDown(screen.getByRole("textbox", { name: "切换到左侧会话" }), { + key: "ArrowDown", + ctrlKey: true, + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { shortcuts: { "session.navigate.left": "Ctrl+ArrowDown" } }, + }, + undefined + ); + }); + + expect(store.get(customShortcutsAtom)).toMatchObject({ + "session.navigate.left": "Ctrl+ArrowDown", + }); + + const shortcutRow = screen.getByText("切换到左侧会话").closest(".shortcuts-item"); + expect(shortcutRow).not.toBeNull(); + expect(within(shortcutRow as HTMLElement).getByText("Ctrl+↓")).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/settings/components/shortcuts-settings.tsx b/packages/web/src/features/settings/components/shortcuts-settings.tsx index f00c820a..f38c22c6 100644 --- a/packages/web/src/features/settings/components/shortcuts-settings.tsx +++ b/packages/web/src/features/settings/components/shortcuts-settings.tsx @@ -49,8 +49,14 @@ export function ShortcutsSettings() { // Build binding string const parts: string[] = []; const isMac = navigator.platform.includes("Mac"); + const isArrowKey = event.key.startsWith("Arrow"); - if (isMac ? event.metaKey : event.ctrlKey) { + if (isMac && event.metaKey) { + parts.push("Mod"); + } + if (event.ctrlKey && (isMac || isArrowKey)) { + parts.push("Ctrl"); + } else if (!isMac && event.ctrlKey) { parts.push("Mod"); } if (event.shiftKey) { diff --git a/packages/web/src/lib/shortcuts.test.ts b/packages/web/src/lib/shortcuts.test.ts new file mode 100644 index 00000000..02119451 --- /dev/null +++ b/packages/web/src/lib/shortcuts.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SHORTCUTS, formatShortcut, matchesShortcut, parseShortcut } from "./shortcuts"; + +describe("shortcuts", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(window.navigator, "platform", "get").mockReturnValue("Win32"); + }); + + it("parses Ctrl+Shift+ArrowLeft", () => { + expect(parseShortcut("Ctrl+Shift+ArrowLeft")).toEqual({ + modifiers: ["Ctrl", "Shift"], + key: "ArrowLeft", + }); + }); + + it("matches Ctrl+ArrowLeft while rejecting bare arrows", () => { + expect( + matchesShortcut( + new KeyboardEvent("keydown", { key: "ArrowLeft", ctrlKey: true }), + "Ctrl+ArrowLeft" + ) + ).toBe(true); + + expect( + matchesShortcut(new KeyboardEvent("keydown", { key: "ArrowLeft" }), "Ctrl+ArrowLeft") + ).toBe(false); + }); + + it("rejects extra modifiers for narrower bindings while matching exact Ctrl+Shift+ArrowRight", () => { + expect( + matchesShortcut( + new KeyboardEvent("keydown", { key: "ArrowRight", ctrlKey: true, shiftKey: true }), + "Ctrl+ArrowRight" + ) + ).toBe(false); + + expect( + matchesShortcut( + new KeyboardEvent("keydown", { key: "ArrowRight", ctrlKey: true, shiftKey: true }), + "Ctrl+Shift+ArrowRight" + ) + ).toBe(true); + }); + + it("formats arrow bindings as Ctrl+⇧+→", () => { + expect(formatShortcut("Ctrl+Shift+ArrowRight")).toBe("Ctrl+⇧+→"); + }); + + it("includes directional defaults for session navigation and workspace switching", () => { + expect(DEFAULT_SHORTCUTS).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "session.navigate.left", + defaultBinding: "Ctrl+ArrowLeft", + }), + expect.objectContaining({ + id: "workspace.next", + defaultBinding: "Ctrl+Shift+ArrowRight", + }), + ]) + ); + }); +}); diff --git a/packages/web/src/lib/shortcuts.ts b/packages/web/src/lib/shortcuts.ts index db26ca61..30240f23 100644 --- a/packages/web/src/lib/shortcuts.ts +++ b/packages/web/src/lib/shortcuts.ts @@ -35,15 +35,15 @@ export const DEFAULT_SHORTCUTS: ShortcutDefinition[] = [ id: "workspace.previous", name: "上一个工作区", description: "切换到上一个工作区标签", - defaultBinding: "Mod+Shift+[", - category: "global", + defaultBinding: "Ctrl+Shift+ArrowLeft", + category: "workspace", }, { id: "workspace.next", name: "下一个工作区", description: "切换到下一个工作区标签", - defaultBinding: "Mod+Shift+]", - category: "global", + defaultBinding: "Ctrl+Shift+ArrowRight", + category: "workspace", }, { id: "focus-mode.toggle", @@ -60,6 +60,34 @@ export const DEFAULT_SHORTCUTS: ShortcutDefinition[] = [ defaultBinding: "Mod+D", category: "workspace", }, + { + id: "session.navigate.left", + name: "切换到左侧会话", + description: "将焦点切换到左侧会话", + defaultBinding: "Ctrl+ArrowLeft", + category: "workspace", + }, + { + id: "session.navigate.right", + name: "切换到右侧会话", + description: "将焦点切换到右侧会话", + defaultBinding: "Ctrl+ArrowRight", + category: "workspace", + }, + { + id: "session.navigate.up", + name: "切换到上方会话", + description: "将焦点切换到上方会话", + defaultBinding: "Ctrl+ArrowUp", + category: "workspace", + }, + { + id: "session.navigate.down", + name: "切换到下方会话", + description: "将焦点切换到下方会话", + defaultBinding: "Ctrl+ArrowDown", + category: "workspace", + }, { id: "agent.split-horizontal", name: "水平分屏", @@ -104,8 +132,13 @@ export function parseShortcut(binding: string): { export function formatShortcut(binding: string): string { return binding .replace("Mod", navigator.platform.includes("Mac") ? "⌘" : "Ctrl") + .replace("Ctrl", "Ctrl") .replace("Shift", "⇧") - .replace("Alt", navigator.platform.includes("Mac") ? "⌥" : "Alt"); + .replace("Alt", navigator.platform.includes("Mac") ? "⌥" : "Alt") + .replace("ArrowLeft", "←") + .replace("ArrowRight", "→") + .replace("ArrowUp", "↑") + .replace("ArrowDown", "↓"); } /** @@ -114,17 +147,15 @@ export function formatShortcut(binding: string): string { export function matchesShortcut(event: KeyboardEvent, binding: string): boolean { const { modifiers, key } = parseShortcut(binding); const isMac = navigator.platform.includes("Mac"); + const expectedCtrl = modifiers.includes("Ctrl") || (!isMac && modifiers.includes("Mod")); + const expectedMeta = isMac && modifiers.includes("Mod"); + const expectedShift = modifiers.includes("Shift"); + const expectedAlt = modifiers.includes("Alt"); - const modPressed = isMac ? event.metaKey : event.ctrlKey; - const shiftPressed = event.shiftKey; - const altPressed = event.altKey; - - // Check modifiers - for (const modifier of modifiers) { - if (modifier === "Mod" && !modPressed) return false; - if (modifier === "Shift" && !shiftPressed) return false; - if (modifier === "Alt" && !altPressed) return false; - } + if (event.ctrlKey !== expectedCtrl) return false; + if (event.metaKey !== expectedMeta) return false; + if (event.shiftKey !== expectedShift) return false; + if (event.altKey !== expectedAlt) return false; // Check key const eventKey = event.key.toLowerCase(); From b94fd9e72399ef2e550da0850ec32420be2832b6 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:16:06 +0800 Subject: [PATCH 15/26] fix mac shortcut capture narrowing --- .../components/shortcuts-settings.test.tsx | 67 ++++++++++++++++++- .../components/shortcuts-settings.tsx | 4 +- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/web/src/features/settings/components/shortcuts-settings.test.tsx b/packages/web/src/features/settings/components/shortcuts-settings.test.tsx index 22d41d22..7eea300f 100644 --- a/packages/web/src/features/settings/components/shortcuts-settings.test.tsx +++ b/packages/web/src/features/settings/components/shortcuts-settings.test.tsx @@ -123,7 +123,10 @@ describe("ShortcutsSettings", () => { const { store } = renderShortcutsSettings(sendCommand); fireEvent.click(screen.getByRole("tab", { name: "工作区" })); - fireEvent.click(await screen.findByText("Ctrl+←")); + const shortcutRow = (await screen.findByText("切换到左侧会话")).closest(".shortcuts-item"); + expect(shortcutRow).not.toBeNull(); + + fireEvent.click(within(shortcutRow as HTMLElement).getByText("Ctrl+←")); fireEvent.keyDown(screen.getByRole("textbox", { name: "切换到左侧会话" }), { key: "ArrowDown", ctrlKey: true, @@ -143,8 +146,66 @@ describe("ShortcutsSettings", () => { "session.navigate.left": "Ctrl+ArrowDown", }); - const shortcutRow = screen.getByText("切换到左侧会话").closest(".shortcuts-item"); - expect(shortcutRow).not.toBeNull(); expect(within(shortcutRow as HTMLElement).getByText("Ctrl+↓")).toBeInTheDocument(); }); + + it("stores macOS Ctrl+letter captures as Mod bindings", async () => { + vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel"); + + const sendCommand = vi.fn().mockResolvedValue({}); + const { store } = renderShortcutsSettings(sendCommand); + + fireEvent.click(screen.getByText("⌘+K")); + fireEvent.keyDown(screen.getByRole("textbox", { name: "命令面板" }), { + key: "p", + ctrlKey: true, + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { shortcuts: { "command-palette.toggle": "Mod+P" } }, + }, + undefined + ); + }); + + expect(store.get(customShortcutsAtom)).toMatchObject({ + "command-palette.toggle": "Mod+P", + }); + expect(screen.getByText("⌘+P")).toBeInTheDocument(); + }); + + it("preserves explicit Ctrl for macOS arrow captures only", async () => { + vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel"); + + const sendCommand = vi.fn().mockResolvedValue({}); + const { store } = renderShortcutsSettings(sendCommand); + + fireEvent.click(screen.getByRole("tab", { name: "工作区" })); + const shortcutRow = (await screen.findByText("切换到左侧会话")).closest(".shortcuts-item"); + expect(shortcutRow).not.toBeNull(); + + fireEvent.click(within(shortcutRow as HTMLElement).getByText("Ctrl+←")); + fireEvent.keyDown(screen.getByRole("textbox", { name: "切换到左侧会话" }), { + key: "ArrowUp", + ctrlKey: true, + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { shortcuts: { "session.navigate.left": "Ctrl+ArrowUp" } }, + }, + undefined + ); + }); + + expect(store.get(customShortcutsAtom)).toMatchObject({ + "session.navigate.left": "Ctrl+ArrowUp", + }); + expect(within(shortcutRow as HTMLElement).getByText("Ctrl+↑")).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/settings/components/shortcuts-settings.tsx b/packages/web/src/features/settings/components/shortcuts-settings.tsx index f38c22c6..6374d01e 100644 --- a/packages/web/src/features/settings/components/shortcuts-settings.tsx +++ b/packages/web/src/features/settings/components/shortcuts-settings.tsx @@ -54,10 +54,12 @@ export function ShortcutsSettings() { if (isMac && event.metaKey) { parts.push("Mod"); } - if (event.ctrlKey && (isMac || isArrowKey)) { + if (event.ctrlKey && isArrowKey) { parts.push("Ctrl"); } else if (!isMac && event.ctrlKey) { parts.push("Mod"); + } else if (isMac && event.ctrlKey) { + parts.push("Mod"); } if (event.shiftKey) { parts.push("Shift"); From e442d319c78a6cffe9943b7fd7a8a339702c6c30 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:22:38 +0800 Subject: [PATCH 16/26] feat: add spatial pane navigation helper --- .../agent-panes/pane-navigation.test.ts | 139 +++++++++++++++ .../features/agent-panes/pane-navigation.ts | 161 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 packages/web/src/features/agent-panes/pane-navigation.test.ts create mode 100644 packages/web/src/features/agent-panes/pane-navigation.ts diff --git a/packages/web/src/features/agent-panes/pane-navigation.test.ts b/packages/web/src/features/agent-panes/pane-navigation.test.ts new file mode 100644 index 00000000..eb0d76d4 --- /dev/null +++ b/packages/web/src/features/agent-panes/pane-navigation.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { PaneNode } from "./atoms/pane-layout"; +import { findAdjacentSessionId } from "./pane-navigation"; + +describe("pane-navigation", () => { + it("finds horizontal neighbors in a simple split", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess-left" }, + { id: "right", type: "leaf", sessionId: "sess-right" }, + ], + }; + + expect(findAdjacentSessionId(layout, "sess-left", "right")).toBe("sess-right"); + expect(findAdjacentSessionId(layout, "sess-right", "left")).toBe("sess-left"); + }); + + it("returns null when no candidate exists in the requested direction", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess-left" }, + { id: "right", type: "leaf", sessionId: "sess-right" }, + ], + }; + + expect(findAdjacentSessionId(layout, "sess-left", "left")).toBeNull(); + expect(findAdjacentSessionId(layout, "sess-right", "right")).toBeNull(); + }); + + it("follows visible geometry in a 2x2 layout", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { + id: "left-column", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "top-left", type: "leaf", sessionId: "sess-top-left" }, + { id: "bottom-left", type: "leaf", sessionId: "sess-bottom-left" }, + ], + }, + { + id: "right-column", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "top-right", type: "leaf", sessionId: "sess-top-right" }, + { id: "bottom-right", type: "leaf", sessionId: "sess-bottom-right" }, + ], + }, + ], + }; + + expect(findAdjacentSessionId(layout, "sess-top-left", "right")).toBe("sess-top-right"); + expect(findAdjacentSessionId(layout, "sess-top-left", "down")).toBe("sess-bottom-left"); + expect(findAdjacentSessionId(layout, "sess-bottom-right", "up")).toBe("sess-top-right"); + expect(findAdjacentSessionId(layout, "sess-bottom-right", "left")).toBe("sess-bottom-left"); + }); + + it("ignores draft leaves when choosing the next session", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 1 / 3, + children: [ + { id: "left", type: "leaf", sessionId: "sess-left" }, + { + id: "right-stack", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "right-top", type: "leaf" }, + { id: "right-bottom", type: "leaf", sessionId: "sess-bottom-right" }, + ], + }, + ], + }; + + expect(findAdjacentSessionId(layout, "sess-left", "right")).toBe("sess-bottom-right"); + }); + + it("breaks ties by the smallest perpendicular center delta", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.4, + children: [ + { + id: "left-stack", + type: "split", + direction: "vertical", + ratio: 0.4, + children: [ + { + id: "active-row", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "active", type: "leaf", sessionId: "sess-active" }, + { id: "draft", type: "leaf" }, + ], + }, + { id: "bottom-left", type: "leaf", sessionId: "sess-bottom-left" }, + ], + }, + { + id: "right-stack", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "top-right", type: "leaf", sessionId: "sess-top-right" }, + { id: "bottom-right", type: "leaf", sessionId: "sess-bottom-right" }, + ], + }, + ], + }; + + expect(findAdjacentSessionId(layout, "sess-active", "right")).toBe("sess-top-right"); + }); +}); diff --git a/packages/web/src/features/agent-panes/pane-navigation.ts b/packages/web/src/features/agent-panes/pane-navigation.ts new file mode 100644 index 00000000..2f36b2ab --- /dev/null +++ b/packages/web/src/features/agent-panes/pane-navigation.ts @@ -0,0 +1,161 @@ +import type { PaneNode } from "./atoms/pane-layout"; + +export type PaneDirection = "left" | "right" | "up" | "down"; + +interface PaneRect { + sessionId: string; + left: number; + top: number; + right: number; + bottom: number; +} + +const ROOT_RECT = { + left: 0, + top: 0, + right: 1, + bottom: 1, +} as const; + +const EPSILON = 1e-9; + +export function findAdjacentSessionId( + layout: PaneNode, + activeSessionId: string, + direction: PaneDirection +): string | null { + const leaves = collectSessionRects(layout, ROOT_RECT); + const activeRect = leaves.find((leaf) => leaf.sessionId === activeSessionId); + if (!activeRect) { + return null; + } + + let bestCandidate: PaneRect | null = null; + let bestOverlap = false; + let bestEdgeDistance = Number.POSITIVE_INFINITY; + let bestCenterDelta = Number.POSITIVE_INFINITY; + + for (const candidate of leaves) { + if (candidate.sessionId === activeSessionId) { + continue; + } + + const edgeDistance = getDirectionalEdgeDistance(activeRect, candidate, direction); + if (edgeDistance === null) { + continue; + } + + const overlaps = hasPerpendicularOverlap(activeRect, candidate, direction); + const centerDelta = getPerpendicularCenterDelta(activeRect, candidate, direction); + + if ( + bestCandidate === null || + (overlaps && !bestOverlap) || + (overlaps === bestOverlap && edgeDistance < bestEdgeDistance - EPSILON) || + (overlaps === bestOverlap && + Math.abs(edgeDistance - bestEdgeDistance) <= EPSILON && + centerDelta < bestCenterDelta - EPSILON) + ) { + bestCandidate = candidate; + bestOverlap = overlaps; + bestEdgeDistance = edgeDistance; + bestCenterDelta = centerDelta; + } + } + + return bestCandidate?.sessionId ?? null; +} + +function collectSessionRects( + node: PaneNode, + rect: { left: number; top: number; right: number; bottom: number } +): PaneRect[] { + if (node.type === "leaf") { + return node.sessionId ? [{ sessionId: node.sessionId, ...rect }] : []; + } + + const children = node.children ?? []; + if (children.length === 0) { + return []; + } + + if (children.length === 1) { + return collectSessionRects(children[0]!, rect); + } + + const ratio = node.ratio ?? 0.5; + if (node.direction === "horizontal") { + const splitX = rect.left + (rect.right - rect.left) * ratio; + return [ + ...collectSessionRects(children[0]!, { ...rect, right: splitX }), + ...collectSessionRects(children[1]!, { ...rect, left: splitX }), + ]; + } + + const splitY = rect.top + (rect.bottom - rect.top) * ratio; + return [ + ...collectSessionRects(children[0]!, { ...rect, bottom: splitY }), + ...collectSessionRects(children[1]!, { ...rect, top: splitY }), + ]; +} + +function getDirectionalEdgeDistance( + activeRect: PaneRect, + candidate: PaneRect, + direction: PaneDirection +): number | null { + switch (direction) { + case "left": { + const distance = activeRect.left - candidate.right; + return distance >= -EPSILON ? Math.max(distance, 0) : null; + } + case "right": { + const distance = candidate.left - activeRect.right; + return distance >= -EPSILON ? Math.max(distance, 0) : null; + } + case "up": { + const distance = activeRect.top - candidate.bottom; + return distance >= -EPSILON ? Math.max(distance, 0) : null; + } + case "down": { + const distance = candidate.top - activeRect.bottom; + return distance >= -EPSILON ? Math.max(distance, 0) : null; + } + } +} + +function hasPerpendicularOverlap( + activeRect: PaneRect, + candidate: PaneRect, + direction: PaneDirection +): boolean { + if (direction === "left" || direction === "right") { + return spansOverlap(activeRect.top, activeRect.bottom, candidate.top, candidate.bottom); + } + + return spansOverlap(activeRect.left, activeRect.right, candidate.left, candidate.right); +} + +function getPerpendicularCenterDelta( + activeRect: PaneRect, + candidate: PaneRect, + direction: PaneDirection +): number { + if (direction === "left" || direction === "right") { + return Math.abs( + getCenter(activeRect.top, activeRect.bottom) - getCenter(candidate.top, candidate.bottom) + ); + } + + return Math.abs( + getCenter(activeRect.left, activeRect.right) - getCenter(candidate.left, candidate.right) + ); +} + +function spansOverlap(startA: number, endA: number, startB: number, endB: number): boolean { + return Math.min(endA, endB) - Math.max(startA, startB) > EPSILON; +} + +function getCenter(start: number, end: number): number { + return (start + end) / 2; +} From 1341f260e5e001f89066830e042af4804e3adb8b Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:26:05 +0800 Subject: [PATCH 17/26] fix: avoid duplicate mod capture on mac --- .../components/shortcuts-settings.test.tsx | 29 +++++++++++++++++++ .../components/shortcuts-settings.tsx | 6 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/web/src/features/settings/components/shortcuts-settings.test.tsx b/packages/web/src/features/settings/components/shortcuts-settings.test.tsx index 7eea300f..ba3f6f1f 100644 --- a/packages/web/src/features/settings/components/shortcuts-settings.test.tsx +++ b/packages/web/src/features/settings/components/shortcuts-settings.test.tsx @@ -177,6 +177,35 @@ describe("ShortcutsSettings", () => { expect(screen.getByText("⌘+P")).toBeInTheDocument(); }); + it("does not duplicate Mod when macOS captures Meta+Ctrl+letter", async () => { + vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel"); + + const sendCommand = vi.fn().mockResolvedValue({}); + const { store } = renderShortcutsSettings(sendCommand); + + fireEvent.click(screen.getByText("⌘+K")); + fireEvent.keyDown(screen.getByRole("textbox", { name: "命令面板" }), { + key: "p", + metaKey: true, + ctrlKey: true, + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { shortcuts: { "command-palette.toggle": "Mod+P" } }, + }, + undefined + ); + }); + + expect(store.get(customShortcutsAtom)).toMatchObject({ + "command-palette.toggle": "Mod+P", + }); + expect(screen.getByText("⌘+P")).toBeInTheDocument(); + }); + it("preserves explicit Ctrl for macOS arrow captures only", async () => { vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel"); diff --git a/packages/web/src/features/settings/components/shortcuts-settings.tsx b/packages/web/src/features/settings/components/shortcuts-settings.tsx index 6374d01e..d5ab1281 100644 --- a/packages/web/src/features/settings/components/shortcuts-settings.tsx +++ b/packages/web/src/features/settings/components/shortcuts-settings.tsx @@ -51,14 +51,16 @@ export function ShortcutsSettings() { const isMac = navigator.platform.includes("Mac"); const isArrowKey = event.key.startsWith("Arrow"); - if (isMac && event.metaKey) { + const hasMacMod = isMac && event.metaKey; + + if (hasMacMod) { parts.push("Mod"); } if (event.ctrlKey && isArrowKey) { parts.push("Ctrl"); } else if (!isMac && event.ctrlKey) { parts.push("Mod"); - } else if (isMac && event.ctrlKey) { + } else if (isMac && event.ctrlKey && !hasMacMod) { parts.push("Mod"); } if (event.shiftKey) { From ef27b724e0ace05727a6fb7b38fc4e2cf3d0b47a Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:29:16 +0800 Subject: [PATCH 18/26] test: cover pane navigation edge-distance ranking --- .../agent-panes/pane-navigation.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/web/src/features/agent-panes/pane-navigation.test.ts b/packages/web/src/features/agent-panes/pane-navigation.test.ts index eb0d76d4..fa210c5e 100644 --- a/packages/web/src/features/agent-panes/pane-navigation.test.ts +++ b/packages/web/src/features/agent-panes/pane-navigation.test.ts @@ -136,4 +136,55 @@ describe("pane-navigation", () => { expect(findAdjacentSessionId(layout, "sess-active", "right")).toBe("sess-top-right"); }); + + it("prefers the nearest edge before perpendicular center distance", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.25, + children: [ + { + id: "left-stack", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "active", type: "leaf", sessionId: "sess-active" }, + { id: "bottom-left", type: "leaf", sessionId: "sess-bottom-left" }, + ], + }, + { + id: "right-region", + type: "split", + direction: "horizontal", + ratio: 1 / 3, + children: [ + { + id: "near-column", + type: "split", + direction: "vertical", + ratio: 0.9, + children: [ + { id: "near-right", type: "leaf", sessionId: "sess-near-right" }, + { id: "bottom-middle", type: "leaf", sessionId: "sess-bottom-middle" }, + ], + }, + { + id: "far-column", + type: "split", + direction: "vertical", + ratio: 0.6, + children: [ + { id: "far-right", type: "leaf", sessionId: "sess-far-right" }, + { id: "bottom-right", type: "leaf", sessionId: "sess-bottom-right" }, + ], + }, + ], + }, + ], + }; + + expect(findAdjacentSessionId(layout, "sess-active", "right")).toBe("sess-near-right"); + }); }); From 55eb266d453eb1eb28d8330f2f832719e02e0c20 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:30:17 +0800 Subject: [PATCH 19/26] docs: add session pane drag reorder plan --- .../2026-05-24-session-pane-drag-reorder.md | 1508 +++++++++++++++++ 1 file changed, 1508 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-24-session-pane-drag-reorder.md diff --git a/docs/superpowers/plans/2026-05-24-session-pane-drag-reorder.md b/docs/superpowers/plans/2026-05-24-session-pane-drag-reorder.md new file mode 100644 index 00000000..87a3c047 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-session-pane-drag-reorder.md @@ -0,0 +1,1508 @@ +# Session Pane Drag Reorder 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 desktop-only session pane drag-and-drop so a session pane can be dragged by a header handle and dropped onto another pane to swap sessions or insert left/right/top/bottom around the target. + +**Architecture:** Keep the existing binary split tree and server-backed `workspace.uiState.paneLayout`. Implement pure tree mutations first, then expose drag-specific pane actions, then add a workspace-scoped drag controller that uses pane `DOMRect` hit testing instead of `event.target`, and finally wire the controller into `AgentPanes`, `SessionCard`, and `DraftLauncher` with overlay feedback. `paneId` remains the stable layout identity and `center` drop swaps or fills `sessionId` content without changing `paneId`. + +**Tech Stack:** TypeScript, React 19, Jotai, Vitest, Testing Library, Playwright, existing `agent-panes` feature CSS in `components.css` + +**Spec reference:** `docs/superpowers/specs/2026-05-24-session-pane-drag-reorder-design.md` + +--- + +## File Structure + +**New files:** +- `packages/web/src/features/agent-panes/actions/pane-drag-types.ts` +- `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts` +- `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx` +- `e2e/specs/sessions/pane-drag-reorder.spec.ts` + +**Modified files:** +- `packages/web/src/features/agent-panes/pane-layout-tree.ts` +- `packages/web/src/features/agent-panes/pane-layout-tree.test.ts` +- `packages/web/src/features/agent-panes/actions/use-pane-actions.ts` +- `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` +- `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx` +- `packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx` +- `packages/web/src/styles/components.css` + +**No changes in this plan:** +- `packages/web/src/features/agent-panes/views/shared/pane-layout.tsx` +- mobile pane interactions +- cross-workspace drag/drop +- split ratio persistence format +- backend APIs or workspace data contracts + +## Shared Implementation Rules + +- Drag starts only from a dedicated handle rendered inside `SessionCard` header actions. +- Draft panes are never drag sources. +- Session panes accept `left`, `right`, `top`, `bottom`, and `center`. +- Draft panes accept only `center`. +- `center` over a session pane swaps `sessionId` values only. +- `center` over a draft pane moves the source session into the draft leaf and removes the source leaf from its old path. +- Edge insertions always remove the source first, then wrap the target leaf in a fresh `split` with `ratio: 0.5`. +- `paneId` is always used to resolve source and target leaves. + +### Task 1: Add Tree Drag Mutations And Unit Tests + +**Files:** +- Modify: `packages/web/src/features/agent-panes/pane-layout-tree.ts` +- Modify: `packages/web/src/features/agent-panes/pane-layout-tree.test.ts` + +- [ ] **Step 1: Write the failing tree tests for swap, draft move, edge insert, and no-op cases** + +Add these focused cases to `packages/web/src/features/agent-panes/pane-layout-tree.test.ts`: + +```ts +import { + insertPaneAtEdge, + moveSessionToDraftPane, + swapPaneSessionsByPaneId, +} from "./pane-layout-tree"; + +it("swaps session ids between two session panes without changing pane ids", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(swapPaneSessionsByPaneId(layout, "left", "right")).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_2" }, + { id: "right", type: "leaf", sessionId: "sess_1" }, + ], + }); +}); + +it("moves a session into a draft leaf and collapses the old source branch", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { + id: "left-split", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "left-top", type: "leaf", sessionId: "sess_1" }, + { id: "left-bottom", type: "leaf", sessionId: "sess_2" }, + ], + }, + { id: "right", type: "leaf" }, + ], + }; + + expect(moveSessionToDraftPane(layout, "left-bottom", "right")).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left-top", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }); +}); + +it("wraps the target leaf with a horizontal split on left insert", () => { + vi.spyOn(Date, "now").mockReturnValue(1700000000000); + + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "left")).toEqual({ + id: "split-right-left-1700000000000", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }); +}); + +it("returns the original tree when attempting to drag onto the same pane", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "left", "left")).toBe(layout); + expect(swapPaneSessionsByPaneId(layout, "left", "left")).toBe(layout); +}); + +it("rejects draft edge insertion and preserves the input layout", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "right")).toBe(layout); +}); +``` + +- [ ] **Step 2: Run the tree tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/pane-layout-tree.test.ts +``` + +Expected: FAIL with missing exports such as `swapPaneSessionsByPaneId` and `insertPaneAtEdge`. + +- [ ] **Step 3: Implement the pure helper layer in `pane-layout-tree.ts`** + +Add deterministic helper signatures and the minimum recursive helpers needed for extract, rewrite, and wrap: + +```ts +type PaneDropPlacement = "left" | "right" | "top" | "bottom" | "center"; + +interface ExtractLeafResult { + nextTree: PaneNode; + extractedLeaf: PaneNode | null; +} + +function findLeafByPaneId(node: PaneNode, paneId: string): PaneNode | null { + if (node.type === "leaf") { + return node.id === paneId ? node : null; + } + + for (const child of node.children ?? []) { + const match = findLeafByPaneId(child, paneId); + if (match) { + return match; + } + } + + return null; +} + +function removeLeafByPaneId(node: PaneNode, paneId: string): PaneNode | null { + if (node.type === "leaf") { + return node.id === paneId ? null : node; + } + + const nextChildren = (node.children ?? []) + .map((child) => removeLeafByPaneId(child, paneId)) + .filter((child): child is PaneNode => child !== null); + + if (nextChildren.length === node.children?.length) { + return node; + } + + if (nextChildren.length === 0) { + return null; + } + + if (nextChildren.length === 1) { + return nextChildren[0]!; + } + + return { + ...node, + children: nextChildren, + }; +} + +function replaceLeafByPaneId( + node: PaneNode, + paneId: string, + factory: (target: PaneNode) => PaneNode +): PaneNode { + if (node.type === "leaf") { + return node.id === paneId ? factory(node) : node; + } + + let changed = false; + const nextChildren = (node.children ?? []).map((child) => { + const nextChild = replaceLeafByPaneId(child, paneId, factory); + if (nextChild !== child) { + changed = true; + } + return nextChild; + }); + + return changed ? { ...node, children: nextChildren } : node; +} + +function createDragSplitId(parentId: string, targetPaneId: string, placement: Exclude): string { + return `split-${targetPaneId}-${placement}-${Date.now()}`; +} +``` + +Then add the drag mutations: + +```ts +export function swapPaneSessionsByPaneId( + node: PaneNode, + sourcePaneId: string, + targetPaneId: string +): PaneNode { + if (sourcePaneId === targetPaneId) { + return node; + } + + const source = findLeafByPaneId(node, sourcePaneId); + const target = findLeafByPaneId(node, targetPaneId); + if (!source?.sessionId || !target?.sessionId) { + return node; + } + + const withSource = replaceLeafByPaneId(node, sourcePaneId, (leaf) => ({ + ...leaf, + sessionId: target.sessionId, + })); + + return replaceLeafByPaneId(withSource, targetPaneId, (leaf) => ({ + ...leaf, + sessionId: source.sessionId!, + })); +} + +export function moveSessionToDraftPane( + node: PaneNode, + sourcePaneId: string, + targetPaneId: string +): PaneNode { + if (sourcePaneId === targetPaneId) { + return node; + } + + const source = findLeafByPaneId(node, sourcePaneId); + const target = findLeafByPaneId(node, targetPaneId); + if (!source?.sessionId || !target || target.sessionId) { + return node; + } + + const stripped = removeLeafByPaneId(node, sourcePaneId) ?? { id: node.id, type: "leaf" }; + return assignSessionToPane(stripped, targetPaneId, source.sessionId); +} + +export function insertPaneAtEdge( + node: PaneNode, + sourcePaneId: string, + targetPaneId: string, + placement: Exclude +): PaneNode { + if (sourcePaneId === targetPaneId) { + return node; + } + + const source = findLeafByPaneId(node, sourcePaneId); + const target = findLeafByPaneId(node, targetPaneId); + if (!source?.sessionId || !target || !target.sessionId) { + return node; + } + + const stripped = removeLeafByPaneId(node, sourcePaneId) ?? { id: node.id, type: "leaf" }; + const incomingLeaf: PaneNode = { + id: source.id, + type: "leaf", + sessionId: source.sessionId, + }; + + return replaceLeafByPaneId(stripped, targetPaneId, (leaf) => { + const splitDirection = placement === "left" || placement === "right" ? "horizontal" : "vertical"; + const splitId = createDragSplitId(stripped.id, leaf.id, placement); + const children = + placement === "left" || placement === "top" + ? [incomingLeaf, leaf] + : [leaf, incomingLeaf]; + + return { + id: splitId, + type: "split", + direction: splitDirection, + ratio: 0.5, + children, + }; + }); +} +``` + +- [ ] **Step 4: Run the tree tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/pane-layout-tree.test.ts +``` + +Expected: PASS with all existing pane layout tests plus the new drag mutation cases. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/agent-panes/pane-layout-tree.ts packages/web/src/features/agent-panes/pane-layout-tree.test.ts +git commit -m "feat: add pane drag tree mutations" +``` + +### Task 2: Expose Drag-Specific Pane Actions + +**Files:** +- Create: `packages/web/src/features/agent-panes/actions/pane-drag-types.ts` +- Modify: `packages/web/src/features/agent-panes/actions/use-pane-actions.ts` +- Modify: `packages/web/src/features/agent-panes/index.tsx` +- Modify: `packages/web/src/features/agent-panes/index.test.tsx` + +- [ ] **Step 1: Write the failing integration tests for drag actions** + +Extend `packages/web/src/features/agent-panes/index.test.tsx` with mocks that expose drag callbacks from `SessionCard` and `DraftLauncher`: + +```ts +type MockSessionCardProps = { + sessionId: string; + paneId?: string; + onPaneDrop?: (intent: PaneDropIntent) => void; +}; + +type MockDraftLauncherProps = { + paneId?: string; + onPaneDrop?: (intent: PaneDropIntent) => void; +}; + +const mockDraftLauncher = vi.fn(({ paneId, onPaneDrop }: MockDraftLauncherProps) => ( +
+ +
+)); +``` + +Add action coverage: + +```ts +it("swaps pane sessions when a center drop targets another session pane", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "drop-center-sess_1" })); + + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_2" }, + { id: "right", type: "leaf", sessionId: "sess_1" }, + ], + }); + }); +}); + +it("moves a session into a draft pane on draft center drop", async () => { + const { store } = createAgentPaneStore({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf" }, + ], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "move-to-draft-right" })); + + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "right", + type: "leaf", + sessionId: "sess_1", + }); + }); +}); +``` + +- [ ] **Step 2: Run the agent-panes integration tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/index.test.tsx +``` + +Expected: FAIL because `PaneDropIntent` and drag-specific callbacks are not defined yet. + +- [ ] **Step 3: Add shared drag intent types and extend `usePaneActions()`** + +Create `packages/web/src/features/agent-panes/actions/pane-drag-types.ts`: + +```ts +export type PaneDropPlacement = "left" | "right" | "top" | "bottom" | "center"; +export type PaneDropTargetType = "session" | "draft"; + +export interface PaneDropIntent { + sourcePaneId: string; + targetPaneId: string; + placement: PaneDropPlacement; + targetType: PaneDropTargetType; +} +``` + +Update `use-pane-actions.ts` to import the new tree helpers and expose drag actions: + +```ts +import type { PaneDropPlacement } from "./pane-drag-types"; +import { + insertPaneAtEdge, + moveSessionToDraftPane, + swapPaneSessionsByPaneId, +} from "../pane-layout-tree"; + +const swapPaneSessions = useCallback( + (sourcePaneId: string, targetPaneId: string) => { + applyLayout((current) => swapPaneSessionsByPaneId(current, sourcePaneId, targetPaneId)); + }, + [applyLayout] +); + +const moveSessionToDraft = useCallback( + (sourcePaneId: string, targetPaneId: string) => { + applyLayout((current) => moveSessionToDraftPane(current, sourcePaneId, targetPaneId)); + }, + [applyLayout] +); + +const insertSessionPaneAtEdge = useCallback( + ( + sourcePaneId: string, + targetPaneId: string, + placement: Exclude + ) => { + applyLayout((current) => insertPaneAtEdge(current, sourcePaneId, targetPaneId, placement)); + }, + [applyLayout] +); +``` + +Return these from the hook: + +```ts +return { + appendSession, + appendSessionToMobileColumn, + assignSession, + closeDraftPane, + closeSessionPane, + insertSessionPaneAtEdge, + moveSessionToDraft, + removeSessionPane, + replaceSession, + replaceWithSession, + splitDraftPane, + splitSessionPane, + swapPaneSessions, +}; +``` + +- [ ] **Step 4: Wire a simple drop-intent dispatch path in `AgentPanes` for test coverage** + +Before building the full controller, add a thin handler in `index.tsx` so the mocked component tests can assert action selection. Keep this as a local helper that Task 3 reuses from the pointer-driven controller: + +```ts +const handlePaneDrop = useCallback( + (intent: PaneDropIntent) => { + if (intent.placement === "center") { + if (intent.targetType === "draft") { + paneActions.moveSessionToDraft(intent.sourcePaneId, intent.targetPaneId); + return; + } + + paneActions.swapPaneSessions(intent.sourcePaneId, intent.targetPaneId); + return; + } + + paneActions.insertSessionPaneAtEdge(intent.sourcePaneId, intent.targetPaneId, intent.placement); + }, + [paneActions] +); +``` + +Pass it to leaf renderers: + +```ts + + + +``` + +- [ ] **Step 5: Run the integration tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/index.test.tsx +``` + +Expected: PASS with the new drag-action dispatch coverage and no regressions in split/close persistence tests. + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/features/agent-panes/actions/pane-drag-types.ts packages/web/src/features/agent-panes/actions/use-pane-actions.ts packages/web/src/features/agent-panes/index.tsx packages/web/src/features/agent-panes/index.test.tsx +git commit -m "feat: add pane drag action dispatch" +``` + +### Task 3: Build The Drag Controller And Hit Testing + +**Files:** +- Create: `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts` +- Create: `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx` +- Modify: `packages/web/src/features/agent-panes/index.tsx` + +- [ ] **Step 1: Write failing controller tests for placement calculation and drop intent dispatch** + +Create `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx` with a minimal harness: + +```ts +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { usePaneDragController } from "./use-pane-drag-controller"; + +describe("usePaneDragController", () => { + it("returns left placement when the pointer is inside the left edge band", () => { + const onDrop = vi.fn(); + const { result } = renderHook(() => usePaneDragController({ onDrop })); + + act(() => { + result.current.registerPane("target", { + type: "session", + element: { + getBoundingClientRect: () => + ({ + left: 100, + top: 100, + right: 500, + bottom: 500, + width: 400, + height: 400, + }) as DOMRect, + } as HTMLElement, + }); + result.current.startDrag({ + paneId: "source", + sessionId: "sess_1", + title: "Session 1", + providerLabel: "Claude", + }); + result.current.handlePointerMove({ clientX: 120, clientY: 250 } as PointerEvent); + }); + + expect(result.current.state.hoverTargetPaneId).toBe("target"); + expect(result.current.state.hoverPlacement).toBe("left"); + }); + + it("treats a draft pane as center-only and dispatches a draft center intent on pointer up", () => { + const onDrop = vi.fn(); + const { result } = renderHook(() => usePaneDragController({ onDrop })); + + act(() => { + result.current.registerPane("draft", { + type: "draft", + element: { + getBoundingClientRect: () => + ({ + left: 100, + top: 100, + right: 500, + bottom: 500, + width: 400, + height: 400, + }) as DOMRect, + } as HTMLElement, + }); + result.current.startDrag({ + paneId: "source", + sessionId: "sess_1", + title: "Session 1", + providerLabel: "Claude", + }); + result.current.handlePointerMove({ clientX: 250, clientY: 250 } as PointerEvent); + result.current.handlePointerUp(); + }); + + expect(onDrop).toHaveBeenCalledWith({ + sourcePaneId: "source", + targetPaneId: "draft", + placement: "center", + targetType: "draft", + }); + }); +}); +``` + +- [ ] **Step 2: Run the controller test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/actions/use-pane-drag-controller.test.tsx +``` + +Expected: FAIL because the hook file does not exist yet. + +- [ ] **Step 3: Implement the controller hook with pane registry and global pointer listeners** + +Create `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts`: + +```ts +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PaneDropIntent, PaneDropPlacement, PaneDropTargetType } from "./pane-drag-types"; + +interface RegisteredPane { + type: PaneDropTargetType; + element: HTMLElement; +} + +interface DragSourceSnapshot { + paneId: string; + sessionId: string; + title: string; + providerLabel: string; +} + +interface PaneDragState { + isDragging: boolean; + source: DragSourceSnapshot | null; + hoverTargetPaneId: string | null; + hoverPlacement: PaneDropPlacement | null; + previewX: number; + previewY: number; +} + +const EDGE_RATIO = 0.22; +const EDGE_MIN = 48; +const EDGE_MAX = 96; +``` + +Implement the public API: + +```ts +export function usePaneDragController({ onDrop }: { onDrop: (intent: PaneDropIntent) => void }) { + const paneRegistry = useRef(new Map()); + const [state, setState] = useState({ + isDragging: false, + source: null, + hoverTargetPaneId: null, + hoverPlacement: null, + previewX: 0, + previewY: 0, + }); + + const registerPane = useCallback((paneId: string, entry: RegisteredPane | null) => { + if (!entry) { + paneRegistry.current.delete(paneId); + return; + } + paneRegistry.current.set(paneId, entry); + }, []); + + const startDrag = useCallback((source: DragSourceSnapshot) => { + document.body.classList.add("is-dragging-pane"); + setState({ + isDragging: true, + source, + hoverTargetPaneId: null, + hoverPlacement: null, + previewX: 0, + previewY: 0, + }); + }, []); + + const clearDrag = useCallback(() => { + document.body.classList.remove("is-dragging-pane"); + setState({ + isDragging: false, + source: null, + hoverTargetPaneId: null, + hoverPlacement: null, + previewX: 0, + previewY: 0, + }); + }, []); +``` + +Add hit testing: + +```ts + const resolvePlacement = useCallback( + (paneId: string, pane: RegisteredPane, clientX: number, clientY: number): PaneDropPlacement | null => { + const rect = pane.element.getBoundingClientRect(); + if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) { + return null; + } + + if (state.source?.paneId === paneId) { + return null; + } + + if (pane.type === "draft") { + return "center"; + } + + const edgeX = Math.max(EDGE_MIN, Math.min(EDGE_MAX, rect.width * EDGE_RATIO)); + const edgeY = Math.max(EDGE_MIN, Math.min(EDGE_MAX, rect.height * EDGE_RATIO)); + + if (clientX <= rect.left + edgeX) return "left"; + if (clientX >= rect.right - edgeX) return "right"; + if (clientY <= rect.top + edgeY) return "top"; + if (clientY >= rect.bottom - edgeY) return "bottom"; + return "center"; + }, + [state.source?.paneId] + ); +``` + +And event handlers: + +```ts + const handlePointerMove = useCallback((event: PointerEvent) => { + setState((current) => { + if (!current.isDragging) { + return current; + } + + let hoverTargetPaneId: string | null = null; + let hoverPlacement: PaneDropPlacement | null = null; + for (const [paneId, pane] of paneRegistry.current.entries()) { + const placement = resolvePlacement(paneId, pane, event.clientX, event.clientY); + if (!placement) { + continue; + } + hoverTargetPaneId = paneId; + hoverPlacement = placement; + break; + } + + return { + ...current, + hoverTargetPaneId, + hoverPlacement, + previewX: event.clientX, + previewY: event.clientY, + }; + }); + }, [resolvePlacement]); + + const handlePointerUp = useCallback(() => { + setState((current) => { + if ( + current.isDragging && + current.source && + current.hoverTargetPaneId && + current.hoverPlacement + ) { + const target = paneRegistry.current.get(current.hoverTargetPaneId); + if (target) { + onDrop({ + sourcePaneId: current.source.paneId, + targetPaneId: current.hoverTargetPaneId, + placement: current.hoverPlacement, + targetType: target.type, + }); + } + } + return current; + }); + clearDrag(); + }, [clearDrag, onDrop]); + + useEffect(() => { + if (!state.isDragging) { + return; + } + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + }, [handlePointerMove, handlePointerUp, state.isDragging]); +``` + +Return the controller API: + +```ts + return { + state, + clearDrag, + handlePointerMove, + handlePointerUp, + registerPane, + startDrag, + }; +} +``` + +- [ ] **Step 4: Integrate the hook into `AgentPanes` and thread the controller props down** + +Update `index.tsx`: + +```ts +const dragController = usePaneDragController({ onDrop: handlePaneDrop }); +``` + +Pass pane registration and drag state into `PaneNodeRenderer`: + +```ts + +``` + +Expand renderer props: + +```ts +interface PaneNodeRendererProps { + ... + dragController: ReturnType; +} +``` + +Prepare per-leaf data: + +```ts +const hoverPlacement = + dragController.state.hoverTargetPaneId === node.id ? dragController.state.hoverPlacement : null; +const isDragging = dragController.state.isDragging; +``` + +- [ ] **Step 5: Run the controller and integration tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/actions/use-pane-drag-controller.test.tsx src/features/agent-panes/index.test.tsx +``` + +Expected: PASS with correct placement resolution and drop intent dispatch. + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/features/agent-panes/actions/pane-drag-types.ts packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx packages/web/src/features/agent-panes/index.tsx packages/web/src/features/agent-panes/index.test.tsx +git commit -m "feat: add pane drag controller" +``` + +### Task 4: Wire Drag Handle, Drop Surfaces, And Overlay UI + +**Files:** +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx` +- Modify: `packages/web/src/features/agent-panes/index.tsx` +- Modify: `packages/web/src/styles/components.css` + +- [ ] **Step 1: Write the failing UI tests for drag handle and overlay behavior** + +Add focused assertions to `session-card.test.tsx`: + +```ts +it("renders a pane drag handle button in the header actions", () => { + const { store } = createSessionStore({ + terminalId: "term-live", + state: "running", + endedAt: undefined, + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Drag pane" })).toBeInTheDocument(); +}); + +it("starts pane drag only from the drag handle", () => { + const { store } = createSessionStore({ + terminalId: "term-live", + state: "running", + endedAt: undefined, + }); + const onPaneDragStart = vi.fn(); + + render( + + + + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Drag pane" })); + fireEvent.pointerDown(screen.getByText("SESSION-56")); + + expect(onPaneDragStart).toHaveBeenCalledTimes(1); + expect(onPaneDragStart).toHaveBeenCalledWith( + expect.objectContaining({ + paneId: "pane-1", + sessionId: "sess_123456", + providerLabel: "Codex", + }) + ); +}); +``` + +Add coverage to `draft-launcher.test.tsx`: + +```ts +it("renders a draft drop label when pane drag hover is active", () => { + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand: vi.fn(), + subscribe: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + expect(screen.getByText("Move here")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the component tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/components/session-card.test.tsx src/features/agent-panes/views/shared/draft-launcher.test.tsx +``` + +Expected: FAIL because the new props and drag UI do not exist. + +- [ ] **Step 3: Add the drag handle and leaf wrappers** + +Update `session-card.tsx` props: + +```ts +import { GripVertical } from "lucide-react"; +import type { PaneDropPlacement } from "../../actions/pane-drag-types"; + +interface SessionCardProps { + paneId?: string; + onPaneDragStart?: (source: { + paneId: string; + sessionId: string; + title: string; + providerLabel: string; + }) => void; + dragState?: { + isDragging: boolean; + isActiveDropTarget: boolean; + hoverPlacement: PaneDropPlacement | null; + }; +} +``` + +Add the drag handle before split/close buttons: + +```tsx + + } + onPointerDown={(event) => { + event.stopPropagation(); + if (!paneId) { + return; + } + onPaneDragStart?.({ + paneId, + sessionId: session.id, + title: sessionTitle, + providerLabel, + }); + }} + size="sm" + /> + +``` + +Wrap the card body with drag classes and overlay: + +```tsx +
+ {dragState?.isDragging ? ( +
+
Swap
+
+ ) : null} +``` + +Update `draft-launcher.tsx`: + +```ts +interface DraftLauncherProps { + dragState?: { + isDragging: boolean; + isActiveDropTarget: boolean; + hoverPlacement: "center" | null; + }; +} +``` + +Render draft overlay: + +```tsx +
+ {dragState?.isDragging ? ( +
+
Move here
+
+ ) : null} +``` + +- [ ] **Step 4: Create a dedicated leaf component and wire drag state from `AgentPanes`** + +In `index.tsx`, add a dedicated `PaneLeaf` component so `useRef` and `useEffect` stay outside the recursive branch-switching logic in `PaneNodeRenderer`: + +```tsx +interface PaneLeafProps { + node: Extract; + workspaceId: string; + dragController: ReturnType; + onAssignSession: (paneId: string, sessionId: string) => void; + onCloseDraftPane: (paneId: string) => void; + onCloseSession: (sessionId: string) => void; + onCloseSessionCommand: ( + sessionId: string, + paneDisposition?: "draft" | "remove" + ) => Promise; + onPaneDrop: (intent: PaneDropIntent) => void; + onReplaceWithSession: (sessionId: string) => void; + onSplitDraftPane: (paneId: string, direction: "horizontal" | "vertical") => void; + onSplitSession: (sessionId: string, direction: "horizontal" | "vertical") => void; +} + +const PaneLeaf: FC = ({ + node, + workspaceId, + dragController, + onAssignSession, + onCloseDraftPane, + onCloseSession, + onCloseSessionCommand, + onPaneDrop, + onReplaceWithSession, + onSplitDraftPane, + onSplitSession, +}) => { + const leafRef = useRef(null); + + useEffect(() => { + if (!leafRef.current) { + return; + } + + dragController.registerPane(node.id, { + type: node.sessionId ? "session" : "draft", + element: leafRef.current, + }); + + return () => { + dragController.registerPane(node.id, null); + }; + }, [dragController, node.id, node.sessionId]); + + return
; +}; +``` + +Render `PaneLeaf` from `PaneNodeRenderer` instead of calling hooks directly in the `node.type === "leaf"` branch. Inside `PaneLeaf`, render `SessionCard` or `DraftLauncher` with the registered wrapper: + +```tsx +
+ +
+``` + +Do the equivalent for `DraftLauncher`, but coerce `hoverPlacement` to `"center"` only. + +- [ ] **Step 5: Add the drag overlay and body-state CSS** + +Append styles to `packages/web/src/styles/components.css`: + +```css +body.is-dragging-pane { + cursor: grabbing; + user-select: none; +} + +.agent-pane-leaf { + position: relative; + min-width: 0; + min-height: 0; +} + +.session-action-btn-drag { + cursor: grab; +} + +body.is-dragging-pane .session-action-btn-drag { + cursor: grabbing; +} + +.pane-drop-overlay { + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + border: 1px dashed color-mix(in srgb, var(--accent) 55%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + z-index: 3; +} + +.pane-drop-overlay--left::before, +.pane-drop-overlay--right::before, +.pane-drop-overlay--top::before, +.pane-drop-overlay--bottom::before, +.pane-drop-overlay--center::before, +.pane-drop-overlay--draft::before { + content: ""; + position: absolute; + background: color-mix(in srgb, var(--accent) 22%, transparent); +} + +.pane-drop-overlay--left::before { + inset: 0 auto 0 0; + width: 24%; +} + +.pane-drop-overlay--right::before { + inset: 0 0 0 auto; + width: 24%; +} + +.pane-drop-overlay--top::before { + inset: 0 0 auto 0; + height: 24%; +} + +.pane-drop-overlay--bottom::before { + inset: auto 0 0 0; + height: 24%; +} + +.pane-drop-overlay--center::before, +.pane-drop-overlay--draft::before { + inset: 22%; +} + +.pane-drop-overlay__center { + position: absolute; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-elevated) 92%, transparent); + border: 1px solid var(--border); + color: var(--text-primary); + font-size: 11px; +} +``` + +- [ ] **Step 6: Run the component and integration tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/components/session-card.test.tsx src/features/agent-panes/views/shared/draft-launcher.test.tsx src/features/agent-panes/index.test.tsx +``` + +Expected: PASS with drag handle, draft overlay, and leaf wiring covered. + +- [ ] **Step 7: Commit** + +```bash +git add packages/web/src/features/agent-panes/views/shared/session-card.tsx packages/web/src/features/agent-panes/components/session-card.test.tsx packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx packages/web/src/features/agent-panes/index.tsx packages/web/src/styles/components.css +git commit -m "feat: wire pane drag ui" +``` + +### Task 5: Add End-To-End Desktop Drag Coverage + +**Files:** +- Create: `e2e/specs/sessions/pane-drag-reorder.spec.ts` + +- [ ] **Step 1: Add the end-to-end desktop drag scenarios** + +Create `e2e/specs/sessions/pane-drag-reorder.spec.ts`: + +```ts +import { expect, test } from "@playwright/test"; +import { launchClaudeSession, openWorkspace, waitForSessionReady } from "../helpers/workspace-session"; + +test.describe("session pane drag reorder", () => { + test("swaps two session panes by dropping on center", async ({ page }) => { + await openWorkspace(page); + const firstPane = await launchClaudeSession(page); + await waitForSessionReady(page); + + await page.getByRole("button", { name: "Split horizontal" }).first().click(); + await page.locator(".agent-provider-card-codex").first().click(); + + const panes = page.locator(".session-card.agent-pane[data-session-id]"); + await expect(panes).toHaveCount(2, { timeout: 20000 }); + + const beforeIds = await panes.evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-session-id")) + ); + + const sourceHandle = panes.nth(0).getByRole("button", { name: "Drag pane" }); + const sourceBox = await sourceHandle.boundingBox(); + const target = panes.nth(1); + const targetBox = await target.boundingBox(); + if (!sourceBox || !targetBox) { + throw new Error("Missing pane drag geometry"); + } + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); + await page.mouse.down(); + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2); + await page.mouse.up(); + + await expect + .poll(async () => + page.locator(".session-card.agent-pane[data-session-id]").evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-session-id")) + ) + ) + .toEqual([beforeIds[1], beforeIds[0]]); + }); + + test("moves a session into a draft pane by dropping on the draft center", async ({ page }) => { + await openWorkspace(page); + const firstPane = await launchClaudeSession(page); + await waitForSessionReady(page); + + await page.getByRole("button", { name: "Split horizontal" }).first().click(); + const sourcePane = page.locator(".session-card.agent-pane[data-session-id]").first(); + const draftPane = page.locator(".agent-draft-launcher").first(); + const sourceHandle = sourcePane.getByRole("button", { name: "Drag pane" }); + const sourceBox = await sourceHandle.boundingBox(); + const draftBox = await draftPane.boundingBox(); + if (!sourceBox || !draftBox) { + throw new Error("Missing draft pane box"); + } + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); + await page.mouse.down(); + await page.mouse.move(draftBox.x + draftBox.width / 2, draftBox.y + draftBox.height / 2); + await page.mouse.up(); + + await expect(page.locator(".agent-draft-launcher")).toHaveCount(0); + await expect(page.locator(".session-card.agent-pane[data-session-id]")).toHaveCount(1); + }); +}); +``` + +- [ ] **Step 2: Run the focused verification suite** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/pane-layout-tree.test.ts src/features/agent-panes/actions/use-pane-drag-controller.test.tsx src/features/agent-panes/components/session-card.test.tsx src/features/agent-panes/views/shared/draft-launcher.test.tsx src/features/agent-panes/index.test.tsx +pnpm --dir e2e exec playwright test --config playwright.config.ts e2e/specs/sessions/pane-drag-reorder.spec.ts +``` + +Expected: PASS for both the focused unit/integration suite and the new Playwright spec. + +- [ ] **Step 3: Commit** + +```bash +git add packages/web/src/features/agent-panes/index.test.tsx e2e/specs/sessions/pane-drag-reorder.spec.ts +git commit -m "test: cover pane drag reorder" +``` + +## Final Verification + +- [ ] **Step 1: Run formatting or lint only if the touched files need it** + +Run: + +```bash +pnpm exec biome check packages/web/src/features/agent-panes packages/web/src/styles/components.css e2e/specs/sessions/pane-drag-reorder.spec.ts +``` + +Expected: PASS with zero diagnostics for the changed files. + +- [ ] **Step 2: Run the full web package test suite for fresh evidence** + +Run: + +```bash +pnpm --filter @coder-studio/web test +``` + +Expected: PASS for the full `@coder-studio/web` Vitest suite. + +- [ ] **Step 3: Run typecheck for the web package** + +Run: + +```bash +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS with zero TypeScript errors. + +- [ ] **Step 4: Run the focused end-to-end pane drag spec again** + +Run: + +```bash +pnpm --dir e2e exec playwright test --config playwright.config.ts e2e/specs/sessions/pane-drag-reorder.spec.ts +``` + +Expected: PASS with both desktop drag scenarios green. + +- [ ] **Step 5: Review spec coverage before declaring completion** + +Check these against the final diff: + +```txt +[ ] Session pane drag handle exists and starts drag +[ ] Session pane supports left/right/top/bottom/center drop +[ ] Draft pane supports center-only drop +[ ] Center on session swaps sessionId only +[ ] Center on draft moves session and collapses source branch +[ ] Edge insert wraps target with fresh split and ratio 0.5 +[ ] Invalid drops are no-ops +[ ] Desktop-only behavior does not alter mobile-specific files +[ ] Layout changes still persist via workspace.uiState.set +``` + +- [ ] **Step 6: Final commit** + +```bash +git add packages/web/src/features/agent-panes packages/web/src/styles/components.css e2e/specs/sessions/pane-drag-reorder.spec.ts +git commit -m "feat: add session pane drag reorder" +``` From 2b0cdcce0b482a022f0b25d744d680ededc0217f Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:31:17 +0800 Subject: [PATCH 20/26] docs: add seasonal themes implementation plan --- .../plans/2026-05-24-seasonal-themes.md | 1123 +++++++++++++++++ 1 file changed, 1123 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-24-seasonal-themes.md diff --git a/docs/superpowers/plans/2026-05-24-seasonal-themes.md b/docs/superpowers/plans/2026-05-24-seasonal-themes.md new file mode 100644 index 00000000..877d94a0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-seasonal-themes.md @@ -0,0 +1,1123 @@ +# Seasonal Themes 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 eight built-in seasonal themes (`spring|summer|autumn|winter` x `light|dark`) to the existing app theme system so Web UI, Monaco, terminal, icon tone, and the settings picker all express the approved four-season design without changing theme persistence or switching rules. + +**Architecture:** Extend the existing registry-driven theme model instead of introducing a second theming layer. The implementation should keep semantic status colors stable, add seasonal color definition at the registry/token level, and let all existing consumers continue resolving the active theme through `themeId`. + +**Tech Stack:** React 19, TypeScript 6, Jotai, Vite, Vitest + Testing Library, Monaco editor, xterm.js, CSS custom properties, existing theme registry/settings infrastructure. + +**Spec reference:** `docs/superpowers/specs/2026-05-24-seasonal-themes-design.md` + +--- + +## File Structure + +**Modified files:** +- `packages/web/src/theme/registry.ts` +- `packages/web/src/theme/resolve.ts` +- `packages/web/src/theme/registry.test.ts` +- `packages/web/src/theme/resolve.test.ts` +- `packages/web/src/theme/icon-theme.test.ts` +- `packages/web/src/styles/tokens.css` +- `packages/web/src/styles/tokens-touch.test.ts` +- `packages/web/src/features/settings/components/settings-page.tsx` +- `packages/web/src/features/settings/components/settings-page.test.tsx` +- `packages/web/src/locales/en.json` +- `packages/web/src/locales/zh.json` +- `packages/web/src/ui-preview/catalog.test.tsx` +- `packages/web/src/ui-preview/scene-metadata.test.ts` + +**Likely no new files needed:** +- The current theme system already supports additional theme IDs, Monaco definitions, terminal palettes, and icon theme overrides through `registry.ts`. +- The current `Select` already supports disabled options in both desktop listbox and mobile sheet, so seasonal grouping can be implemented without changing the shared component API. + +**Primary ownership boundaries during execution:** +- Registry/resolution/test work stays in `packages/web/src/theme/*` +- Token/theme CSS work stays in `packages/web/src/styles/*` +- Settings picker, i18n, and picker tests stay in `packages/web/src/features/settings/*` plus locale JSON +- Preview/test fallout stays in `packages/web/src/ui-preview/*` + +## Task 1: Extend Theme Registry and Resolvers for Seasonal Families + +**Files:** +- Modify: `packages/web/src/theme/registry.ts` +- Modify: `packages/web/src/theme/resolve.ts` +- Modify: `packages/web/src/theme/registry.test.ts` +- Modify: `packages/web/src/theme/resolve.test.ts` + +- [ ] **Step 1: Write failing registry and resolver tests for the seasonal theme contract** + +Update `packages/web/src/theme/registry.test.ts` so the theme inventory test expects 16 built-in IDs and full family coverage: + +```ts +expect(THEMES).toHaveLength(16); +expect(THEME_IDS).toEqual( + expect.arrayContaining([ + "mint-dark", + "mint-light", + "graphite-dark", + "graphite-light", + "nord-dark", + "nord-light", + "hc-dark", + "hc-light", + "spring-light", + "spring-dark", + "summer-light", + "summer-dark", + "autumn-light", + "autumn-dark", + "winter-light", + "winter-dark", + ]) +); +``` + +Update the family coverage assertion to require: + +```ts +{ + mint: ["dark", "light"], + graphite: ["dark", "light"], + nord: ["dark", "light"], + hc: ["dark", "light"], + spring: ["light", "dark"], + summer: ["light", "dark"], + autumn: ["light", "dark"], + winter: ["light", "dark"], +} +``` + +Add targeted palette checks to prove the new themes are distinct and aligned with the approved design: + +```ts +const springLight = THEMES.find((theme) => theme.id === "spring-light"); +const summerDark = THEMES.find((theme) => theme.id === "summer-dark"); +const autumnLight = THEMES.find((theme) => theme.id === "autumn-light"); +const winterDark = THEMES.find((theme) => theme.id === "winter-dark"); + +expect(springLight?.terminalTheme).toEqual( + expect.objectContaining({ + background: expect.any(String), + cursor: expect.any(String), + selectionBackground: expect.any(String), + }) +); +expect(summerDark?.monaco.colors).toEqual( + expect.objectContaining({ + "editor.background": expect.any(String), + "editorCursor.foreground": expect.any(String), + "editor.selectionBackground": expect.any(String), + }) +); +expect(autumnLight?.family).toBe("autumn"); +expect(winterDark?.family).toBe("winter"); +``` + +Update `packages/web/src/theme/resolve.test.ts` with direct resolver assertions: + +```ts +expect(getThemeById("spring-light").id).toBe("spring-light"); +expect(getThemeFamily("summer-dark")).toBe("summer"); +expect(getThemeVariant("autumn-light")).toBe("light"); +expect(getThemeIdForFamilyVariant("winter", "dark")).toBe("winter-dark"); +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/theme/registry.test.ts \ + src/theme/resolve.test.ts +``` + +Expected: failures because the registry still exposes only the original eight themes and `ThemeFamily` does not yet include the seasonal families. + +- [ ] **Step 3: Extend `ThemeFamily` and add the eight seasonal registry entries** + +In `packages/web/src/theme/registry.ts`, widen the family union: + +```ts +export type ThemeFamily = + | "mint" + | "graphite" + | "nord" + | "hc" + | "spring" + | "summer" + | "autumn" + | "winter"; +``` + +Then define eight new `AppThemeDefinition` entries in `THEMES_REGISTRY`, keeping the existing model unchanged: + +```ts +{ + id: "spring-light", + family: "spring", + kind: "light", + labelKey: "settings.theme.spring_light", + pairedThemeId: "spring-dark", + isHighContrast: false, + documentThemeAttr: "spring-light", + terminalTheme: springLightTerminal, + iconTheme: springLightIconTheme, + monaco: springLightMonaco, +}, +{ + id: "spring-dark", + family: "spring", + kind: "dark", + labelKey: "settings.theme.spring_dark", + pairedThemeId: "spring-light", + isHighContrast: false, + documentThemeAttr: "spring-dark", + terminalTheme: springDarkTerminal, + iconTheme: springDarkIconTheme, + monaco: springDarkMonaco, +}, +``` + +Add the remaining six entries with the same field structure and these exact pairings: + +```ts +"summer-light" <-> "summer-dark" +"autumn-light" <-> "autumn-dark" +"winter-light" <-> "winter-dark" +``` + +Implementation rules: +- Keep `pairedThemeId` symmetrical. +- Keep `documentThemeAttr === id`. +- Keep `isHighContrast` false for all seasonal families. +- Keep terminal ANSI roles stable; use seasonal intent mainly in `cursor`, `selectionBackground`, and chosen accent channels. +- Keep Monaco backgrounds readable and neutral enough for long editing sessions; use seasonal accent mostly in cursor, selection, `keyword`, and `string`. + +- [ ] **Step 4: Add concrete seasonal terminal, Monaco, and icon definitions** + +Still in `packages/web/src/theme/registry.ts`, define focused constants near the existing palette constants: + +```ts +const springLightTerminal: TerminalThemeDefinition = { + background: "#fff8f7", + foreground: "#34282a", + cursor: "#c85c72", + cursorAccent: "#fff8f7", + selectionBackground: "#f3d9de", + selectionForeground: "#34282a", + black: "#2f2628", + red: "#c94f63", + green: "#5f8f63", + yellow: "#b98a48", + blue: "#8d7bb2", + magenta: "#b66d9b", + cyan: "#6d8eb1", + white: "#b7a6aa", + brightBlack: "#8f7e82", + brightRed: "#d96579", + brightGreen: "#74a576", + brightYellow: "#caa15b", + brightBlue: "#9e8cc1", + brightMagenta: "#c47da9", + brightCyan: "#82a3c5", + brightWhite: "#34282a", +}; + +const summerDarkTerminal: TerminalThemeDefinition = { + background: "#101813", + foreground: "#e2ede5", + cursor: "#5ea97a", + cursorAccent: "#101813", + selectionBackground: "#22372a", + selectionForeground: "#e2ede5", + black: "#263029", + red: "#c96c72", + green: "#5ea97a", + yellow: "#c1a25e", + blue: "#6f95c6", + magenta: "#8a84c6", + cyan: "#5f9e98", + white: "#b6c8bb", + brightBlack: "#5a6c5f", + brightRed: "#d78286", + brightGreen: "#79c191", + brightYellow: "#d7b974", + brightBlue: "#86aada", + brightMagenta: "#9f98d7", + brightCyan: "#78b7b0", + brightWhite: "#e2ede5", +}; +``` + +Define concrete Monaco constants the same way: + +```ts +const springLightMonaco: MonacoThemeDefinition = { + base: "vs", + inherit: true, + rules: [ + { token: "comment", foreground: "8b7f83" }, + { token: "string", foreground: "8c7852" }, + { token: "keyword", foreground: "c85c72" }, + ], + colors: { + "editor.background": "#fff8f7", + "editor.foreground": "#34282a", + "editorLineNumber.foreground": "#b29ca1", + "editorCursor.foreground": "#c85c72", + "editor.selectionBackground": "#f3d9de", + }, +}; + +const winterDarkMonaco: MonacoThemeDefinition = { + base: "vs-dark", + inherit: true, + rules: [ + { token: "comment", foreground: "7f8c99" }, + { token: "string", foreground: "9aa8b8" }, + { token: "keyword", foreground: "7da2c7" }, + ], + colors: { + "editor.background": "#11161b", + "editor.foreground": "#e7edf4", + "editorLineNumber.foreground": "#66717d", + "editorCursor.foreground": "#7da2c7", + "editor.selectionBackground": "#233244", + }, +}; +``` + +For icon themes, define concrete constants and keep glyphs stable while shifting only accent-heavy semantics: + +```ts +const springLightIconTheme = createIconTheme({ + "agent.provider.codex": { + ...BASE_ICON_THEME.icons["agent.provider.codex"], + tone: "accent", + }, + "mobile.dock.agent": { + ...BASE_ICON_THEME.icons["mobile.dock.agent"], + tone: "accent", + }, + "terminal.action.new": { + ...BASE_ICON_THEME.icons["terminal.action.new"], + tone: "accent", + }, + "git.branch": { + ...BASE_ICON_THEME.icons["git.branch"], + tone: "accent", + }, + "git.commit": { + ...BASE_ICON_THEME.icons["git.commit"], + tone: "accent", + }, +}); + +const winterDarkIconTheme = createIconTheme({ + "agent.provider.codex": { + ...BASE_ICON_THEME.icons["agent.provider.codex"], + tone: "accent", + }, + "mobile.dock.agent": { + ...BASE_ICON_THEME.icons["mobile.dock.agent"], + tone: "info", + }, + "mobile.dock.files": { + ...BASE_ICON_THEME.icons["mobile.dock.files"], + tone: "info", + }, + "mobile.dock.terminal": { + ...BASE_ICON_THEME.icons["mobile.dock.terminal"], + tone: "info", + }, + "terminal.action.new": { + ...BASE_ICON_THEME.icons["terminal.action.new"], + tone: "info", + }, + "git.branch": { + ...BASE_ICON_THEME.icons["git.branch"], + tone: "accent", + }, + "git.commit": { + ...BASE_ICON_THEME.icons["git.commit"], + tone: "accent", + }, +}); +``` + +Keep semantic state icons (`state.success`, `state.warning`, `state.error`, `state.info`) unchanged so seasonal accent does not swallow status meaning. + +- [ ] **Step 5: Keep resolver behavior unchanged except for the new families** + +In `packages/web/src/theme/resolve.ts`, do not change fallback behavior. The only functional extension should be that the existing helpers can now resolve the seasonal families: + +```ts +export function getThemeIdForFamilyVariant( + family: ThemeFamily, + variant: "dark" | "light" +): string | null { + return THEMES.find((theme) => theme.family === family && theme.kind === variant)?.id ?? null; +} +``` + +No new persistence format, no new legacy mappings, and the default fallback remains `mint-dark`. + +- [ ] **Step 6: Run the focused registry verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/theme/registry.test.ts \ + src/theme/resolve.test.ts +``` + +Expected: all assertions pass with the new 16-theme registry. + +- [ ] **Step 7: Commit Task 1** + +Run: + +```bash +git add \ + packages/web/src/theme/registry.ts \ + packages/web/src/theme/resolve.ts \ + packages/web/src/theme/registry.test.ts \ + packages/web/src/theme/resolve.test.ts +git commit -m "feat: add seasonal theme registry definitions" +``` + +## Task 2: Add Seasonal CSS Token Blocks and Style Coverage + +**Files:** +- Modify: `packages/web/src/styles/tokens.css` +- Modify: `packages/web/src/styles/tokens-touch.test.ts` +- Modify: `packages/web/src/theme/icon-theme.test.ts` + +- [ ] **Step 1: Write failing tests for token coverage across all built-in themes** + +In `packages/web/src/styles/tokens-touch.test.ts`, extend `builtInThemes`: + +```ts +const builtInThemes = [ + "mint-dark", + "mint-light", + "graphite-dark", + "graphite-light", + "nord-dark", + "nord-light", + "hc-dark", + "hc-light", + "spring-light", + "spring-dark", + "summer-light", + "summer-dark", + "autumn-light", + "autumn-dark", + "winter-light", + "winter-dark", +] as const; +``` + +Update the named theme block assertion to require the new selectors: + +```ts +expect(stylesheet).toContain('[data-theme="spring-light"]'); +expect(stylesheet).toContain('[data-theme="spring-dark"]'); +expect(stylesheet).toContain('[data-theme="summer-light"]'); +expect(stylesheet).toContain('[data-theme="summer-dark"]'); +expect(stylesheet).toContain('[data-theme="autumn-light"]'); +expect(stylesheet).toContain('[data-theme="autumn-dark"]'); +expect(stylesheet).toContain('[data-theme="winter-light"]'); +expect(stylesheet).toContain('[data-theme="winter-dark"]'); +``` + +Add token assertions for the per-theme overlay overrides block near the bottom of the file: + +```ts +expect(getRuleBlock('[data-theme="spring-light"]')).toContain("--state-focus-ring-color"); +expect(getRuleBlock('[data-theme="summer-dark"]')).toContain("--surface-overlay-bg"); +expect(getRuleBlock('[data-theme="autumn-light"]')).toContain("--radius-overlay"); +expect(getRuleBlock('[data-theme="winter-dark"]')).toContain("--gap-content"); +``` + +In `packages/web/src/theme/icon-theme.test.ts`, extend every explicit built-in theme loop to include the seasonal theme IDs, and add a focused assertion that seasonal themes keep shared status/icon hierarchy stable: + +```ts +for (const themeId of [ + "spring-light", + "spring-dark", + "summer-light", + "summer-dark", + "autumn-light", + "autumn-dark", + "winter-light", + "winter-dark", +] as const) { + expect(getIconPresentation(themeId, "git.footer.diff")).toEqual( + expect.objectContaining({ tone: "warning" }) + ); + expect(getIconPresentation(themeId, "git.footer.push")).toEqual( + expect.objectContaining({ tone: "success" }) + ); +} +``` + +- [ ] **Step 2: Run the focused style/icon tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/tokens-touch.test.ts \ + src/theme/icon-theme.test.ts +``` + +Expected: failures because the new theme selectors and seasonal icon-theme coverage do not yet exist. + +- [ ] **Step 3: Add the eight seasonal `[data-theme="..."]` token blocks** + +In `packages/web/src/styles/tokens.css`, add one full token block for each seasonal theme, following the same structure used by the existing theme families: + +```css +[data-theme="spring-light"] { + --bg-page: #fff4f3; + --bg-surface: #fffaf9; + --bg-sidebar: #f9eceb; + --bg-terminal: #fff7f6; + --bg-hover: #f5e2e0; + --bg-active: #efd1d0; + --bg-disabled: #f4efef; + --bg-input: #ffffff; + + --border: #e5c9cc; + --border-light: #edd8da; + --border-focus: #c85c72; + --border-error: #d85d74; + + --text-primary: #34282a; + --text-secondary: #6f585d; + --text-tertiary: #9a8388; + --text-disabled: #baa7ab; + --text-inverse: #fffaf9; + + --accent-blue: #9d7fa8; + --accent-green: #7a9a6f; + --accent-amber: #bf8b4d; + --accent-pink: #c85c72; + --accent-red: var(--color-error); + --accent-purple: #a36aa2; + + --color-success: #4f8a66; + --color-warning: #b68442; + --color-error: #d85d74; + --color-info: #6d8eb1; + --bg-panel: color-mix(in srgb, var(--bg-surface) 92%, var(--bg-sidebar)); + --bg-elevated: color-mix(in srgb, var(--bg-surface) 96%, white 4%); + --state-focus-ring-color: var(--border-focus); + --state-focus-ring-offset: 2px; + --state-focus-ring-width: 2px; + --state-hover-bg: var(--bg-hover); + --state-hover-border: var(--border-light); + --state-hover-text: var(--text-primary); + --state-active-bg: var(--bg-active); + --state-selected-bg: color-mix(in srgb, var(--accent-pink) 18%, var(--bg-surface)); + --state-selected-border: color-mix(in srgb, var(--accent-pink) 48%, var(--border)); + --state-selected-text: var(--text-primary); + --state-disabled-bg: var(--bg-disabled); + --state-disabled-border: var(--border); + --state-disabled-text: var(--text-disabled); + --state-success-bg: color-mix(in srgb, var(--color-success) 18%, var(--bg-surface)); + --state-success-border: color-mix(in srgb, var(--color-success) 48%, var(--border)); + --state-success-text: var(--color-success); + --state-warning-bg: color-mix(in srgb, var(--color-warning) 18%, var(--bg-surface)); + --state-warning-border: color-mix(in srgb, var(--color-warning) 48%, var(--border)); + --state-warning-text: var(--color-warning); + --state-error-bg: color-mix(in srgb, var(--color-error) 18%, var(--bg-surface)); + --state-error-border: color-mix(in srgb, var(--color-error) 48%, var(--border)); + --state-error-text: var(--color-error); + --state-info-bg: color-mix(in srgb, var(--color-info) 18%, var(--bg-surface)); + --state-info-border: color-mix(in srgb, var(--color-info) 48%, var(--border)); + --state-info-text: var(--color-info); + --surface-canvas: var(--bg-page); + --surface-panel: var(--bg-panel); + --surface-panel-border: var(--border); + --surface-elevated: var(--bg-elevated); + --surface-elevated-border: var(--border-light); + --surface-input: var(--bg-input); + --surface-input-border: var(--border); + --surface-muted: var(--bg-sidebar); + --surface-inverse: var(--text-primary); + --overlay-backdrop: color-mix(in srgb, var(--bg-page) 68%, transparent); + --overlay-scrim: color-mix(in srgb, var(--bg-page) 82%, transparent); + --overlay-panel: var(--bg-elevated); + --overlay-panel-border: var(--border-light); + --overlay-local-backdrop: color-mix(in srgb, var(--bg-page) 54%, transparent); + --overlay-local-panel: var(--bg-panel); + --overlay-local-panel-border: var(--border); + --icon-primary: var(--text-primary); + --icon-secondary: var(--text-secondary); + --icon-muted: var(--text-tertiary); + --icon-accent: var(--accent-pink); + --icon-success: var(--color-success); + --icon-warning: var(--color-warning); + --icon-error: var(--color-error); + --icon-info: var(--color-info); + --icon-file-folder: #d8848d; + --icon-file-code: var(--accent-blue); + --icon-file-data: var(--accent-purple); + --icon-file-doc: #a98f93; + --icon-file-media: #da98a1; + --icon-file-default: var(--text-secondary); + --icon-git-staged: var(--color-success); + --icon-git-modified: var(--color-warning); + --icon-git-deleted: var(--color-error); + --icon-git-untracked: var(--color-info); + --icon-surface-subtle: color-mix(in srgb, var(--text-secondary) 16%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, var(--accent-pink) 18%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, var(--color-success) 18%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, var(--color-warning) 18%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, var(--color-error) 18%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, var(--color-info) 18%, var(--bg-surface)); + --shadow-sm: 0 1px 2px rgba(63, 38, 42, 0.08); + --shadow-md: 0 4px 12px rgba(63, 38, 42, 0.1); + --shadow-lg: 0 8px 32px rgba(63, 38, 42, 0.12); + --shadow-xl: 0 16px 48px rgba(63, 38, 42, 0.16); + --shadow-glow: 0 0 12px rgba(200, 92, 114, 0.18); + --scrollbar-thumb: #dbc1c5; +} +``` + +Use these exact seasonal accent targets when filling the other seven blocks: +- `spring-dark`: `--border-focus: #d77488`, `--accent-pink: #d77488`, `--icon-accent: #d77488`, `--surface-overlay-bg: color-mix(in srgb, #211618 96%, transparent)` +- `summer-light`: `--border-focus: #5f9a67`, `--accent-green: #5f9a67`, `--icon-accent: #5f9a67`, `--surface-overlay-bg: color-mix(in srgb, #fbfdf9 96%, transparent)` +- `summer-dark`: `--border-focus: #79c191`, `--accent-green: #79c191`, `--icon-accent: #79c191`, `--surface-overlay-bg: color-mix(in srgb, #162019 96%, transparent)` +- `autumn-light`: `--border-focus: #b98946`, `--accent-amber: #b98946`, `--icon-accent: #b98946`, `--surface-overlay-bg: color-mix(in srgb, #fffaf2 96%, transparent)` +- `autumn-dark`: `--border-focus: #d0a35a`, `--accent-amber: #d0a35a`, `--icon-accent: #d0a35a`, `--surface-overlay-bg: color-mix(in srgb, #221a12 96%, transparent)` +- `winter-light`: `--border-focus: #7d9fbe`, `--color-info: #7d9fbe`, `--icon-accent: #7d9fbe`, `--surface-overlay-bg: color-mix(in srgb, #fbfcfe 96%, transparent)` +- `winter-dark`: `--border-focus: #8bb0d3`, `--color-info: #8bb0d3`, `--icon-accent: #8bb0d3`, `--surface-overlay-bg: color-mix(in srgb, #161b22 96%, transparent)` + +Use the existing theme blocks as the exact token template. Every seasonal block must define the same background, border, text, accent, semantic, icon, and shadow tokens already defined by the non-seasonal themes. + +- [ ] **Step 4: Add the matching per-theme overlay/focus override blocks** + +Near the bottom override section that currently contains: + +```css +[data-theme="mint-dark"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #6cb6ff; + --surface-overlay-bg: color-mix(in srgb, #131b22 96%, transparent); +} + +[data-theme="mint-light"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #158f77; + --surface-overlay-bg: color-mix(in srgb, #ffffff 96%, transparent); +} +``` + +add entries for all eight seasonal themes: + +```css +[data-theme="spring-light"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #c85c72; + --surface-overlay-bg: color-mix(in srgb, #fffaf9 96%, transparent); +} +``` + +Add these exact sibling override blocks as well: + +```css +[data-theme="spring-dark"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #d77488; + --surface-overlay-bg: color-mix(in srgb, #211618 96%, transparent); +} + +[data-theme="summer-light"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #5f9a67; + --surface-overlay-bg: color-mix(in srgb, #fbfdf9 96%, transparent); +} + +[data-theme="summer-dark"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #79c191; + --surface-overlay-bg: color-mix(in srgb, #162019 96%, transparent); +} + +[data-theme="autumn-light"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #b98946; + --surface-overlay-bg: color-mix(in srgb, #fffaf2 96%, transparent); +} + +[data-theme="autumn-dark"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #d0a35a; + --surface-overlay-bg: color-mix(in srgb, #221a12 96%, transparent); +} + +[data-theme="winter-light"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #7d9fbe; + --surface-overlay-bg: color-mix(in srgb, #fbfcfe 96%, transparent); +} + +[data-theme="winter-dark"] { + --gap-content: var(--sp-3); + --radius-overlay: var(--radius-xl); + --state-focus-ring-color: #8bb0d3; + --surface-overlay-bg: color-mix(in srgb, #161b22 96%, transparent); +} +``` + +- [ ] **Step 5: Ensure seasonal icon accents are driven by token colors, not semantic state colors** + +While editing `tokens.css`, make the seasonal accent direction visible mainly through: + +```css +--icon-accent: var(--accent-pink); /* spring */ +--icon-accent: var(--accent-green); /* summer */ +--icon-accent: var(--accent-amber); /* autumn */ +--icon-accent: var(--color-info); /* winter */ +--icon-surface-accent: color-mix(in srgb, var(--icon-accent) 18%, var(--bg-surface)); +--shadow-glow: 0 0 12px rgba(200, 92, 114, 0.18); /* spring reference */ +--shadow-glow: 0 0 12px rgba(121, 193, 145, 0.2); /* summer reference */ +--shadow-glow: 0 0 12px rgba(208, 163, 90, 0.2); /* autumn reference */ +--shadow-glow: 0 0 12px rgba(139, 176, 211, 0.18); /* winter reference */ +--state-selected-bg: color-mix(in srgb, var(--icon-accent) 18%, var(--bg-surface)); +--state-selected-border: color-mix(in srgb, var(--icon-accent) 48%, var(--border)); +``` + +Do not change these semantic mappings: + +```css +--icon-success: var(--color-success); +--icon-warning: var(--color-warning); +--icon-error: var(--color-error); +--icon-info: var(--color-info); +``` + +This preserves the spec’s boundary between seasonal accent and system status semantics. + +- [ ] **Step 6: Run the focused style/icon verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/tokens-touch.test.ts \ + src/theme/icon-theme.test.ts +``` + +Expected: token coverage and icon hierarchy tests pass for all 16 themes. + +- [ ] **Step 7: Commit Task 2** + +Run: + +```bash +git add \ + packages/web/src/styles/tokens.css \ + packages/web/src/styles/tokens-touch.test.ts \ + packages/web/src/theme/icon-theme.test.ts +git commit -m "feat: add seasonal theme tokens" +``` + +## Task 3: Update Theme Picker Ordering, Grouping, and Locale Strings + +**Files:** +- 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/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write failing settings tests for seasonal options and grouping** + +In `packages/web/src/features/settings/components/settings-page.test.tsx`, extend the appearance picker coverage with explicit seasonal expectations: + +```ts +const picker = await screen.findByRole("button", { name: "Theme Mint Dark" }); +fireEvent.click(picker); + +const listbox = await screen.findByRole("listbox", { name: "Theme" }); +expect(within(listbox).getByRole("option", { name: "Spring Light" })).toBeInTheDocument(); +expect(within(listbox).getByRole("option", { name: "Spring Dark" })).toBeInTheDocument(); +expect(within(listbox).getByRole("option", { name: "Summer Light" })).toBeInTheDocument(); +expect(within(listbox).getByRole("option", { name: "Autumn Dark" })).toBeInTheDocument(); +expect(within(listbox).getByRole("option", { name: "Winter Dark" })).toBeInTheDocument(); +``` + +Add assertions for disabled section headers rendered as non-selectable options: + +```ts +expect(within(listbox).getByRole("option", { name: "Core Themes" })).toHaveAttribute( + "aria-disabled", + "true" +); +expect(within(listbox).getByRole("option", { name: "Seasonal Themes" })).toHaveAttribute( + "aria-disabled", + "true" +); +``` + +Add a selection assertion proving seasonal themes can still be chosen: + +```ts +fireEvent.click(within(listbox).getByRole("option", { name: "Winter Dark" })); + +await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + appearance: { + themeId: "winter-dark", + }, + }, + }, + undefined + ); +}); +``` + +- [ ] **Step 2: Run the focused settings test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx +``` + +Expected: failures because the theme picker still maps directly from `THEMES` with no seasonal labels or grouping. + +- [ ] **Step 3: Add locale strings for seasonal themes and picker groups** + +In both locale files, extend `settings.theme` with: + +```json +"family_spring": "Spring", +"family_summer": "Summer", +"family_autumn": "Autumn", +"family_winter": "Winter", +"group_core": "Core Themes", +"group_seasonal": "Seasonal Themes", +"spring_light": "Spring Light", +"spring_dark": "Spring Dark", +"summer_light": "Summer Light", +"summer_dark": "Summer Dark", +"autumn_light": "Autumn Light", +"autumn_dark": "Autumn Dark", +"winter_light": "Winter Light", +"winter_dark": "Winter Dark" +``` + +And the Chinese equivalents: + +```json +"family_spring": "春", +"family_summer": "夏", +"family_autumn": "秋", +"family_winter": "冬", +"group_core": "基础主题", +"group_seasonal": "四季主题", +"spring_light": "春·浅色", +"spring_dark": "春·深色", +"summer_light": "夏·浅色", +"summer_dark": "夏·深色", +"autumn_light": "秋·浅色", +"autumn_dark": "秋·深色", +"winter_light": "冬·浅色", +"winter_dark": "冬·深色" +``` + +- [ ] **Step 4: Replace direct `THEMES.map(...)` picker generation with ordered grouped options** + +In `packages/web/src/features/settings/components/settings-page.tsx`, replace: + +```ts +const themeOptions = THEMES.map((registeredTheme) => ({ + value: registeredTheme.id, + label: t(registeredTheme.labelKey), +})); +``` + +with an explicit ordered option builder: + +```ts +const CORE_THEME_IDS = [ + "mint-dark", + "mint-light", + "graphite-dark", + "graphite-light", + "nord-dark", + "nord-light", + "hc-dark", + "hc-light", +] as const; + +const SEASONAL_THEME_IDS = [ + "spring-light", + "spring-dark", + "summer-light", + "summer-dark", + "autumn-light", + "autumn-dark", + "winter-light", + "winter-dark", +] as const; + +const themeDefinitionsById = new Map(THEMES.map((theme) => [theme.id, theme])); + +const themeOptions = [ + { value: "__group_core", label: t("settings.theme.group_core"), disabled: true }, + ...CORE_THEME_IDS.map((themeId) => ({ + value: themeId, + label: t(themeDefinitionsById.get(themeId)!.labelKey), + })), + { value: "__group_seasonal", label: t("settings.theme.group_seasonal"), disabled: true }, + ...SEASONAL_THEME_IDS.map((themeId) => ({ + value: themeId, + label: t(themeDefinitionsById.get(themeId)!.labelKey), + })), +]; +``` + +Implementation rules: +- Keep the existing `Select` component API unchanged. +- Use disabled options for section headers so they appear in both desktop listbox and mobile sheet. +- Preserve the existing `handleThemeSelection` behavior; only real theme options should call it. +- Keep the current selected label based on the stored theme ID, not the header rows. + +- [ ] **Step 5: Run the focused settings verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx +``` + +Expected: the appearance picker renders seasonal items, shows disabled group headers, and updates `appearance.themeId` correctly for seasonal choices. + +- [ ] **Step 6: Commit Task 3** + +Run: + +```bash +git add \ + packages/web/src/features/settings/components/settings-page.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "feat: add seasonal themes to settings picker" +``` + +## Task 4: Fix Secondary Test and Preview Fallout + +**Files:** +- Modify: `packages/web/src/ui-preview/catalog.test.tsx` +- Modify: `packages/web/src/ui-preview/scene-metadata.test.ts` +- Modify: `packages/web/src/theme/icon-theme.test.ts` + +- [ ] **Step 1: Write/adjust failing tests for theme-typed preview helpers** + +In `packages/web/src/ui-preview/catalog.test.tsx`, widen the `renderScene` helper from the hard-coded mint union: + +```ts +import type { UiPreviewSceneTheme } from "./scene-metadata"; + +function renderScene( + sceneId: string, + device: "desktop" | "mobile" = "desktop", + theme: UiPreviewSceneTheme = "mint-dark" +) { + const scene = getUiPreviewScene(sceneId); + if (!scene) { + throw new Error(`Missing scene ${sceneId}`); + } + + installMatchMedia(device); + const context = { theme, locale: "en" as const, device }; + const store = buildUiPreviewStore(scene.seed(context)); + const router = scene.router(context); + + document.documentElement.setAttribute("data-theme", getThemeById(theme).documentThemeAttr); + document.documentElement.setAttribute("lang", "en"); + document.body.dataset.uiPreviewDevice = device; + + return render( + + + + + + + + ); +} +``` + +If the file still uses literal unions elsewhere, replace them with `UiPreviewSceneTheme` or `(typeof THEME_IDS)[number]`. + +Add or keep assertions that route-backed scenes still enumerate all built-in themes through `THEME_IDS`, so the new seasonal themes are automatically covered: + +```ts +expect( + UI_PREVIEW_SCENE_METADATA.filter( + (scene) => + scene.source === "real-route" && + (scene.id === "workspace-desktop" || scene.id === "workspace-mobile") + ).map((scene) => scene.themes) +).toEqual([[...THEME_IDS], [...THEME_IDS]]); +``` + +- [ ] **Step 2: Run the affected preview/theme tests to verify any fallout** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/ui-preview/catalog.test.tsx \ + src/ui-preview/scene-metadata.test.ts \ + src/theme/icon-theme.test.ts +``` + +Expected: at least `catalog.test.tsx` fails before the helper type is widened if the literal union is still present. + +- [ ] **Step 3: Fix preview typing and any remaining explicit built-in theme lists** + +Apply the narrowest changes necessary: +- Replace explicit `"mint-dark" | "mint-light"` helper unions with the real built-in theme type. +- Update any remaining hard-coded theme arrays in tests so they include all seasonal IDs or derive from `THEME_IDS`. +- Do not change scene metadata behavior unless a test proves a real mismatch. + +Preferred patterns: + +```ts +for (const themeId of THEME_IDS) { + expect(getThemeById(themeId).documentThemeAttr).toBe(themeId); +} +``` + +or: + +```ts +type BuiltInThemeId = (typeof THEME_IDS)[number]; +``` + +- [ ] **Step 4: Run the fallout verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/ui-preview/catalog.test.tsx \ + src/ui-preview/scene-metadata.test.ts \ + src/theme/icon-theme.test.ts +``` + +Expected: preview/theme tests pass without special-casing the old eight-theme inventory. + +- [ ] **Step 5: Commit Task 4** + +Run: + +```bash +git add \ + packages/web/src/ui-preview/catalog.test.tsx \ + packages/web/src/ui-preview/scene-metadata.test.ts \ + packages/web/src/theme/icon-theme.test.ts +git commit -m "test: align preview coverage with seasonal themes" +``` + +## Task 5: Final Verification and Implementation Review + +**Files:** +- Review all files changed by Tasks 1-4 + +- [ ] **Step 1: Run the full focused verification suite** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/theme/registry.test.ts \ + src/theme/resolve.test.ts \ + src/theme/icon-theme.test.ts \ + src/styles/tokens-touch.test.ts \ + src/features/settings/components/settings-page.test.tsx \ + src/ui-preview/catalog.test.tsx \ + src/ui-preview/scene-metadata.test.ts +``` + +Expected: all focused theme-related tests pass. + +- [ ] **Step 2: Run package-level web tests if the focused suite is green** + +Run: + +```bash +pnpm --filter @coder-studio/web test +``` + +Expected: package-level web tests pass. If unrelated pre-existing failures appear, record them with exact test names before deciding whether any are caused by the seasonal-theme changes. + +- [ ] **Step 3: Run lint on the touched files or repo-wide check if required by the workspace** + +Run: + +```bash +pnpm ci:lint +``` + +Expected: no lint violations introduced by the seasonal-theme changes. + +- [ ] **Step 4: Re-read the spec and verify the implementation against each requirement** + +Use this checklist: +- eight seasonal built-in themes exist +- existing theme-switching mechanism is unchanged +- Web UI, Monaco, terminal, and icon theme all have seasonal definitions +- spring uses flower-red accent without collapsing into error +- summer uses life-green accent without collapsing into success +- autumn uses amber/wheat/yellow family without becoming warning yellow +- winter uses quiet white/cold gray-blue without becoming normal info blue +- settings UI clearly exposes seasonal themes and light/dark pairing + +If any item fails, fix it before concluding. + +- [ ] **Step 5: Commit final cleanup if needed** + +Run: + +```bash +git add packages/web/src +git commit -m "chore: polish seasonal theme coverage" +``` + +Only create this commit if verification uncovered real follow-up fixes after Tasks 1-4. + +## Execution Notes + +- Follow TDD within each task: write the failing test, run it to confirm the failure, then implement the smallest code change that makes it pass. +- Keep seasonal accent expression stronger in `accent` / `selection` / `focus` / `icon accent` than in large surfaces. +- Do not change persisted settings shape, fallback theme behavior, or shared `Select` component API. +- Do not introduce a second seasonal settings model, automatic season switching, background images, or animation-based season effects. From eb74baad3650e46b3a50dc06d99430c785400de2 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:40:52 +0800 Subject: [PATCH 21/26] test: cover pane navigation overlap priority --- .../agent-panes/pane-navigation.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/web/src/features/agent-panes/pane-navigation.test.ts b/packages/web/src/features/agent-panes/pane-navigation.test.ts index fa210c5e..a5a1359c 100644 --- a/packages/web/src/features/agent-panes/pane-navigation.test.ts +++ b/packages/web/src/features/agent-panes/pane-navigation.test.ts @@ -187,4 +187,55 @@ describe("pane-navigation", () => { expect(findAdjacentSessionId(layout, "sess-active", "right")).toBe("sess-near-right"); }); + + it("prefers an overlapping candidate over a closer non-overlapping candidate", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.25, + children: [ + { + id: "left-stack", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "active", type: "leaf", sessionId: "sess-active" }, + { id: "bottom-left", type: "leaf", sessionId: "sess-bottom-left" }, + ], + }, + { + id: "right-region", + type: "split", + direction: "horizontal", + ratio: 0.2, + children: [ + { + id: "near-column", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "near-top", type: "leaf" }, + { id: "near-bottom", type: "leaf", sessionId: "sess-near-bottom" }, + ], + }, + { + id: "overlap-column", + type: "split", + direction: "vertical", + ratio: 0.25, + children: [ + { id: "overlap-top", type: "leaf", sessionId: "sess-overlap-top" }, + { id: "overlap-bottom", type: "leaf", sessionId: "sess-overlap-bottom" }, + ], + }, + ], + }, + ], + }; + + expect(findAdjacentSessionId(layout, "sess-active", "right")).toBe("sess-overlap-top"); + }); }); From 20aff31e06e92e49d04b77c970191d442992df6c Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:47:02 +0800 Subject: [PATCH 22/26] docs: add semantic color system implementation plan --- ...26-05-24-semantic-color-system-big-bang.md | 1588 +++++++++++++++++ 1 file changed, 1588 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-24-semantic-color-system-big-bang.md diff --git a/docs/superpowers/plans/2026-05-24-semantic-color-system-big-bang.md b/docs/superpowers/plans/2026-05-24-semantic-color-system-big-bang.md new file mode 100644 index 00000000..da70656b --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-semantic-color-system-big-bang.md @@ -0,0 +1,1588 @@ +# Semantic Color System Big-Bang 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:** Replace the current mixed color APIs in `packages/web` with one semantic color system so components only consume sanctioned semantic/material/domain tokens and never hardcode or locally compute color behavior. + +**Architecture:** Rebuild `packages/web/src/styles/tokens.css` into four layers: private reference colors, public semantic colors, material outputs that absorb glass/opacity runtime inputs, and domain/component-role tokens for git/diff/icon/control/status usage. Migrate consumers in `base.css`, `components.css`, and shared UI modules onto those tokens in slices, keep temporary migration aliases only inside `tokens.css`, then delete the old public token interface after all consumers are moved and the guard tests prove the boundary is closed. + +**Tech Stack:** React 19, TypeScript, CSS custom properties, CSS Modules, Vitest, Vite, Testing Library, pnpm workspace scripts. + +--- + +**Spec reference:** `docs/superpowers/specs/2026-05-24-semantic-color-system-big-bang-design.md` + +**Git hygiene:** The current worktree is clean. Commit this plan on the current branch first, then create an isolated worktree under `.worktrees/` for implementation. Never revert unrelated edits if any appear later. + +## File Structure + +**Modified files** +- `packages/web/src/styles/tokens.css` + - Rebuild the color contract into reference, semantic, material, and domain/component-role layers. + - Keep migration-only aliases during the middle tasks and delete them in Task 8. +- `packages/web/src/styles/tokens-touch.test.ts` + - Lock the new token layer, glass/high-contrast overrides, and legacy-token removal. +- `packages/web/src/styles/base.css` + - Rebind links, selections, loading shells, and themed icon utilities to semantic/material/domain tokens. +- `packages/web/src/styles/base.theme.test.ts` + - Assert the new base-shell and icon token usage. +- `packages/web/src/styles/components.css` + - Migrate shared shells, workspace surfaces, feature surfaces, git/diff/status treatments, and remaining global consumers off raw colors and legacy tokens. +- `packages/web/src/styles/components.theme.test.ts` + - Assert the migrated selectors and runtime-aware material behavior. +- `packages/web/src/styles/foundations.guard.test.ts` + - Keep the existing shared-foundation guard working while color-specific guardrails move into the new test below. +- `packages/web/src/styles/color-system.guard.test.ts` + - New migration guard that tracks raw color usage, forbidden runtime/material references, and legacy public token consumption until the final state is empty. +- `packages/web/src/components/ui/workbench-layer/index.module.css` + - Replace direct backdrop runtime usage with material tokens. +- `packages/web/src/components/ui/button/index.module.css` +- `packages/web/src/components/ui/icon-button/index.module.css` +- `packages/web/src/components/ui/input/index.module.css` +- `packages/web/src/components/ui/textarea/index.module.css` +- `packages/web/src/components/ui/tabs/index.module.css` +- `packages/web/src/components/ui/segmented-control/index.module.css` +- `packages/web/src/components/ui/kbd/index.module.css` +- `packages/web/src/components/ui/switch/index.module.css` +- `packages/web/src/components/ui/spinner/index.module.css` +- `packages/web/src/components/ui/action-menu/index.module.css` +- `packages/web/src/components/ui/datetime-picker/index.module.css` +- `packages/web/src/components/ui/tag/index.module.css` +- `packages/web/src/components/ui/badge/index.module.css` +- `packages/web/src/components/ui/pill/index.module.css` +- `packages/web/src/components/ui/notice/index.module.css` +- `packages/web/src/components/ui/toast/index.module.css` +- `packages/web/src/components/ui/status-dot/index.module.css` +- `packages/web/src/components/ui/tooltip/index.module.css` +- `packages/web/src/components/ui/modal/index.module.css` +- `packages/web/src/components/ui/drawer/index.module.css` +- `packages/web/src/components/ui/local-overlay/index.module.css` +- `packages/web/src/components/ui/popover/index.module.css` +- `packages/web/src/components/ui/progress-bar/index.module.css` +- `packages/web/src/components/ui/empty-state/index.module.css` +- `packages/web/src/components/ui/confirm-dialog/index.module.css` + - Rebind shared UI modules so they consume only semantic/domain/component-role tokens. +- `packages/web/src/theme/registry.ts` +- `packages/web/src/theme/registry.test.ts` + - Preserve Monaco/xterm/icon protocol colors as the sanctioned exception layer and keep transparent workspace editor backgrounds. +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` +- `packages/web/src/features/code-editor/components/monaco-host.test.tsx` +- `packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx` + - Preserve the protocol exception boundary while keeping workspace-rendered content transparent and color-system compliant. + +**Created files** +- `packages/web/src/styles/color-system.guard.test.ts` + - Dedicated guardrail for raw colors, runtime-variable leaks, reference-token leaks, and legacy interface leaks. + +**Testing commands used in this plan** +- `pnpm --filter @coder-studio/web exec vitest run src/styles/color-system.guard.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/tokens-touch.test.ts src/styles/base.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/__tests__/xterm-host.test.tsx src/theme/registry.test.ts src/features/code-editor/components/monaco-host.test.tsx src/features/code-editor/components/monaco-diff-host.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run` +- `pnpm ci:test:workspace` +- `pnpm ci:typecheck` +- `pnpm ci:lint` + +--- + +### Task 1: Add Migration Guardrails For The Color-System Rewrite + +**Files:** +- Create: `packages/web/src/styles/color-system.guard.test.ts` +- Modify: `packages/web/src/styles/foundations.guard.test.ts` +- Test: `packages/web/src/styles/color-system.guard.test.ts` + +- [ ] **Step 1: Write the failing migration guard** + +Create `packages/web/src/styles/color-system.guard.test.ts` with an empty expected-inventory so the first run fails and shows the current leakage set: + +```ts +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const files = [ + "src/styles/base.css", + "src/styles/components.css", + "src/components/ui/workbench-layer/index.module.css", + "src/components/ui/button/index.module.css", + "src/components/ui/icon-button/index.module.css", + "src/components/ui/input/index.module.css", + "src/components/ui/textarea/index.module.css", + "src/components/ui/tabs/index.module.css", + "src/components/ui/segmented-control/index.module.css", + "src/components/ui/kbd/index.module.css", + "src/components/ui/switch/index.module.css", + "src/components/ui/spinner/index.module.css", + "src/components/ui/action-menu/index.module.css", + "src/components/ui/datetime-picker/index.module.css", + "src/components/ui/tag/index.module.css", + "src/components/ui/badge/index.module.css", + "src/components/ui/pill/index.module.css", + "src/components/ui/notice/index.module.css", + "src/components/ui/toast/index.module.css", + "src/components/ui/status-dot/index.module.css", + "src/components/ui/tooltip/index.module.css", + "src/components/ui/modal/index.module.css", + "src/components/ui/drawer/index.module.css", + "src/components/ui/local-overlay/index.module.css", + "src/components/ui/popover/index.module.css", + "src/components/ui/progress-bar/index.module.css", + "src/components/ui/empty-state/index.module.css", + "src/components/ui/confirm-dialog/index.module.css", +].map((file) => [file, readFileSync(`${process.cwd()}/${file}`, "utf8")] as const); + +const rawColorPattern = + /#[0-9A-Fa-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(|color-mix\(|\bblur\(\d/; +const runtimePattern = /--app-surface-opacity|--app-surface-backdrop-filter|data-appearance-glass/; +const privateRefPattern = /var\(--ref-/; +const legacyPublicPattern = /var\(--(?:bg-|accent-|color-|ws-)/; + +function offenders(pattern: RegExp) { + return files.filter(([, source]) => pattern.test(source)).map(([file]) => file).sort(); +} + +describe("color-system migration guard", () => { + it("tracks the remaining raw-color consumers explicitly", () => { + expect(offenders(rawColorPattern)).toEqual([]); + }); + + it("tracks the remaining runtime appearance consumers explicitly", () => { + expect(offenders(runtimePattern)).toEqual([]); + }); + + it("forbids private reference tokens outside tokens.css", () => { + expect(offenders(privateRefPattern)).toEqual([]); + }); + + it("tracks the remaining legacy public token consumers explicitly", () => { + expect(offenders(legacyPublicPattern)).toEqual([]); + }); +}); +``` + +Also update `packages/web/src/styles/foundations.guard.test.ts` so the shared UI list includes every module this migration touches: + +```ts +const sharedUiSources = [ + "src/components/ui/button/index.module.css", + "src/components/ui/icon-button/index.module.css", + "src/components/ui/input/index.module.css", + "src/components/ui/textarea/index.module.css", + "src/components/ui/switch/index.module.css", + "src/components/ui/tabs/index.module.css", + "src/components/ui/segmented-control/index.module.css", + "src/components/ui/kbd/index.module.css", + "src/components/ui/popover/index.module.css", + "src/components/ui/action-menu/index.module.css", + "src/components/ui/tag/index.module.css", + "src/components/ui/badge/index.module.css", + "src/components/ui/pill/index.module.css", + "src/components/ui/tooltip/index.module.css", + "src/components/ui/notice/index.module.css", + "src/components/ui/modal/index.module.css", + "src/components/ui/drawer/index.module.css", + "src/components/ui/toast/index.module.css", + "src/components/ui/local-overlay/index.module.css", + "src/components/ui/progress-bar/index.module.css", + "src/components/ui/status-dot/index.module.css", + "src/components/ui/empty-state/index.module.css", + "src/components/ui/confirm-dialog/index.module.css", + "src/components/ui/datetime-picker/index.module.css", + "src/components/ui/spinner/index.module.css", +].map((file) => [file, readFileSync(`${process.cwd()}/${file}`, "utf8")] as const); +``` + +- [ ] **Step 2: Run the guard to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/color-system.guard.test.ts +``` + +Expected: +- FAIL because the repo still contains raw colors, runtime-variable leaks, and legacy token consumers. + +- [ ] **Step 3: Convert the new test into an explicit migration inventory** + +Replace the empty arrays with the exact current migration set so the test locks scope instead of staying red for the whole rewrite: + +```ts +const expectedRawColorConsumers = [ + "src/components/ui/action-menu/index.module.css", + "src/components/ui/button/index.module.css", + "src/components/ui/datetime-picker/index.module.css", + "src/components/ui/icon-button/index.module.css", + "src/components/ui/input/index.module.css", + "src/components/ui/kbd/index.module.css", + "src/components/ui/pill/index.module.css", + "src/components/ui/segmented-control/index.module.css", + "src/components/ui/spinner/index.module.css", + "src/components/ui/status-dot/index.module.css", + "src/components/ui/switch/index.module.css", + "src/components/ui/tabs/index.module.css", + "src/components/ui/tag/index.module.css", + "src/components/ui/textarea/index.module.css", + "src/styles/base.css", + "src/styles/components.css", +]; + +const expectedRuntimeConsumers = [ + "src/components/ui/workbench-layer/index.module.css", + "src/styles/base.css", + "src/styles/components.css", +]; + +const expectedLegacyPublicConsumers = [ + "src/components/ui/action-menu/index.module.css", + "src/components/ui/button/index.module.css", + "src/components/ui/datetime-picker/index.module.css", + "src/components/ui/icon-button/index.module.css", + "src/components/ui/input/index.module.css", + "src/components/ui/kbd/index.module.css", + "src/components/ui/segmented-control/index.module.css", + "src/components/ui/spinner/index.module.css", + "src/components/ui/status-dot/index.module.css", + "src/components/ui/switch/index.module.css", + "src/components/ui/tabs/index.module.css", + "src/components/ui/tag/index.module.css", + "src/components/ui/textarea/index.module.css", + "src/styles/base.css", + "src/styles/components.css", +]; + +it("tracks the remaining raw-color consumers explicitly", () => { + expect(offenders(rawColorPattern)).toEqual(expectedRawColorConsumers); +}); + +it("tracks the remaining runtime appearance consumers explicitly", () => { + expect(offenders(runtimePattern)).toEqual(expectedRuntimeConsumers); +}); + +it("tracks the remaining legacy public token consumers explicitly", () => { + expect(offenders(legacyPublicPattern)).toEqual(expectedLegacyPublicConsumers); +}); +``` + +Leave the private-reference test hard-failing on `[]`; no consumer should be allowed to touch `--ref-*` during any phase. + +- [ ] **Step 4: Run the guard again** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/color-system.guard.test.ts src/styles/foundations.guard.test.ts +``` + +Expected: +- PASS with the migration inventory fixed in place. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/color-system.guard.test.ts packages/web/src/styles/foundations.guard.test.ts +git commit -m "test(web): add semantic color migration guardrails" +``` + +### Task 2: Rebuild `tokens.css` Into Reference, Semantic, Material, And Domain Layers + +**Files:** +- Modify: `packages/web/src/styles/tokens.css` +- Modify: `packages/web/src/styles/tokens-touch.test.ts` +- Test: `packages/web/src/styles/tokens-touch.test.ts` + +- [ ] **Step 1: Write the failing token assertions** + +Add these tests to `packages/web/src/styles/tokens-touch.test.ts` after the current workspace-material coverage: + +```ts + it("defines the semantic color system layers on :root", () => { + const root = getRuleBlock(":root"); + + expect(root).toContain("--ref-fg-0:"); + expect(root).toContain("--ref-bg-0:"); + expect(root).toContain("--ref-border-0:"); + expect(root).toContain("--ref-status-success:"); + + expect(root).toContain("--text-primary: var(--ref-fg-0)"); + expect(root).toContain("--surface-page: var(--ref-bg-0)"); + expect(root).toContain("--border-default: var(--ref-border-0)"); + expect(root).toContain("--status-success-fg: var(--ref-status-success)"); + + expect(root).toContain("--material-panel:"); + expect(root).toContain("--material-overlay:"); + expect(root).toContain("--material-backdrop-filter:"); + expect(root).toContain("--workspace-sidebar-surface:"); + expect(root).toContain("--workspace-editor-toolbar-surface:"); + + expect(root).toContain("--git-status-added-bg:"); + expect(root).toContain("--diff-added-bg:"); + expect(root).toContain("--icon-primary:"); + expect(root).toContain("--control-primary-bg:"); + expect(root).toContain("--field-ring:"); + expect(root).toContain("--tag-info-bg:"); + expect(root).toContain("--status-dot-running-ring-2:"); + }); + + it("keeps the glass/high-contrast material outputs in the token layer", () => { + const glassRoot = getRuleBlock(':root[data-appearance-glass="on"]'); + const highContrastDark = getRuleBlock(':root[data-theme="hc-dark"]'); + + expect(glassRoot).toContain("--material-backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(glassRoot).toContain("--material-panel: color-mix("); + expect(glassRoot).toContain("--workspace-sidebar-surface: var(--material-elevated)"); + expect(glassRoot).toContain("--workspace-terminal-shell-surface: var(--material-elevated)"); + + expect(highContrastDark).toContain("--material-backdrop-filter: none"); + expect(highContrastDark).toContain("--material-panel: var(--surface-panel)"); + expect(highContrastDark).toContain("--workspace-sidebar-surface: var(--surface-panel)"); + }); + + it("keeps temporary legacy aliases inside tokens.css only during migration", () => { + const root = getRuleBlock(":root"); + + expect(root).toContain("--bg-page: var(--surface-page)"); + expect(root).toContain("--accent-blue: var(--status-info-fg)"); + expect(root).toContain("--color-error: var(--status-danger-fg)"); + expect(root).toContain("--ws-sidebar-bg: var(--workspace-sidebar-surface)"); + }); +``` + +- [ ] **Step 2: Run the token test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/tokens-touch.test.ts +``` + +Expected: +- FAIL because the new `--ref-*`, `--material-*`, `--workspace-*`, and domain/component-role tokens do not exist yet. + +- [ ] **Step 3: Rebuild `tokens.css` with the new layered contract** + +At the top of `:root`, replace the current color definitions with this new structure. Keep spacing, typography, radius, z-index, and non-color foundation tokens intact: + +```css + /* Private reference layer */ + --ref-fg-0: #e5edf3; + --ref-fg-1: #9fb0bc; + --ref-fg-2: #728492; + --ref-fg-3: #4a5b6a; + --ref-fg-inverse: #0a1014; + --ref-bg-0: #0a1014; + --ref-bg-1: #11181f; + --ref-bg-2: #0d141a; + --ref-bg-3: #151f28; + --ref-bg-4: #1a2632; + --ref-bg-5: #1e3040; + --ref-bg-6: #22303c; + --ref-border-0: #1e2a35; + --ref-border-1: #263545; + --ref-border-2: #314454; + --ref-border-focus: #6cb6ff; + --ref-border-danger: #ff9eb0; + --ref-status-success: #78d7b2; + --ref-status-warning: #f1b86a; + --ref-status-danger: #ff9eb0; + --ref-status-info: #6cb6ff; + + /* Public semantic layer */ + --text-primary: var(--ref-fg-0); + --text-secondary: var(--ref-fg-1); + --text-tertiary: var(--ref-fg-2); + --text-disabled: var(--ref-fg-3); + --text-inverse: var(--ref-fg-inverse); + --text-link: var(--ref-status-info); + --text-link-hover: color-mix(in srgb, var(--ref-status-info) 82%, white 18%); + + --surface-page: var(--ref-bg-0); + --surface-panel: color-mix(in srgb, var(--ref-bg-1) 94%, var(--ref-bg-0)); + --surface-elevated: color-mix(in srgb, var(--ref-bg-1) 98%, white 2%); + --surface-input: var(--ref-bg-2); + --surface-muted: var(--ref-bg-2); + --surface-hover: var(--ref-bg-4); + --surface-active: var(--ref-bg-5); + --surface-disabled: var(--ref-bg-3); + + --border-default: var(--ref-border-0); + --border-subtle: var(--ref-border-1); + --border-strong: var(--ref-border-2); + --border-focus: var(--ref-border-focus); + --border-danger: var(--ref-border-danger); + + --status-success-fg: var(--ref-status-success); + --status-success-bg: color-mix(in srgb, var(--ref-status-success) 18%, var(--surface-panel)); + --status-success-border: color-mix(in srgb, var(--ref-status-success) 48%, var(--border-default)); + --status-success-icon: var(--ref-status-success); + --status-warning-fg: var(--ref-status-warning); + --status-warning-bg: color-mix(in srgb, var(--ref-status-warning) 18%, var(--surface-panel)); + --status-warning-border: color-mix(in srgb, var(--ref-status-warning) 48%, var(--border-default)); + --status-warning-icon: var(--ref-status-warning); + --status-danger-fg: var(--ref-status-danger); + --status-danger-bg: color-mix(in srgb, var(--ref-status-danger) 18%, var(--surface-panel)); + --status-danger-border: color-mix(in srgb, var(--ref-status-danger) 48%, var(--border-default)); + --status-danger-icon: var(--ref-status-danger); + --status-info-fg: var(--ref-status-info); + --status-info-bg: color-mix(in srgb, var(--ref-status-info) 18%, var(--surface-panel)); + --status-info-border: color-mix(in srgb, var(--ref-status-info) 48%, var(--border-default)); + --status-info-icon: var(--ref-status-info); +``` + +Then define the material layer and the workspace outputs so runtime inputs are consumed only here: + +```css + --material-backdrop-filter: none; + --material-panel: var(--surface-panel); + --material-elevated: var(--surface-elevated); + --material-overlay: color-mix( + in srgb, + var(--surface-elevated) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); + --material-local-overlay: color-mix( + in srgb, + var(--surface-panel) calc(var(--app-surface-opacity, 0.92) * 100%), + transparent + ); + --material-shell-page: color-mix( + in srgb, + var(--surface-page) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); + --material-shell-topbar: color-mix( + in srgb, + var(--surface-elevated) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); + + --workspace-sidebar-surface: var(--surface-panel); + --workspace-activitybar-surface: var(--surface-panel); + --workspace-statusbar-surface: var(--surface-panel); + --workspace-session-surface: var(--surface-panel); + --workspace-session-active-surface: var(--surface-elevated); + --workspace-session-header-surface: var(--surface-elevated); + --workspace-terminal-shell-surface: var(--surface-panel); + --workspace-terminal-toolbar-surface: var(--surface-elevated); + --workspace-terminal-tabs-surface: var(--surface-elevated); + --workspace-editor-shell-surface: var(--surface-panel); + --workspace-editor-toolbar-surface: var(--surface-elevated); + --workspace-content-surface: transparent; +``` + +Then add domain/component-role tokens that consumers will use instead of local formulas: + +```css + --git-status-added-fg: var(--status-success-fg); + --git-status-added-bg: var(--status-success-bg); + --git-status-added-border: var(--status-success-border); + --git-status-modified-fg: var(--status-warning-fg); + --git-status-modified-bg: var(--status-warning-bg); + --git-status-modified-border: var(--status-warning-border); + --git-status-deleted-fg: var(--status-danger-fg); + --git-status-deleted-bg: var(--status-danger-bg); + --git-status-deleted-border: var(--status-danger-border); + --git-status-untracked-fg: var(--status-info-fg); + --git-status-untracked-bg: var(--status-info-bg); + --git-status-untracked-border: var(--status-info-border); + --git-status-renamed-fg: var(--status-info-fg); + --git-status-renamed-bg: var(--status-info-bg); + --git-status-renamed-border: var(--status-info-border); + + --diff-added-bg: var(--status-success-bg); + --diff-added-border: var(--status-success-border); + --diff-modified-bg: var(--status-info-bg); + --diff-modified-border: var(--status-info-border); + --diff-deleted-bg: var(--status-danger-bg); + --diff-deleted-border: var(--status-danger-border); + + --icon-primary: var(--text-primary); + --icon-secondary: var(--text-secondary); + --icon-muted: var(--text-tertiary); + --icon-success: var(--status-success-icon); + --icon-warning: var(--status-warning-icon); + --icon-error: var(--status-danger-icon); + --icon-info: var(--status-info-icon); + --icon-surface-subtle: color-mix(in srgb, var(--text-secondary) 18%, var(--surface-panel)); + --icon-surface-info: color-mix(in srgb, var(--status-info-fg) 24%, var(--surface-panel)); + + --control-focus-ring: 0 0 0 calc(var(--state-focus-ring-width) * 2) + color-mix(in srgb, var(--border-focus) 35%, transparent); + --control-primary-bg: var(--status-info-fg); + --control-primary-bg-hover: color-mix(in srgb, var(--status-info-fg) 84%, white 16%); + --control-primary-fg: var(--text-inverse); + --control-secondary-bg: color-mix(in srgb, var(--surface-panel) 84%, var(--status-info-fg) 16%); + --control-secondary-bg-hover: color-mix(in srgb, var(--surface-hover) 72%, var(--status-info-fg) 28%); + --control-secondary-border: color-mix(in srgb, var(--border-default) 70%, var(--status-info-fg) 30%); + --control-secondary-border-hover: color-mix(in srgb, var(--border-subtle) 70%, var(--status-info-fg) 30%); + --control-ghost-bg-hover: var(--surface-hover); + --control-ghost-fg: var(--text-secondary); + --control-danger-bg: var(--status-danger-fg); + --control-danger-fg: var(--text-inverse); + --control-spinner-track: color-mix(in srgb, currentColor 30%, var(--surface-page) 70%); + + --field-bg: var(--surface-page); + --field-border: var(--border-default); + --field-border-hover: var(--border-subtle); + --field-ring: 0 0 0 var(--state-focus-ring-width) + color-mix(in srgb, var(--border-focus) 40%, transparent); + --field-invalid-ring: 0 0 0 1px color-mix(in srgb, var(--border-danger) 70%, transparent 30%); + --kbd-surface: color-mix(in srgb, var(--surface-input) 82%, var(--surface-panel) 18%); + --menu-danger-hover-bg: color-mix(in srgb, var(--status-danger-fg) 10%, var(--surface-panel)); + + --tag-info-bg: color-mix(in srgb, var(--status-info-fg) 15%, transparent); + --tag-info-fg: var(--status-info-fg); + --tag-success-bg: color-mix(in srgb, var(--status-success-fg) 15%, transparent); + --tag-success-fg: var(--status-success-fg); + --tag-warning-bg: color-mix(in srgb, var(--status-warning-fg) 15%, transparent); + --tag-warning-fg: var(--status-warning-fg); + --tag-danger-bg: color-mix(in srgb, var(--status-danger-fg) 15%, transparent); + --tag-danger-fg: var(--status-danger-fg); + --tag-accent-bg: color-mix(in srgb, var(--text-secondary) 15%, transparent); + --tag-accent-fg: var(--text-secondary); + + --status-dot-idle: var(--text-tertiary); + --status-dot-starting: var(--status-warning-fg); + --status-dot-running: var(--status-info-fg); + --status-dot-complete: var(--status-success-fg); + --status-dot-error: var(--status-danger-fg); + --status-dot-running-ring-1: color-mix(in srgb, var(--status-info-fg) 26%, transparent); + --status-dot-running-ring-2: color-mix(in srgb, var(--status-info-fg) 12%, transparent); + --status-dot-running-ring-3: color-mix(in srgb, var(--status-info-fg) 22%, transparent); +``` + +Finally, add migration-only aliases at the bottom of each theme block and root block. These aliases are allowed only until Task 8: + +```css + /* Migration-only aliases; delete in Task 8. */ + --bg-page: var(--surface-page); + --bg-surface: var(--surface-panel); + --bg-input: var(--surface-input); + --bg-hover: var(--surface-hover); + --bg-active: var(--surface-active); + --border: var(--border-default); + --border-light: var(--border-subtle); + --border-error: var(--border-danger); + --accent-blue: var(--status-info-fg); + --accent-green: var(--status-success-fg); + --accent-amber: var(--status-warning-fg); + --accent-pink: var(--status-danger-fg); + --color-success: var(--status-success-fg); + --color-warning: var(--status-warning-fg); + --color-error: var(--status-danger-fg); + --color-info: var(--status-info-fg); + --ws-sidebar-bg: var(--workspace-sidebar-surface); + --ws-activitybar-bg: var(--workspace-activitybar-surface); + --ws-statusbar-bg: var(--workspace-statusbar-surface); + --ws-session-bg: var(--workspace-session-surface); + --ws-session-active-bg: var(--workspace-session-active-surface); + --ws-session-header-bg: var(--workspace-session-header-surface); + --ws-terminal-shell-bg: var(--workspace-terminal-shell-surface); + --ws-terminal-toolbar-bg: var(--workspace-terminal-toolbar-surface); + --ws-terminal-tabs-bg: var(--workspace-terminal-tabs-surface); + --ws-editor-shell-bg: var(--workspace-editor-shell-surface); + --ws-editor-toolbar-bg: var(--workspace-editor-toolbar-surface); + --ws-backdrop-filter: var(--material-backdrop-filter); +``` + +Mirror the same variable schema in every theme selector: +- `:root, [data-theme="mint-dark"]` +- `[data-theme="mint-light"]` +- `[data-theme="graphite-dark"]` +- `[data-theme="graphite-light"]` +- `[data-theme="nord-dark"]` +- `[data-theme="nord-light"]` +- `[data-theme="hc-dark"]` +- `[data-theme="hc-light"]` + +Use the current theme palettes as the source of truth while renaming them into the `--ref-*` layer. High-contrast themes must override the material outputs to solid surfaces and `--material-backdrop-filter: none`. + +- [ ] **Step 4: Run the token test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/tokens-touch.test.ts +``` + +Expected: +- PASS with the new layer assertions and migration aliases locked in. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/tokens.css packages/web/src/styles/tokens-touch.test.ts +git commit -m "refactor(web): add layered semantic color tokens" +``` + +### Task 3: Migrate `base.css` To Semantic And Material Tokens + +**Files:** +- Modify: `packages/web/src/styles/base.css` +- Modify: `packages/web/src/styles/base.theme.test.ts` +- Modify: `packages/web/src/styles/color-system.guard.test.ts` +- Test: `packages/web/src/styles/base.theme.test.ts` + +- [ ] **Step 1: Write the failing base assertions** + +Update `packages/web/src/styles/base.theme.test.ts` so the loading shell, links, and icon utilities expect only final semantic/material tokens: + +```ts + it("keeps the app loading shell on semantic material tokens", () => { + const shell = getRuleBlock(".app-loading-shell"); + const card = getRuleBlock(".app-loading-card"); + + expect(shell).toContain("background: var(--material-shell-page)"); + expect(shell).toContain("backdrop-filter: var(--material-backdrop-filter)"); + expect(card).toContain("background: var(--material-overlay)"); + expect(card).toContain("backdrop-filter: var(--material-backdrop-filter)"); + expect(card).toContain("box-shadow: var(--surface-overlay-shadow)"); + expect(shell).not.toContain("--app-surface-opacity"); + expect(card).not.toContain("--app-surface-backdrop-filter"); + }); + + it("maps links and themed icons onto semantic/domain tokens", () => { + expect(getRuleBlock("a")).toContain("color: var(--text-link)"); + expect(getRuleBlock("a:hover")).toContain("color: var(--text-link-hover)"); + expect(getRuleBlock(".themed-icon--tone-warning")).toContain("color: var(--icon-warning)"); + expect(getRuleBlock(".themed-icon--surface-info")).toContain("background: var(--icon-surface-info)"); + }); +``` + +- [ ] **Step 2: Run the base test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/base.theme.test.ts +``` + +Expected: +- FAIL because `base.css` still uses `color-mix(...)`, `--app-surface-*`, and legacy accent tokens directly. + +- [ ] **Step 3: Rebind `base.css` to the new tokens** + +Update the relevant rules in `packages/web/src/styles/base.css`: + +```css +a { + color: var(--text-link); + text-decoration: none; + transition: color var(--duration-fast) var(--ease-out); +} + +a:hover { + color: var(--text-link-hover); +} + +.app-loading-shell { + flex: 1; + display: grid; + place-items: center; + padding: var(--sp-8); + background: var(--material-shell-page); + backdrop-filter: var(--material-backdrop-filter); +} + +.app-loading-card { + width: min(520px, 100%); + padding: var(--sp-8); + border: 1px solid var(--border-default); + border-radius: var(--radius-overlay); + background: var(--material-overlay); + backdrop-filter: var(--material-backdrop-filter); + box-shadow: var(--surface-overlay-shadow); +} + +.themed-icon--tone-error { + color: var(--icon-error); +} + +.themed-icon--surface-subtle { + background: var(--icon-surface-subtle); +} + +.themed-icon--surface-info { + background: var(--icon-surface-info); +} +``` + +Then shrink the migration inventory in `packages/web/src/styles/color-system.guard.test.ts` by removing `src/styles/base.css` from both `expectedRawColorConsumers` and `expectedRuntimeConsumers`. Also remove it from `expectedLegacyPublicConsumers`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/base.theme.test.ts src/styles/color-system.guard.test.ts +``` + +Expected: +- PASS with `base.css` removed from all migration-inventory lists. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/base.css packages/web/src/styles/base.theme.test.ts packages/web/src/styles/color-system.guard.test.ts +git commit -m "refactor(web): move base styles to semantic color tokens" +``` + +### Task 4: Centralize Shared Shell, Workspace, And Protocol Material Usage + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/components/ui/workbench-layer/index.module.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- Modify: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` +- Modify: `packages/web/src/styles/color-system.guard.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + +- [ ] **Step 1: Write the failing workspace/material assertions** + +Update `packages/web/src/styles/components.theme.test.ts` so shell consumers expect final material/workspace tokens instead of runtime inputs: + +```ts + expect(settingsContent).toContain("background: var(--material-shell-page)"); + expect(settingsSurface).toContain("background: var(--material-overlay)"); + expect(appTopbar).toContain("background: var(--material-shell-topbar)"); + expect(appTopbar).toContain("backdrop-filter: var(--material-backdrop-filter)"); + expect(workspaceSidebarPanel).toContain("background: var(--workspace-sidebar-surface)"); + expect(workspaceActivityBar).toContain("background: var(--workspace-activitybar-surface)"); + expect(workspaceStatusBar).toContain("background: var(--workspace-statusbar-surface)"); + expect(sessionCard).toContain("background: var(--workspace-session-surface)"); + expect(activeSessionCard).toContain("background: var(--workspace-session-active-surface)"); + expect(activeSessionHeader).toContain("background: var(--workspace-session-header-surface)"); + expect(terminalToolbar).toContain("background: var(--workspace-terminal-toolbar-surface)"); + expect(bottomTerminalTabs).toContain("background: var(--workspace-terminal-tabs-surface)"); + expect(bottomTerminal).toContain("background: var(--workspace-terminal-shell-surface)"); + expect(bottomTerminalContent).toContain("background: var(--workspace-content-surface)"); + expect(bottomTerminalXterm).toContain("background: var(--workspace-content-surface)"); + expect(xtermScreen).toContain("background: transparent"); + expect(mobileTopbar).toContain("background: var(--material-shell-topbar)"); + expect(mobileBottomStack).toContain("background: var(--material-overlay)"); +``` + +Update the workbench-layer expectation too: + +```ts + expect(backdrop).toContain("background: var(--surface-overlay-backdrop)"); + expect(backdrop).toContain("backdrop-filter: var(--material-backdrop-filter)"); +``` + +Update `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` so the xterm theme keeps a transparent workspace background: + +```ts + expect(theme.background).toBe("#00000000"); + expect(theme.foreground).toBeDefined(); +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: +- FAIL because the shared shells still reference `--app-surface-*`, `--ws-*`, and local `color-mix(...)` formulas. + +- [ ] **Step 3: Migrate shared shell and workspace consumers** + +Update `packages/web/src/styles/components.css`: + +```css +.settings-content { + background: var(--material-shell-page); +} + +.settings-content-surface { + background: var(--material-overlay); + backdrop-filter: var(--material-backdrop-filter); +} + +.app-topbar, +.mobile-topbar { + background: var(--material-shell-topbar); + backdrop-filter: var(--material-backdrop-filter); +} + +.workspace-sidebar-panel { + background: var(--workspace-sidebar-surface); + border-right: 1px solid color-mix(in srgb, var(--border-default) 72%, transparent); + backdrop-filter: var(--material-backdrop-filter); +} + +.workspace-activity-bar { + background: var(--workspace-activitybar-surface); + border-right-color: color-mix(in srgb, var(--border-default) 72%, transparent); + backdrop-filter: var(--material-backdrop-filter); +} + +.workspace-status-bar { + background: var(--workspace-statusbar-surface); + backdrop-filter: var(--material-backdrop-filter); +} + +.session-card { + background: var(--workspace-session-surface); + backdrop-filter: var(--material-backdrop-filter); +} + +.session-card.session-card--active { + background: var(--workspace-session-active-surface); +} + +.session-header, +.session-card.session-card--active > .panel-header, +.session-card.session-card--active .session-header { + background: var(--workspace-session-header-surface); + backdrop-filter: var(--material-backdrop-filter); +} + +.terminal-toolbar { + background: var(--workspace-terminal-toolbar-surface); + backdrop-filter: var(--material-backdrop-filter); +} + +.bottom-terminal-tabs { + background: var(--workspace-terminal-tabs-surface); + backdrop-filter: var(--material-backdrop-filter); +} + +.workspace-bottom-panel > .bottom-terminal { + background: var(--workspace-terminal-shell-surface); + backdrop-filter: var(--material-backdrop-filter); +} + +.bottom-terminal-content, +.bottom-terminal-xterm, +.bottom-terminal-empty, +.workspace-sidebar-panel__content, +.workspace-sidebar-view, +.workspace-sidebar-panel__body, +.workspace-body, +.workspace-main-stage, +.agent-panes, +.agent-pane, +.pane-layout, +.pane-layout-child { + background: var(--workspace-content-surface); +} +``` + +Update `packages/web/src/components/ui/workbench-layer/index.module.css`: + +```css +.backdrop, +:global(.workbench-layer-backdrop) { + background: var(--surface-overlay-backdrop); + backdrop-filter: var(--material-backdrop-filter); +} +``` + +Update `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` so the workspace theme background stays transparent and protocol colors remain in `theme/registry.ts`: + +```ts +const theme = { + ...resolvedTheme.terminalTheme, + background: "#00000000", +}; +``` + +Shrink `packages/web/src/styles/color-system.guard.test.ts` by removing: +- `src/styles/components.css` from `expectedRuntimeConsumers` only if all direct `--app-surface-*` usage is gone. +- `src/components/ui/workbench-layer/index.module.css` from `expectedRuntimeConsumers`. + +Do not remove `src/styles/components.css` from `expectedRawColorConsumers` yet; the file still contains many feature-level formulas at this stage. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/features/terminal-panel/__tests__/xterm-host.test.tsx src/styles/color-system.guard.test.ts +``` + +Expected: +- PASS with runtime/material usage centralized in the token layer. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/components.css packages/web/src/components/ui/workbench-layer/index.module.css packages/web/src/styles/components.theme.test.ts packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx packages/web/src/styles/color-system.guard.test.ts +git commit -m "refactor(web): centralize workspace material tokens" +``` + +### Task 5: Migrate Shared Input And Control Modules + +**Files:** +- Modify: `packages/web/src/styles/tokens.css` +- Modify: `packages/web/src/components/ui/button/index.module.css` +- Modify: `packages/web/src/components/ui/icon-button/index.module.css` +- Modify: `packages/web/src/components/ui/input/index.module.css` +- Modify: `packages/web/src/components/ui/textarea/index.module.css` +- Modify: `packages/web/src/components/ui/tabs/index.module.css` +- Modify: `packages/web/src/components/ui/segmented-control/index.module.css` +- Modify: `packages/web/src/components/ui/kbd/index.module.css` +- Modify: `packages/web/src/components/ui/switch/index.module.css` +- Modify: `packages/web/src/components/ui/spinner/index.module.css` +- Modify: `packages/web/src/components/ui/action-menu/index.module.css` +- Modify: `packages/web/src/components/ui/datetime-picker/index.module.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/styles/color-system.guard.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing module assertions** + +Add targeted expectations to `packages/web/src/styles/components.theme.test.ts`: + +```ts + it("keeps shared controls on derived component-role tokens", () => { + expect(buttonStyles).toContain("background: var(--control-primary-bg)"); + expect(buttonStyles).toContain("background: var(--control-secondary-bg)"); + expect(buttonStyles).toContain("border-color: var(--control-secondary-border)"); + expect(buttonStyles).toContain("box-shadow: var(--control-focus-ring)"); + + expect(iconButtonStyles).toContain("background: var(--control-secondary-bg)"); + expect(iconButtonStyles).toContain("border-color: var(--control-secondary-border)"); + + expect(inputStyles).toContain("background: var(--field-bg)"); + expect(inputStyles).toContain("border: 1px solid var(--field-border)"); + expect(inputStyles).toContain("box-shadow: var(--field-ring)"); + + expect(textareaStyles).toContain("background: var(--field-bg)"); + expect(segmentedControlStylesheet).toContain("background: var(--control-secondary-bg)"); + expect(kbdStylesheet).toContain("background: var(--kbd-surface)"); + expect(statusDotStylesheet).not.toContain("color-mix("); + }); +``` + +- [ ] **Step 2: Run the module test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/styles/color-system.guard.test.ts +``` + +Expected: +- FAIL because these modules still use `color-mix(...)` and legacy `--bg-*` / `--accent-*` tokens directly. + +- [ ] **Step 3: Rebind the shared control modules** + +Use the component-role tokens added in Task 2 and update the modules accordingly. + +`packages/web/src/components/ui/button/index.module.css` + +```css +.btn:focus-visible, +:global(.btn):focus-visible { + box-shadow: 0 0 0 2px var(--surface-page), var(--control-focus-ring); +} + +.primary, +:global(.btn-primary) { + background: var(--control-primary-bg); + color: var(--control-primary-fg); +} + +.primary:hover:not(:disabled):not([aria-disabled="true"]), +:global(.btn-primary):hover:not(:disabled):not([aria-disabled="true"]) { + background: var(--control-primary-bg-hover); +} + +.secondary, +:global(.btn-default), +:global(.btn-secondary) { + background: var(--control-secondary-bg); + border-color: var(--control-secondary-border); + color: var(--text-primary); +} + +.secondary:hover:not(:disabled):not([aria-disabled="true"]), +:global(.btn-default):hover:not(:disabled):not([aria-disabled="true"]), +:global(.btn-secondary):hover:not(:disabled):not([aria-disabled="true"]) { + background: var(--control-secondary-bg-hover); + border-color: var(--control-secondary-border-hover); +} + +.ghost:hover:not(:disabled):not([aria-disabled="true"]), +:global(.btn-ghost):hover:not(:disabled):not([aria-disabled="true"]) { + background: var(--control-ghost-bg-hover); + color: var(--text-primary); +} + +.danger, +:global(.btn-danger) { + background: var(--control-danger-bg); + color: var(--control-danger-fg); +} + +.spinner { + border: calc(var(--sp-1) / 2) solid var(--control-spinner-track); + border-top-color: currentColor; +} +``` + +`packages/web/src/components/ui/icon-button/index.module.css` + +```css +.filled { + background: var(--control-secondary-bg); + border-color: var(--control-secondary-border); + color: var(--text-primary); +} + +.filled:hover:not(:disabled):not([aria-disabled="true"]) { + background: var(--control-secondary-bg-hover); + border-color: var(--control-secondary-border-hover); +} +``` + +`packages/web/src/components/ui/input/index.module.css` and `packages/web/src/components/ui/textarea/index.module.css` + +```css +.input { + background: var(--field-bg); + border: 1px solid var(--field-border); +} + +.input:hover { + border-color: var(--field-border-hover); +} + +.input:focus, +.input:focus-visible { + border-color: var(--border-focus); + box-shadow: var(--field-ring); +} + +.invalid:focus, +.invalid:focus-visible, +.input[aria-invalid="true"]:focus, +.input[aria-invalid="true"]:focus-visible { + border-color: var(--border-danger); + box-shadow: var(--field-invalid-ring); +} +``` + +`packages/web/src/components/ui/kbd/index.module.css` + +```css +.root { + background: var(--kbd-surface); + border-color: var(--border-subtle); + box-shadow: inset 0 -1px 0 var(--border-default); +} +``` + +`packages/web/src/components/ui/action-menu/index.module.css` + +```css +.content { + border: 1px solid var(--border-default); + background: var(--material-overlay); +} + +.item:hover, +.item:focus-visible { + background: var(--surface-hover); +} + +.itemDanger { + color: var(--status-danger-fg); +} + +.itemDanger:hover, +.itemDanger:focus-visible { + background: var(--menu-danger-hover-bg); +} +``` + +`packages/web/src/components/ui/segmented-control/index.module.css`, `tabs/index.module.css`, `switch/index.module.css`, `spinner/index.module.css`, and `datetime-picker/index.module.css` should follow the same rule: replace every raw `color-mix(...)`, `--bg-*`, `--accent-*`, and `--color-*` reference with the new `--control-*`, `--field-*`, `--surface-*`, `--border-*`, and `--status-*` tokens. + +Then remove these files from `expectedRawColorConsumers` and `expectedLegacyPublicConsumers` in `packages/web/src/styles/color-system.guard.test.ts`: +- `src/components/ui/action-menu/index.module.css` +- `src/components/ui/button/index.module.css` +- `src/components/ui/datetime-picker/index.module.css` +- `src/components/ui/icon-button/index.module.css` +- `src/components/ui/input/index.module.css` +- `src/components/ui/kbd/index.module.css` +- `src/components/ui/segmented-control/index.module.css` +- `src/components/ui/spinner/index.module.css` +- `src/components/ui/switch/index.module.css` +- `src/components/ui/tabs/index.module.css` +- `src/components/ui/textarea/index.module.css` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/styles/color-system.guard.test.ts +``` + +Expected: +- PASS with those shared controls removed from the migration inventory. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/tokens.css packages/web/src/components/ui/button/index.module.css packages/web/src/components/ui/icon-button/index.module.css packages/web/src/components/ui/input/index.module.css packages/web/src/components/ui/textarea/index.module.css packages/web/src/components/ui/tabs/index.module.css packages/web/src/components/ui/segmented-control/index.module.css packages/web/src/components/ui/kbd/index.module.css packages/web/src/components/ui/switch/index.module.css packages/web/src/components/ui/spinner/index.module.css packages/web/src/components/ui/action-menu/index.module.css packages/web/src/components/ui/datetime-picker/index.module.css packages/web/src/styles/components.theme.test.ts packages/web/src/styles/color-system.guard.test.ts +git commit -m "refactor(web): move shared controls to semantic color roles" +``` + +### Task 6: Migrate Status, Overlay, And Feedback Modules + +**Files:** +- Modify: `packages/web/src/styles/tokens.css` +- Modify: `packages/web/src/components/ui/tag/index.module.css` +- Modify: `packages/web/src/components/ui/badge/index.module.css` +- Modify: `packages/web/src/components/ui/pill/index.module.css` +- Modify: `packages/web/src/components/ui/notice/index.module.css` +- Modify: `packages/web/src/components/ui/toast/index.module.css` +- Modify: `packages/web/src/components/ui/status-dot/index.module.css` +- Modify: `packages/web/src/components/ui/tooltip/index.module.css` +- Modify: `packages/web/src/components/ui/modal/index.module.css` +- Modify: `packages/web/src/components/ui/drawer/index.module.css` +- Modify: `packages/web/src/components/ui/local-overlay/index.module.css` +- Modify: `packages/web/src/components/ui/popover/index.module.css` +- Modify: `packages/web/src/components/ui/progress-bar/index.module.css` +- Modify: `packages/web/src/components/ui/empty-state/index.module.css` +- Modify: `packages/web/src/components/ui/confirm-dialog/index.module.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/styles/color-system.guard.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing status/domain assertions** + +Extend `packages/web/src/styles/components.theme.test.ts`: + +```ts + it("keeps status and overlay modules on semantic/domain tokens", () => { + expect(tagStyles).toContain("background: var(--tag-info-bg)"); + expect(tagStyles).toContain("color: var(--tag-info-fg)"); + expect(badgeStyles).toContain("background: var(--status-info-fg)"); + expect(pillStylesheet).not.toContain("color-mix("); + + expect(noticeStylesheet).toContain("border-color: var(--status-info-border)"); + expect(noticeStylesheet).toContain("background: var(--status-info-bg)"); + expect(toastStyles).toContain("background: var(--material-overlay)"); + expect(statusDotStylesheet).toContain("background: var(--status-dot-current-color, var(--status-dot-idle))"); + expect(statusDotStylesheet).toContain("var(--status-dot-running-ring-2)"); + + expect(modalStylesheet).toContain("background: var(--material-overlay)"); + expect(drawerStylesheet).toContain("background: var(--material-overlay)"); + expect(localOverlayStylesheet).toContain("background: var(--material-local-overlay)"); + expect(progressBarStylesheet).not.toContain("color-mix("); + expect(confirmDialogStyles).not.toContain("color-mix("); + }); +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/styles/color-system.guard.test.ts +``` + +Expected: +- FAIL because the status/feedback modules still use raw formulas and legacy tokens. + +- [ ] **Step 3: Rebind the modules to domain/component-role tokens** + +`packages/web/src/components/ui/tag/index.module.css` + +```css +:where(.blue), +:global(:where(.badge-blue)) { + background: var(--tag-info-bg); + color: var(--tag-info-fg); +} + +:where(.green), +:global(:where(.badge-green)) { + background: var(--tag-success-bg); + color: var(--tag-success-fg); +} + +:where(.amber), +:global(:where(.badge-amber)) { + background: var(--tag-warning-bg); + color: var(--tag-warning-fg); +} + +:where(.pink), +:global(:where(.badge-pink)) { + background: var(--tag-danger-bg); + color: var(--tag-danger-fg); +} + +:where(.purple), +:global(:where(.badge-purple)), +:where(.neutral), +:global(:where(.badge-gray)) { + background: var(--tag-accent-bg); + color: var(--tag-accent-fg); +} +``` + +`packages/web/src/components/ui/badge/index.module.css` + +```css +:where(.badge), +:global(:where(.topbar-unread)) { + background: var(--status-info-fg); + color: var(--text-inverse); +} +``` + +`packages/web/src/components/ui/notice/index.module.css` + +```css +.notice { + border: 1px solid var(--border-default); + background: var(--material-elevated); +} + +.info { + border-color: var(--status-info-border); + background: var(--status-info-bg); +} + +.success { + border-color: var(--status-success-border); + background: var(--status-success-bg); +} + +.warning { + border-color: var(--status-warning-border); + background: var(--status-warning-bg); +} + +.error { + border-color: var(--status-danger-border); + background: var(--status-danger-bg); +} +``` + +`packages/web/src/components/ui/status-dot/index.module.css` + +```css +.dot { + background: var(--status-dot-current-color, var(--status-dot-idle)); + box-shadow: 0 0 0 1px var(--status-dot-current-ring, transparent); +} + +:global(.session-dot-starting), +:global(.connection-status-dot-connecting), +:global(.connection-status-dot-reconnecting) { + --status-dot-current-color: var(--status-dot-starting); +} + +:global(.session-dot-running) { + --status-dot-current-color: var(--status-dot-running); + --status-dot-current-ring: var(--status-dot-running-ring-1); + box-shadow: + 0 0 0 1px var(--status-dot-running-ring-1), + 0 0 0 5px var(--status-dot-running-ring-2), + 0 0 12px var(--status-dot-running-ring-3); +} + +:global(.connection-status-dot-connected) { + --status-dot-current-color: var(--status-dot-running); +} + +:global(.session-dot-complete) { + --status-dot-current-color: var(--status-dot-complete); +} + +:global(.connection-status-dot-disconnected) { + --status-dot-current-color: var(--status-dot-error); +} +``` + +For `toast`, `tooltip`, `modal`, `drawer`, `local-overlay`, `popover`, `progress-bar`, `empty-state`, and `confirm-dialog`, apply the same rule: replace raw formulas with the new `--material-*`, `--surface-*`, `--status-*`, `--border-*`, and `--tag-*` tokens. Keep `transparent`, `currentColor`, `inherit`, and `none` as the only non-token color keywords. + +Then remove these files from `expectedRawColorConsumers` and `expectedLegacyPublicConsumers` in `packages/web/src/styles/color-system.guard.test.ts`: +- `src/components/ui/pill/index.module.css` +- `src/components/ui/tag/index.module.css` +- `src/components/ui/badge/index.module.css` +- `src/components/ui/notice/index.module.css` +- `src/components/ui/toast/index.module.css` +- `src/components/ui/status-dot/index.module.css` +- `src/components/ui/tooltip/index.module.css` +- `src/components/ui/modal/index.module.css` +- `src/components/ui/drawer/index.module.css` +- `src/components/ui/local-overlay/index.module.css` +- `src/components/ui/popover/index.module.css` +- `src/components/ui/progress-bar/index.module.css` +- `src/components/ui/empty-state/index.module.css` +- `src/components/ui/confirm-dialog/index.module.css` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/styles/color-system.guard.test.ts +``` + +Expected: +- PASS with the feedback/overlay modules removed from the migration inventory. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/tokens.css packages/web/src/components/ui/tag/index.module.css packages/web/src/components/ui/badge/index.module.css packages/web/src/components/ui/pill/index.module.css packages/web/src/components/ui/notice/index.module.css packages/web/src/components/ui/toast/index.module.css packages/web/src/components/ui/status-dot/index.module.css packages/web/src/components/ui/tooltip/index.module.css packages/web/src/components/ui/modal/index.module.css packages/web/src/components/ui/drawer/index.module.css packages/web/src/components/ui/local-overlay/index.module.css packages/web/src/components/ui/popover/index.module.css packages/web/src/components/ui/progress-bar/index.module.css packages/web/src/components/ui/empty-state/index.module.css packages/web/src/components/ui/confirm-dialog/index.module.css packages/web/src/styles/components.theme.test.ts packages/web/src/styles/color-system.guard.test.ts +git commit -m "refactor(web): move status and overlay modules to semantic tokens" +``` + +### Task 7: Migrate Remaining Global Feature Consumers In `components.css` + +**Files:** +- Modify: `packages/web/src/styles/tokens.css` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/styles/color-system.guard.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing feature-surface assertions** + +Add focused coverage for the remaining feature/global selectors that currently still rely on raw formulas: + +```ts + it("routes git, diff, banners, and feature shells through domain tokens", () => { + const worktreeCleanChip = getLastRuleBlock(".worktree-chip-status.worktree-clean"); + const worktreeDirtyChip = getLastRuleBlock(".worktree-chip-status.worktree-dirty"); + const addedLine = getLastRuleBlock(".git-diff-line-added"); + const removedLine = getLastRuleBlock(".git-diff-line-removed"); + const editorHeader = getLastRuleBlock(".code-editor-header"); + const mobileBottomStack = getLastRuleBlock(".mobile-shell__bottom-stack"); + + expect(worktreeCleanChip).toContain("color: var(--git-status-added-fg)"); + expect(worktreeCleanChip).toContain("background: var(--git-status-added-bg)"); + expect(worktreeDirtyChip).toContain("color: var(--git-status-modified-fg)"); + expect(worktreeDirtyChip).toContain("background: var(--git-status-modified-bg)"); + expect(addedLine).toContain("background: var(--diff-added-bg)"); + expect(removedLine).toContain("background: var(--diff-deleted-bg)"); + expect(editorHeader).toContain("background: var(--workspace-editor-toolbar-surface)"); + expect(mobileBottomStack).toContain("background: var(--material-overlay)"); + }); +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/styles/color-system.guard.test.ts +``` + +Expected: +- FAIL because `components.css` still contains raw color formulas and legacy token usage outside the workspace slice. + +- [ ] **Step 3: Remove the remaining raw formulas from `components.css`** + +Migrate the remaining selectors to domain/component-role tokens. The exact selectors will come from the migration inventory locked in Task 1. + +Representative replacements: + +```css +.worktree-chip-status.worktree-clean { + color: var(--git-status-added-fg); + background: var(--git-status-added-bg); + border-color: var(--git-status-added-border); +} + +.worktree-chip-status.worktree-dirty { + color: var(--git-status-modified-fg); + background: var(--git-status-modified-bg); + border-color: var(--git-status-modified-border); +} + +.git-diff-line-added { + background: var(--diff-added-bg); + border-color: var(--diff-added-border); +} + +.git-diff-line-removed { + background: var(--diff-deleted-bg); + border-color: var(--diff-deleted-border); +} + +.code-editor-header { + background: var(--workspace-editor-toolbar-surface); + backdrop-filter: var(--material-backdrop-filter); +} + +.mobile-shell__bottom-stack { + background: var(--material-overlay); + backdrop-filter: var(--material-backdrop-filter); +} +``` + +When a selector currently bakes its own accent mix, add a new derived token in `tokens.css` and consume that token instead of leaving any `color-mix(...)` in `components.css`. + +After the migration, `packages/web/src/styles/color-system.guard.test.ts` should have: +- `expectedRuntimeConsumers = []` +- `expectedLegacyPublicConsumers = []` +- `expectedRawColorConsumers = ["src/styles/components.css"]` + +That state means `components.css` is the only remaining raw-color consumer and Task 8 can focus on deleting the last migration aliases. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/styles/color-system.guard.test.ts +``` + +Expected: +- PASS with `components.css` as the only remaining raw-color inventory entry. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/tokens.css packages/web/src/styles/components.css packages/web/src/styles/components.theme.test.ts packages/web/src/styles/color-system.guard.test.ts +git commit -m "refactor(web): migrate global feature surfaces to semantic colors" +``` + +### Task 8: Remove Legacy Public Color Interfaces And Finish Verification + +**Files:** +- Modify: `packages/web/src/styles/tokens.css` +- Modify: `packages/web/src/styles/tokens-touch.test.ts` +- Modify: `packages/web/src/styles/color-system.guard.test.ts` +- Modify: `packages/web/src/styles/base.theme.test.ts` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/styles/tokens-touch.test.ts` +- Test: `packages/web/src/styles/color-system.guard.test.ts` +- Test: `packages/web/src/styles/base.theme.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing final-state assertions** + +Update `packages/web/src/styles/tokens-touch.test.ts` so migration aliases are now forbidden: + +```ts + it("does not expose the legacy public color interface after migration", () => { + const root = getRuleBlock(":root"); + + expect(root).not.toContain("--bg-page:"); + expect(root).not.toContain("--bg-surface:"); + expect(root).not.toContain("--accent-blue:"); + expect(root).not.toContain("--accent-green:"); + expect(root).not.toContain("--accent-amber:"); + expect(root).not.toContain("--accent-pink:"); + expect(root).not.toContain("--color-success:"); + expect(root).not.toContain("--color-warning:"); + expect(root).not.toContain("--color-error:"); + expect(root).not.toContain("--color-info:"); + expect(root).not.toContain("--ws-sidebar-bg:"); + expect(root).not.toContain("--ws-terminal-shell-bg:"); + }); +``` + +Update `packages/web/src/styles/color-system.guard.test.ts` so the final expected inventories are all empty: + +```ts +const expectedRawColorConsumers: string[] = []; +const expectedRuntimeConsumers: string[] = []; +const expectedLegacyPublicConsumers: string[] = []; +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/tokens-touch.test.ts src/styles/color-system.guard.test.ts +``` + +Expected: +- FAIL because `tokens.css` still carries the migration aliases and `components.css` is still listed as a raw-color consumer. + +- [ ] **Step 3: Delete the migration aliases and clean the last remaining raw-color usage** + +Remove the migration-only alias block from `packages/web/src/styles/tokens.css` completely: + +```css + /* Delete this entire migration-only alias block in the final task. */ + --bg-page: var(--surface-page); + --bg-surface: var(--surface-panel); + --bg-input: var(--surface-input); + --bg-hover: var(--surface-hover); + --bg-active: var(--surface-active); + --border: var(--border-default); + --border-light: var(--border-subtle); + --border-error: var(--border-danger); + --accent-blue: var(--status-info-fg); + --accent-green: var(--status-success-fg); + --accent-amber: var(--status-warning-fg); + --accent-pink: var(--status-danger-fg); + --color-success: var(--status-success-fg); + --color-warning: var(--status-warning-fg); + --color-error: var(--status-danger-fg); + --color-info: var(--status-info-fg); + --ws-sidebar-bg: var(--workspace-sidebar-surface); + --ws-activitybar-bg: var(--workspace-activitybar-surface); + --ws-statusbar-bg: var(--workspace-statusbar-surface); + --ws-session-bg: var(--workspace-session-surface); + --ws-session-active-bg: var(--workspace-session-active-surface); + --ws-session-header-bg: var(--workspace-session-header-surface); + --ws-terminal-shell-bg: var(--workspace-terminal-shell-surface); + --ws-terminal-toolbar-bg: var(--workspace-terminal-toolbar-surface); + --ws-terminal-tabs-bg: var(--workspace-terminal-tabs-surface); + --ws-editor-shell-bg: var(--workspace-editor-shell-surface); + --ws-editor-toolbar-bg: var(--workspace-editor-toolbar-surface); + --ws-backdrop-filter: var(--material-backdrop-filter); +``` + +Then finish the remaining `components.css` cleanup so the raw-color inventory reaches zero. Every lingering `color-mix(...)`, `rgba(...)`, hardcoded hex, `--bg-*`, `--accent-*`, `--color-*`, and `--ws-*` consumer outside `tokens.css`, `theme/registry.ts`, and the protocol exception in `xterm-host.tsx` must be removed. + +- [ ] **Step 4: Run the full verification suite** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run +pnpm ci:test:workspace +pnpm ci:typecheck +pnpm ci:lint +``` + +Expected: +- All tests PASS +- Typecheck PASS +- Lint PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/tokens.css packages/web/src/styles/tokens-touch.test.ts packages/web/src/styles/color-system.guard.test.ts packages/web/src/styles/base.theme.test.ts packages/web/src/styles/components.theme.test.ts +git commit -m "refactor(web): finalize semantic color system" +``` + +## Self-Review + +- **Spec coverage:** The plan covers the required layering (`tokens.css`), runtime/material centralization, workspace and overlay migration, shared module migration, git/diff/icon/status convergence, and final legacy-interface removal with hard guardrails. +- **Placeholder scan:** No `TODO`, `TBD`, or “handle appropriately” placeholders remain. Every task names files, tests, commands, and representative code to write. +- **Type consistency:** The same final token names are used throughout the plan: + - private refs: `--ref-*` + - public semantics: `--text-*`, `--surface-*`, `--border-*`, `--status-*` + - material: `--material-*`, `--workspace-*` + - domain/component roles: `--git-*`, `--diff-*`, `--icon-*`, `--control-*`, `--field-*`, `--tag-*`, `--status-dot-*` + +Plan complete and saved to `docs/superpowers/plans/2026-05-24-semantic-color-system-big-bang.md`. Two execution options: + +1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration +2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints From 1510d06f65fe9c075b0249337bc618e22ba0e9d3 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:51:37 +0800 Subject: [PATCH 23/26] feat: wire desktop workspace navigation shortcuts --- ...se-workspace-navigation-shortcuts.test.tsx | 250 ++++++++++++++++++ .../use-workspace-navigation-shortcuts.ts | 107 ++++++++ .../web/src/features/workspace/index.test.tsx | 102 +++++++ .../views/desktop/workspace-desktop-view.tsx | 3 + 4 files changed, 462 insertions(+) create mode 100644 packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx create mode 100644 packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.ts diff --git a/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx b/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx new file mode 100644 index 00000000..34d34757 --- /dev/null +++ b/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx @@ -0,0 +1,250 @@ +import { act, fireEvent, renderHook, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { lastViewedTargetAtom } from "../../../atoms/app-ui"; +import { wsClientAtom } from "../../../atoms/connection"; +import { activeWorkspaceIdAtom, workspacesAtom } from "../../../atoms/workspaces"; +import { customShortcutsAtom } from "../../../lib/shortcuts"; +import { seedReadyWorkspaceState } from "../../../test-utils/workspace-state"; +import { paneLayoutAtomFamily } from "../../agent-panes/atoms/pane-layout"; +import { useWorkspaceNavigationShortcuts } from "./use-workspace-navigation-shortcuts"; + +function wrapperFor(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useWorkspaceNavigationShortcuts", () => { + it("moves to an adjacent session and persists both last viewed target and activeSessionId", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation(async (op: string, payload: unknown) => { + if (op === "workspace.lastViewedTarget.set") { + const target = payload as { workspaceId: string; sessionId?: string }; + return { + workspaceId: target.workspaceId, + sessionId: target.sessionId, + updatedAt: 10, + }; + } + + if (op === "workspace.uiState.set") { + const { workspaceId, uiState } = payload as { + workspaceId: string; + uiState: { activeSessionId?: string }; + }; + + const current = store.get(workspacesAtom)[workspaceId]; + return { + ...current, + uiState: { + ...current.uiState, + ...uiState, + }, + }; + } + + return null; + }); + + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-1": { + id: "ws-1", + path: "/workspace-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + activeSessionId: "sess-left", + }, + }, + }); + store.set(activeWorkspaceIdAtom, "ws-1"); + store.set(paneLayoutAtomFamily("ws-1"), { + id: "root", + type: "split", + direction: "horizontal", + children: [ + { id: "left", type: "leaf", sessionId: "sess-left" }, + { id: "right", type: "leaf", sessionId: "sess-right" }, + ], + }); + + renderHook(() => useWorkspaceNavigationShortcuts("ws-1"), { + wrapper: wrapperFor(store), + }); + + fireEvent.keyDown(window, { key: "ArrowRight", ctrlKey: true }); + + await waitFor(() => { + expect(store.get(lastViewedTargetAtom)).toMatchObject({ + workspaceId: "ws-1", + sessionId: "sess-right", + }); + }); + + expect(store.get(workspacesAtom)["ws-1"]?.uiState.activeSessionId).toBe("sess-right"); + expect(sendCommand).toHaveBeenCalledWith( + "workspace.lastViewedTarget.set", + { workspaceId: "ws-1", sessionId: "sess-right" }, + undefined + ); + expect(sendCommand).toHaveBeenCalledWith( + "workspace.uiState.set", + expect.objectContaining({ + workspaceId: "ws-1", + uiState: expect.objectContaining({ + activeSessionId: "sess-right", + }), + }), + undefined + ); + }); + + it("switches to the next workspace through the existing selection path without mutating the current workspace active session", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation(async (op: string, payload: unknown) => { + if (op === "workspace.lastViewedTarget.set") { + const target = payload as { workspaceId: string; sessionId?: string }; + return { + workspaceId: target.workspaceId, + sessionId: target.sessionId, + updatedAt: 20, + }; + } + + return null; + }); + + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-1": { + id: "ws-1", + path: "/workspace-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + activeSessionId: "sess-left", + }, + }, + "ws-2": { + id: "ws-2", + path: "/workspace-2", + targetRuntime: "native", + openedAt: 2, + lastActiveAt: 2, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + activeSessionId: "sess-other", + }, + }, + }); + store.set(activeWorkspaceIdAtom, "ws-1"); + store.set(paneLayoutAtomFamily("ws-1"), { + id: "root", + type: "leaf", + sessionId: "sess-left", + }); + store.set(customShortcutsAtom, {}); + + renderHook(() => useWorkspaceNavigationShortcuts("ws-1"), { + wrapper: wrapperFor(store), + }); + + fireEvent.keyDown(window, { + key: "ArrowRight", + ctrlKey: true, + shiftKey: true, + }); + + await waitFor(() => { + expect(store.get(activeWorkspaceIdAtom)).toBe("ws-2"); + }); + + expect(store.get(lastViewedTargetAtom)).toMatchObject({ + workspaceId: "ws-2", + }); + expect(store.get(workspacesAtom)["ws-1"]?.uiState.activeSessionId).toBe("sess-left"); + expect(sendCommand).toHaveBeenCalledWith( + "workspace.lastViewedTarget.set", + { workspaceId: "ws-2", sessionId: undefined }, + undefined + ); + expect(sendCommand).not.toHaveBeenCalledWith( + "workspace.uiState.set", + expect.objectContaining({ workspaceId: "ws-1" }), + undefined + ); + }); + + it("still handles matching workspace shortcuts even when the event is already defaultPrevented", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation(async (_op: string, payload: unknown) => { + const target = payload as { workspaceId: string; sessionId?: string }; + return { + workspaceId: target.workspaceId, + sessionId: target.sessionId, + updatedAt: 30, + }; + }); + + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-1": { + id: "ws-1", + path: "/workspace-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + "ws-2": { + id: "ws-2", + path: "/workspace-2", + targetRuntime: "native", + openedAt: 2, + lastActiveAt: 2, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(activeWorkspaceIdAtom, "ws-1"); + + renderHook(() => useWorkspaceNavigationShortcuts("ws-1"), { + wrapper: wrapperFor(store), + }); + + const event = new KeyboardEvent("keydown", { + key: "ArrowRight", + ctrlKey: true, + shiftKey: true, + }); + + act(() => { + event.preventDefault(); + window.dispatchEvent(event); + }); + + await waitFor(() => { + expect(store.get(activeWorkspaceIdAtom)).toBe("ws-2"); + }); + }); +}); diff --git a/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.ts b/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.ts new file mode 100644 index 00000000..5c4aa4a7 --- /dev/null +++ b/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.ts @@ -0,0 +1,107 @@ +import { useAtomValue, useStore } from "jotai"; +import { useEffect } from "react"; +import { activeWorkspaceAtom, orderedWorkspaceIdsAtom } from "../../../atoms/workspaces"; +import { customShortcutsAtom, getEffectiveBinding, matchesShortcut } from "../../../lib/shortcuts"; +import { paneLayoutAtomFamily } from "../../agent-panes/atoms/pane-layout"; +import { findAdjacentSessionId, type PaneDirection } from "../../agent-panes/pane-navigation"; +import { usePersistWorkspaceLastViewedTarget } from "./use-persist-workspace-last-viewed-target"; +import { useSelectWorkspaceTarget } from "./use-select-workspace-target"; +import { useWorkspaceUiStatePersistence } from "./use-workspace-ui-state-persistence"; + +const SESSION_SHORTCUTS: Array<{ id: string; direction: PaneDirection }> = [ + { id: "session.navigate.left", direction: "left" }, + { id: "session.navigate.right", direction: "right" }, + { id: "session.navigate.up", direction: "up" }, + { id: "session.navigate.down", direction: "down" }, +]; + +const WORKSPACE_SHORTCUTS: Array<{ id: string; step: -1 | 1 }> = [ + { id: "workspace.previous", step: -1 }, + { id: "workspace.next", step: 1 }, +]; + +export function useWorkspaceNavigationShortcuts(workspaceId: string) { + const store = useStore(); + const activeWorkspace = useAtomValue(activeWorkspaceAtom); + const orderedWorkspaceIds = useAtomValue(orderedWorkspaceIdsAtom); + const customBindings = useAtomValue(customShortcutsAtom); + const persistLastViewedTarget = usePersistWorkspaceLastViewedTarget(); + const selectWorkspaceTarget = useSelectWorkspaceTarget(); + const { persistUiState } = useWorkspaceUiStatePersistence(workspaceId); + + useEffect(() => { + if (!workspaceId) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + const sessionShortcut = SESSION_SHORTCUTS.find(({ id }) => + matchesShortcut(event, getEffectiveBinding(id, customBindings)) + ); + if (sessionShortcut) { + event.preventDefault(); + + const workspace = activeWorkspace; + if (!workspace || workspace.id !== workspaceId) { + return; + } + + const activeSessionId = workspace.uiState.activeSessionId; + if (!activeSessionId) { + return; + } + + const layout = store.get(paneLayoutAtomFamily(workspaceId)); + const nextSessionId = findAdjacentSessionId( + layout, + activeSessionId, + sessionShortcut.direction + ); + if (!nextSessionId) { + return; + } + + void persistLastViewedTarget({ + workspaceId, + sessionId: nextSessionId, + }); + void persistUiState({ activeSessionId: nextSessionId }); + return; + } + + const workspaceShortcut = WORKSPACE_SHORTCUTS.find(({ id }) => + matchesShortcut(event, getEffectiveBinding(id, customBindings)) + ); + if (!workspaceShortcut) { + return; + } + + event.preventDefault(); + + const currentWorkspaceId = activeWorkspace?.id ?? workspaceId; + const currentIndex = orderedWorkspaceIds.indexOf(currentWorkspaceId); + if (currentIndex === -1) { + return; + } + + const nextWorkspaceId = orderedWorkspaceIds[currentIndex + workspaceShortcut.step]; + if (!nextWorkspaceId) { + return; + } + + void selectWorkspaceTarget(nextWorkspaceId); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + activeWorkspace, + customBindings, + orderedWorkspaceIds, + persistLastViewedTarget, + persistUiState, + selectWorkspaceTarget, + store, + workspaceId, + ]); +} diff --git a/packages/web/src/features/workspace/index.test.tsx b/packages/web/src/features/workspace/index.test.tsx index 1c0e3017..6c8cac65 100644 --- a/packages/web/src/features/workspace/index.test.tsx +++ b/packages/web/src/features/workspace/index.test.tsx @@ -2,9 +2,11 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import { createStore, Provider } from "jotai"; import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { lastViewedTargetAtom } from "../../atoms/app-ui"; import { connectionStatusAtom, wsClientAtom } from "../../atoms/connection"; import { activeWorkspaceIdAtom, workspaceOrderAtom, workspacesAtom } from "../../atoms/workspaces"; import { seedReadyWorkspaceState } from "../../test-utils/workspace-state"; +import { paneLayoutAtomFamily } from "../agent-panes/atoms/pane-layout"; import { updateStateAtom } from "../updates/atoms"; import { activeFilePathAtomFamily, @@ -471,6 +473,106 @@ describe("WorkspacePage", () => { expect(screen.getByTestId("git-panel")).toBeInTheDocument(); }); + it("mounts desktop workspace navigation shortcuts and switches workspaces on Ctrl+Shift+ArrowRight", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, payload: unknown) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + if (op === "session.list") { + return []; + } + + if (op === "workspace.lastViewedTarget.set") { + const target = payload as { workspaceId: string; sessionId?: string }; + return { + workspaceId: target.workspaceId, + sessionId: target.sessionId, + updatedAt: 10, + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-a": { + id: "ws-a", + path: "/workspace-a", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + activeSessionId: "sess-a", + }, + }, + "ws-b": { + id: "ws-b", + path: "/workspace-b", + targetRuntime: "native", + openedAt: 2, + lastActiveAt: 2, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + activeSessionId: "sess-b", + }, + }, + }); + store.set(activeWorkspaceIdAtom, "ws-a"); + store.set(paneLayoutAtomFamily("ws-a"), { + id: "root", + type: "leaf", + sessionId: "sess-a", + }); + store.set(paneLayoutAtomFamily("ws-b"), { + id: "root", + type: "leaf", + sessionId: "sess-b", + }); + + render( + + + + } /> + + + + ); + + await screen.findByTestId("file-tree-panel"); + + fireEvent.keyDown(window, { + key: "ArrowRight", + ctrlKey: true, + shiftKey: true, + }); + + await waitFor(() => { + expect(store.get(activeWorkspaceIdAtom)).toBe("ws-b"); + }); + + expect(store.get(lastViewedTargetAtom)).toMatchObject({ + workspaceId: "ws-b", + }); + }); + it("renders the content search input when the Search activity item is active", 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 1aa19ec4..38096f53 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 @@ -9,6 +9,7 @@ import { PanelHeader } from "../../../shared/components/panel-header"; import { TerminalPanel } from "../../../terminal-panel"; import { TopBar } from "../../../topbar"; import { useWorkspaceFullscreen } from "../../actions/use-workspace-fullscreen"; +import { useWorkspaceNavigationShortcuts } from "../../actions/use-workspace-navigation-shortcuts"; import { useWorkspaceScreenModel } from "../../actions/use-workspace-screen-model"; import { sidebarCollapsedAtom } from "../../atoms"; import { sanitizeDesktopSidebarView } from "../../atoms/layout"; @@ -58,6 +59,8 @@ const WorkspaceDesktopScene: FC = () => { const setSidebarCollapsed = useSetAtom(sidebarCollapsedAtom); const activeSidebarView = sanitizeDesktopSidebarView(desktopSidebarView); + useWorkspaceNavigationShortcuts(workspace.id); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || isEditableTarget(event.target)) { From ed470ff1d42b442cd5bb23e45b55f7996a549390 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:57:01 +0800 Subject: [PATCH 24/26] test: fix defaultPrevented shortcut coverage --- .../actions/use-workspace-navigation-shortcuts.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx b/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx index 34d34757..1719befe 100644 --- a/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx +++ b/packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx @@ -236,10 +236,13 @@ describe("useWorkspaceNavigationShortcuts", () => { key: "ArrowRight", ctrlKey: true, shiftKey: true, + cancelable: true, }); + event.preventDefault(); + expect(event.defaultPrevented).toBe(true); + act(() => { - event.preventDefault(); window.dispatchEvent(event); }); From 83d50dcdc2baa127800ad7963d714d9c81c47244 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 16:15:13 +0800 Subject: [PATCH 25/26] feat: forward xterm theme background as COLORFGBG to spawned PTYs Inject COLORFGBG (derived from the active xterm.js theme background via Rec. 601 luma) into the env of every PTY the server spawns. This gives Claude Code, Codex, and other TUIs a reliable light/dark signal so they can match the page theme. Windows ConPTY intercepts OSC 11 background-color queries and never forwards xterm.js's response back to the child, leaving TUIs stuck on their dark default. On a light coder-studio theme this surfaces as a stark black bar behind user messages. COLORFGBG is the only signal that survives the ConPTY layer. - TerminalSpec gains an optional themeBackground hex string forwarded through session.create / terminal.create command schemas - TerminalManager derives COLORFGBG once at spawn time and lets spec.env override when callers need to pin a specific value - useTerminalThemeBackground hook reads the active theme atom and is threaded through every session/terminal create call site --- packages/server/src/commands/session.ts | 5 + packages/server/src/commands/terminal.ts | 5 + packages/server/src/session/manager.ts | 7 ++ packages/server/src/terminal/manager.test.ts | 112 +++++++++++++++++- packages/server/src/terminal/manager.ts | 64 ++++++++++ packages/server/src/terminal/types.ts | 9 ++ .../actions/use-provider-launcher.test.tsx | 24 ++-- .../actions/use-provider-launcher.ts | 5 + .../components/session-card.test.tsx | 5 +- .../src/features/agent-panes/index.test.tsx | 5 +- .../agent-panes/views/shared/session-card.tsx | 3 + .../web/src/features/diagnostics/page.tsx | 3 + .../use-create-shell-terminal.test.tsx | 5 +- .../actions/use-create-shell-terminal.ts | 3 + .../views/shared/file-tree-panel.test.tsx | 10 +- .../src/shells/mobile-shell/index.test.tsx | 10 +- packages/web/src/theme/index.ts | 1 + .../theme/use-terminal-theme-background.ts | 18 +++ 18 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 packages/web/src/theme/use-terminal-theme-background.ts diff --git a/packages/server/src/commands/session.ts b/packages/server/src/commands/session.ts index 4bbbebbe..0a917c41 100644 --- a/packages/server/src/commands/session.ts +++ b/packages/server/src/commands/session.ts @@ -42,6 +42,10 @@ registerCommand( workspaceId: z.string(), providerId: z.string(), draft: z.string().optional(), + themeBackground: z + .string() + .regex(/^#[0-9a-fA-F]{3,8}$/) + .optional(), }), async (args, ctx) => { // Get workspace @@ -75,6 +79,7 @@ registerCommand( providerId: args.providerId, provider, draft: args.draft, + themeBackground: args.themeBackground, }); } ); diff --git a/packages/server/src/commands/terminal.ts b/packages/server/src/commands/terminal.ts index a763a8d6..792f3c5c 100644 --- a/packages/server/src/commands/terminal.ts +++ b/packages/server/src/commands/terminal.ts @@ -154,6 +154,10 @@ registerCommand( cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(), cwdPath: z.string().optional(), + themeBackground: z + .string() + .regex(/^#[0-9a-fA-F]{3,8}$/) + .optional(), }), async (args, ctx) => { const workspace = ctx.workspaceMgr.get(args.workspaceId); @@ -204,6 +208,7 @@ registerCommand( cwd, cols: args.cols ?? 120, rows: args.rows ?? 30, + themeBackground: args.themeBackground, }); return terminal; diff --git a/packages/server/src/session/manager.ts b/packages/server/src/session/manager.ts index f758e2fe..d2209f0f 100644 --- a/packages/server/src/session/manager.ts +++ b/packages/server/src/session/manager.ts @@ -31,6 +31,12 @@ export interface CreateSessionRequest { providerId: string; provider: ProviderDefinition; draft?: string; + /** + * Hex color of the xterm.js theme background that will render this + * session's terminal. Forwarded to TerminalSpec.themeBackground so the + * PTY env can advertise COLORFGBG to the agent CLI (see TerminalManager). + */ + themeBackground?: string; } export interface SessionLogger { @@ -249,6 +255,7 @@ export class SessionManager { CODER_STUDIO_SESSION_ID: sessionId, }, title: req.provider.displayName, + themeBackground: req.themeBackground, }; // Create terminal (delegates to TerminalManager) diff --git a/packages/server/src/terminal/manager.test.ts b/packages/server/src/terminal/manager.test.ts index 9aeeb225..53bc177c 100644 --- a/packages/server/src/terminal/manager.test.ts +++ b/packages/server/src/terminal/manager.test.ts @@ -3,7 +3,7 @@ import type { DomainEvent, Terminal } from "@coder-studio/core"; import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; import { EventBus } from "../bus/event-bus"; -import { TerminalManager } from "./manager"; +import { computeColorFgBg, TerminalManager } from "./manager"; import { RingBuffer } from "./ring-buffer"; import * as snapshotBufferModule from "./terminal-snapshot-buffer"; import type { PtyHost, PtyProcess, TerminalDatabase, TerminalSpec } from "./types"; @@ -164,6 +164,116 @@ describe("TerminalManager", () => { expect(spawnOptions.env.FORCE_COLOR).toBe("0"); }); + it("injects COLORFGBG=0;15 for a light themeBackground", () => { + const spec: TerminalSpec = { + workspaceId: "ws-123", + kind: "shell", + argv: ["bash"], + cwd: "/home/user", + themeBackground: "#fcfffd", + }; + + manager.create(spec); + + const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1]; + expect(spawnOptions.env.COLORFGBG).toBe("0;15"); + }); + + it("injects COLORFGBG=15;0 for a dark themeBackground", () => { + const spec: TerminalSpec = { + workspaceId: "ws-123", + kind: "shell", + argv: ["bash"], + cwd: "/home/user", + themeBackground: "#0b1218", + }; + + manager.create(spec); + + const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1]; + expect(spawnOptions.env.COLORFGBG).toBe("15;0"); + }); + + it("omits COLORFGBG when themeBackground is not provided", () => { + const spec: TerminalSpec = { + workspaceId: "ws-123", + kind: "shell", + argv: ["bash"], + cwd: "/home/user", + }; + + manager.create(spec); + + const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1]; + expect(spawnOptions.env.COLORFGBG).toBeUndefined(); + }); + + it("omits COLORFGBG when themeBackground is malformed", () => { + const spec: TerminalSpec = { + workspaceId: "ws-123", + kind: "shell", + argv: ["bash"], + cwd: "/home/user", + themeBackground: "not-a-color", + }; + + manager.create(spec); + + const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1]; + expect(spawnOptions.env.COLORFGBG).toBeUndefined(); + }); + + it("lets spec.env override the derived COLORFGBG when explicitly set", () => { + const spec: TerminalSpec = { + workspaceId: "ws-123", + kind: "shell", + argv: ["bash"], + cwd: "/home/user", + themeBackground: "#fcfffd", + env: { + COLORFGBG: "7;0", + }, + }; + + manager.create(spec); + + const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1]; + expect(spawnOptions.env.COLORFGBG).toBe("7;0"); + }); + }); + + describe("computeColorFgBg", () => { + it.each([ + ["#ffffff", "0;15"], + ["#fcfffd", "0;15"], + ["#f5f7fa", "0;15"], + ["#fff", "0;15"], + ])("returns 0;15 (light bg) for %s", (input, expected) => { + expect(computeColorFgBg(input)).toBe(expected); + }); + + it.each([ + ["#000000", "15;0"], + ["#0b1218", "15;0"], + ["#2e3440", "15;0"], + ["#000", "15;0"], + ])("returns 15;0 (dark bg) for %s", (input, expected) => { + expect(computeColorFgBg(input)).toBe(expected); + }); + + it("accepts #RRGGBBAA and ignores the alpha channel", () => { + expect(computeColorFgBg("#ffffff80")).toBe("0;15"); + expect(computeColorFgBg("#00000080")).toBe("15;0"); + }); + + it("returns undefined for malformed input", () => { + expect(computeColorFgBg("")).toBeUndefined(); + expect(computeColorFgBg("white")).toBeUndefined(); + expect(computeColorFgBg("#xyz")).toBeUndefined(); + expect(computeColorFgBg("#12")).toBeUndefined(); + expect(computeColorFgBg("rgb(0,0,0)")).toBeUndefined(); + }); + it("should throw error on spawn failure", () => { const spawnError = new Error("Command not found"); mockPtyHost.spawn = vi.fn().mockImplementation(() => { diff --git a/packages/server/src/terminal/manager.ts b/packages/server/src/terminal/manager.ts index a6a0a79a..6a0f0b0a 100644 --- a/packages/server/src/terminal/manager.ts +++ b/packages/server/src/terminal/manager.ts @@ -22,6 +22,59 @@ function isTerminalTraceEnabled(): boolean { return process.env.CODER_STUDIO_TERMINAL_TRACE === "1"; } +/** + * Parse a #RRGGBB / #RRGGBBAA / #RGB hex color into 8-bit RGB. + * Returns null for any unrecognized input so callers can skip injection. + */ +function parseHexColor(input: string): { r: number; g: number; b: number } | null { + const trimmed = input.trim(); + if (!trimmed.startsWith("#")) { + return null; + } + + const hex = trimmed.slice(1); + let r: number; + let g: number; + let b: number; + if (hex.length === 3) { + r = Number.parseInt(hex[0]! + hex[0]!, 16); + g = Number.parseInt(hex[1]! + hex[1]!, 16); + b = Number.parseInt(hex[2]! + hex[2]!, 16); + } else if (hex.length === 6 || hex.length === 8) { + r = Number.parseInt(hex.slice(0, 2), 16); + g = Number.parseInt(hex.slice(2, 4), 16); + b = Number.parseInt(hex.slice(4, 6), 16); + } else { + return null; + } + + if ([r, g, b].some((v) => Number.isNaN(v))) { + return null; + } + return { r, g, b }; +} + +/** + * Derive an xterm-style COLORFGBG value from a theme background hex color. + * + * Returns "0;15" (dark text on light background) when the perceived luma is + * above the threshold, "15;0" (light text on dark background) otherwise. + * Returns undefined when the input cannot be parsed so the caller leaves the + * variable untouched and the child TUI falls back to its own default. + * + * Luma uses the Rec. 601 coefficients; the threshold is 0.5 which is the same + * cutoff most TUIs (Ink, chalk, bat, delta) use internally. + */ +export function computeColorFgBg(background: string): string | undefined { + const rgb = parseHexColor(background); + if (!rgb) { + return undefined; + } + + const luma = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + return luma > 0.5 ? "0;15" : "15;0"; +} + function countOccurrences(text: string, needle: string): number { return text.split(needle).length - 1; } @@ -109,6 +162,16 @@ export class TerminalManager { // FORCE_COLOR makes agent CLIs that are spawned directly (e.g. Claude Code // via Ink/chalk, Codex) emit ANSI colors without relying on the user's // shell rc to set it. spec.env can still override explicitly. + // + // COLORFGBG advertises the frontend xterm theme's light/dark intent so + // TUIs that auto-pick color schemes (Claude Code, Codex, bat, delta, …) + // can match the page background. We derive it from spec.themeBackground. + // On Windows the ConPTY layer intercepts OSC 11 background-color queries + // and never forwards xterm.js's response back to the child, so this env + // variable is the only signal that survives the round trip. + const derivedColorFgBg = spec.themeBackground + ? computeColorFgBg(spec.themeBackground) + : undefined; const terminalEnv: Record = { ...Object.fromEntries( Object.entries(process.env).filter((e): e is [string, string] => e[1] != null) @@ -116,6 +179,7 @@ export class TerminalManager { TERM: "xterm-256color", COLORTERM: "truecolor", FORCE_COLOR: "3", + ...(derivedColorFgBg ? { COLORFGBG: derivedColorFgBg } : {}), ...spec.env, }; diff --git a/packages/server/src/terminal/types.ts b/packages/server/src/terminal/types.ts index 87660fcc..7f2825b8 100644 --- a/packages/server/src/terminal/types.ts +++ b/packages/server/src/terminal/types.ts @@ -14,6 +14,15 @@ export interface TerminalSpec { cols?: number; rows?: number; title?: string; + /** + * Hex color (e.g. "#fcfffd") of the frontend xterm.js theme background that + * will render this terminal. The TerminalManager uses it to derive + * COLORFGBG so child TUIs (Claude Code, Codex, …) can pick a matching + * light/dark color scheme. This is the only signal that survives the + * Windows ConPTY layer, which otherwise intercepts OSC 11 background + * queries and forces TUIs into their dark fallback. + */ + themeBackground?: string; } /** diff --git a/packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx b/packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx index 81eab1d0..b91e082e 100644 --- a/packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx +++ b/packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx @@ -169,10 +169,14 @@ describe("useProviderLauncher", () => { await waitFor(() => { expect(dispatch).toHaveBeenCalledWith("provider.install.get", { jobId: "job-1" }); expect(dispatch).toHaveBeenCalledWith("provider.runtimeStatus", {}); - expect(dispatch).toHaveBeenCalledWith("session.create", { - workspaceId: "ws-1", - providerId: "claude", - }); + expect(dispatch).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ + workspaceId: "ws-1", + providerId: "claude", + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }) + ); }); expect(onSessionCreated).toHaveBeenCalledWith( @@ -278,10 +282,14 @@ describe("useProviderLauncher", () => { await waitFor(() => { expect(dispatch).toHaveBeenCalledWith("provider.runtimeStatus", {}); - expect(dispatch).toHaveBeenCalledWith("session.create", { - workspaceId: "ws-1", - providerId: "claude", - }); + expect(dispatch).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ + workspaceId: "ws-1", + providerId: "claude", + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }) + ); expect(result.current.states.claude.runtime?.available).toBe(false); expect(result.current.states.claude.inlineError).toBe("Claude CLI is missing"); }); diff --git a/packages/web/src/features/agent-panes/actions/use-provider-launcher.ts b/packages/web/src/features/agent-panes/actions/use-provider-launcher.ts index bcbac310..f7340c99 100644 --- a/packages/web/src/features/agent-panes/actions/use-provider-launcher.ts +++ b/packages/web/src/features/agent-panes/actions/use-provider-launcher.ts @@ -7,6 +7,7 @@ import type { } from "@coder-studio/core"; import { useEffect, useRef, useState } from "react"; import type { DispatchCommand } from "../../../atoms/connection"; +import { useTerminalThemeBackground } from "../../../theme"; export type ProviderId = "claude" | "codex"; @@ -65,6 +66,7 @@ export function useProviderLauncher( ): UseProviderLauncherResult { const [states, setStates] = useState>(buildStateMap()); const pollingTimers = useRef>>({}); + const themeBackground = useTerminalThemeBackground(); useEffect(() => { let cancelled = false; @@ -190,6 +192,7 @@ export function useProviderLauncher( const createResult = await dispatch("session.create", { workspaceId, providerId, + themeBackground, }); if (createResult.ok && createResult.data) { @@ -259,6 +262,7 @@ export function useProviderLauncher( const createResult = await dispatch("session.create", { workspaceId, providerId, + themeBackground, }); if (createResult.ok && createResult.data) { @@ -315,6 +319,7 @@ export function useProviderLauncher( const createResult = await dispatch("session.create", { workspaceId, providerId, + themeBackground, }); if (createResult.ok && createResult.data) { diff --git a/packages/web/src/features/agent-panes/components/session-card.test.tsx b/packages/web/src/features/agent-panes/components/session-card.test.tsx index 9ebd2b44..ecec9e4d 100644 --- a/packages/web/src/features/agent-panes/components/session-card.test.tsx +++ b/packages/web/src/features/agent-panes/components/session-card.test.tsx @@ -163,10 +163,11 @@ describe("SessionCard", () => { await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( "session.create", - { + expect.objectContaining({ workspaceId: "ws-123", providerId: "codex", - }, + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }), undefined ); }); diff --git a/packages/web/src/features/agent-panes/index.test.tsx b/packages/web/src/features/agent-panes/index.test.tsx index a30ef44d..c8aa23d8 100644 --- a/packages/web/src/features/agent-panes/index.test.tsx +++ b/packages/web/src/features/agent-panes/index.test.tsx @@ -760,10 +760,11 @@ describe("AgentPanes", () => { await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( "session.create", - { + expect.objectContaining({ workspaceId: "ws-1", providerId: "claude", - }, + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }), undefined ); }); diff --git a/packages/web/src/features/agent-panes/views/shared/session-card.tsx b/packages/web/src/features/agent-panes/views/shared/session-card.tsx index 0a32ed4f..514d48fb 100644 --- a/packages/web/src/features/agent-panes/views/shared/session-card.tsx +++ b/packages/web/src/features/agent-panes/views/shared/session-card.tsx @@ -15,6 +15,7 @@ import { dispatchCommandAtom } from "../../../../atoms/connection"; import { sessionByIdAtomFamily, sessionsAtom } from "../../../../atoms/sessions"; import { workspaceByIdAtomFamily } from "../../../../atoms/workspaces"; import { IconButton, StatusDot, Tag, Tooltip } from "../../../../components/ui"; +import { useTerminalThemeBackground } from "../../../../theme"; import { PanelHeader } from "../../../shared/components/panel-header"; import { useSupervisor } from "../../../supervisor/actions/use-supervisor"; import { SupervisorCard } from "../../../supervisor/views/shared/supervisor-card"; @@ -57,6 +58,7 @@ export const SessionCard: FC = ({ const session = useAtomValue(sessionByIdAtomFamily(sessionId)); const dispatch = useAtomValue(dispatchCommandAtom); const setSessions = useSetAtom(sessionsAtom); + const themeBackground = useTerminalThemeBackground(); const workspace = useAtomValue( workspaceByIdAtomFamily(session?.workspaceId ?? "__workspace_empty__") ); @@ -101,6 +103,7 @@ export const SessionCard: FC = ({ const createResult = await dispatch("session.create", { workspaceId: session.workspaceId, providerId: session.providerId, + themeBackground, }); if (!createResult.ok || !createResult.data) { return; diff --git a/packages/web/src/features/diagnostics/page.tsx b/packages/web/src/features/diagnostics/page.tsx index c6d694d7..53c6be71 100644 --- a/packages/web/src/features/diagnostics/page.tsx +++ b/packages/web/src/features/diagnostics/page.tsx @@ -22,6 +22,7 @@ import { import { Button, Notice, Tag, ThemedIcon } from "../../components/ui"; import { useViewport } from "../../hooks/use-viewport"; import { useTranslation } from "../../lib/i18n"; +import { useTerminalThemeBackground } from "../../theme"; import { defaultPaneLayout, type PaneNode, @@ -203,6 +204,7 @@ export function DiagnosticsPage() { const intent = parseDiagnosticsSearch(location.search); const store = useStore(); const dispatch = useAtomValue(dispatchCommandAtom); + const themeBackground = useTerminalThemeBackground(); const connectionStatus = useAtomValue(connectionStatusAtom); const persistLastViewedTarget = usePersistWorkspaceLastViewedTarget(); const setActiveWorkspaceId = useSetAtom(activeWorkspaceIdAtom); @@ -354,6 +356,7 @@ export function DiagnosticsPage() { const createResult = await dispatch("session.create", { workspaceId, providerId, + themeBackground, }); if (!createResult.ok || !createResult.data) { diff --git a/packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx b/packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx index bc48e099..d205f4b7 100644 --- a/packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx +++ b/packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx @@ -51,10 +51,11 @@ describe("useCreateShellTerminal", () => { expect(sendCommand).toHaveBeenCalledWith( "terminal.create", - { + expect.objectContaining({ workspaceId: "ws-test", cwdPath: "src", - }, + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }), undefined ); expect(store.get(terminalIdsAtomFamily("ws-test"))).toEqual(["term_1", "term_2"]); diff --git a/packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts b/packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts index d369410d..94749d0e 100644 --- a/packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts +++ b/packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts @@ -2,6 +2,7 @@ import type { Terminal as TerminalDto } from "@coder-studio/core"; import { useAtomValue, useSetAtom, useStore } from "jotai"; import { dispatchCommandAtom } from "../../../atoms/connection"; import { useTranslation } from "../../../lib/i18n"; +import { useTerminalThemeBackground } from "../../../theme"; import { pushToastAtom } from "../../notifications/atoms"; import { terminalActiveIdAtomFamily, @@ -25,6 +26,7 @@ export function useCreateShellTerminal(workspaceId: string | null) { const dispatch = useAtomValue(dispatchCommandAtom); const pushToast = useSetAtom(pushToastAtom); const store = useStore(); + const themeBackground = useTerminalThemeBackground(); return { async createShellTerminal(args: { cwdPath?: string } = {}) { @@ -41,6 +43,7 @@ export function useCreateShellTerminal(workspaceId: string | null) { const result = await dispatch("terminal.create", { workspaceId, cwdPath: args.cwdPath, + themeBackground, }); if (!result.ok || !result.data) { 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 e55a68cd..7ea3de74 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 @@ -1606,10 +1606,11 @@ describe("FileTreePanel", () => { await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( "terminal.create", - { + expect.objectContaining({ workspaceId: "ws-test", cwdPath: "src", - }, + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }), undefined ); }); @@ -1624,10 +1625,11 @@ describe("FileTreePanel", () => { await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( "terminal.create", - { + expect.objectContaining({ workspaceId: "ws-test", cwdPath: undefined, - }, + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }), undefined ); }); diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx index d259afe1..ed8a7e32 100644 --- a/packages/web/src/shells/mobile-shell/index.test.tsx +++ b/packages/web/src/shells/mobile-shell/index.test.tsx @@ -1571,10 +1571,11 @@ describe("MobileShell Phase 2 workspace", () => { await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( "session.create", - { + expect.objectContaining({ workspaceId: "ws-1", providerId: "codex", - }, + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }), undefined ); }); @@ -1699,10 +1700,11 @@ describe("MobileShell Phase 2 workspace", () => { }); expect(sendCommand).toHaveBeenCalledWith( "session.create", - { + expect.objectContaining({ workspaceId: "ws-1", providerId: "codex", - }, + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }), undefined ); await waitFor(() => { diff --git a/packages/web/src/theme/index.ts b/packages/web/src/theme/index.ts index d0ffdf2a..d2eed582 100644 --- a/packages/web/src/theme/index.ts +++ b/packages/web/src/theme/index.ts @@ -28,3 +28,4 @@ export { getThemeVariant, resolveStoredThemeId, } from "./resolve"; +export { useTerminalThemeBackground } from "./use-terminal-theme-background"; diff --git a/packages/web/src/theme/use-terminal-theme-background.ts b/packages/web/src/theme/use-terminal-theme-background.ts new file mode 100644 index 00000000..20c966c0 --- /dev/null +++ b/packages/web/src/theme/use-terminal-theme-background.ts @@ -0,0 +1,18 @@ +/** + * Returns the hex background color of the currently selected xterm.js theme. + * + * Used by session/terminal creation flows to forward the active terminal + * background to the server, which derives COLORFGBG so child TUIs (Claude + * Code, Codex, …) can match the page's light/dark intent. This is the only + * signal that survives the Windows ConPTY layer — OSC 11 background-color + * queries get intercepted by ConPTY and never reach xterm.js. + */ + +import { useAtomValue } from "jotai"; +import { themeAtom } from "../atoms/app-ui"; +import { getThemeById } from "./resolve"; + +export function useTerminalThemeBackground(): string { + const themeId = useAtomValue(themeAtom); + return getThemeById(themeId).terminalTheme.background; +} From 535c3c09cc5895e3b3b949067633f8a6bb3644f8 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 25 May 2026 13:28:30 +0000 Subject: [PATCH 26/26] test: stabilize release verification and add patch changeset --- .changeset/few-turtles-teach.md | 7 +++++++ .../web/src/features/diagnostics/index.test.tsx | 11 +++++++---- .../terminal-panel/__tests__/xterm-host.test.tsx | 14 ++++++++------ .../workspace/views/shared/worktree-modal.test.tsx | 9 ++++----- packages/web/src/ui-preview/build.test.ts | 2 +- packages/web/src/ui-preview/catalog.test.tsx | 7 +++++-- 6 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 .changeset/few-turtles-teach.md diff --git a/.changeset/few-turtles-teach.md b/.changeset/few-turtles-teach.md new file mode 100644 index 00000000..8116a399 --- /dev/null +++ b/.changeset/few-turtles-teach.md @@ -0,0 +1,7 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Improve desktop workspace ergonomics by adding keyboard pane navigation, +supporting workspace path drops into terminal sessions, and launching themed +PTYs with terminal-aware background environment hints. diff --git a/packages/web/src/features/diagnostics/index.test.tsx b/packages/web/src/features/diagnostics/index.test.tsx index 5b720063..9b86d4df 100644 --- a/packages/web/src/features/diagnostics/index.test.tsx +++ b/packages/web/src/features/diagnostics/index.test.tsx @@ -469,10 +469,13 @@ describe("DiagnosticsPage", () => { } if (op === "session.create") { - expect(args).toEqual({ - workspaceId: "ws-1", - providerId: "claude", - }); + expect(args).toEqual( + expect.objectContaining({ + workspaceId: "ws-1", + providerId: "claude", + themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), + }) + ); return { id: "sess-1", workspaceId: "ws-1", 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 2b47e201..cc6b2306 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 @@ -2021,12 +2021,14 @@ describe("XtermHost", () => { ).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, - }) - ); + await waitFor(() => { + 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 () => { diff --git a/packages/web/src/features/workspace/views/shared/worktree-modal.test.tsx b/packages/web/src/features/workspace/views/shared/worktree-modal.test.tsx index b62703e5..48f81632 100644 --- a/packages/web/src/features/workspace/views/shared/worktree-modal.test.tsx +++ b/packages/web/src/features/workspace/views/shared/worktree-modal.test.tsx @@ -88,13 +88,12 @@ describe("WorktreeModal", () => { }); expect(document.querySelector(".drawer-backdrop")).toBeTruthy(); - expect(document.querySelector(".mobile-sheet")).toBeNull(); expect(screen.getByRole("dialog", { name: worktree.name })).toBeInTheDocument(); expect(document.querySelector(".drawer-panel")).toBeTruthy(); expect(document.querySelector(".modal-card-lg")).toBeNull(); - expect(screen.getByText("Latest Commit")).toBeInTheDocument(); - expect(screen.getByText("abc1234")).toBeInTheDocument(); - expect(screen.getByText("Initial mobile sheet setup")).toBeInTheDocument(); + expect(await screen.findByText("Latest Commit")).toBeInTheDocument(); + expect(await screen.findByText("abc1234")).toBeInTheDocument(); + expect(await screen.findByText("Initial mobile sheet setup")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Close" })).toHaveClass("btn", "btn-ghost", "btn-sm"); }); @@ -145,7 +144,7 @@ describe("WorktreeModal", () => { expect(screen.getByRole("tablist", { name: "Worktree" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Status" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tab", { name: "Status" })).toHaveClass("worktree-tab", "active"); - expect(screen.getByText("Latest Commit")).toBeInTheDocument(); + expect(await screen.findByText("Latest Commit")).toBeInTheDocument(); }); it("does not render without an explicit workspace until the workspace list is ready", () => { diff --git a/packages/web/src/ui-preview/build.test.ts b/packages/web/src/ui-preview/build.test.ts index 99b5e9c2..0ef8606e 100644 --- a/packages/web/src/ui-preview/build.test.ts +++ b/packages/web/src/ui-preview/build.test.ts @@ -36,5 +36,5 @@ describe("ui-preview build outputs", () => { }); expect(() => readFileSync(join(outDir, "ui-preview.html"), "utf8")).not.toThrow(); - }, 30_000); + }, 60_000); }); diff --git a/packages/web/src/ui-preview/catalog.test.tsx b/packages/web/src/ui-preview/catalog.test.tsx index 055c186c..9d8f220d 100644 --- a/packages/web/src/ui-preview/catalog.test.tsx +++ b/packages/web/src/ui-preview/catalog.test.tsx @@ -161,12 +161,15 @@ describe("UI preview catalog", () => { it("renders the mobile terminal showcase without the replay loading overlay", async () => { renderScene("mobile-terminal-sheet", "mobile"); + expect(await screen.findByRole("button", { name: "New Terminal" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Close Terminal" })).toBeInTheDocument(); await waitFor(() => { + expect( + document.querySelector(".mobile-sheet--terminal .terminal-toolbar-mobile-row") + ).toBeTruthy(); expect(screen.queryByText("Restoring terminal output...")).not.toBeInTheDocument(); }); expect(document.querySelector(".mobile-sheet--terminal")).toBeTruthy(); - expect(screen.getByRole("button", { name: "New Terminal" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Close Terminal" })).toBeInTheDocument(); expect( document.querySelector(".mobile-sheet--terminal .terminal-toolbar-mobile-row") ).toBeTruthy();