diff --git a/DESIGN.md b/DESIGN.md index 1408aaa0..e1a58e32 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -83,6 +83,7 @@ Rules: - `ChatTranscriptPane` owns the transcript stage through `.app-transcript-stage` and renders `MessagesTimeline` with scroll-to-bottom chrome using `--app-scroll-button-*`. - `MessagesTimeline` renders assistant messages in `.app-assistant-message`, user messages in `.app-user-message`, and file-change/activity summaries through `--app-work-row-*`, `--app-diff-card-*`, and metadata tokens. +- `.message-action-group` is the shared visibility pattern for user and assistant message actions. Actions remain discoverable at `0.6` opacity at rest and transition to full opacity over `200ms` on message hover or `focus-within`, including keyboard focus. Preserve their existing hit targets, accessible names/tooltips, and focus styling; do not introduce per-message opacity variants. - `ChatMarkdown` owns markdown scannability: headings, links, inline code role classes, code blocks, copy buttons, tables, local generated images, and lazy image rendering. - `ComposerPlanModeBlockedReason` renders complete, wrapped Plan recovery guidance above the compact composer toolbar so narrow panes keep the required action visible. - Chat-output semantic roles must stay conservative: file paths, commands, theme tokens, success/warning/error states can be colored; ordinary text stays neutral. @@ -125,18 +126,20 @@ Rules: ## 8. Motion -| Motion | Token/Pattern | Rule | -| ----------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| Chat pane entry | `.chat-pane-enter`, `220ms cubic-bezier(0.22, 1, 0.36, 1)` | Use for empty/transcript pane swaps; respect reduced motion. | -| Terminal running dot | `.terminal-running-indicator__dot`, `640ms ease-in-out`, opacity/scale only | Keep as CSS animation to avoid JS timers across many terminals. | -| Generated image shimmer | `chat-generated-image-shimmer`, `1.6s ease-in-out` | Loading feedback for generated images only. | -| Micro-interactions | Tailwind `transition-colors`, `transition-opacity`, `duration-120/140/150/200` patterns | Prefer color/opacity/transform transitions. | -| Ultrathink | `ultrathink-*` spectrum animations, 10s linear | Existing special mode only; do not use as general decoration. | +| Motion | Token/Pattern | Rule | +| ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Chat pane entry | `.chat-pane-enter`, `220ms cubic-bezier(0.22, 1, 0.36, 1)` | Use for empty/transcript pane swaps; respect reduced motion. | +| Persistent status pulse | `.status-pulse`, `2s` stepped opacity-only duty cycle | Use only for running terminal dots, the pulsing Sidebar project-status dot, and the timeline working ellipsis; keep it in CSS without JS timers. | +| Generated image shimmer | `chat-generated-image-shimmer`, `1.6s ease-in-out` | Loading feedback for generated images only. | +| Micro-interactions | Tailwind `transition-colors`, `transition-opacity`, `duration-120/140/150/200` patterns | Prefer color/opacity/transform transitions. | +| Ultrathink | `ultrathink-*` spectrum animations, 10s linear | Existing special mode only; do not use as general decoration. | Rules: - Animate `opacity`, `transform`, and color/filter changes only. Do not animate layout properties for ordinary UI. - Respect `prefers-reduced-motion` for non-essential animation. +- Preserve `.status-pulse` per-dot staggers: terminal dots use `0/160/320/480ms`, and timeline working dots use `0/200/400ms`. Under reduced motion, the indicator stays visible at full opacity with no animation or transform. +- Completion or a non-running state must remove or stop `.status-pulse` and render the settled/static state. Finite spinners, loading skeletons, generated-image shimmer, and ultrathink motion are explicit exclusions and retain their own patterns. - Do not add decorative motion to transcript, diff, terminal, settings, or browser surfaces unless it improves state comprehension. ## 9. Implementation Rules diff --git a/apps/desktop/src/contextMenuPopup.test.ts b/apps/desktop/src/contextMenuPopup.test.ts new file mode 100644 index 00000000..e54bcd5d --- /dev/null +++ b/apps/desktop/src/contextMenuPopup.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + popupContextMenu, + type ContextMenuPopup, + type ContextMenuPopupOptions, + type ContextMenuWindowResolver, +} from "./contextMenuPopup"; + +type TestWindow = { + readonly name: string; + readonly webContents: { + readonly getZoomFactor: ReturnType number>>; + }; +}; + +function makeWindow(name: string, zoomFactor: number): TestWindow { + return { + name, + webContents: { + getZoomFactor: vi.fn(() => zoomFactor), + }, + }; +} + +describe("popupContextMenu", () => { + const callback = vi.fn(); + const popup = vi.fn<(options: ContextMenuPopupOptions) => void>(); + const menu: ContextMenuPopup = { popup }; + + beforeEach(() => { + callback.mockReset(); + popup.mockReset(); + }); + + it("uses sender-owner zoom and binds the popup to the sender owner", () => { + // Given a sender owner whose zoom conflicts with the focused and main windows + const senderOwner = makeWindow("sender", 2); + const focusedWindow = makeWindow("focused", 1.25); + const mainWindow = makeWindow("main", 0.8); + const windows: ContextMenuWindowResolver = { + getSenderWindow: vi.fn(() => senderOwner), + getFocusedWindow: vi.fn(() => focusedWindow), + getMainWindow: vi.fn(() => mainWindow), + }; + + // When the popup is opened for fractional renderer coordinates + const didPopup = popupContextMenu({ menu, position: { x: 10.8, y: 20.2 }, callback }, windows); + + // Then only the sender owner supplies the zoom and exact popup options + expect(didPopup).toBe(true); + expect(windows.getSenderWindow).toHaveBeenCalledOnce(); + expect(windows.getFocusedWindow).not.toHaveBeenCalled(); + expect(windows.getMainWindow).not.toHaveBeenCalled(); + expect(senderOwner.webContents.getZoomFactor).toHaveBeenCalledOnce(); + expect(focusedWindow.webContents.getZoomFactor).not.toHaveBeenCalled(); + expect(mainWindow.webContents.getZoomFactor).not.toHaveBeenCalled(); + expect(popup).toHaveBeenCalledWith({ + window: senderOwner, + x: 21, + y: 40, + callback, + }); + }); + + it("falls back to the focused window before the main window", () => { + // Given no sender owner and both focused and main windows + const focusedWindow = makeWindow("focused", 1.5); + const mainWindow = makeWindow("main", 2); + const windows: ContextMenuWindowResolver = { + getSenderWindow: vi.fn(() => null), + getFocusedWindow: vi.fn(() => focusedWindow), + getMainWindow: vi.fn(() => mainWindow), + }; + + // When the popup is opened + const didPopup = popupContextMenu({ menu, position: { x: 8, y: 10 }, callback }, windows); + + // Then the focused window owns the popup and main fallback is not consulted + expect(didPopup).toBe(true); + expect(windows.getFocusedWindow).toHaveBeenCalledOnce(); + expect(windows.getMainWindow).not.toHaveBeenCalled(); + expect(popup).toHaveBeenCalledWith({ + window: focusedWindow, + x: 12, + y: 15, + callback, + }); + }); + + it("falls back to the main window when sender and focused owners are unavailable", () => { + // Given only the main window is available + const mainWindow = makeWindow("main", 1.25); + const windows: ContextMenuWindowResolver = { + getSenderWindow: vi.fn(() => null), + getFocusedWindow: vi.fn(() => null), + getMainWindow: vi.fn(() => mainWindow), + }; + + // When the popup is opened + const didPopup = popupContextMenu({ menu, position: { x: 8, y: 12 }, callback }, windows); + + // Then the main window owns the popup + expect(didPopup).toBe(true); + expect(windows.getMainWindow).toHaveBeenCalledOnce(); + expect(popup).toHaveBeenCalledWith({ + window: mainWindow, + x: 10, + y: 15, + callback, + }); + }); + + it.each([ + { label: "missing position", position: undefined, zoomFactor: 1.5 }, + { label: "invalid coordinates", position: { x: -1, y: 10 }, zoomFactor: 1.5 }, + { label: "invalid zoom", position: { x: 10, y: 20 }, zoomFactor: Number.NaN }, + ])("omits x and y for $label without throwing", ({ position, zoomFactor }) => { + // Given an owner and popup input that cannot be explicitly positioned + const senderOwner = makeWindow("sender", zoomFactor); + const windows: ContextMenuWindowResolver = { + getSenderWindow: vi.fn(() => senderOwner), + getFocusedWindow: vi.fn(() => null), + getMainWindow: vi.fn(() => null), + }; + + // When the native popup is opened + const openPopup = () => popupContextMenu({ menu, position, callback }, windows); + + // Then it uses native cursor placement without throwing + expect(openPopup).not.toThrow(); + expect(senderOwner.webContents.getZoomFactor).toHaveBeenCalledOnce(); + expect(popup).toHaveBeenCalledWith({ window: senderOwner, callback }); + }); + + it("does not popup when no owner window is available", () => { + // Given no sender, focused, or main owner + const windows: ContextMenuWindowResolver = { + getSenderWindow: vi.fn(() => null), + getFocusedWindow: vi.fn(() => null), + getMainWindow: vi.fn(() => null), + }; + + // When the popup is requested + const didPopup = popupContextMenu({ menu, position: { x: 10, y: 20 }, callback }, windows); + + // Then native popup is skipped cleanly + expect(didPopup).toBe(false); + expect(popup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/contextMenuPopup.ts b/apps/desktop/src/contextMenuPopup.ts new file mode 100644 index 00000000..d1b1f397 --- /dev/null +++ b/apps/desktop/src/contextMenuPopup.ts @@ -0,0 +1,51 @@ +import { resolveMenuPopupPosition, type MenuPopupPosition } from "./menuCoordinates"; + +type ZoomedMenuWindow = { + readonly webContents: { + readonly getZoomFactor: () => number; + }; +}; + +export type ContextMenuPopupOptions = { + readonly window: TWindow; + readonly x?: number; + readonly y?: number; + readonly callback: () => void; +}; + +export type ContextMenuPopup = { + readonly popup: (options: ContextMenuPopupOptions) => void; +}; + +export type ContextMenuWindowResolver = { + readonly getSenderWindow: () => TWindow | null; + readonly getFocusedWindow: () => TWindow | null; + readonly getMainWindow: () => TWindow | null; +}; + +type ContextMenuPopupRequest = { + readonly menu: ContextMenuPopup; + readonly position: MenuPopupPosition | undefined; + readonly callback: () => void; +}; + +export function popupContextMenu( + request: ContextMenuPopupRequest, + windows: ContextMenuWindowResolver, +): boolean { + const owner = windows.getSenderWindow() ?? windows.getFocusedWindow() ?? windows.getMainWindow(); + if (!owner) { + return false; + } + + const zoomFactor = owner.webContents.getZoomFactor(); + const popupPosition = request.position + ? resolveMenuPopupPosition(request.position, zoomFactor) + : undefined; + request.menu.popup({ + window: owner, + ...popupPosition, + callback: request.callback, + }); + return true; +} diff --git a/apps/desktop/src/fullscreenBridge.test.ts b/apps/desktop/src/fullscreenBridge.test.ts new file mode 100644 index 00000000..d1a7b6d7 --- /dev/null +++ b/apps/desktop/src/fullscreenBridge.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + DESKTOP_FULLSCREEN_CHANGE_CHANNEL, + DESKTOP_IS_FULLSCREEN_CHANNEL, +} from "./fullscreenWindow"; +import { + createFullscreenBridge, + type FullscreenIpcRenderer, + type FullscreenRendererListener, +} from "./fullscreenBridge"; + +class FakeIpcRenderer implements FullscreenIpcRenderer { + readonly sendSync = vi.fn((_channel: string): unknown => false); + readonly onCalls = vi.fn(); + readonly removeCalls = vi.fn(); + private listener: FullscreenRendererListener | null = null; + + on(channel: string, listener: FullscreenRendererListener): this { + this.onCalls(channel, listener); + this.listener = listener; + return this; + } + + removeListener(channel: string, listener: FullscreenRendererListener): this { + this.removeCalls(channel, listener); + if (this.listener === listener) { + this.listener = null; + } + return this; + } + + emit(payload: unknown): void { + this.listener?.({}, payload); + } +} + +describe("desktop fullscreen preload bridge", () => { + it("performs a synchronous read and accepts only literal true", () => { + const renderer = new FakeIpcRenderer(); + const bridge = createFullscreenBridge(renderer); + + renderer.sendSync.mockReturnValueOnce(true).mockReturnValueOnce(1).mockReturnValueOnce("true"); + + expect(bridge.getIsFullscreen()).toBe(true); + expect(bridge.getIsFullscreen()).toBe(false); + expect(bridge.getIsFullscreen()).toBe(false); + expect(renderer.sendSync).toHaveBeenCalledTimes(3); + expect(renderer.sendSync).toHaveBeenNthCalledWith(1, DESKTOP_IS_FULLSCREEN_CHANNEL); + }); + + it("forwards boolean events and rejects malformed payloads", () => { + const renderer = new FakeIpcRenderer(); + const bridge = createFullscreenBridge(renderer); + const listener = vi.fn(); + + const unsubscribe = bridge.onFullscreenChange(listener); + renderer.emit(true); + renderer.emit(false); + renderer.emit(1); + renderer.emit("false"); + renderer.emit(null); + + expect(listener.mock.calls).toEqual([[true], [false]]); + expect(renderer.onCalls).toHaveBeenCalledExactlyOnceWith( + DESKTOP_FULLSCREEN_CHANGE_CHANNEL, + expect.any(Function), + ); + + unsubscribe(); + renderer.emit(true); + expect(listener).toHaveBeenCalledTimes(2); + expect(renderer.removeCalls).toHaveBeenCalledExactlyOnceWith( + DESKTOP_FULLSCREEN_CHANGE_CHANNEL, + expect.any(Function), + ); + }); +}); diff --git a/apps/desktop/src/fullscreenBridge.ts b/apps/desktop/src/fullscreenBridge.ts new file mode 100644 index 00000000..7bfb459d --- /dev/null +++ b/apps/desktop/src/fullscreenBridge.ts @@ -0,0 +1,33 @@ +import type { DesktopBridge } from "@jcode/contracts"; + +import { + DESKTOP_FULLSCREEN_CHANGE_CHANNEL, + DESKTOP_IS_FULLSCREEN_CHANNEL, +} from "./fullscreenWindow"; + +export type FullscreenRendererListener = (event: unknown, isFullscreen: unknown) => void; + +export interface FullscreenIpcRenderer { + sendSync(channel: string): unknown; + on(channel: string, listener: FullscreenRendererListener): this; + removeListener(channel: string, listener: FullscreenRendererListener): this; +} + +type FullscreenBridge = Required>; + +export function createFullscreenBridge(ipcRenderer: FullscreenIpcRenderer): FullscreenBridge { + return { + getIsFullscreen: () => ipcRenderer.sendSync(DESKTOP_IS_FULLSCREEN_CHANNEL) === true, + onFullscreenChange: (listener) => { + const wrappedListener: FullscreenRendererListener = (_event, isFullscreen) => { + if (typeof isFullscreen !== "boolean") return; + listener(isFullscreen); + }; + + ipcRenderer.on(DESKTOP_FULLSCREEN_CHANGE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(DESKTOP_FULLSCREEN_CHANGE_CHANNEL, wrappedListener); + }; + }, + }; +} diff --git a/apps/desktop/src/fullscreenWindow.test.ts b/apps/desktop/src/fullscreenWindow.test.ts new file mode 100644 index 00000000..29922185 --- /dev/null +++ b/apps/desktop/src/fullscreenWindow.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + DESKTOP_FULLSCREEN_CHANGE_CHANNEL, + readSenderWindowFullscreen, + registerFullscreenWindowEvents, + type FullscreenEventName, + type FullscreenEventWindow, +} from "./fullscreenWindow"; + +class FakeFullscreenWindow implements FullscreenEventWindow { + readonly sent = vi.fn(); + readonly webContents = { send: this.sent }; + private readonly listeners = new Map void>>(); + + on(event: FullscreenEventName, listener: () => void): this { + const listeners = this.listeners.get(event) ?? new Set<() => void>(); + listeners.add(listener); + this.listeners.set(event, listeners); + return this; + } + + removeListener(event: FullscreenEventName, listener: () => void): this { + this.listeners.get(event)?.delete(listener); + return this; + } + + emit(event: FullscreenEventName): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(); + } + } + + listenerCount(event: FullscreenEventName): number { + return this.listeners.get(event)?.size ?? 0; + } +} + +describe("desktop fullscreen main-process bridge", () => { + it("reads fullscreen from the BrowserWindow selected for the exact IPC sender", () => { + const sender = { id: 7 }; + const senderWindow = { isFullScreen: vi.fn(() => true) }; + const fromWebContents = vi.fn((candidate: typeof sender) => + candidate === sender ? senderWindow : null, + ); + + expect(readSenderWindowFullscreen(sender, fromWebContents)).toBe(true); + expect(fromWebContents).toHaveBeenCalledExactlyOnceWith(sender); + expect(senderWindow.isFullScreen).toHaveBeenCalledOnce(); + }); + + it("falls back to false when the IPC sender no longer belongs to a window", () => { + const sender = { id: 8 }; + + expect(readSenderWindowFullscreen(sender, () => null)).toBe(false); + }); + + it("broadcasts every native enter and leave event, including repeated events", () => { + const window = new FakeFullscreenWindow(); + const cleanup = registerFullscreenWindowEvents(window); + + window.emit("enter-full-screen"); + window.emit("enter-full-screen"); + window.emit("leave-full-screen"); + + expect(window.sent.mock.calls).toEqual([ + [DESKTOP_FULLSCREEN_CHANGE_CHANNEL, true], + [DESKTOP_FULLSCREEN_CHANGE_CHANNEL, true], + [DESKTOP_FULLSCREEN_CHANGE_CHANNEL, false], + ]); + cleanup(); + }); + + it("removes both event listeners during window teardown", () => { + const window = new FakeFullscreenWindow(); + const cleanup = registerFullscreenWindowEvents(window); + + expect(window.listenerCount("enter-full-screen")).toBe(1); + expect(window.listenerCount("leave-full-screen")).toBe(1); + + cleanup(); + window.emit("enter-full-screen"); + window.emit("leave-full-screen"); + + expect(window.listenerCount("enter-full-screen")).toBe(0); + expect(window.listenerCount("leave-full-screen")).toBe(0); + expect(window.sent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/fullscreenWindow.ts b/apps/desktop/src/fullscreenWindow.ts new file mode 100644 index 00000000..718a77b5 --- /dev/null +++ b/apps/desktop/src/fullscreenWindow.ts @@ -0,0 +1,40 @@ +export const DESKTOP_IS_FULLSCREEN_CHANNEL = "desktop:is-fullscreen"; +export const DESKTOP_FULLSCREEN_CHANGE_CHANNEL = "desktop:fullscreen-change"; + +export type FullscreenEventName = "enter-full-screen" | "leave-full-screen"; + +export interface FullscreenReadableWindow { + isFullScreen(): boolean; +} + +export interface FullscreenEventWindow { + readonly webContents: { + send(channel: string, isFullscreen: boolean): void; + }; + on(event: FullscreenEventName, listener: () => void): this; + removeListener(event: FullscreenEventName, listener: () => void): this; +} + +export function readSenderWindowFullscreen( + sender: TSender, + fromWebContents: (sender: TSender) => FullscreenReadableWindow | null, +): boolean { + return fromWebContents(sender)?.isFullScreen() ?? false; +} + +export function registerFullscreenWindowEvents(window: FullscreenEventWindow): () => void { + const enterFullscreen = (): void => { + window.webContents.send(DESKTOP_FULLSCREEN_CHANGE_CHANNEL, true); + }; + const leaveFullscreen = (): void => { + window.webContents.send(DESKTOP_FULLSCREEN_CHANGE_CHANNEL, false); + }; + + window.on("enter-full-screen", enterFullscreen); + window.on("leave-full-screen", leaveFullscreen); + + return () => { + window.removeListener("enter-full-screen", enterFullscreen); + window.removeListener("leave-full-screen", leaveFullscreen); + }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 10fddea8..572ec679 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -40,6 +40,12 @@ import { RotatingFileSink } from "@jcode/shared/logging"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { waitForBackendStartupReady } from "./backendStartupReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { popupContextMenu } from "./contextMenuPopup"; +import { + DESKTOP_IS_FULLSCREEN_CHANNEL, + readSenderWindowFullscreen, + registerFullscreenWindowEvents, +} from "./fullscreenWindow"; import { openInitialBackendWindow } from "./initialBackendWindowOpen"; import { shouldAllowMediaPermissionRequest } from "./mediaPermissions"; import { ServerListeningDetector } from "./serverListeningDetector"; @@ -1654,6 +1660,13 @@ function registerIpcHandlers(): void { normalizeDesktopWsUrl(backendWsUrl) ?? resolveDesktopWsUrlFromEnv(process.env); }); + ipcMain.removeAllListeners(DESKTOP_IS_FULLSCREEN_CHANNEL); + ipcMain.on(DESKTOP_IS_FULLSCREEN_CHANNEL, (event: IpcMainEvent) => { + event.returnValue = readSenderWindowFullscreen(event.sender, (sender) => + BrowserWindow.fromWebContents(sender), + ); + }); + ipcMain.removeHandler(DESKTOP_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); ipcMain.handle(DESKTOP_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, async () => getLocalEnvironmentBootstrap(), @@ -1759,7 +1772,7 @@ function registerIpcHandlers(): void { ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle( CONTEXT_MENU_CHANNEL, - async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { + async (event, items: ContextMenuItem[], position?: { x: number; y: number }) => { const normalizedItems = items .filter((item) => typeof item.id === "string" && typeof item.label === "string") .map((item) => ({ @@ -1772,21 +1785,6 @@ function registerIpcHandlers(): void { return null; } - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - return new Promise((resolve) => { const template: MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; @@ -1814,11 +1812,21 @@ function registerIpcHandlers(): void { } const menu = Menu.buildFromTemplate(template); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); + const didPopup = popupContextMenu( + { + menu, + position, + callback: () => resolve(null), + }, + { + getSenderWindow: () => BrowserWindow.fromWebContents(event.sender), + getFocusedWindow: () => BrowserWindow.getFocusedWindow(), + getMainWindow: () => mainWindow, + }, + ); + if (!didPopup) { + resolve(null); + } }); }, ); @@ -1963,6 +1971,7 @@ function createWindow(): BrowserWindow { webviewTag: true, }, }); + const unregisterFullscreenWindowEvents = registerFullscreenWindowEvents(window); browserManager.setWindow(window); window.webContents.on("context-menu", (event, params) => { @@ -2029,6 +2038,7 @@ function createWindow(): BrowserWindow { } window.on("closed", () => { + unregisterFullscreenWindowEvents(); if (mainWindow === window) { mainWindow = null; } diff --git a/apps/desktop/src/menuCoordinates.test.ts b/apps/desktop/src/menuCoordinates.test.ts new file mode 100644 index 00000000..0068b268 --- /dev/null +++ b/apps/desktop/src/menuCoordinates.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMenuPopupPosition } from "./menuCoordinates"; + +describe("resolveMenuPopupPosition", () => { + it.each([ + { zoomFactor: 0.8, expected: { x: 8, y: 16 } }, + { zoomFactor: 1, expected: { x: 10, y: 20 } }, + { zoomFactor: 1.25, expected: { x: 12, y: 25 } }, + { zoomFactor: 1.5, expected: { x: 15, y: 30 } }, + { zoomFactor: 2, expected: { x: 20, y: 40 } }, + ])("scales CSS pixels at $zoomFactor zoom", ({ zoomFactor, expected }) => { + // Given renderer coordinates expressed in CSS pixels + const position = { x: 10, y: 20 }; + + // When the native popup position is resolved for the window zoom + const result = resolveMenuPopupPosition(position, zoomFactor); + + // Then the coordinates are scaled once in native-window points + expect(result).toEqual(expected); + }); + + it("floors only after multiplying fractional coordinates", () => { + // Given fractional CSS-pixel coordinates + const position = { x: 10.8, y: 20.2 }; + + // When they are resolved at 200% zoom + const result = resolveMenuPopupPosition(position, 2); + + // Then flooring happens after scaling + expect(result).toEqual({ x: 21, y: 40 }); + }); + + it("keeps zero coordinates as an explicit native position", () => { + // Given the renderer origin + const position = { x: 0, y: 0 }; + + // When it is resolved at a valid zoom + const result = resolveMenuPopupPosition(position, 1.25); + + // Then the native popup remains explicitly positioned at the origin + expect(result).toEqual({ x: 0, y: 0 }); + }); + + it.each([ + { label: "negative x", position: { x: -1, y: 20 } }, + { label: "negative y", position: { x: 10, y: -1 } }, + { label: "NaN x", position: { x: Number.NaN, y: 20 } }, + { label: "positive-infinite x", position: { x: Number.POSITIVE_INFINITY, y: 20 } }, + { label: "negative-infinite x", position: { x: Number.NEGATIVE_INFINITY, y: 20 } }, + { label: "NaN y", position: { x: 10, y: Number.NaN } }, + { label: "positive-infinite y", position: { x: 10, y: Number.POSITIVE_INFINITY } }, + { label: "negative-infinite y", position: { x: 10, y: Number.NEGATIVE_INFINITY } }, + ])("omits explicit coordinates for $label", ({ position }) => { + // Given a renderer position outside the accepted coordinate domain + // When the popup position is resolved + const result = resolveMenuPopupPosition(position, 1); + + // Then Electron can use native cursor placement + expect(result).toBeUndefined(); + }); + + it.each([ + { label: "zero", zoomFactor: 0 }, + { label: "negative", zoomFactor: -1 }, + { label: "NaN", zoomFactor: Number.NaN }, + { label: "positive infinity", zoomFactor: Number.POSITIVE_INFINITY }, + { label: "negative infinity", zoomFactor: Number.NEGATIVE_INFINITY }, + ])("omits explicit coordinates for $label zoom", ({ zoomFactor }) => { + // Given a valid renderer position and an invalid owner zoom + // When the popup position is resolved + const result = resolveMenuPopupPosition({ x: 10, y: 20 }, zoomFactor); + + // Then Electron can use native cursor placement + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/menuCoordinates.ts b/apps/desktop/src/menuCoordinates.ts new file mode 100644 index 00000000..afd0f3b3 --- /dev/null +++ b/apps/desktop/src/menuCoordinates.ts @@ -0,0 +1,25 @@ +export type MenuPopupPosition = { + readonly x: number; + readonly y: number; +}; + +export function resolveMenuPopupPosition( + position: MenuPopupPosition, + zoomFactor: number, +): MenuPopupPosition | undefined { + if ( + !Number.isFinite(position.x) || + !Number.isFinite(position.y) || + position.x < 0 || + position.y < 0 || + !Number.isFinite(zoomFactor) || + zoomFactor <= 0 + ) { + return undefined; + } + + return { + x: Math.floor(position.x * zoomFactor), + y: Math.floor(position.y * zoomFactor), + }; +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c23b8fe8..c091a08a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -14,6 +14,7 @@ import { resolveDesktopWsUrlFromEnv, } from "./desktopWsBridge"; import { SERVER_TRANSCRIBE_VOICE_CHANNEL } from "./voiceTranscription"; +import { createFullscreenBridge } from "./fullscreenBridge"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const SAVE_FILE_CHANNEL = "desktop:save-file"; @@ -42,6 +43,7 @@ function getDesktopWsUrl(): string | null { contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: getDesktopWsUrl, + ...createFullscreenBridge(ipcRenderer), getLocalEnvironmentBootstrap: () => ipcRenderer.invoke(DESKTOP_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL), getServerExposureState: () => ipcRenderer.invoke(DESKTOP_SERVER_EXPOSURE_STATE_CHANNEL), diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index d3c09cde..feecd933 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -148,6 +148,53 @@ function initRepoWithoutCommit(cwd: string): Effect.Effect { + return Effect.gen(function* () { + // Given + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + for (const filePath of [...input.selectedPaths, ...input.potentialMatches]) { + yield* writeTextFile(path.join(tmp, filePath), `${filePath}\n`); + } + + // When + const context = yield* core.prepareCommitContext(tmp, input.selectedPaths); + + // Then + expect(context).not.toBeNull(); + if (context === null) return; + + const cachedNameStatus = yield* git(tmp, ["diff", "--cached", "--name-status"]); + const cachedPatch = yield* git(tmp, ["diff", "--cached", "--patch"]); + const statusShort = yield* git(tmp, ["status", "--short"]); + const expectedNameStatus = input.selectedPaths + .toSorted() + .map((filePath) => `A\t${filePath}`) + .join("\n"); + + expect(cachedNameStatus).toBe(expectedNameStatus); + expect(context.stagedSummary).toBe(expectedNameStatus); + for (const selectedPath of input.selectedPaths) { + const diffHeader = `diff --git a/${selectedPath} b/${selectedPath}`; + expect(cachedPatch).toContain(diffHeader); + expect(context.stagedPatch).toContain(diffHeader); + } + for (const potentialMatch of input.potentialMatches) { + expect(cachedNameStatus).not.toContain(potentialMatch); + expect(cachedPatch).not.toContain(potentialMatch); + expect(statusShort).toContain(`?? ${potentialMatch}`); + } + }); +} + function commitWithDate( cwd: string, fileName: string, @@ -2317,6 +2364,34 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("prepareCommitContext treats bracket pathspec characters as literal", () => + assertSelectedPathsAreLiteral({ + selectedPaths: ["selected[1].txt"], + potentialMatches: ["selected1.txt"], + }), + ); + + it.effect("prepareCommitContext treats wildcard pathspec characters as literal", () => + assertSelectedPathsAreLiteral({ + selectedPaths: ["selected*.txt", "selected?.md"], + potentialMatches: ["selected-sibling.txt", "selected1.md"], + }), + ); + + it.effect("prepareCommitContext treats pathspec magic prefixes as literal", () => + assertSelectedPathsAreLiteral({ + selectedPaths: [":(exclude)selected.txt"], + potentialMatches: ["ordinary.txt"], + }), + ); + + it.effect("prepareCommitContext preserves leading-dash and plain literal paths", () => + assertSelectedPathsAreLiteral({ + selectedPaths: ["-selected.txt", "plain.txt"], + potentialMatches: ["unselected.txt"], + }), + ); + it.effect("prepareCommitContext stages everything when filePaths is undefined", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 89bf16ea..c5a14018 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1726,6 +1726,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" Effect.catch(() => Effect.void), ); yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ + "--literal-pathspecs", "add", "-A", "--", diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 0fc2ab56..1fb98636 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -22,7 +22,6 @@ import type { import { ServerProviderUpdateError } from "@jcode/contracts"; import { parseCodexConfigModelProvider } from "@jcode/shared/codexConfig"; import { decodeJsonResult } from "@jcode/shared/schemaJson"; -import { query as claudeQuery, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { Array, @@ -93,6 +92,7 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance"; import { collectUint8StreamText } from "../../stream/collectUint8StreamText"; +import { probeClaudeSubscription } from "../claudeSubscriptionProbe"; const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; @@ -528,57 +528,6 @@ function extractCodexAccountTypeFromOutput(result: CommandResult): string | unde return walk(parsed.success); } -// ── Claude SDK capability probe ───────────────────────────────────── -// -// Spawns a lightweight Claude Agent SDK session and reads the -// initialization result. The prompt is a never-yielding AsyncIterable so -// no user message reaches the Anthropic API — we get account metadata -// (including subscription type) from local IPC, then abort the -// subprocess. Used as a fallback when `claude auth status` output -// doesn't include subscription info. - -const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; - -function waitForAbortSignal(signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve(); - return new Promise((resolve) => { - signal.addEventListener("abort", () => resolve(), { once: true }); - }); -} - -const probeClaudeSubscription = () => { - const abort = new AbortController(); - return Effect.tryPromise(async () => { - const q = claudeQuery({ - // oxlint-disable-next-line require-yield - prompt: (async function* (): AsyncGenerator { - await waitForAbortSignal(abort.signal); - })(), - options: { - persistSession: false, - abortController: abort, - settingSources: ["user", "project", "local"], - allowedTools: [], - stderr: () => {}, - }, - }); - const init = await q.initializationResult(); - return { subscriptionType: init.account?.subscriptionType }; - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (!abort.signal.aborted) abort.abort(); - }), - ), - Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), - Effect.result, - Effect.map((result) => { - if (Result.isFailure(result)) return undefined; - return Option.isSome(result.success) ? result.success.value : undefined; - }), - ); -}; - export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: ServerProviderStatusState; readonly authStatus: ServerProviderAuthStatus; @@ -1923,7 +1872,8 @@ export const ProviderHealthLive = Layer.effect( const claudeSubscriptionCache = yield* Cache.make({ capacity: 1, timeToLive: Duration.minutes(5), - lookup: (_: "claude") => probeClaudeSubscription(), + lookup: (_: "claude") => + probeClaudeSubscription({ homeDir: OS.homedir(), environment: process.env }), }); const resolveClaudeSubscription = Cache.get(claudeSubscriptionCache, "claude").pipe( Effect.map((probe) => probe?.subscriptionType), diff --git a/apps/server/src/provider/claudeSubscriptionProbe.test.ts b/apps/server/src/provider/claudeSubscriptionProbe.test.ts new file mode 100644 index 00000000..e7e19a73 --- /dev/null +++ b/apps/server/src/provider/claudeSubscriptionProbe.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { Duration, Effect, Fiber } from "effect"; +import { TestClock } from "effect/testing"; + +import { + buildClaudeSubscriptionProbeQuery, + probeClaudeSubscription, + type ClaudeSubscriptionProbeDependencies, + type ClaudeSubscriptionQueryInput, +} from "./claudeSubscriptionProbe"; + +const HOME_DIR = "/users/tester/../tester"; +const PROJECT_DIR = "/hostile/project"; + +function makeAbortTracker(): { + readonly controller: AbortController; + readonly createAbortController: () => AbortController; +} { + const controller = new AbortController(); + return { controller, createAbortController: () => controller }; +} + +describe("buildClaudeSubscriptionProbeQuery", () => { + it("builds an isolated SDK configuration from a normalized home and string environment", () => { + // Given + const abortController = new AbortController(); + + // When + const built = buildClaudeSubscriptionProbeQuery({ + abortController, + homeDir: HOME_DIR, + environment: { + HOME: PROJECT_DIR, + PWD: PROJECT_DIR, + OLDPWD: `${PROJECT_DIR}/previous`, + INIT_CWD: PROJECT_DIR, + PROJECT_HINT: `prefix:${PROJECT_DIR}:suffix`, + npm_config_local_prefix: PROJECT_DIR, + npm_package_json: `${PROJECT_DIR}/package.json`, + XDG_CONFIG_HOME: `${PROJECT_DIR}/config`, + CLAUDE_CONFIG_DIR: `${PROJECT_DIR}/claude`, + PATH: "/usr/bin", + HTTP_PROXY: undefined, + HTTPS_PROXY: "https://proxy.example.test", + NODE_EXTRA_CA_CERTS: "/etc/ssl/custom.pem", + ANTHROPIC_API_KEY: "sk-ant-test", + LANG: "en_CA.UTF-8", + TMPDIR: "/tmp", + OMITTED: undefined, + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + }); + + // Then + expect(built.cwd).toBe("/users/tester/.jcode/provider-probes/claude"); + expect(JSON.stringify(built)).not.toContain(PROJECT_DIR); + expect(built.options).toEqual({ + abortController, + cwd: "/users/tester/.jcode/provider-probes/claude", + env: { + HOME: "/users/tester", + PATH: "/usr/bin", + HTTPS_PROXY: "https://proxy.example.test", + NODE_EXTRA_CA_CERTS: "/etc/ssl/custom.pem", + ANTHROPIC_API_KEY: "sk-ant-test", + LANG: "en_CA.UTF-8", + TMPDIR: "/tmp", + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + settingSources: [], + mcpServers: {}, + strictMcpConfig: true, + tools: [], + allowedTools: [], + skills: [], + plugins: [], + agents: {}, + hooks: {}, + persistSession: false, + stderr: expect.any(Function), + }); + }); +}); + +describe("probeClaudeSubscription", () => { + it("creates fresh query state every time the returned Effect is executed", async () => { + const controllers: AbortController[] = []; + const queriedControllers: AbortController[] = []; + const dependencies: ClaudeSubscriptionProbeDependencies = { + createAbortController: () => { + const controller = new AbortController(); + controllers.push(controller); + return controller; + }, + makeDirectory: async () => {}, + query: (input) => { + const { abortController } = input.options; + expect(abortController).toBeInstanceOf(AbortController); + if (abortController) queriedControllers.push(abortController); + return { + initializationResult: async () => ({ account: { subscriptionType: "pro" } }), + }; + }, + }; + const probe = probeClaudeSubscription({ + homeDir: HOME_DIR, + environment: {}, + dependencies, + }); + + await Effect.runPromise(probe); + await Effect.runPromise(probe); + + expect(controllers).toHaveLength(2); + expect(queriedControllers).toEqual(controllers); + expect(new Set(controllers).size).toBe(2); + expect(controllers.every((controller) => controller.signal.aborted)).toBe(true); + }); + + it("creates the isolated cwd before returning initialization metadata without a prompt", async () => { + // Given + const abort = makeAbortTracker(); + const events: string[] = []; + let promptResult: Promise> | undefined; + let capturedInput: ClaudeSubscriptionQueryInput | undefined; + const dependencies: ClaudeSubscriptionProbeDependencies = { + createAbortController: abort.createAbortController, + makeDirectory: async () => { + events.push("mkdir"); + }, + query: (input) => { + events.push("query"); + capturedInput = input; + promptResult = input.prompt[Symbol.asyncIterator]().next(); + return { + initializationResult: async () => { + events.push("initialization"); + return { account: { subscriptionType: "pro" } }; + }, + }; + }, + }; + + // When + const result = await Effect.runPromise( + probeClaudeSubscription({ + homeDir: HOME_DIR, + environment: { HOME: PROJECT_DIR }, + dependencies, + }), + ); + + // Then + expect(result).toEqual({ subscriptionType: "pro" }); + expect(events).toEqual(["mkdir", "query", "initialization"]); + expect(capturedInput?.options.cwd).toBe("/users/tester/.jcode/provider-probes/claude"); + expect(abort.controller.signal.aborted).toBe(true); + expect(await promptResult).toEqual({ value: undefined, done: true }); + }); + + it("soft-returns undefined and aborts when directory creation fails", async () => { + // Given + const abort = makeAbortTracker(); + let queried = false; + const dependencies: ClaudeSubscriptionProbeDependencies = { + createAbortController: abort.createAbortController, + makeDirectory: async () => { + throw new Error("read-only filesystem"); + }, + query: () => { + queried = true; + throw new Error("query must not run"); + }, + }; + + // When + const result = await Effect.runPromise( + probeClaudeSubscription({ homeDir: HOME_DIR, environment: {}, dependencies }), + ); + + // Then + expect(result).toBeUndefined(); + expect(queried).toBe(false); + expect(abort.controller.signal.aborted).toBe(true); + }); + + it("soft-returns undefined and aborts when the query fails", async () => { + // Given + const abort = makeAbortTracker(); + const dependencies: ClaudeSubscriptionProbeDependencies = { + createAbortController: abort.createAbortController, + makeDirectory: async () => {}, + query: () => ({ + initializationResult: async () => { + throw new Error("query failed"); + }, + }), + }; + + // When + const result = await Effect.runPromise( + probeClaudeSubscription({ homeDir: HOME_DIR, environment: {}, dependencies }), + ); + + // Then + expect(result).toBeUndefined(); + expect(abort.controller.signal.aborted).toBe(true); + }); + + it("soft-returns undefined and aborts on a controlled timeout", async () => { + // Given + const abort = makeAbortTracker(); + const dependencies: ClaudeSubscriptionProbeDependencies = { + createAbortController: abort.createAbortController, + makeDirectory: async () => {}, + query: () => ({ + initializationResult: () => new Promise(() => {}), + }), + }; + + // When + const result = await Effect.gen(function* () { + const fiber = yield* Effect.forkChild( + probeClaudeSubscription({ + homeDir: HOME_DIR, + environment: {}, + dependencies, + timeoutMs: 25, + }), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(25)); + return yield* Fiber.join(fiber); + }).pipe(Effect.provide(TestClock.layer()), Effect.scoped, Effect.runPromise); + + // Then + expect(result).toBeUndefined(); + expect(abort.controller.signal.aborted).toBe(true); + }); +}); diff --git a/apps/server/src/provider/claudeSubscriptionProbe.ts b/apps/server/src/provider/claudeSubscriptionProbe.ts new file mode 100644 index 00000000..cfc66ef5 --- /dev/null +++ b/apps/server/src/provider/claudeSubscriptionProbe.ts @@ -0,0 +1,185 @@ +import { mkdir } from "node:fs/promises"; +import * as NodePath from "node:path"; +import { + query as claudeQuery, + type Options, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { Effect, Option, Result } from "effect"; + +const CLAUDE_SUBSCRIPTION_PROBE_TIMEOUT_MS = 8_000; + +// Security boundary: retain only child launch, network, locale/temp, and recognized +// provider-auth compatibility. Parent project/config context must never reach the probe. +const CLAUDE_SUBSCRIPTION_PROBE_ENVIRONMENT_KEYS = [ + "PATH", + "Path", + "PATHEXT", + "SYSTEMROOT", + "SystemRoot", + "WINDIR", + "COMSPEC", + "ComSpec", + "SHELL", + "LANG", + "LANGUAGE", + "LC_ALL", + "LC_CTYPE", + "TZ", + "TMPDIR", + "TMP", + "TEMP", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_CUSTOM_HEADERS", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ROLE_ARN", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_EC2_METADATA_DISABLED", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GCLOUD_PROJECT", + "CLOUD_ML_REGION", + "ANTHROPIC_VERTEX_PROJECT_ID", + "ANTHROPIC_FOUNDRY_API_KEY", + "ANTHROPIC_FOUNDRY_BASE_URL", + "ANTHROPIC_FOUNDRY_RESOURCE", +] as const; + +export type ClaudeSubscriptionQueryInput = { + readonly prompt: AsyncIterable; + readonly options: Options; +}; + +export type ClaudeSubscriptionProbeDependencies = { + readonly createAbortController: () => AbortController; + readonly makeDirectory: (path: string) => Promise; + readonly query: (input: ClaudeSubscriptionQueryInput) => { + readonly initializationResult: () => Promise<{ + readonly account?: { readonly subscriptionType?: string }; + }>; + }; +}; + +type ClaudeSubscriptionProbeInput = { + readonly homeDir: string; + readonly environment: NodeJS.ProcessEnv; + readonly dependencies?: ClaudeSubscriptionProbeDependencies; + readonly timeoutMs?: number; +}; + +const defaultDependencies: ClaudeSubscriptionProbeDependencies = { + createAbortController: () => new AbortController(), + makeDirectory: async (path) => { + await mkdir(path, { recursive: true }); + }, + query: (input) => claudeQuery(input), +}; + +export function buildClaudeSubscriptionProbeQuery(input: { + readonly abortController: AbortController; + readonly homeDir: string; + readonly environment: NodeJS.ProcessEnv; +}): { readonly cwd: string; readonly options: Options } { + const home = NodePath.resolve(input.homeDir.trim()); + const cwd = NodePath.join(home, ".jcode", "provider-probes", "claude"); + const environment: Record = {}; + for (const key of CLAUDE_SUBSCRIPTION_PROBE_ENVIRONMENT_KEYS) { + const value = input.environment[key]; + if (typeof value === "string") environment[key] = value; + } + return { + cwd, + options: { + abortController: input.abortController, + cwd, + env: { + ...environment, + HOME: home, + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + settingSources: [], + mcpServers: {}, + strictMcpConfig: true, + tools: [], + allowedTools: [], + skills: [], + plugins: [], + agents: {}, + hooks: {}, + persistSession: false, + stderr: () => {}, + }, + }; +} + +export function probeClaudeSubscription( + input: ClaudeSubscriptionProbeInput, +): Effect.Effect<{ readonly subscriptionType: string | undefined } | undefined> { + return Effect.suspend(() => { + const dependencies = input.dependencies ?? defaultDependencies; + const abortController = dependencies.createAbortController(); + const built = buildClaudeSubscriptionProbeQuery({ + abortController, + homeDir: input.homeDir, + environment: input.environment, + }); + + return Effect.tryPromise({ + try: async () => { + await dependencies.makeDirectory(built.cwd); + const query = dependencies.query({ + // oxlint-disable-next-line require-yield + prompt: (async function* (): AsyncGenerator { + if (abortController.signal.aborted) return; + await new Promise((resolve) => { + abortController.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + })(), + options: built.options, + }); + const initialization = await query.initializationResult(); + return { subscriptionType: initialization.account?.subscriptionType }; + }, + catch: (cause) => cause, + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (!abortController.signal.aborted) abortController.abort(); + }), + ), + Effect.timeoutOption(input.timeoutMs ?? CLAUDE_SUBSCRIPTION_PROBE_TIMEOUT_MS), + Effect.result, + Effect.map((result) => { + if (Result.isFailure(result)) return undefined; + return Option.isSome(result.success) ? result.success.value : undefined; + }), + ); + }); +} diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index bc6d96ca..a645a105 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -50,6 +50,16 @@ describe("normalizeCustomModelSlugs", () => { }); }); +describe("Claude custom model settings", () => { + it("uses the canonical Sonnet 5 model ID as the example", () => { + const claudeSettings = MODEL_PROVIDER_SETTINGS.find( + (settings) => settings.provider === "claudeAgent", + ); + + expect(claudeSettings?.example).toBe("claude-sonnet-5"); + }); +}); + describe("getAppModelOptions", () => { it("appends saved custom models after the built-in options", () => { const options = getAppModelOptions("codex", ["custom/internal-model"]); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index b3e5f2f8..f9976974 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -209,7 +209,7 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record { it("renders runtime access as a tokenized state chip", () => { @@ -20,4 +25,9 @@ describe("BranchToolbar structure", () => { expect(branchToolbarSource).toContain("providerRateLimits={usageSummary.rateLimits}"); expect(branchToolbarSource).toContain("providerUsageLines={usageSummary.usageLines}"); }); + + it("uses the shared combobox scroll fade without a selector-local duplicate", () => { + expect(branchSelectorSource.match(/scrollFade/g) ?? []).toHaveLength(0); + expect(comboboxSource.match(/]*scrollFade/g) ?? []).toHaveLength(1); + }); }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.browser.tsx b/apps/web/src/components/BranchToolbarBranchSelector.browser.tsx new file mode 100644 index 00000000..c3982f2a --- /dev/null +++ b/apps/web/src/components/BranchToolbarBranchSelector.browser.tsx @@ -0,0 +1,548 @@ +import "../index.css"; + +import type { + GitBranch, + GitListBranchesResult, + GitStatusResult, + NativeApi, +} from "@jcode/contracts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { page, userEvent } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { gitQueryKeys } from "../lib/gitReactQuery"; +import { buildThemeCssVariables, getCodeThemeSeed } from "../theme/theme.logic"; +import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; + +const TEST_CWD = "/repo/jcode"; +const THEMES = ["light", "dark"] as const; +const WIDTHS = [375, 1280] as const; +const appliedThemeVariables = new Set(); + +function applyTheme(theme: (typeof THEMES)[number]): void { + const root = document.documentElement; + const { variables } = buildThemeCssVariables( + { codeThemeId: "github", theme: getCodeThemeSeed("github", theme) }, + theme, + ); + root.classList.toggle("dark", theme === "dark"); + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + appliedThemeVariables.add(name); + } +} + +function resetTheme(): void { + document.documentElement.classList.remove("dark"); + for (const name of appliedThemeVariables) document.documentElement.style.removeProperty(name); + appliedThemeVariables.clear(); +} + +function makeBranches(count: number, prefix = "feature/branch-"): GitBranch[] { + return Array.from({ length: count }, (_, index) => ({ + name: index === 0 ? "main" : `${prefix}${String(index).padStart(2, "0")}`, + current: index === 0, + isDefault: index === 0, + worktreePath: null, + })); +} + +function makeStatus(): GitStatusResult { + return { + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/dirty.ts", insertions: 12, deletions: 3 }], + insertions: 12, + deletions: 3, + }, + hasUpstream: true, + upstreamBranch: "origin/main", + aheadCount: 0, + behindCount: 0, + pr: null, + }; +} + +async function mountSelector( + branchCount: number, + onSetThreadWorkspace: (patch: { + branch?: string | null; + worktreePath?: string | null; + }) => void = () => {}, + options: { + branchPrefix?: string; + onCheckoutPullRequestRequest?: (reference: string) => void; + } = {}, +) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const branchResult: GitListBranchesResult = { + isRepo: true, + hasOriginRemote: true, + branches: makeBranches(branchCount, options.branchPrefix), + }; + let checkedOutBranch = "main"; + const gitApi: Pick = { + checkout: vi.fn(async ({ branch }) => { + checkedOutBranch = branch; + }), + listBranches: vi.fn(async () => branchResult), + status: vi.fn(async () => ({ ...makeStatus(), branch: checkedOutBranch })), + }; + window.nativeApi = { git: gitApi } as NativeApi; + client.setQueryData(gitQueryKeys.branches(TEST_CWD), branchResult); + client.setQueryData(gitQueryKeys.status(TEST_CWD), makeStatus()); + + return render( + +
+ +
+
, + ); +} + +async function getViewport(): Promise { + let viewport: HTMLElement | null = null; + await vi.waitFor(() => { + viewport = document.querySelector('[data-slot="scroll-area-viewport"]'); + expect(viewport).toBeInstanceOf(HTMLElement); + }); + if (!viewport) throw new Error("Branch selector scroll viewport was not rendered."); + return viewport; +} + +async function openSelector(container: HTMLElement): Promise { + const trigger = container.querySelector('[data-slot="combobox-trigger"]'); + expect(trigger).toBeInstanceOf(HTMLElement); + if (!trigger) throw new Error("Branch selector trigger was not rendered."); + trigger.click(); + return getViewport(); +} + +function overflowMetrics(viewport: HTMLElement) { + const style = getComputedStyle(viewport); + return { + clientHeight: viewport.clientHeight, + scrollHeight: viewport.scrollHeight, + scrollTop: viewport.scrollTop, + start: Number.parseFloat(style.getPropertyValue("--scroll-area-overflow-y-start")) || 0, + end: Number.parseFloat(style.getPropertyValue("--scroll-area-overflow-y-end")) || 0, + maskImage: style.maskImage, + }; +} + +async function waitForOverflow(viewport: HTMLElement, state: "top" | "middle" | "end") { + await vi.waitFor(() => { + const metrics = overflowMetrics(viewport); + expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight); + expect(metrics.maskImage).not.toBe("none"); + if (state === "top") { + expect(metrics.start).toBe(0); + expect(metrics.end).toBeGreaterThan(0); + } else if (state === "middle") { + expect(metrics.start).toBeGreaterThan(0); + expect(metrics.end).toBeGreaterThan(0); + } else { + expect(metrics.start).toBeGreaterThan(0); + expect(metrics.end).toBe(0); + } + }); +} + +function dispatchKeyboard(input: Element, key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key, + }); + input.dispatchEvent(event); + return event; +} + +describe("BranchToolbarBranchSelector overflow", () => { + afterEach(async () => { + resetTheme(); + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "nativeApi"); + await page.viewport(1280, 720); + }); + + it.each([ + { count: 39, virtualized: false }, + { count: 40, virtualized: false }, + { count: 41, virtualized: true }, + ])( + "uses the exact virtualization threshold at $count branches", + async ({ count, virtualized }) => { + const screen = await mountSelector(count); + try { + const viewport = await openSelector(screen.container); + await waitForOverflow(viewport, "top"); + const scrollViewports = Array.from( + document.querySelectorAll('[data-slot="scroll-area-viewport"]'), + ); + expect(scrollViewports).toHaveLength(1); + expect( + scrollViewports.filter((element) => getComputedStyle(element).maskImage !== "none"), + ).toHaveLength(1); + const renderedItems = document.querySelectorAll('[data-slot="combobox-item"]').length; + if (virtualized) { + expect(renderedItems).toBeLessThan(count); + } else { + expect(renderedItems).toBe(count); + } + expect(page.getByText("Uncommitted: 1 file")).toBeVisible(); + } finally { + await screen.unmount(); + } + }, + ); + + it.each([39, 40])( + "leaves PageDown uncancelled with %s nonvirtual branches", + async (branchCount) => { + const screen = await mountSelector(branchCount); + try { + await openSelector(screen.container); + const searchInput = page.getByPlaceholder("Search branches...").element(); + + const pageDown = dispatchKeyboard(searchInput, "PageDown"); + + expect(pageDown.defaultPrevented).toBe(false); + } finally { + await screen.unmount(); + } + }, + ); + + it("leaves PageDown uncancelled after search disables virtualization", async () => { + const screen = await mountSelector(50); + try { + await openSelector(screen.container); + const search = page.getByPlaceholder("Search branches..."); + await search.fill("feature/branch-0"); + await vi.waitFor(() => + expect(document.querySelectorAll('[data-slot="combobox-item"]')).toHaveLength(9), + ); + const searchInput = search.element(); + + const filteredPageDown = dispatchKeyboard(searchInput, "PageDown"); + expect(filteredPageDown.defaultPrevented).toBe(false); + } finally { + await screen.unmount(); + } + }); + + it("keeps PageDown highlight and viewport aligned across 44px and 48px rows", async () => { + const screen = await mountSelector(50, () => {}, { + branchPrefix: "#123/branch-", + onCheckoutPullRequestRequest: () => {}, + }); + try { + const viewport = await openSelector(screen.container); + const search = page.getByPlaceholder("Search branches..."); + await search.fill("#123"); + await vi.waitFor(() => + expect(document.querySelectorAll('[data-slot="combobox-item"]').length).toBeGreaterThan(1), + ); + await vi.waitFor(() => { + const firstFilteredBranch = document.querySelector( + '[data-branch-picker-index="1"]', + ); + expect(firstFilteredBranch?.textContent).toContain("#123/branch-01"); + expect(firstFilteredBranch?.textContent).not.toContain("main"); + }); + search.element().focus(); + const initialActiveId = search.element().getAttribute("aria-activedescendant"); + const initialActiveElement = initialActiveId + ? document.getElementById(initialActiveId) + : null; + const initialActiveIndex = Number.parseInt( + initialActiveElement?.dataset["branchPickerIndex"] ?? "", + 10, + ); + expect(initialActiveElement?.isConnected).toBe(true); + expect(Number.isInteger(initialActiveIndex)).toBe(true); + + const branchList = document.querySelector('[data-slot="combobox-list"]'); + const pagingViewport = branchList?.parentElement; + expect(pagingViewport).toBeInstanceOf(HTMLElement); + if (!pagingViewport) return; + const fixtureRowSizes = new Map( + Array.from( + document.querySelectorAll("[data-branch-picker-index]"), + (item) => + [ + Number.parseInt(item.dataset["branchPickerIndex"] ?? "", 10), + item.getBoundingClientRect().height, + ] as const, + ), + ); + const fixtureRowSize = (index: number): number => { + const size = fixtureRowSizes.get(index); + expect([28, 44, 48]).toContain(size); + return size ?? 28; + }; + let expectedTargetIndex = initialActiveIndex; + let traversedHeight = fixtureRowSize(initialActiveIndex); + while (expectedTargetIndex < 49) { + const nextIndex = expectedTargetIndex + 1; + const nextHeight = fixtureRowSize(nextIndex); + if (traversedHeight + nextHeight > pagingViewport.clientHeight) break; + traversedHeight += nextHeight; + expectedTargetIndex = nextIndex; + } + const expectedTargetText = + expectedTargetIndex === 0 + ? "Checkout Pull Request" + : `#123/branch-${String(expectedTargetIndex).padStart(2, "0")}`; + + const pageDown = dispatchKeyboard(search.element(), "PageDown"); + + expect(pageDown.defaultPrevented).toBe(true); + await vi.waitFor(() => { + const activeId = search.element().getAttribute("aria-activedescendant"); + const activeElement = activeId ? document.getElementById(activeId) : null; + expect(activeElement?.dataset["branchPickerIndex"]).toBe(String(expectedTargetIndex)); + expect(activeElement?.textContent).toContain(expectedTargetText); + expect(activeElement?.isConnected).toBe(true); + expect(activeElement?.getBoundingClientRect().top).toBeGreaterThanOrEqual( + viewport.getBoundingClientRect().top, + ); + expect(activeElement?.getBoundingClientRect().bottom).toBeLessThanOrEqual( + viewport.getBoundingClientRect().bottom, + ); + }); + + const pageUp = dispatchKeyboard(search.element(), "PageUp"); + + expect(pageUp.defaultPrevented).toBe(true); + await vi.waitFor(() => { + const activeId = search.element().getAttribute("aria-activedescendant"); + const activeElement = activeId ? document.getElementById(activeId) : null; + expect(activeElement?.dataset["branchPickerIndex"]).toBe(String(initialActiveIndex)); + expect(activeElement?.textContent).toBe(initialActiveElement?.textContent); + expect(activeElement?.isConnected).toBe(true); + }); + } finally { + await screen.unmount(); + } + }); + + it.each([41, 50])("handles PageDown with %s open virtualized branches", async (branchCount) => { + const screen = await mountSelector(branchCount); + try { + const viewport = await openSelector(screen.container); + const searchInput = page.getByPlaceholder("Search branches...").element(); + + const pageDown = dispatchKeyboard(searchInput, "PageDown"); + + expect(pageDown.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(viewport.scrollTop).toBeGreaterThan(0)); + } finally { + await screen.unmount(); + } + }); + + it("reports real top, middle, and end overflow for a virtualized branch list", async () => { + await page.viewport(375, 720); + const screen = await mountSelector(50); + try { + const viewport = await openSelector(screen.container); + await waitForOverflow(viewport, "top"); + const statusRow = page + .getByText("Uncommitted: 1 file") + .element() + .closest('[data-slot="combobox-item"]'); + expect(statusRow).toBeInstanceOf(HTMLElement); + const statusRowHeight = statusRow?.getBoundingClientRect().height; + if (!statusRow) return; + expect(statusRowHeight).toBe(48); + const renderedRows = Array.from( + document.querySelectorAll('[data-slot="combobox-item"]'), + ); + for (const [index, row] of renderedRows.entries()) { + expect(row.getBoundingClientRect().height).toBe(row === statusRow ? 48 : 28); + const nextRow = renderedRows[index + 1]; + if (nextRow) { + expect(nextRow.getBoundingClientRect().top).toBeGreaterThanOrEqual( + row.getBoundingClientRect().bottom, + ); + } + } + const topMetrics = overflowMetrics(viewport); + + viewport.scrollTop = (viewport.scrollHeight - viewport.clientHeight) / 2; + viewport.dispatchEvent(new Event("scroll")); + await waitForOverflow(viewport, "middle"); + const middleMetrics = overflowMetrics(viewport); + + viewport.scrollTop = viewport.scrollHeight; + viewport.dispatchEvent(new Event("scroll")); + await waitForOverflow(viewport, "end"); + await expect.element(page.getByText("feature/branch-49", { exact: true })).toBeVisible(); + expect(topMetrics.start).toBe(0); + expect(topMetrics.end).toBeGreaterThan(0); + expect(middleMetrics.start).toBeGreaterThan(0); + expect(middleMetrics.end).toBeGreaterThan(0); + expect(overflowMetrics(viewport).end).toBe(0); + expect(statusRowHeight).toBeGreaterThan(0); + } finally { + await screen.unmount(); + } + }); + + it("resets overflow after search shrink and after close and reopen", async () => { + const screen = await mountSelector(50); + try { + const viewport = await openSelector(screen.container); + viewport.scrollTop = viewport.scrollHeight; + viewport.dispatchEvent(new Event("scroll")); + await waitForOverflow(viewport, "end"); + + const search = page.getByPlaceholder("Search branches..."); + await search.fill("feature/branch-0"); + await vi.waitFor(() => { + expect(document.querySelectorAll('[data-slot="combobox-item"]')).toHaveLength(9); + expect(viewport.scrollTop).toBe(0); + }); + await waitForOverflow(viewport, "top"); + await expect.element(page.getByText("feature/branch-09", { exact: true })).toBeVisible(); + + search.element().focus(); + await userEvent.keyboard("{Escape}"); + await vi.waitFor(() => expect(viewport.isConnected).toBe(false)); + const reopenedViewport = await openSelector(screen.container); + await waitForOverflow(reopenedViewport, "top"); + expect(reopenedViewport.scrollTop).toBe(0); + await expect.element(page.getByText("Uncommitted: 1 file")).toBeVisible(); + } finally { + await screen.unmount(); + } + }); + + it("keeps keyboard and wheel navigation connected to the virtual list", async () => { + const onSetThreadWorkspace = vi.fn(); + const screen = await mountSelector(50, onSetThreadWorkspace); + try { + const viewport = await openSelector(screen.container); + const search = page.getByPlaceholder("Search branches..."); + search.element().focus(); + await userEvent.keyboard("{ArrowDown}"); + expect(document.querySelector("[data-highlighted]")?.textContent).toContain( + "feature/branch-01", + ); + await userEvent.keyboard("{PageDown}"); + await vi.waitFor(() => { + expect(viewport.scrollTop).toBeGreaterThan(0); + const activeId = search.element().getAttribute("aria-activedescendant"); + const activeElement = activeId ? document.getElementById(activeId) : null; + expect(activeElement?.textContent).not.toContain("feature/branch-01"); + expect(activeElement?.isConnected).toBe(true); + }); + + viewport.scrollTop = 0; + viewport.dispatchEvent(new Event("scroll")); + await userEvent.wheel(viewport, { delta: { y: 420 } }); + await vi.waitFor(() => expect(viewport.scrollTop).toBeGreaterThan(0)); + + let syntheticArrowDownCount = 0; + const countSyntheticArrowDown = (event: Event) => { + if (event instanceof KeyboardEvent && event.key === "ArrowDown") { + syntheticArrowDownCount += 1; + } + }; + search.element().addEventListener("keydown", countSyntheticArrowDown); + const end = dispatchKeyboard(search.element(), "End"); + search.element().removeEventListener("keydown", countSyntheticArrowDown); + expect(end.defaultPrevented).toBe(true); + expect(syntheticArrowDownCount).toBeGreaterThan(0); + expect(syntheticArrowDownCount).toBeLessThanOrEqual(49); + await waitForOverflow(viewport, "end"); + expect(document.activeElement).toBe(search.element()); + const finalRow = page.getByText("feature/branch-49", { exact: true }); + await expect.element(finalRow).toBeVisible(); + const bounds = finalRow.element().closest('[data-slot="combobox-item"]'); + expect(bounds?.getBoundingClientRect().bottom).toBeLessThanOrEqual( + viewport.getBoundingClientRect().bottom, + ); + const activeId = search.element().getAttribute("aria-activedescendant"); + const activeElement = activeId ? document.getElementById(activeId) : null; + expect(activeElement?.textContent).toContain("feature/branch-49"); + expect(activeElement?.isConnected).toBe(true); + await userEvent.keyboard("{ArrowUp}"); + expect(document.querySelector("[data-highlighted]")?.textContent).toContain( + "feature/branch-48", + ); + const repeatedEnd = dispatchKeyboard(search.element(), "End"); + expect(repeatedEnd.defaultPrevented).toBe(true); + dispatchKeyboard(search.element(), "Enter"); + await vi.waitFor(() => expect(viewport.isConnected).toBe(false)); + await vi.waitFor(() => + expect(onSetThreadWorkspace).toHaveBeenCalledWith({ + branch: "feature/branch-49", + worktreePath: null, + }), + ); + const reopenedViewport = await openSelector(screen.container); + await waitForOverflow(reopenedViewport, "top"); + await expect.element(page.getByText("Uncommitted: 1 file")).toBeVisible(); + } finally { + await screen.unmount(); + } + }); + + it.each(THEMES.flatMap((theme) => WIDTHS.map((width) => ({ theme, width }))))( + "verifies $theme overflow states at $width px", + async ({ theme, width }) => { + await page.viewport(width, 720); + applyTheme(theme); + const screen = await mountSelector(50); + try { + const viewport = await openSelector(screen.container); + await waitForOverflow(viewport, "top"); + await vi.waitFor(() => { + const popup = document.querySelector('[data-slot="combobox-popup"]'); + expect(popup).toBeInstanceOf(HTMLElement); + expect(popup ? getComputedStyle(popup).opacity : "0").toBe("1"); + }); + viewport.scrollTop = (viewport.scrollHeight - viewport.clientHeight) / 2; + viewport.dispatchEvent(new Event("scroll")); + await waitForOverflow(viewport, "middle"); + const popup = document.querySelector('[data-slot="combobox-popup"]'); + const popupSurface = popup?.parentElement; + const viewportStyle = getComputedStyle(viewport); + expect(popupSurface).toBeInstanceOf(HTMLElement); + expect( + popupSurface ? getComputedStyle(popupSurface).backgroundColor : "transparent", + ).not.toBe("rgba(0, 0, 0, 0)"); + expect(viewportStyle.maskComposite).not.toBe("none"); + const viewportBounds = viewport.getBoundingClientRect(); + const elementBelowViewport = document.elementFromPoint( + viewportBounds.left + viewportBounds.width / 2, + viewportBounds.bottom + 8, + ); + expect(popup?.contains(elementBelowViewport)).toBe(true); + viewport.scrollTop = viewport.scrollHeight; + viewport.dispatchEvent(new Event("scroll")); + await waitForOverflow(viewport, "end"); + await expect.element(page.getByText("feature/branch-49", { exact: true })).toBeVisible(); + } finally { + await screen.unmount(); + } + }, + ); +}); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.logic.test.ts b/apps/web/src/components/BranchToolbarBranchSelector.logic.test.ts new file mode 100644 index 00000000..520c53c6 --- /dev/null +++ b/apps/web/src/components/BranchToolbarBranchSelector.logic.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BRANCH_PICKER_BRANCH_ROW_HEIGHT, + BRANCH_PICKER_CURRENT_BRANCH_SUMMARY_ROW_HEIGHT, + BRANCH_PICKER_DEFAULT_VIEWPORT_HEIGHT, + BRANCH_PICKER_PULL_REQUEST_ROW_HEIGHT, + getBranchNavigationTargetIndex, +} from "./BranchToolbarBranchSelector.logic"; + +const MIXED_ROW_HEIGHTS = [ + BRANCH_PICKER_BRANCH_ROW_HEIGHT, + BRANCH_PICKER_PULL_REQUEST_ROW_HEIGHT, + BRANCH_PICKER_CURRENT_BRANCH_SUMMARY_ROW_HEIGHT, + BRANCH_PICKER_BRANCH_ROW_HEIGHT, +] as const; + +function estimateMixedRowHeight(index: number): number { + return MIXED_ROW_HEIGHTS[index] ?? BRANCH_PICKER_BRANCH_ROW_HEIGHT; +} + +describe("getBranchNavigationTargetIndex", () => { + it("moves PageDown by the rows that fit in the viewport budget", () => { + expect( + getBranchNavigationTargetIndex({ + currentIndex: 0, + key: "PageDown", + lastIndex: 3, + viewportHeight: BRANCH_PICKER_DEFAULT_VIEWPORT_HEIGHT, + estimateItemSize: estimateMixedRowHeight, + }), + ).toBe(3); + }); + + it("moves PageUp by the rows that fit in the viewport budget", () => { + expect( + getBranchNavigationTargetIndex({ + currentIndex: 3, + key: "PageUp", + lastIndex: 3, + viewportHeight: 148, + estimateItemSize: estimateMixedRowHeight, + }), + ).toBe(0); + }); + + it("moves End directly to the bounded last index without measuring rows", () => { + const estimateItemSize = vi.fn(() => BRANCH_PICKER_BRANCH_ROW_HEIGHT); + + expect( + getBranchNavigationTargetIndex({ + currentIndex: 1, + key: "End", + lastIndex: 3, + viewportHeight: 0, + estimateItemSize, + }), + ).toBe(3); + expect(estimateItemSize).not.toHaveBeenCalled(); + }); + + it.each([ + { currentIndex: -4, key: "PageUp" as const, expected: 0 }, + { currentIndex: 99, key: "PageDown" as const, expected: 3 }, + { currentIndex: 99, key: "End" as const, expected: 3 }, + ])("bounds $key navigation from current index $currentIndex", (input) => { + expect( + getBranchNavigationTargetIndex({ + currentIndex: input.currentIndex, + key: input.key, + lastIndex: 3, + viewportHeight: BRANCH_PICKER_DEFAULT_VIEWPORT_HEIGHT, + estimateItemSize: estimateMixedRowHeight, + }), + ).toBe(input.expected); + }); + + it("stays within a zero last-index bound", () => { + expect( + getBranchNavigationTargetIndex({ + currentIndex: 2, + key: "PageDown", + lastIndex: 0, + viewportHeight: BRANCH_PICKER_DEFAULT_VIEWPORT_HEIGHT, + estimateItemSize: estimateMixedRowHeight, + }), + ).toBe(0); + }); + + it.each([ + { viewportHeight: 0, expected: 0 }, + { viewportHeight: 27, expected: 0 }, + { viewportHeight: 71, expected: 0 }, + { viewportHeight: 72, expected: 1 }, + { viewportHeight: 119, expected: 1 }, + { viewportHeight: 120, expected: 2 }, + ])("handles a $viewportHeight px PageDown viewport budget", ({ viewportHeight, expected }) => { + expect( + getBranchNavigationTargetIndex({ + currentIndex: 0, + key: "PageDown", + lastIndex: 3, + viewportHeight, + estimateItemSize: estimateMixedRowHeight, + }), + ).toBe(expected); + }); + + it.each([ + { viewportHeight: 0, expected: 2 }, + { viewportHeight: 47, expected: 2 }, + { viewportHeight: 91, expected: 2 }, + { viewportHeight: 92, expected: 1 }, + { viewportHeight: 119, expected: 1 }, + { viewportHeight: 120, expected: 0 }, + ])("handles a $viewportHeight px PageUp viewport budget", ({ viewportHeight, expected }) => { + expect( + getBranchNavigationTargetIndex({ + currentIndex: 2, + key: "PageUp", + lastIndex: 3, + viewportHeight, + estimateItemSize: estimateMixedRowHeight, + }), + ).toBe(expected); + }); +}); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.logic.ts b/apps/web/src/components/BranchToolbarBranchSelector.logic.ts new file mode 100644 index 00000000..b0fa17bb --- /dev/null +++ b/apps/web/src/components/BranchToolbarBranchSelector.logic.ts @@ -0,0 +1,45 @@ +export const BRANCH_PICKER_BRANCH_ROW_HEIGHT = 28; +export const BRANCH_PICKER_PULL_REQUEST_ROW_HEIGHT = 44; +export const BRANCH_PICKER_CURRENT_BRANCH_SUMMARY_ROW_HEIGHT = 48; +export const BRANCH_PICKER_DEFAULT_VIEWPORT_HEIGHT = 224; + +export type BranchPickerRowHeight = + | typeof BRANCH_PICKER_BRANCH_ROW_HEIGHT + | typeof BRANCH_PICKER_PULL_REQUEST_ROW_HEIGHT + | typeof BRANCH_PICKER_CURRENT_BRANCH_SUMMARY_ROW_HEIGHT; + +export const BRANCH_PICKER_ROW_CLASS_NAME_BY_HEIGHT: Readonly< + Record +> = { + [BRANCH_PICKER_BRANCH_ROW_HEIGHT]: "h-7 min-h-7", + [BRANCH_PICKER_PULL_REQUEST_ROW_HEIGHT]: "h-11 min-h-11", + [BRANCH_PICKER_CURRENT_BRANCH_SUMMARY_ROW_HEIGHT]: "h-12 min-h-12", +}; + +export type BranchNavigationKey = "PageDown" | "PageUp" | "End"; + +export function getBranchNavigationTargetIndex(input: { + readonly currentIndex: number; + readonly key: BranchNavigationKey; + readonly lastIndex: number; + readonly viewportHeight: number; + readonly estimateItemSize: (index: number) => number; +}): number { + const boundedLastIndex = Number.isInteger(input.lastIndex) ? Math.max(0, input.lastIndex) : 0; + const normalizedCurrentIndex = Number.isInteger(input.currentIndex) ? input.currentIndex : 0; + const currentIndex = Math.min(boundedLastIndex, Math.max(0, normalizedCurrentIndex)); + if (input.key === "End") return boundedLastIndex; + + const direction = input.key === "PageDown" ? 1 : -1; + let targetIndex = currentIndex; + let traversedHeight = input.estimateItemSize(currentIndex); + while (true) { + const nextIndex = targetIndex + direction; + if (nextIndex < 0 || nextIndex > boundedLastIndex) break; + const nextHeight = input.estimateItemSize(nextIndex); + if (traversedHeight + nextHeight > input.viewportHeight) break; + traversedHeight += nextHeight; + targetIndex = nextIndex; + } + return targetIndex; +} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index d4d8d1da..4f64c890 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -8,6 +8,7 @@ import { ChevronDownIcon, PlusIcon } from "~/lib/icons"; import { GoGitBranch } from "react-icons/go"; import { type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, useCallback, useDeferredValue, useEffect, @@ -35,6 +36,14 @@ import { resolvePostCheckoutStatusCwd, shouldSyncLocalThreadBranch, } from "./BranchToolbar.logic"; +import { + BRANCH_PICKER_BRANCH_ROW_HEIGHT, + BRANCH_PICKER_CURRENT_BRANCH_SUMMARY_ROW_HEIGHT, + BRANCH_PICKER_DEFAULT_VIEWPORT_HEIGHT, + BRANCH_PICKER_PULL_REQUEST_ROW_HEIGHT, + BRANCH_PICKER_ROW_CLASS_NAME_BY_HEIGHT, + getBranchNavigationTargetIndex, +} from "./BranchToolbarBranchSelector.logic"; import { Button } from "./ui/button"; import { Dialog, @@ -657,45 +666,59 @@ export function BranchToolbarBranchSelector({ onSetThreadWorkspace, ]); - const handleOpenChange = useCallback( - (open: boolean) => { - setIsBranchMenuOpen(open); - if (!open) { - setBranchQuery(""); - return; + const branchListScrollElementRef = useRef(null); + const branchListAttachGenerationRef = useRef(0); + const highlightedBranchIndexRef = useRef(0); + const estimateBranchItemSize = useCallback( + (index: number) => { + const itemValue = filteredBranchPickerItems[index]; + if (!itemValue) return BRANCH_PICKER_BRANCH_ROW_HEIGHT; + if (itemValue === checkoutPullRequestItemValue) { + return BRANCH_PICKER_PULL_REQUEST_ROW_HEIGHT; } - void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(branchCwd), - }); + const branch = branchByName.get(itemValue); + return branch && getCurrentBranchChangeSummary(branch, branchStatusQuery.data) + ? BRANCH_PICKER_CURRENT_BRANCH_SUMMARY_ROW_HEIGHT + : BRANCH_PICKER_BRANCH_ROW_HEIGHT; }, - [branchCwd, queryClient], + [branchByName, branchStatusQuery.data, checkoutPullRequestItemValue, filteredBranchPickerItems], ); - - const branchListScrollElementRef = useRef(null); const branchListVirtualizer = useVirtualizer({ count: filteredBranchPickerItems.length, - estimateSize: (index) => { - const itemValue = filteredBranchPickerItems[index]; - if (!itemValue) return 28; - if (itemValue === checkoutPullRequestItemValue) return 44; - const branch = branchByName.get(itemValue); - return branch && getCurrentBranchChangeSummary(branch, branchStatusQuery.data) ? 48 : 28; - }, + estimateSize: estimateBranchItemSize, getScrollElement: () => branchListScrollElementRef.current, overscan: 12, enabled: isBranchMenuOpen && shouldVirtualizeBranchList, initialRect: { - height: 224, + height: BRANCH_PICKER_DEFAULT_VIEWPORT_HEIGHT, width: 0, }, }); const virtualBranchRows = branchListVirtualizer.getVirtualItems(); const setBranchListRef = useCallback( (element: HTMLDivElement | null) => { + const attachGeneration = ++branchListAttachGenerationRef.current; branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; if (element) { + const scrollElement = branchListScrollElementRef.current; + highlightedBranchIndexRef.current = 0; + scrollElement?.scrollTo({ top: 0 }); + branchListVirtualizer.scrollToOffset(0, { align: "start" }); branchListVirtualizer.measure(); + queueMicrotask(() => { + if ( + !scrollElement || + branchListAttachGenerationRef.current !== attachGeneration || + branchListScrollElementRef.current !== scrollElement + ) { + return; + } + scrollElement.scrollTop = 0; + scrollElement.dispatchEvent(new Event("scroll")); + branchListVirtualizer.scrollToIndex(0, { align: "start" }); + branchListVirtualizer.measure(); + }); } }, [branchListVirtualizer], @@ -703,7 +726,15 @@ export function BranchToolbarBranchSelector({ useEffect(() => { if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; + const attachGeneration = branchListAttachGenerationRef.current; + const scrollElement = branchListScrollElementRef.current; queueMicrotask(() => { + if ( + branchListAttachGenerationRef.current !== attachGeneration || + branchListScrollElementRef.current !== scrollElement + ) { + return; + } branchListVirtualizer.measure(); }); }, [ @@ -714,6 +745,92 @@ export function BranchToolbarBranchSelector({ shouldVirtualizeBranchList, ]); + useEffect(() => { + if (!isBranchMenuOpen) return; + highlightedBranchIndexRef.current = 0; + const scrollElement = branchListScrollElementRef.current; + if (scrollElement) { + scrollElement.scrollTop = 0; + branchListVirtualizer.scrollToOffset(0, { align: "start" }); + } + }, [branchListVirtualizer, isBranchMenuOpen, normalizedDeferredBranchQuery]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + highlightedBranchIndexRef.current = 0; + branchListVirtualizer.scrollToIndex(0, { align: "start" }); + const scrollElement = branchListScrollElementRef.current; + if (scrollElement) { + scrollElement.scrollTop = 0; + } + setBranchQuery(""); + } + setIsBranchMenuOpen(open); + if (open) { + void queryClient.invalidateQueries({ + queryKey: gitQueryKeys.branches(branchCwd), + }); + } + }, + [branchCwd, branchListVirtualizer, queryClient], + ); + + const handleBranchInputKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; + const navigationKey = event.key; + if (navigationKey !== "PageDown" && navigationKey !== "PageUp" && navigationKey !== "End") { + return; + } + const lastIndex = filteredBranchPickerItems.length - 1; + if (lastIndex < 0) return; + const activeDescendantId = event.currentTarget.getAttribute("aria-activedescendant"); + const activeDescendantIndex = activeDescendantId + ? Number.parseInt( + document.getElementById(activeDescendantId)?.dataset["branchPickerIndex"] ?? "", + 10, + ) + : Number.NaN; + const resolvedCurrentIndex = Number.isInteger(activeDescendantIndex) + ? activeDescendantIndex + : Math.max(0, highlightedBranchIndexRef.current); + const currentIndex = Math.min(lastIndex, Math.max(0, resolvedCurrentIndex)); + const targetIndex = getBranchNavigationTargetIndex({ + currentIndex, + key: navigationKey, + lastIndex, + viewportHeight: + branchListScrollElementRef.current?.clientHeight ?? BRANCH_PICKER_DEFAULT_VIEWPORT_HEIGHT, + estimateItemSize: estimateBranchItemSize, + }); + + event.preventDefault(); + const directionKey = navigationKey === "PageUp" ? "ArrowUp" : "ArrowDown"; + const movementCount = Math.abs(targetIndex - currentIndex); + for (let movement = 0; movement < movementCount; movement += 1) { + event.currentTarget.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: directionKey, + }), + ); + } + highlightedBranchIndexRef.current = targetIndex; + branchListVirtualizer.scrollToIndex(targetIndex, { + align: navigationKey === "End" ? "end" : "start", + }); + }, + [ + branchListVirtualizer, + estimateBranchItemSize, + filteredBranchPickerItems.length, + isBranchMenuOpen, + shouldVirtualizeBranchList, + ], + ); + const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, @@ -728,6 +845,8 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + data-branch-picker-index={index} + className={`${BRANCH_PICKER_ROW_CLASS_NAME_BY_HEIGHT[BRANCH_PICKER_PULL_REQUEST_ROW_HEIGHT]} text-sm`} style={style} onClick={() => { if (!prReference || !onCheckoutPullRequestRequest) { @@ -739,7 +858,7 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest(prReference); }} > -
+
Checkout Pull Request {prReference}
@@ -755,6 +874,9 @@ export function BranchToolbarBranchSelector({ branch, branchStatusQuery.data, ); + const branchRowHeight = currentBranchChangeSummary + ? BRANCH_PICKER_CURRENT_BRANCH_SUMMARY_ROW_HEIGHT + : BRANCH_PICKER_BRANCH_ROW_HEIGHT; const badge = branch.current ? "current" : hasSecondaryWorktree @@ -770,11 +892,12 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - className={ + data-branch-picker-index={index} + className={`${BRANCH_PICKER_ROW_CLASS_NAME_BY_HEIGHT[branchRowHeight]} text-sm ${ itemValue === resolvedActiveBranch ? "bg-[var(--color-background-elevated-secondary)] text-[var(--color-text-foreground)]" - : undefined - } + : "" + }`} style={style} onClick={() => selectBranch(branch)} > @@ -814,6 +937,7 @@ export function BranchToolbarBranchSelector({ virtualized={shouldVirtualizeBranchList} onItemHighlighted={(_value, eventDetails) => { if (!isBranchMenuOpen || eventDetails.index < 0) return; + highlightedBranchIndexRef.current = eventDetails.index; branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); }} onOpenChange={handleOpenChange} @@ -838,6 +962,7 @@ export function BranchToolbarBranchSelector({ size="sm" value={branchQuery} onChange={(event) => setBranchQuery(event.target.value)} + onKeyDown={handleBranchInputKeyDown} />
No branches found. diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9cd81bb8..1bcac686 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -34,6 +34,7 @@ import { isLocalImageMarkdownSrc } from "../lib/localImageUrls"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; +import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { readNativeApi } from "../nativeApi"; import type { ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { GeneratedMarkdownImage } from "./chat/GeneratedMarkdownImage"; @@ -95,6 +96,7 @@ type MarkdownRehypePlugins = NonNullable< const MARKDOWN_REMARK_PLUGINS: MarkdownRemarkPlugins = [ remarkGfm, [remarkMath, { singleDollarTextMath: true }], + remarkNormalizeListItemIndentation, ]; const LITERAL_DOLLAR_PLACEHOLDER = "CHATMARKDOWNLITERALDOLLARPLACEHOLDER"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6fef7222..eb910cb2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -230,6 +230,7 @@ import { Skeleton } from "./ui/skeleton"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { terminalRuntimeRegistry } from "./terminal/terminalRuntimeRegistry"; import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; +import { MacTrafficLightInsetLayout, useMacTrafficLightInset } from "~/macTrafficLightInset"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -891,6 +892,7 @@ export default function ChatView({ const setStoreThreadWorkspace = useStore((store) => store.setThreadWorkspace); const allThreads = useStore(useRef(createAllThreadsSelector()).current); const { settings, serverSettings } = useAppSettings(); + const macTitlebarInset = useMacTrafficLightInset("titlebar"); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -8205,15 +8207,17 @@ export default function ChatView({ )} {isElectron && ( -
+ } > No active thread -
+ )}
@@ -8882,12 +8886,18 @@ export default function ChatView({ return (
{/* Top bar */} -
+ } > setRenameDialogOpen(true)} {...(onCloseThreadPane ? { onCloseThreadPane } : {})} /> -
+ (null); const previousDiffOpenRef = useRef(false); + const totalPatchStatDescriptionId = useId(); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const routeThreadId = useParams({ @@ -442,6 +444,10 @@ export default function DiffPanel({ }); }, [renderablePatch]); const totalPatchStat = useMemo(() => summarizePatchStats(repoPatch), [repoPatch]); + const totalPatchStatAccessibleLabel = + totalPatchStat && hasNonZeroStat(totalPatchStat) + ? formatDiffStatAccessibleLabel(totalPatchStat.additions, totalPatchStat.deletions) + : undefined; useEffect(() => { if (diffOpen && !previousDiffOpenRef.current) { @@ -930,9 +936,17 @@ export default function DiffPanel({ }} aria-pressed={surfaceMode === "total"} aria-label="Choose repo diff source" + aria-describedby={ + totalPatchStatAccessibleLabel ? totalPatchStatDescriptionId : undefined + } /> } > + {totalPatchStatAccessibleLabel ? ( + + {totalPatchStatAccessibleLabel} + + ) : null} {REPO_DIFF_SCOPE_LABELS[repoDiffScope]} {totalPatchStat && hasNonZeroStat(totalPatchStat) ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6a2507ce..07dfedba 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -90,6 +90,7 @@ import { isElectron } from "../env"; import { APP_BASE_NAME, APP_VERSION, APP_WORDMARK_SUFFIX } from "../branding"; import { showConfirmDialogFallback } from "../confirmDialogFallback"; import { isMacPlatform, newCommandId, newProjectId, newThreadId, randomUUID } from "../lib/utils"; +import { MacTrafficLightInsetLayout, useMacTrafficLightInset } from "../macTrafficLightInset"; import { persistAppStateNow, useStore } from "../store"; import { selectDisplayedPinnedThreadOrder, @@ -1284,6 +1285,7 @@ export default function Sidebar() { const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const isOnWorkspace = pathname.startsWith("/workspace"); const { settings: appSettings, updateSettings } = useAppSettings(); + const macTitlebarInset = useMacTrafficLightInset("titlebar"); const { handleNewThread } = useHandleNewThread(); const { handleNewChat } = useHandleNewChat(); const { createThreadHandoff } = useThreadHandoff(); @@ -4778,7 +4780,7 @@ export default function Sidebar() { className={cn( "absolute -right-0.5 top-0.5 size-1.5 rounded-full", projectStatus.dotClass, - projectStatus.pulse ? "animate-pulse" : "", + projectStatus.pulse ? "status-pulse" : "", )} /> ) : null} @@ -5553,14 +5555,16 @@ export default function Sidebar() { <> {isElectron ? ( <> - + } > {titlebarControls} - + ) : ( diff --git a/apps/web/src/components/StatusMotion.browser.tsx b/apps/web/src/components/StatusMotion.browser.tsx new file mode 100644 index 00000000..77faf142 --- /dev/null +++ b/apps/web/src/components/StatusMotion.browser.tsx @@ -0,0 +1,141 @@ +import "../index.css"; + +import { cdp } from "vitest/browser"; +import { afterEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +import TerminalActivityIndicator from "./terminal/TerminalActivityIndicator"; +import { MessagesTimeline } from "./chat/MessagesTimeline"; + +type MotionPreference = "no-preference" | "reduce"; + +async function emulateReducedMotion(preference: MotionPreference): Promise { + const session = cdp(); + const send = Reflect.get(session, "send"); + if (typeof send !== "function") { + throw new Error("Vitest browser CDP session does not expose send()."); + } + await Reflect.apply(send, session, [ + "Emulation.setEmulatedMedia", + { + features: [{ name: "prefers-reduced-motion", value: preference }], + }, + ]); +} + +function StatusMotionFixture({ active }: { readonly active: boolean }) { + return ( +
+
+
+ {active ? : Terminal idle} +
+
+ {active ? ( +
+
+
+ {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="dark" + timestampFormat="locale" + workspaceRoot={undefined} + /> +
+
+ ); +} + +function statusPulses(container: Element): HTMLElement[] { + return Array.from(container.querySelectorAll(".status-pulse")); +} + +describe("persistent status motion", () => { + afterEach(async () => { + await emulateReducedMotion("no-preference"); + document.body.innerHTML = ""; + }); + + it("animates all three persistent families and preserves timeline delays", async () => { + await emulateReducedMotion("no-preference"); + const screen = await render(); + try { + const pulses = statusPulses(screen.container); + expect(pulses).toHaveLength(8); + for (const pulse of pulses) { + const style = getComputedStyle(pulse); + expect(style.animationName).toBe("status-pulse"); + expect(style.animationDuration).toBe("2s"); + expect(Number.parseFloat(style.opacity)).toBeGreaterThanOrEqual(0.5); + expect(Number.parseFloat(style.opacity)).toBeLessThanOrEqual(1); + expect(style.transform).toBe("none"); + } + + const timelineDelays = Array.from( + screen.container.querySelectorAll( + "[data-testid='timeline-status'] .status-pulse", + ), + (pulse) => getComputedStyle(pulse).animationDelay, + ); + expect(timelineDelays).toEqual(["0s", "0.2s", "0.4s"]); + } finally { + await screen.unmount(); + } + }); + + it("keeps reduced-motion statuses visible and stable without animation", async () => { + await emulateReducedMotion("reduce"); + const screen = await render(); + try { + const pulses = statusPulses(screen.container); + expect(pulses).toHaveLength(8); + for (const pulse of pulses) { + const style = getComputedStyle(pulse); + expect(style.animationName).toBe("none"); + expect(style.opacity).toBe("1"); + expect(style.transform).toBe("none"); + expect(style.visibility).toBe("visible"); + } + } finally { + await screen.unmount(); + } + }); + + it("removes persistent pulses when terminal, project, and timeline become idle", async () => { + await emulateReducedMotion("no-preference"); + const screen = await render(); + try { + expect(statusPulses(screen.container)).toHaveLength(8); + await screen.rerender(); + expect(statusPulses(screen.container)).toHaveLength(0); + expect(screen.container.textContent).toContain("Terminal idle"); + expect(screen.container.textContent).toContain("Project idle"); + expect(screen.container.textContent).toContain("Send a message to start the conversation."); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/chat/ChangedFilesTree.browser.tsx b/apps/web/src/components/chat/ChangedFilesTree.browser.tsx index 76ba3908..efc301e3 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.browser.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.browser.tsx @@ -12,6 +12,14 @@ const FILES: TurnDiffFileChange[] = [ { path: "apps/web/src/app.ts", kind: "modified", additions: 1, deletions: 0 }, ]; +const GEOMETRY_FILES: TurnDiffFileChange[] = [ + { path: "README.md", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/web/src/app.ts", kind: "modified", additions: 15_000, deletions: 9 }, + { path: "apps/web/src/components/leaf.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "apps/web/package.json", kind: "modified", additions: 1, deletions: 0 }, + { path: "apps/server/main.ts", kind: "modified", additions: 1, deletions: 0 }, +]; + describe("ChangedFilesTree", () => { afterEach(() => { window.localStorage.clear(); @@ -73,4 +81,56 @@ describe("ChangedFilesTree", () => { await screen.unmount(); } }); + + it("keeps root and nested rows on the established 14px indentation rhythm", async () => { + const screen = await render( +
+ +
, + ); + try { + await vi.waitFor(() => { + expect(page.getByText("app.ts")).toBeVisible(); + }); + + const rowFor = (name: string): HTMLButtonElement => { + const row = page.getByText(name, { exact: true }).element().closest("button"); + expect(row).toBeInstanceOf(HTMLButtonElement); + if (!(row instanceof HTMLButtonElement)) { + throw new TypeError(`Expected ${name} to render inside a button row.`); + } + return row; + }; + const labelLeft = (name: string): number => + page.getByText(name, { exact: true }).element().getBoundingClientRect().left; + + expect(getComputedStyle(rowFor("README.md")).paddingLeft).toBe("8px"); + expect(getComputedStyle(rowFor("apps")).paddingLeft).toBe("8px"); + expect(getComputedStyle(rowFor("web")).paddingLeft).toBe("22px"); + expect(getComputedStyle(rowFor("src")).paddingLeft).toBe("36px"); + expect(getComputedStyle(rowFor("app.ts")).paddingLeft).toBe("50px"); + expect(labelLeft("web") - labelLeft("apps")).toBeCloseTo(14, 3); + expect(labelLeft("src") - labelLeft("web")).toBeCloseTo(14, 3); + expect(labelLeft("app.ts") - labelLeft("src")).toBeCloseTo(14, 3); + + const appStat = screen.container.querySelector( + '[aria-label="15,000 additions, 9 deletions"]', + ); + expect(appStat).toBeInstanceOf(HTMLElement); + expect(appStat?.textContent).toBe("+15k-9"); + const appStatGrid = appStat?.querySelector("[data-diff-stat-grid]"); + expect(appStatGrid).toBeInstanceOf(HTMLElement); + expect( + getComputedStyle(appStatGrid ?? document.body).gridTemplateColumns.split(" "), + ).toHaveLength(2); + } finally { + await screen.unmount(); + } + }); }); diff --git a/apps/web/src/components/chat/DiffStatLabel.browser.tsx b/apps/web/src/components/chat/DiffStatLabel.browser.tsx new file mode 100644 index 00000000..893844c1 --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.browser.tsx @@ -0,0 +1,201 @@ +import "../../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { DiffStatLabel } from "./DiffStatLabel"; +import { formatDiffStatAccessibleLabel } from "./DiffStatLabel.logic"; + +describe("DiffStatLabel", () => { + afterEach(async () => { + await page.viewport(1280, 720); + document.body.innerHTML = ""; + }); + + it("exposes localized exact counts once while preserving compact visual order", async () => { + const screen = await render(); + try { + const wrapper = screen.container.querySelector( + '[aria-label="1,500 additions, 9 deletions"]', + ); + expect(wrapper).toBeInstanceOf(HTMLElement); + if (!wrapper) return; + expect(wrapper.getAttribute("role")).toBe("img"); + expect(wrapper.textContent).toBe("(+1.5k-9)"); + expect(wrapper.querySelector("[data-diff-stat-additions]")?.getAttribute("aria-hidden")).toBe( + "true", + ); + expect(wrapper.querySelector("[data-diff-stat-deletions]")?.getAttribute("aria-hidden")).toBe( + "true", + ); + } finally { + await screen.unmount(); + } + }); + + it("uses equal fixed columns that align asymmetric magnitudes", async () => { + const screen = await render( +
+ + +
, + ); + try { + const grids = Array.from( + screen.container.querySelectorAll("[data-diff-stat-grid]"), + ); + expect(grids).toHaveLength(2); + + for (const grid of grids) { + const columns = getComputedStyle(grid).gridTemplateColumns.split(" "); + expect(getComputedStyle(grid).display).toBe("inline-grid"); + expect(columns).toHaveLength(2); + expect(columns[0]).toBe(columns[1]); + expect(Number.parseFloat(columns[0] ?? "0")).toBeGreaterThan(0); + } + + const firstAdditions = grids[0]?.querySelector("[data-diff-stat-additions]"); + const secondAdditions = grids[1]?.querySelector("[data-diff-stat-additions]"); + const firstDeletions = grids[0]?.querySelector("[data-diff-stat-deletions]"); + const secondDeletions = grids[1]?.querySelector("[data-diff-stat-deletions]"); + expect(firstAdditions?.getBoundingClientRect().width).toBeCloseTo( + secondAdditions?.getBoundingClientRect().width ?? 0, + 3, + ); + expect(firstDeletions?.getBoundingClientRect().width).toBeCloseTo( + secondDeletions?.getBoundingClientRect().width ?? 0, + 3, + ); + } finally { + await screen.unmount(); + } + }); + + it("shows the same exact localized counts in the tooltip", async () => { + const screen = await render(); + try { + const labelElement = screen.container.querySelector( + '[aria-label="15,000 additions, 9 deletions"]', + ); + expect(labelElement).toBeInstanceOf(HTMLElement); + if (!labelElement) return; + const label = page.getByLabelText("15,000 additions, 9 deletions"); + await label.hover(); + + await vi.waitFor(() => { + expect(page.getByText("15,000 additions, 9 deletions", { exact: true })).toBeVisible(); + }); + } finally { + await screen.unmount(); + } + }); + + it("normalizes invalid counts and stays contained at 375px with increased type", async () => { + await page.viewport(375, 720); + const screen = await render( +
+ +
, + ); + try { + const host = screen.getByTestId("narrow-stat-host").element(); + const label = screen.container.querySelector( + '[aria-label="0 additions, 1,000,000,000,000 deletions"]', + ); + expect(label).toBeInstanceOf(HTMLElement); + if (!label) return; + expect(label.textContent).toBe("+0-1000b"); + expect(label.getBoundingClientRect().right).toBeLessThanOrEqual( + host.getBoundingClientRect().right, + ); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); + } finally { + await screen.unmount(); + } + }); + + it("keeps exact counts available when a focusable ancestor has its own label", async () => { + const exactLabel = formatDiffStatAccessibleLabel(15_000, 9, "en-US"); + const screen = await render( +
+ + +
, + ); + try { + for (const accessibleName of ["Choose repo diff source", "Open diff for Edited file"]) { + const button = page.getByRole("button", { name: accessibleName }); + await expect.element(button).toHaveAttribute("aria-description", exactLabel); + button.element().focus(); + expect(document.activeElement).toBe(button.element()); + } + const statWrappers = screen.container.querySelectorAll("[data-diff-stat-label]"); + expect(statWrappers).toHaveLength(2); + for (const wrapper of statWrappers) { + expect(wrapper.tabIndex).toBe(-1); + } + } finally { + await screen.unmount(); + } + }); + + it.each([14, 24])( + "keeps long signed glyphs painted inside each 4ch cell at %spx", + async (fontSize) => { + const screen = await render( +
+ +
, + ); + try { + const host = screen.container.querySelector("[data-diff-stat-font-host]"); + expect(host).toBeInstanceOf(HTMLElement); + if (!host) return; + const grid = host.querySelector("[data-diff-stat-grid]"); + expect(grid).toBeInstanceOf(HTMLElement); + const fourCharacterProbe = document.createElement("span"); + fourCharacterProbe.className = "font-chat-code"; + fourCharacterProbe.style.cssText = "position:absolute;display:block;width:4ch"; + host.append(fourCharacterProbe); + const expectedCellWidth = fourCharacterProbe.getBoundingClientRect().width; + + let additionsGlyphBounds: DOMRect | undefined; + let deletionsGlyphBounds: DOMRect | undefined; + for (const kind of ["additions", "deletions"] as const) { + const cell = screen.container.querySelector(`[data-diff-stat-${kind}]`); + const glyph = screen.container.querySelector( + `[data-diff-stat-${kind}-glyph]`, + ); + expect(cell).toBeInstanceOf(HTMLElement); + if (!cell) continue; + const cellBounds = cell.getBoundingClientRect(); + const glyphBounds = glyph ? glyph.getBoundingClientRect() : textBounds(cell); + expect(cellBounds.width).toBeCloseTo(expectedCellWidth, 3); + expect(glyphBounds.left).toBeGreaterThanOrEqual(cellBounds.left); + expect(glyphBounds.right).toBeLessThanOrEqual(cellBounds.right); + if (kind === "additions") { + additionsGlyphBounds = glyphBounds; + } else { + deletionsGlyphBounds = glyphBounds; + } + } + if (additionsGlyphBounds && deletionsGlyphBounds) { + expect(additionsGlyphBounds.right).toBeLessThanOrEqual(deletionsGlyphBounds.left); + } + } finally { + await screen.unmount(); + } + }, + ); +}); + +function textBounds(element: HTMLElement): DOMRect { + const range = document.createRange(); + range.selectNodeContents(element); + return range.getBoundingClientRect(); +} diff --git a/apps/web/src/components/chat/DiffStatLabel.consumers.test.ts b/apps/web/src/components/chat/DiffStatLabel.consumers.test.ts new file mode 100644 index 00000000..dab30ce1 --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.consumers.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const DIFF_PANEL_SOURCE = readFileSync(new URL("../DiffPanel.tsx", import.meta.url), "utf8"); +const MESSAGES_TIMELINE_SOURCE = readFileSync( + new URL("./MessagesTimeline.tsx", import.meta.url), + "utf8", +); +const CHANGED_FILES_TREE_SOURCE = readFileSync( + new URL("./ChangedFilesTree.tsx", import.meta.url), + "utf8", +); + +describe("DiffStatLabel consumers", () => { + it("keeps all six diff-stat call sites on the shared component", () => { + const callSiteCount = [ + DIFF_PANEL_SOURCE, + MESSAGES_TIMELINE_SOURCE, + CHANGED_FILES_TREE_SOURCE, + ].reduce((total, source) => total + (source.match(/ { + expect(DIFF_PANEL_SOURCE).toMatch( + /aria-describedby=\{\s*totalPatchStatAccessibleLabel\s*\?\s*totalPatchStatDescriptionId/u, + ); + expect(MESSAGES_TIMELINE_SOURCE).toContain("aria-describedby={changedFileStatDescriptionId}"); + expect(DIFF_PANEL_SOURCE).toContain("formatDiffStatAccessibleLabel("); + expect(MESSAGES_TIMELINE_SOURCE).toContain("formatDiffStatAccessibleLabel("); + }); + + it("associates hidden exact descriptions at both focusable consumer call sites", () => { + expect(DIFF_PANEL_SOURCE).toMatch( + /aria-describedby=\{\s*totalPatchStatAccessibleLabel\s*\?\s*totalPatchStatDescriptionId/u, + ); + expect(DIFF_PANEL_SOURCE).toMatch( + /id=\{totalPatchStatDescriptionId\}[^>]*className="sr-only"/u, + ); + expect(MESSAGES_TIMELINE_SOURCE).toMatch(/aria-describedby=\{changedFileStatDescriptionId\}/u); + expect(MESSAGES_TIMELINE_SOURCE).toMatch( + /id=\{changedFileStatDescriptionId\}[^>]*className="sr-only"/u, + ); + }); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.logic.test.ts b/apps/web/src/components/chat/DiffStatLabel.logic.test.ts new file mode 100644 index 00000000..a3178a1a --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.logic.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + formatCompactDiffCount, + formatDiffStatAccessibleLabel, + formatExactDiffCount, + normalizeDiffCount, +} from "./DiffStatLabel.logic"; + +describe("normalizeDiffCount", () => { + it.each([ + [0, 0], + [-42, 0], + [Number.NaN, 0], + [Number.POSITIVE_INFINITY, 0], + [Number.NEGATIVE_INFINITY, 0], + [42.9, 42], + ])("normalizes %s to %s", (value, expected) => { + expect(normalizeDiffCount(value)).toBe(expected); + }); +}); + +describe("formatCompactDiffCount", () => { + it.each([ + [0, "0"], + [-42, "0"], + [Number.NaN, "0"], + [Number.POSITIVE_INFINITY, "0"], + [Number.NEGATIVE_INFINITY, "0"], + [42.9, "42"], + [1, "1"], + [999, "999"], + [1_000, "1k"], + [1_500, "1.5k"], + [9_900, "9.9k"], + [9_960, "10k"], + [10_000, "10k"], + [999_499, "999k"], + [999_500, "1m"], + [1_000_000, "1m"], + [9_900_000, "9.9m"], + [9_960_000, "10m"], + [10_000_000, "10m"], + [999_499_999, "999m"], + [999_500_000, "1b"], + [1_000_000_000, "1b"], + [9_900_000_000, "9.9b"], + [10_000_000_000, "10b"], + [1_500_000_000, "1.5b"], + [1_000_000_000_000, "1000b"], + ])("formats %s as %s", (value, expected) => { + expect(formatCompactDiffCount(value)).toBe(expected); + }); +}); + +describe("formatExactDiffCount", () => { + it("localizes the normalized unabridged integer", () => { + expect(formatExactDiffCount(1_000_000_000_000.9, "en-US")).toBe("1,000,000,000,000"); + expect(formatExactDiffCount(Number.POSITIVE_INFINITY, "en-US")).toBe("0"); + }); +}); + +describe("formatDiffStatAccessibleLabel", () => { + it("keeps exact localized additions before deletions", () => { + expect(formatDiffStatAccessibleLabel(15_000, 9, "en-US")).toBe("15,000 additions, 9 deletions"); + }); + + it.each([ + [1, 1, "1 addition, 1 deletion"], + [1, 2, "1 addition, 2 deletions"], + [2, 1, "2 additions, 1 deletion"], + [2, 2, "2 additions, 2 deletions"], + ])("pluralizes %s additions and %s deletions independently", (additions, deletions, expected) => { + expect(formatDiffStatAccessibleLabel(additions, deletions, "en-US")).toBe(expected); + }); + + it("uses localized exact numbers while retaining independently selected labels", () => { + expect(formatDiffStatAccessibleLabel(1, 1_500, "de-DE")).toBe("1 addition, 1.500 deletions"); + }); + + it("keeps English nouns plural for French-formatted zero counts", () => { + expect(formatDiffStatAccessibleLabel(0, 0, "fr-FR")).toBe("0 additions, 0 deletions"); + }); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.logic.ts b/apps/web/src/components/chat/DiffStatLabel.logic.ts index afdc1a85..7789a2eb 100644 --- a/apps/web/src/components/chat/DiffStatLabel.logic.ts +++ b/apps/web/src/components/chat/DiffStatLabel.logic.ts @@ -1,3 +1,50 @@ +const COMPACT_DIFF_UNITS = [ + { divisor: 1_000, suffix: "k" }, + { divisor: 1_000_000, suffix: "m" }, + { divisor: 1_000_000_000, suffix: "b" }, +] as const; + export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { return stat.additions > 0 || stat.deletions > 0; } + +export function normalizeDiffCount(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0; + return Math.trunc(value); +} + +export function formatCompactDiffCount(value: number): string { + const count = normalizeDiffCount(value); + if (count < 1_000) return String(count); + + const unitIndex = count >= 1_000_000_000 ? 2 : count >= 1_000_000 ? 1 : 0; + return formatCountWithUnit(count, unitIndex); +} + +export function formatExactDiffCount(value: number, locales?: Intl.LocalesArgument): string { + return normalizeDiffCount(value).toLocaleString(locales); +} + +export function formatDiffStatAccessibleLabel( + additions: number, + deletions: number, + locales?: Intl.LocalesArgument, +): string { + const normalizedAdditions = normalizeDiffCount(additions); + const normalizedDeletions = normalizeDiffCount(deletions); + const additionLabel = normalizedAdditions === 1 ? "addition" : "additions"; + const deletionLabel = normalizedDeletions === 1 ? "deletion" : "deletions"; + return `${formatExactDiffCount(normalizedAdditions, locales)} ${additionLabel}, ${formatExactDiffCount(normalizedDeletions, locales)} ${deletionLabel}`; +} + +function formatCountWithUnit(count: number, unitIndex: number): string { + const unit = COMPACT_DIFF_UNITS[unitIndex]; + if (!unit) return String(count); + + const scaled = count / unit.divisor; + const rounded = scaled < 10 ? Math.round(scaled * 10) / 10 : Math.round(scaled); + if (rounded >= 1_000 && unitIndex < COMPACT_DIFF_UNITS.length - 1) { + return formatCountWithUnit(count, unitIndex + 1); + } + return `${rounded}${unit.suffix}`; +} diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index e5485844..9cb6c71e 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -1,17 +1,79 @@ import { memo } from "react"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { formatCompactDiffCount, formatDiffStatAccessibleLabel } from "./DiffStatLabel.logic"; + export const DiffStatLabel = memo(function DiffStatLabel(props: { additions: number; deletions: number; showParentheses?: boolean; }) { const { additions, deletions, showParentheses = false } = props; + const exactLabel = formatDiffStatAccessibleLabel(additions, deletions); + const compactAdditions = `+${formatCompactDiffCount(additions)}`; + const compactDeletions = `-${formatCompactDiffCount(deletions)}`; + return ( - <> - {showParentheses && (} - +{additions} - -{deletions} - {showParentheses && )} - + + + {showParentheses ? ( + + ) : null} + + {showParentheses ? ( + + ) : null} + + } + /> + {exactLabel} + ); }); + +function compactGlyphStyle(value: string): { readonly transform: string } { + const scale = Math.min(1, 4 / value.length); + return { transform: `scaleX(${scale})` }; +} diff --git a/apps/web/src/components/chat/DiffStatLabel.visual.browser.tsx b/apps/web/src/components/chat/DiffStatLabel.visual.browser.tsx new file mode 100644 index 00000000..600cadf0 --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.visual.browser.tsx @@ -0,0 +1,164 @@ +import "../../index.css"; + +import { TurnId } from "@jcode/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +import type { WorkLogEntry } from "../../session-logic"; +import type { TurnDiffFileChange } from "../../types"; +import { buildThemeCssVariables, getCodeThemeSeed } from "../../theme/theme.logic"; +import { ChangedFilesTree } from "./ChangedFilesTree"; +import { DiffStatLabel } from "./DiffStatLabel"; +import { SimpleWorkEntryRow } from "./MessagesTimeline"; + +const TREE_FILES: readonly TurnDiffFileChange[] = [ + { path: "README.md", kind: "modified", additions: 999, deletions: 1 }, + { + path: "apps/web/src/DiffStatLabel.tsx", + kind: "modified", + additions: 9_900_000, + deletions: 10_000, + }, + { path: "apps/web/package.json", kind: "modified", additions: 1_500, deletions: 0 }, + { path: "apps/server/main.ts", kind: "modified", additions: 1, deletions: 1 }, +]; + +const TIMELINE_FILE = "apps/web/src/components/chat/MessagesTimeline.tsx"; +const TIMELINE_ENTRY = { + id: "visual-diff-stat-work", + createdAt: "2026-07-16T12:00:00.000Z", + label: "File Change", + tone: "tool", + requestKind: "file-change", + changedFiles: [TIMELINE_FILE], +} satisfies WorkLogEntry; + +const VIEWPORTS = [375, 768, 1280] as const; +const THEMES = ["light", "dark"] as const; +const appliedThemeVariableNames = new Set(); + +function applyFixtureTheme(theme: (typeof THEMES)[number]): void { + const root = document.documentElement; + const { variables } = buildThemeCssVariables( + { codeThemeId: "github", theme: getCodeThemeSeed("github", theme) }, + theme, + ); + root.classList.toggle("dark", theme === "dark"); + root.setAttribute("data-theme-variant", theme); + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + appliedThemeVariableNames.add(name); + } +} + +function resetFixtureTheme(): void { + const root = document.documentElement; + root.classList.remove("dark"); + root.removeAttribute("data-theme-variant"); + for (const name of appliedThemeVariableNames) { + root.style.removeProperty(name); + } + appliedThemeVariableNames.clear(); +} + +describe("DiffStatLabel visual matrix", () => { + afterEach(async () => { + resetFixtureTheme(); + window.localStorage.clear(); + document.body.innerHTML = ""; + await page.viewport(1280, 720); + }); + + it.each(THEMES.flatMap((theme) => VIEWPORTS.map((width) => ({ theme, width }))))( + "verifies shared diff statistics in $theme at $width px", + async ({ theme, width }) => { + await page.viewport(width, 720); + applyFixtureTheme(theme); + + const screen = await render( +
+
+
+ + Branch changes + + + + +
+ +
+

+ Timeline checkpoint +

+ {}} + workspaceRoot="/repo/jcode" + /> +
+ +
+
+ Changed files +
+ {}} + /> +
+
+
, + ); + try { + const grids = screen.container.querySelectorAll("[data-diff-stat-grid]"); + expect(grids.length).toBeGreaterThanOrEqual(7); + for (const grid of grids) { + const columns = getComputedStyle(grid).gridTemplateColumns.split(" "); + expect(columns).toHaveLength(2); + expect(columns[0]).toBe(columns[1]); + } + const timelineRow = screen.container.querySelector( + '[data-file-change-row="true"]', + ); + const timelineDescriptionId = timelineRow?.getAttribute("aria-describedby"); + expect(timelineDescriptionId).toBeTypeOf("string"); + expect(timelineDescriptionId).not.toBe(""); + if (!timelineDescriptionId) return; + expect(timelineDescriptionId).toBe(timelineDescriptionId.trim()); + expect(timelineDescriptionId).not.toMatch(/\s/u); + + const timelineDescription = document.getElementById(timelineDescriptionId); + expect(timelineDescription).toBeInstanceOf(HTMLElement); + expect(screen.container.contains(timelineDescription)).toBe(true); + expect(timelineDescription).toHaveClass("sr-only"); + expect(timelineDescription?.textContent).toBe("15,000 additions, 9 deletions"); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); + } finally { + await screen.unmount(); + } + }, + ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index e5a01ae1..c1ae53ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -1,11 +1,12 @@ import "../../index.css"; -import { page } from "vitest/browser"; +import { MessageId, TurnId } from "@jcode/contracts"; +import { cdp, page, userEvent } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import type { WorkLogEntry } from "../../session-logic"; -import { SimpleWorkEntryRow } from "./MessagesTimeline"; +import { MessagesTimeline, SimpleWorkEntryRow } from "./MessagesTimeline"; type DetailedWorkLogEntry = WorkLogEntry & { readonly output?: string; @@ -26,6 +27,107 @@ async function renderTimeline(entry: DetailedWorkLogEntry) { ); } +async function renderMessageActions() { + const onRevertUserMessage = vi.fn(); + const onEditUserMessage = vi.fn(() => true); + const screen = await render( +
+ {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={ + new Map([[MessageId.makeUnsafe("message-browser-user"), 1]]) + } + onRevertUserMessage={onRevertUserMessage} + onEditUserMessage={onEditUserMessage} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + /> +
, + ); + + return { onRevertUserMessage, screen }; +} + +async function waitForMessageActionOpacity( + actionGroup: HTMLElement, + targetOpacity: number, +): Promise { + await vi.waitFor(() => { + expect(Number.parseFloat(getComputedStyle(actionGroup).opacity)).toBe(targetOpacity); + }); +} + +function actionGroupFor(button: Element): HTMLElement { + const group = button.parentElement; + if (!(group instanceof HTMLElement)) { + throw new Error("Message action button is missing its action group."); + } + return group; +} + +async function emulateCoarsePointerNoHover(enabled: boolean): Promise { + const session = cdp(); + const send = Reflect.get(session, "send"); + if (typeof send !== "function") { + throw new Error("Vitest browser CDP session does not expose send()."); + } + + await Reflect.apply(send, session, ["Emulation.setTouchEmulationEnabled", { enabled }]); + await Reflect.apply(send, session, [ + "Emulation.setEmulatedMedia", + { + features: enabled + ? [ + { name: "hover", value: "none" }, + { name: "pointer", value: "coarse" }, + ] + : [], + }, + ]); +} + describe("MessagesTimeline activity details", () => { afterEach(() => { document.body.innerHTML = ""; @@ -115,3 +217,126 @@ describe("MessagesTimeline activity details", () => { } }); }); + +describe("MessagesTimeline message actions", () => { + afterEach(async () => { + await emulateCoarsePointerNoHover(false); + await page.viewport(1280, 720); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("keeps user and assistant actions visible at rest and fully reveals them on hover or focus", async () => { + const { screen } = await renderMessageActions(); + try { + const userRow = page.getByRole("article", { name: "User message" }); + const assistantRow = page.getByRole("article", { name: "Assistant message" }); + const userCopy = userRow.getByRole("button", { name: "Copy message" }); + const assistantCopy = assistantRow.getByRole("button", { name: "Copy message" }); + const userActions = actionGroupFor(userCopy.element()); + const assistantActions = actionGroupFor(assistantCopy.element()); + + expect(Number.parseFloat(getComputedStyle(userActions).opacity)).toBe(0.6); + expect(Number.parseFloat(getComputedStyle(assistantActions).opacity)).toBe(0.6); + expect(getComputedStyle(userActions).transitionDuration).toBe("0.2s"); + expect(getComputedStyle(assistantActions).transitionDuration).toBe("0.2s"); + + await userRow.hover(); + await waitForMessageActionOpacity(userActions, 1); + expect(Number.parseFloat(getComputedStyle(userActions).opacity)).toBe(1); + + await assistantRow.hover(); + await waitForMessageActionOpacity(assistantActions, 1); + expect(Number.parseFloat(getComputedStyle(assistantActions).opacity)).toBe(1); + + await assistantRow.unhover(); + userCopy.element().focus(); + await waitForMessageActionOpacity(userActions, 1); + expect(document.activeElement).toBe(userCopy.element()); + expect(Number.parseFloat(getComputedStyle(userActions).opacity)).toBe(1); + } finally { + await screen.unmount(); + } + }); + + it("retains accessible user and assistant actions and invokes each enabled action", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + const { onRevertUserMessage, screen } = await renderMessageActions(); + try { + const userRow = page.getByRole("article", { name: "User message" }); + const assistantRow = page.getByRole("article", { name: "Assistant message" }); + const userCopy = userRow.getByRole("button", { name: "Copy message" }); + const edit = userRow.getByRole("button", { name: "Edit message" }); + const revert = userRow.getByRole("button", { name: "Revert to this message" }); + const assistantCopy = assistantRow.getByRole("button", { name: "Copy message" }); + + await expect(userCopy).toBeEnabled(); + await expect(edit).toBeEnabled(); + await expect(revert).toBeEnabled(); + await expect(assistantCopy).toBeEnabled(); + + await userCopy.click(); + await vi.waitFor(() => { + expect(writeText).toHaveBeenCalledWith("Browser action user message"); + }); + + await assistantCopy.click(); + await vi.waitFor(() => { + expect(writeText).toHaveBeenCalledWith("Browser action assistant message"); + }); + + await revert.click(); + expect(onRevertUserMessage).toHaveBeenCalledWith( + MessageId.makeUnsafe("message-browser-user"), + ); + + await edit.click(); + await expect(page.getByRole("textbox", { name: "Edit message" })).toHaveValue( + "Browser action user message", + ); + } finally { + await screen.unmount(); + } + }); + + it("keeps actions visible, contained, clickable, and keyboard reachable on coarse narrow input", async () => { + await page.viewport(375, 720); + await emulateCoarsePointerNoHover(true); + const { onRevertUserMessage, screen } = await renderMessageActions(); + try { + expect(window.matchMedia("(hover: none)").matches).toBe(true); + expect(window.matchMedia("(pointer: coarse)").matches).toBe(true); + + const userRow = page.getByRole("article", { name: "User message" }); + const assistantRow = page.getByRole("article", { name: "Assistant message" }); + const userCopy = userRow.getByRole("button", { name: "Copy message" }); + const edit = userRow.getByRole("button", { name: "Edit message" }); + const revert = userRow.getByRole("button", { name: "Revert to this message" }); + const assistantCopy = assistantRow.getByRole("button", { name: "Copy message" }); + const actionGroups = [ + actionGroupFor(userCopy.element()), + actionGroupFor(assistantCopy.element()), + ]; + + for (const group of actionGroups) { + const bounds = group.getBoundingClientRect(); + expect(Number.parseFloat(getComputedStyle(group).opacity)).toBeGreaterThan(0); + expect(bounds.left).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(window.innerWidth); + } + + userCopy.element().focus(); + await userEvent.tab(); + expect(document.activeElement).toBe(edit.element()); + await userEvent.tab(); + expect(document.activeElement).toBe(revert.element()); + await userEvent.tab(); + expect(document.activeElement).toBe(assistantCopy.element()); + + await revert.click(); + expect(onRevertUserMessage).toHaveBeenCalledOnce(); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 604a1341..eb75c47f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1707,6 +1707,9 @@ describe("MessagesTimeline", () => { expect(markup).toContain("MessagesTimeline.test.tsx"); expect(markup).toContain("+1"); expect(markup).toContain("-1"); + expect(markup).toMatch( + /aria-describedby="[^"]+".*?]+id="[^"]+"[^>]+class="sr-only">1 addition, 1 deletion<\/span>/u, + ); expect(markup).not.toContain( "File Change - apps/web/src/components/chat/MessagesTimeline.test.tsx", ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0cd0f201..ef59e7eb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -10,6 +10,7 @@ import { memo, useCallback, useEffect, + useId, useMemo, useRef, useState, @@ -42,6 +43,7 @@ import { Button } from "../ui/button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { DiffStatLabel } from "./DiffStatLabel"; +import { formatDiffStatAccessibleLabel } from "./DiffStatLabel.logic"; import { FileEntryIcon } from "./FileEntryIcon"; import { MentionChipIcon } from "./MentionChipIcon"; import { MessageActionButton } from "./MessageActionButton"; @@ -709,7 +711,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ) : null} {!isEditingThisMessage && (
-
+
{displayedUserMessage.copyText && ( )} @@ -1085,7 +1087,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {assistantMeta}

{assistantCopyState.visible ? ( -
+
- - - + + +
)} @@ -2021,6 +2023,7 @@ export const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: Simple const subagentSummary = subagentCardSummary(workEntry); const subagentMeta = subagentCardMeta(workEntry); const [expanded, setExpanded] = useState(false); + const changedFileStatDescriptionIdBase = useId(); const canExpand = hasExpandableActivityDetails(workEntry); const toggleDetailsExpanded = () => setExpanded((current) => !current); @@ -2031,8 +2034,14 @@ export const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: Simple
{showEditedRows ? (
- {changedFiles.map((changedFilePath) => { + {changedFiles.map((changedFilePath, changedFileIndex) => { const changedFileStat = fileDiffStatByPath?.get(changedFilePath); + const changedFileStatAccessibleLabel = changedFileStat + ? formatDiffStatAccessibleLabel(changedFileStat.additions, changedFileStat.deletions) + : undefined; + const changedFileStatDescriptionId = changedFileStat + ? `${changedFileStatDescriptionIdBase}-${changedFileIndex}` + : undefined; const canOpenEditedDiff = Boolean(turnId && onOpenTurnDiff); const changedFileLabel = `${toolWorkEntryHeading(workEntry)} ${basename(changedFilePath)}`; const changedFileAriaLabel = canOpenEditedDiff @@ -2055,6 +2064,7 @@ export const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: Simple title={changedFilePath} disabled={!canOpenEditedDiff && !canExpand} aria-label={changedFileAriaLabel} + aria-describedby={changedFileStatDescriptionId} aria-expanded={!canOpenEditedDiff && canExpand ? expanded : undefined} onClick={() => { if (turnId && onOpenTurnDiff) { @@ -2064,6 +2074,11 @@ export const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: Simple } }} > + {changedFileStatAccessibleLabel ? ( + + {changedFileStatAccessibleLabel} + + ) : null} (); + +function applyFixtureTheme(theme: (typeof VISUAL_THEMES)[number]): void { + const root = document.documentElement; + const { variables } = buildThemeCssVariables( + { codeThemeId: "github", theme: getCodeThemeSeed("github", theme) }, + theme, + ); + root.classList.toggle("dark", theme === "dark"); + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + appliedThemeVariables.add(name); + } +} + +function resetFixtureTheme(): void { + document.documentElement.classList.remove("dark"); + for (const name of appliedThemeVariables) { + document.documentElement.style.removeProperty(name); + } + appliedThemeVariables.clear(); +} const MODEL_OPTIONS_BY_PROVIDER = { claudeAgent: [ { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-5", name: "Claude Sonnet 5" }, { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, ], @@ -129,6 +158,7 @@ async function mountPicker(props: { >; }) { const host = document.createElement("div"); + host.className = "min-h-dvh bg-[var(--app-surface-canvas)] p-4 text-foreground"; document.body.append(host); const onProviderModelChange = vi.fn(); const screen = await render( @@ -156,9 +186,11 @@ async function mountPicker(props: { } describe("ProviderModelPicker", () => { - afterEach(() => { + afterEach(async () => { + resetFixtureTheme(); document.body.innerHTML = ""; localStorage.clear(); + await page.viewport(1280, 720); }); it("shows provider submenus when provider switching is allowed", async () => { @@ -223,6 +255,146 @@ describe("ProviderModelPicker", () => { } }); + it("selects Sonnet 5 when the Claude Code version supports it", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + lockedProvider: "claudeAgent", + providers: [ + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + version: "2.1.197", + checkedAt: "2026-07-17T00:00:00.000Z", + }, + ], + }); + + try { + await page.getByRole("button").click(); + const sonnet5 = page.getByRole("menuitemradio", { name: "Claude Sonnet 5" }); + await expect.element(sonnet5).toBeEnabled(); + await sonnet5.click(); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith("claudeAgent", "claude-sonnet-5"); + } finally { + await mounted.cleanup(); + } + }); + + it("describes Sonnet 5 capabilities in the model choice without changing older models", async () => { + // Given a supported Claude model menu with authoritative metadata for Sonnet 5 only + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + lockedProvider: "claudeAgent", + providers: [ + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + version: "2.1.197", + checkedAt: "2026-07-17T00:00:00.000Z", + }, + ], + }); + + try { + // When the user opens the model menu + await page.getByRole("button").click(); + + // Then Sonnet 5 exposes a concise visible and accessible selection summary + await expect + .element( + page.getByRole("menuitemradio", { + name: /Claude Sonnet 5.*1M context.*128K max output.*Adaptive thinking/u, + }), + ) + .toBeEnabled(); + await expect + .element(page.getByText("1M context · 128K max output · Adaptive thinking")) + .toBeVisible(); + + // And a model without intrinsic metadata remains free of an invented detail line + await expect + .element(page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6", exact: true })) + .toBeEnabled(); + } finally { + await mounted.cleanup(); + } + }); + + it("disables Sonnet 5 with an accessible upgrade reason on a known-old provider", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + lockedProvider: "claudeAgent", + providers: [ + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + version: "2.1.196", + checkedAt: "2026-07-17T00:00:00.000Z", + }, + ], + }); + + try { + await page.getByRole("button").click(); + + await expect + .element( + page.getByRole("menuitemradio", { + name: /Claude Sonnet 5.*1M context.*128K max output.*Adaptive thinking.*Update Claude to 2\.1\.197 or newer/u, + }), + ) + .toBeDisabled(); + await expect + .element(page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" })) + .toBeEnabled(); + expect(mounted.onProviderModelChange).not.toHaveBeenCalled(); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps Sonnet 5 selectable without an incompatibility reason for malformed versions", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + lockedProvider: "claudeAgent", + providers: [ + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + version: "2.1.197-alpha..1", + checkedAt: "2026-07-17T00:00:00.000Z", + }, + ], + }); + + try { + await page.getByRole("button").click(); + const sonnet5 = page.getByRole("menuitemradio", { name: /Claude Sonnet 5/u }); + await expect.element(sonnet5).toBeEnabled(); + await expect + .element(page.getByText("1M context · 128K max output · Adaptive thinking")) + .toBeVisible(); + expect(document.body.textContent).not.toContain("Update Claude to 2.1.197 or newer"); + await sonnet5.click(); + expect(mounted.onProviderModelChange).toHaveBeenCalledWith("claudeAgent", "claude-sonnet-5"); + } finally { + await mounted.cleanup(); + } + }); + it("groups upstream OpenCode models by provider label", async () => { const mounted = await mountPicker({ provider: "opencode", @@ -546,4 +718,56 @@ describe("ProviderModelPicker", () => { await mounted.cleanup(); } }); + + it.each( + VISUAL_THEMES.flatMap((theme) => + VISUAL_WIDTHS.flatMap((width) => + (["supported", "known-old", "malformed"] as const).map((state) => ({ + theme, + width, + state, + })), + ), + ), + )( + "lays out the $state Sonnet 5 picker in $theme at $width px", + async ({ theme, width, state }) => { + await page.viewport(width, 720); + applyFixtureTheme(theme); + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + lockedProvider: "claudeAgent", + providers: [ + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + version: + state === "supported" + ? "2.1.197" + : state === "known-old" + ? "2.1.196" + : "2.1.197-alpha..1", + checkedAt: "2026-07-17T00:00:00.000Z", + }, + ], + }); + + try { + await page.getByRole("button").click(); + const sonnet5 = page.getByRole("menuitemradio", { name: /Claude Sonnet 5/u }); + await expect.element(sonnet5).toBeVisible(); + if (state === "known-old") { + await expect.element(sonnet5).toBeDisabled(); + } else { + await expect.element(sonnet5).toBeEnabled(); + } + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); + } finally { + await mounted.cleanup(); + } + }, + ); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts b/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts new file mode 100644 index 00000000..0193e242 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { + formatModelTokenCount, + resolveModelCapabilityDescription, +} from "./ProviderModelPicker.logic"; + +describe("formatModelTokenCount", () => { + it.each([ + [0, "0"], + [999, "999"], + [1_000, "1K"], + [1_500, "1.5K"], + [128_000, "128K"], + [999_999, "999.999K"], + [1_000_000, "1M"], + [1_500_000, "1.5M"], + ])("formats %s tokens as %s without rounding", (tokens, expected) => { + expect(formatModelTokenCount(tokens)).toBe(expected); + }); +}); + +describe("resolveModelCapabilityDescription", () => { + it("describes every defined Sonnet 5 capability in display order", () => { + expect(resolveModelCapabilityDescription("claudeAgent", "claude-sonnet-5")).toBe( + "1M context · 128K max output · Adaptive thinking", + ); + }); + + it("returns null when the model has no descriptive capability metadata", () => { + expect(resolveModelCapabilityDescription("claudeAgent", "claude-sonnet-4-6")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.logic.ts b/apps/web/src/components/chat/ProviderModelPicker.logic.ts new file mode 100644 index 00000000..631c32a2 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.logic.ts @@ -0,0 +1,34 @@ +import type { ProviderKind } from "@jcode/contracts"; +import { getModelCapabilities } from "@jcode/shared/model"; + +export function formatModelTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + return `${tokens / 1_000_000}M`; + } + if (tokens >= 1_000) { + return `${tokens / 1_000}K`; + } + return String(tokens); +} + +export function resolveModelCapabilityDescription( + provider: ProviderKind, + model: string, +): string | null { + const capabilities = getModelCapabilities(provider, model); + const details: string[] = []; + + if (capabilities.contextWindowTokens !== undefined) { + details.push(`${formatModelTokenCount(capabilities.contextWindowTokens)} context`); + } + if (capabilities.maxOutputTokens !== undefined) { + details.push(`${formatModelTokenCount(capabilities.maxOutputTokens)} max output`); + } + if (capabilities.thinkingMode !== undefined) { + details.push( + capabilities.thinkingMode === "adaptive" ? "Adaptive thinking" : "Extended thinking", + ); + } + + return details.length > 0 ? details.join(" · ") : null; +} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index d419414d..71e2e28b 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -3,8 +3,14 @@ // Layer: Chat composer presentation // Depends on: provider availability metadata, shared menu primitives, and picker trigger styling. -import { type ModelSlug, type ProviderKind, type ServerProviderStatus } from "@jcode/contracts"; +import { + type ModelSlug, + PROVIDER_DISPLAY_NAMES, + type ProviderKind, + type ServerProviderStatus, +} from "@jcode/contracts"; import { resolveSelectableModel } from "@jcode/shared/model"; +import { resolveModelCompatibility } from "@jcode/shared/modelCompatibility"; import * as Schema from "effect/Schema"; import { Fragment, memo, useCallback, useDeferredValue, useMemo, useState } from "react"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; @@ -48,6 +54,7 @@ import { import { useLocalStorage } from "../../hooks/useLocalStorage"; import { StarFilledIcon, StarIcon } from "../../lib/icons"; import { Skeleton } from "../ui/skeleton"; +import { resolveModelCapabilityDescription } from "./ProviderModelPicker.logic"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { value: ProviderKind; @@ -319,6 +326,18 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; if (!value) return; + const modelOption = props.modelOptionsByProvider[provider].find( + (option) => option.slug === value, + ); + const compatibility = resolveModelCompatibility({ + provider, + providerDisplayName: PROVIDER_DISPLAY_NAMES[provider], + model: value, + ...(modelOption ? { modelDisplayName: modelOption.name } : {}), + providerVersion: + props.providers?.find((entry) => entry.provider === provider)?.version ?? null, + }); + if (!compatibility.selectable) return; const resolvedModel = resolveSelectableModel( provider, value, @@ -363,6 +382,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { } const providerOptions = props.modelOptionsByProvider[provider]; + const liveProvider = props.providers?.find((entry) => entry.provider === provider); const shouldShowSearch = (provider === "kilo" || provider === "opencode" || @@ -399,13 +419,43 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {group.label ? {group.label} : null} {group.options.map((modelOption) => { const isFavorite = favoriteModelSlugSet?.has(modelOption.slug) ?? false; + const compatibility = resolveModelCompatibility({ + provider, + providerDisplayName: PROVIDER_DISPLAY_NAMES[provider], + model: modelOption.slug, + modelDisplayName: modelOption.name, + providerVersion: liveProvider?.version ?? null, + }); + const capabilityDescription = resolveModelCapabilityDescription( + provider, + modelOption.slug, + ); + const modelLabel = + capabilityDescription !== null ? ( + + {modelOption.name} + + {capabilityDescription} + + + ) : ( + modelOption.name + ); return ( setMenuOpen(false)} + disabled={!compatibility.selectable} + onClick={compatibility.selectable ? () => setMenuOpen(false) : undefined} > - {favoriteModelSlugSet !== undefined ? ( + {!compatibility.selectable ? ( + + {modelLabel} + + {compatibility.reason} + + + ) : favoriteModelSlugSet !== undefined ? ( {modelOption.name} + + )} +
+ + ); +} + +function MacTrafficLightFixture({ + isFullscreen, + state, + theme, +}: { + readonly isFullscreen: boolean; + readonly state: FixtureState; + readonly theme: FixtureTheme; +}) { + return ( +
+
+
+

+ Simulated macOS Electron · {state} +

+

+ Native traffic-light clearance +

+
+
+ {consumers.map((consumer) => ( + + ))} +
+
+
+ ); +} + +afterEach(() => { + document.documentElement.classList.remove("dark"); + document.body.innerHTML = ""; +}); + +describe("simulated macOS fullscreen traffic-light layout", () => { + it.each([ + { state: "startup", isFullscreen: false, theme: "light", width: 1280 }, + { state: "windowed", isFullscreen: false, theme: "light", width: 1280 }, + { state: "fullscreen", isFullscreen: true, theme: "light", width: 1280 }, + { state: "race-fullscreen", isFullscreen: true, theme: "light", width: 1280 }, + { state: "startup", isFullscreen: false, theme: "dark", width: 1280 }, + { state: "windowed", isFullscreen: false, theme: "dark", width: 1280 }, + { state: "fullscreen", isFullscreen: true, theme: "dark", width: 1280 }, + { state: "race-fullscreen", isFullscreen: true, theme: "dark", width: 1280 }, + { state: "windowed", isFullscreen: false, theme: "light", width: 375 }, + { state: "fullscreen", isFullscreen: true, theme: "light", width: 375 }, + { state: "windowed", isFullscreen: false, theme: "dark", width: 375 }, + { state: "fullscreen", isFullscreen: true, theme: "dark", width: 375 }, + ] as const)( + "applies production geometry for $state in $theme at $width px", + async ({ state, isFullscreen, theme, width }) => { + // Given the exact production layout primitive at a desktop lifecycle state and width + await page.viewport(width, 800); + document.documentElement.classList.toggle("dark", theme === "dark"); + const screen = await render( + , + ); + + try { + // When all four production variants render + for (const consumer of consumers) { + const origin = screen.getByTestId(`${consumer.id}-origin`).element(); + const content = screen.getByTestId(`${consumer.id}-content`).element(); + const insetElement = + consumer.kind === "titlebar" + ? screen.getByTestId(`${consumer.id}-inset`).element() + : content; + const expectedInset = isFullscreen ? 0 : consumer.kind === "titlebar" ? 90 : 76; + const computedStyle = getComputedStyle(insetElement); + + // Then the real primitive owns the expected style and geometry + expect( + Math.round( + Number.parseFloat( + consumer.insetProperty === "padding-left" + ? computedStyle.paddingLeft + : computedStyle.marginLeft, + ), + ), + consumer.id, + ).toBe(expectedInset); + expect( + Math.round(content.getBoundingClientRect().left - origin.getBoundingClientRect().left), + consumer.id, + ).toBe(expectedInset); + expect(content.getBoundingClientRect().right, consumer.id).toBeLessThanOrEqual( + origin.getBoundingClientRect().right, + ); + } + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); + } finally { + await screen.unmount(); + } + }, + ); + + it.each(["light", "dark"] as const)( + "keeps every control reachable in the $theme theme", + async (theme) => { + await page.viewport(768, 800); + document.documentElement.classList.toggle("dark", theme === "dark"); + const screen = await render( + , + ); + + try { + for (const consumer of consumers) { + const content = screen.getByTestId(`${consumer.id}-content`).element(); + content.focus(); + expect(document.activeElement).toBe(content); + } + } finally { + await screen.unmount(); + } + }, + ); + + it("preserves caller styles when the current sidebar placement disables an inset", async () => { + const screen = await render( + , + ); + + try { + const element = screen.getByTestId("disabled-placement").element(); + expect(getComputedStyle(element).paddingLeft).toBe("0px"); + expect(getComputedStyle(element).paddingRight).toBe("20px"); + } finally { + await screen.unmount(); + } + }); + + it("keeps regular browser and non-Mac desktop controls at zero", () => { + for (const kind of ["titlebar", "collapsed-sidebar-trigger"] as const) { + expect( + resolveMacTrafficLightInset({ kind, isElectron: false, isMac: true, isFullscreen: false }), + ).toBe(0); + expect( + resolveMacTrafficLightInset({ kind, isElectron: true, isMac: false, isFullscreen: false }), + ).toBe(0); + } + }); +}); diff --git a/apps/web/src/components/terminal/TerminalActivityIndicator.tsx b/apps/web/src/components/terminal/TerminalActivityIndicator.tsx index 2d277b62..0807b38c 100644 --- a/apps/web/src/components/terminal/TerminalActivityIndicator.tsx +++ b/apps/web/src/components/terminal/TerminalActivityIndicator.tsx @@ -42,9 +42,9 @@ export default function TerminalActivityIndicator({ > {RUNNING_INDICATOR_OFFSETS_MS.map((delayMs) => ( ))} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 4e7e8ea6..ccc4fb58 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -4,8 +4,8 @@ import { cva, type VariantProps } from "class-variance-authority"; import { FiSidebar } from "react-icons/fi"; import * as React from "react"; import { cn } from "~/lib/utils"; -import { isElectron } from "~/env"; import { useAppSettings } from "~/appSettings"; +import { MacTrafficLightInsetLayout, useMacTrafficLightInset } from "~/macTrafficLightInset"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -350,23 +350,24 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { const { isMobile, open } = useSidebar(); const { settings } = useAppSettings(); + const macCollapsedTriggerInset = useMacTrafficLightInset("collapsed-sidebar-trigger"); if (!isMobile && open) { return null; } return ( - } + style={style} /> ); } diff --git a/apps/web/src/desktopFullscreen.test.ts b/apps/web/src/desktopFullscreen.test.ts new file mode 100644 index 00000000..0a51e826 --- /dev/null +++ b/apps/web/src/desktopFullscreen.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createDesktopFullscreenStore, type DesktopFullscreenBridge } from "./desktopFullscreen"; + +type FullscreenListener = (isFullscreen: boolean) => void; + +function createControllableBridge(initialState = false) { + let state = initialState; + let listener: FullscreenListener | null = null; + let lastListener: FullscreenListener | null = null; + let duringSubscribe: (() => void) | null = null; + const getIsFullscreen = vi.fn(() => state); + const unsubscribe = vi.fn(() => { + listener = null; + }); + const onFullscreenChange = vi.fn((nextListener: FullscreenListener) => { + listener = nextListener; + lastListener = nextListener; + duringSubscribe?.(); + return unsubscribe; + }); + const bridge: DesktopFullscreenBridge = { getIsFullscreen, onFullscreenChange }; + + return { + bridge, + getIsFullscreen, + onFullscreenChange, + unsubscribe, + setState(nextState: boolean) { + state = nextState; + }, + emit(nextState: boolean) { + state = nextState; + listener?.(nextState); + }, + emitStale(nextState: boolean) { + state = nextState; + lastListener?.(nextState); + }, + setDuringSubscribe(effect: (() => void) | null) { + duringSubscribe = effect; + }, + }; +} + +describe("desktop fullscreen external store", () => { + it("initializes synchronously and defaults to false without the optional bridge", () => { + const present = createControllableBridge(true); + + expect(createDesktopFullscreenStore(() => present.bridge).getSnapshot()).toBe(true); + expect(createDesktopFullscreenStore(() => undefined).getSnapshot()).toBe(false); + }); + + it("rereads immediately after subscribing to close the event-before-subscribe race", () => { + const controlled = createControllableBridge(false); + const store = createDesktopFullscreenStore(() => controlled.bridge); + const listener = vi.fn(); + + controlled.setState(true); + const unsubscribe = store.subscribe(listener); + + expect(store.getSnapshot()).toBe(true); + expect(listener).toHaveBeenCalledOnce(); + unsubscribe(); + }); + + it("handles an event delivered during native subscription without duplicating notification", () => { + const controlled = createControllableBridge(false); + const store = createDesktopFullscreenStore(() => controlled.bridge); + const listener = vi.fn(); + controlled.setDuringSubscribe(() => controlled.emit(true)); + + const unsubscribe = store.subscribe(listener); + + expect(store.getSnapshot()).toBe(true); + expect(listener).toHaveBeenCalledOnce(); + expect(controlled.getIsFullscreen).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it("deduplicates repeated state while sharing one native subscription", () => { + const controlled = createControllableBridge(false); + const store = createDesktopFullscreenStore(() => controlled.bridge); + const first = vi.fn(); + const second = vi.fn(); + + const unsubscribeFirst = store.subscribe(first); + const unsubscribeSecond = store.subscribe(second); + controlled.emit(true); + controlled.emit(true); + + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledOnce(); + expect(controlled.onFullscreenChange).toHaveBeenCalledOnce(); + + unsubscribeFirst(); + expect(controlled.unsubscribe).not.toHaveBeenCalled(); + unsubscribeSecond(); + expect(controlled.unsubscribe).toHaveBeenCalledOnce(); + }); + + it("stops updates after the last unsubscribe and reattaches cleanly on remount", () => { + const controlled = createControllableBridge(false); + const store = createDesktopFullscreenStore(() => controlled.bridge); + const first = vi.fn(); + const unsubscribeFirst = store.subscribe(first); + + unsubscribeFirst(); + controlled.emitStale(true); + expect(store.getSnapshot()).toBe(false); + expect(first).not.toHaveBeenCalled(); + + const second = vi.fn(); + const unsubscribeSecond = store.subscribe(second); + expect(store.getSnapshot()).toBe(true); + expect(second).toHaveBeenCalledOnce(); + expect(controlled.onFullscreenChange).toHaveBeenCalledTimes(2); + unsubscribeSecond(); + }); +}); diff --git a/apps/web/src/desktopFullscreen.ts b/apps/web/src/desktopFullscreen.ts new file mode 100644 index 00000000..fea1c1ef --- /dev/null +++ b/apps/web/src/desktopFullscreen.ts @@ -0,0 +1,74 @@ +import { useSyncExternalStore } from "react"; + +export interface DesktopFullscreenBridge { + getIsFullscreen?: () => boolean; + onFullscreenChange?: (listener: (isFullscreen: boolean) => void) => () => void; +} + +export interface DesktopFullscreenStore { + readonly getSnapshot: () => boolean; + readonly subscribe: (listener: () => void) => () => void; +} + +function readFullscreen(bridge: DesktopFullscreenBridge | undefined): boolean { + return bridge?.getIsFullscreen?.() === true; +} + +export function createDesktopFullscreenStore( + getBridge: () => DesktopFullscreenBridge | undefined, +): DesktopFullscreenStore { + let isFullscreen = readFullscreen(getBridge()); + let unsubscribeFromBridge: (() => void) | null = null; + let subscriptionGeneration = 0; + const listeners = new Set<() => void>(); + + const update = (nextIsFullscreen: boolean): void => { + if (isFullscreen === nextIsFullscreen) return; + isFullscreen = nextIsFullscreen; + for (const listener of listeners) { + listener(); + } + }; + + return { + getSnapshot: () => isFullscreen, + subscribe: (listener) => { + listeners.add(listener); + + if (listeners.size === 1) { + const bridge = getBridge(); + const currentGeneration = ++subscriptionGeneration; + unsubscribeFromBridge = + bridge?.onFullscreenChange?.((nextIsFullscreen) => { + if (subscriptionGeneration !== currentGeneration) return; + update(nextIsFullscreen); + }) ?? null; + update(readFullscreen(bridge)); + } + + let isSubscribed = true; + return () => { + if (!isSubscribed) return; + isSubscribed = false; + listeners.delete(listener); + if (listeners.size !== 0) return; + + subscriptionGeneration += 1; + unsubscribeFromBridge?.(); + unsubscribeFromBridge = null; + }; + }, + }; +} + +const desktopFullscreenStore = createDesktopFullscreenStore(() => + typeof window === "undefined" ? undefined : window.desktopBridge, +); + +export function useDesktopFullscreen(): boolean { + return useSyncExternalStore( + desktopFullscreenStore.subscribe, + desktopFullscreenStore.getSnapshot, + () => false, + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f869dbdb..71ee5892 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -66,6 +66,11 @@ background: var(--app-control-icon-hover-bg, var(--color-background-button-secondary-hover)); } + .message-action-group { + opacity: 0.6; + transition: opacity 200ms; + } + /* Let the renderer switch between Codex-style translucent and opaque shells without duplicating sidebar markup or class strings. */ .app-sidebar-surface { @@ -75,25 +80,28 @@ -webkit-backdrop-filter: var(--app-sidebar-backdrop-filter, none); } - /* Animate terminal run state in CSS so many open terminals don't schedule JS timers. */ - .terminal-running-indicator__dot { - animation: terminal-running-indicator-pulse 640ms ease-in-out infinite; - opacity: 0.24; - transform: scale(0.7); - will-change: opacity, transform; + /* Persistent status motion stays in CSS so many active surfaces don't schedule JS timers. */ + .status-pulse { + animation: status-pulse 2s infinite; + will-change: opacity; } } -@keyframes terminal-running-indicator-pulse { +@keyframes status-pulse { 0%, - 100% { - opacity: 0.24; - transform: scale(0.7); + 40% { + opacity: 1; + animation-timing-function: steps(6); + } + + 50%, + 90% { + opacity: 0.5; + animation-timing-function: steps(6); } - 35% { - opacity: 0.92; - transform: scale(1); + 100% { + opacity: 1; } } @@ -186,6 +194,13 @@ .chat-pane-enter { animation: none; } + + .status-pulse { + animation-name: none; + opacity: 1; + transform: none; + will-change: auto; + } } /* Suppress all transitions during theme changes */ diff --git a/apps/web/src/macTrafficLightInset.reproduction.test.tsx b/apps/web/src/macTrafficLightInset.reproduction.test.tsx new file mode 100644 index 00000000..d3e70ec0 --- /dev/null +++ b/apps/web/src/macTrafficLightInset.reproduction.test.tsx @@ -0,0 +1,70 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { + MacTrafficLightInsetLayout, + resolveMacTrafficLightInset, + type MacTrafficLightInsetKind, + type MacTrafficLightInsetProperty, +} from "./macTrafficLightInset"; + +const consumers = [ + { id: "sidebar-titlebar", kind: "titlebar", insetProperty: "padding-left" }, + { id: "empty-chat-titlebar", kind: "titlebar", insetProperty: "padding-left" }, + { id: "active-chat-titlebar", kind: "titlebar", insetProperty: "padding-left" }, + { + id: "collapsed-sidebar-trigger", + kind: "collapsed-sidebar-trigger", + insetProperty: "margin-left", + }, +] as const satisfies ReadonlyArray<{ + readonly id: string; + readonly insetProperty: MacTrafficLightInsetProperty; + readonly kind: MacTrafficLightInsetKind; +}>; + +function MacElectronTrafficLightFixture({ isFullscreen }: { readonly isFullscreen: boolean }) { + return ( +
+ {consumers.map((consumer) => { + const inset = resolveMacTrafficLightInset({ + kind: consumer.kind, + isElectron: true, + isMac: true, + isFullscreen, + }); + return ( + + ); + })} +
+ ); +} + +describe("simulated macOS Electron traffic-light layouts", () => { + it("applies all four windowed consumer styles through the production primitive", () => { + // Given a rendered simulated macOS Electron window + // When it is windowed + const html = renderToStaticMarkup(); + + // Then titlebars and the collapsed trigger reserve their distinct native clearances + expect(html.match(/padding-left:90px/g)).toHaveLength(3); + expect(html.match(/margin-left:76px/g)).toHaveLength(1); + }); + + it("removes all four production primitive styles while fullscreen", () => { + // Given the same rendered simulated macOS Electron layouts + // When the native window is already fullscreen + const html = renderToStaticMarkup(); + + // Then no consumer retains either windowed reservation + expect(html).not.toContain("padding-left:90px"); + expect(html).not.toContain("margin-left:76px"); + expect(html.match(/data-mac-traffic-light-inset="0"/g)).toHaveLength(4); + }); +}); diff --git a/apps/web/src/macTrafficLightInset.structure.test.ts b/apps/web/src/macTrafficLightInset.structure.test.ts new file mode 100644 index 00000000..b6ecd584 --- /dev/null +++ b/apps/web/src/macTrafficLightInset.structure.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const insetSource = readFileSync(new URL("./macTrafficLightInset.ts", import.meta.url), "utf8"); +const consumerSources = [ + readFileSync(new URL("./components/Sidebar.tsx", import.meta.url), "utf8"), + readFileSync(new URL("./components/ChatView.tsx", import.meta.url), "utf8"), + readFileSync(new URL("./components/ui/sidebar.tsx", import.meta.url), "utf8"), +]; +const browserFixtureSource = readFileSync( + new URL("./components/macTrafficLightInset.browser.tsx", import.meta.url), + "utf8", +); + +describe("macOS traffic-light inset production wiring", () => { + it("imports one shared production layout primitive at all four consumer sites", () => { + // Given the shared inset module and the three files that own four consumer branches + // When their imports and JSX are inspected as secondary wiring proof + const combinedConsumers = consumerSources.join("\n"); + + // Then the actual runtime primitive is defined once and rendered exactly four times + expect(insetSource).toContain("export function MacTrafficLightInsetLayout"); + for (const source of consumerSources) { + expect(source).toContain("MacTrafficLightInsetLayout"); + } + expect(combinedConsumers.match(/ { + expect(browserFixtureSource).toContain(" { + expect(consumerSources.join("\n")).not.toMatch(/(?:sm:)?pl-\[90px\]|ml-\[76px\]/u); + }); +}); diff --git a/apps/web/src/macTrafficLightInset.test.ts b/apps/web/src/macTrafficLightInset.test.ts new file mode 100644 index 00000000..ff6bcc63 --- /dev/null +++ b/apps/web/src/macTrafficLightInset.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMacTrafficLightInset } from "./macTrafficLightInset"; + +describe("macOS traffic-light inset", () => { + it.each([ + { kind: "titlebar", expected: 90 }, + { kind: "collapsed-sidebar-trigger", expected: 76 }, + ] as const)("keeps the $expected px $kind inset only in a windowed Mac desktop", (testCase) => { + expect( + resolveMacTrafficLightInset({ + kind: testCase.kind, + isElectron: true, + isMac: true, + isFullscreen: false, + }), + ).toBe(testCase.expected); + }); + + it.each([ + { isElectron: false, isMac: true, isFullscreen: false }, + { isElectron: true, isMac: false, isFullscreen: false }, + { isElectron: true, isMac: true, isFullscreen: true }, + ])( + "returns no inset outside a windowed Mac desktop: $isElectron/$isMac/$isFullscreen", + (input) => { + expect(resolveMacTrafficLightInset({ kind: "titlebar", ...input })).toBe(0); + expect(resolveMacTrafficLightInset({ kind: "collapsed-sidebar-trigger", ...input })).toBe(0); + }, + ); +}); diff --git a/apps/web/src/macTrafficLightInset.ts b/apps/web/src/macTrafficLightInset.ts new file mode 100644 index 00000000..9522121b --- /dev/null +++ b/apps/web/src/macTrafficLightInset.ts @@ -0,0 +1,67 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; + +import { useDesktopFullscreen } from "./desktopFullscreen"; +import { isElectron } from "./env"; +import { isMacPlatform } from "./lib/utils"; + +export type MacTrafficLightInsetKind = "titlebar" | "collapsed-sidebar-trigger"; + +export type MacTrafficLightInsetProperty = "padding-left" | "margin-left"; + +export type MacTrafficLightInsetInput = { + readonly kind: MacTrafficLightInsetKind; + readonly isElectron: boolean; + readonly isMac: boolean; + readonly isFullscreen: boolean; +}; + +export function resolveMacTrafficLightInset(input: MacTrafficLightInsetInput): number { + if (!input.isElectron || !input.isMac || input.isFullscreen) { + return 0; + } + + return input.kind === "titlebar" ? 90 : 76; +} + +export function MacTrafficLightInsetLayout({ + enabled = true, + inset, + insetProperty, + render, + style, + ...props +}: useRender.ComponentProps<"div"> & { + readonly enabled?: boolean; + readonly inset: number; + readonly insetProperty: MacTrafficLightInsetProperty; +}) { + const resolvedStyle = + enabled && inset > 0 + ? insetProperty === "padding-left" + ? { ...style, paddingLeft: inset } + : { ...style, marginLeft: inset } + : style; + const defaultProps = { + "data-mac-traffic-light-inset": enabled ? inset : 0, + "data-mac-traffic-light-inset-property": insetProperty, + style: resolvedStyle, + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +export function useMacTrafficLightInset(kind: MacTrafficLightInsetKind): number { + const isFullscreen = useDesktopFullscreen(); + + return resolveMacTrafficLightInset({ + kind, + isElectron, + isMac: typeof navigator !== "undefined" && isMacPlatform(navigator.platform), + isFullscreen, + }); +} diff --git a/apps/web/src/markdown-list-indentation.test.tsx b/apps/web/src/markdown-list-indentation.test.tsx new file mode 100644 index 00000000..44a3a31f --- /dev/null +++ b/apps/web/src/markdown-list-indentation.test.tsx @@ -0,0 +1,170 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import { describe, expect, it } from "vitest"; + +import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; + +function renderMarkdown(markdown: string): string { + return renderToStaticMarkup( + + {markdown} + , + ); +} + +describe("Markdown list indentation controls", () => { + it("preserves a conventionally indented nested list", () => { + // Given + const markdown = "- parent\n - child"; + + // When + const html = renderMarkdown(markdown); + + // Then + expect(html).toMatch(/
  • parent\s*
      /); + expect(html).toContain("
    • child
    • "); + expect(html).not.toContain("
      ");
      +  });
      +
      +  it("preserves fenced code within a list item", () => {
      +    // Given
      +    const markdown = "- ```ts\n  const value = 1;\n  ```";
      +
      +    // When
      +    const html = renderMarkdown(markdown);
      +
      +    // Then
      +    expect(html).toContain('
      const value = 1;');
      +  });
      +
      +  it("preserves a conventional same-line indented code block", () => {
      +    // Given
      +    const markdown = "-     const value = 1;";
      +
      +    // When
      +    const html = renderMarkdown(markdown);
      +
      +    // Then
      +    expect(html).toContain("
      const value = 1;");
      +  });
      +
      +  it("preserves indented code beginning below a list marker", () => {
      +    // Given
      +    const markdown = "-\n      const value = 1;";
      +
      +    // When
      +    const html = renderMarkdown(markdown);
      +
      +    // Then
      +    expect(html).toContain("
      const value = 1;");
      +  });
      +});
      +
      +describe("over-indented same-line Markdown list recovery", () => {
      +  it("preserves content beyond the bounded recovery depth as literal code", () => {
      +    const markdown = Array.from({ length: 55 }, (_, depth) => {
      +      const indentation = " ".repeat(depth * 8);
      +      return `${depth === 0 ? "" : indentation}-       level ${depth}`;
      +    }).join("\n\n");
      +
      +    const html = renderMarkdown(markdown);
      +
      +    expect(html).toContain("level 54");
      +    expect(html).toContain("
      ");
      +    expect(html.match(/
        /gu)?.length ?? 0).toBeLessThan(55); + }); + + it("recovers an unordered list item as list text", () => { + // Given + const markdown = "- unordered item"; + + // When + const html = renderMarkdown(markdown); + + // Then + expect(html).toContain("
          "); + expect(html).toContain("
        • unordered item
        • "); + expect(html).not.toContain("

          "); + expect(html).not.toContain("

          ");
          +  });
          +
          +  it("recovers an ordered list item as list text", () => {
          +    // Given
          +    const markdown = "1.      ordered item";
          +
          +    // When
          +    const html = renderMarkdown(markdown);
          +
          +    // Then
          +    expect(html).toContain("
            "); + expect(html).toContain("
          1. ordered item
          2. "); + expect(html).not.toContain("

            "); + expect(html).not.toContain("

            ");
            +  });
            +
            +  it("reparses GFM inline markup in recovered content", () => {
            +    // Given
            +    const markdown =
            +      "-       **important** [docs](https://example.com) use `inline code`, not ~~plain text~~";
            +
            +    // When
            +    const html = renderMarkdown(markdown);
            +
            +    // Then
            +    expect(html).toContain("important");
            +    expect(html).toContain('docs');
            +    expect(html).toContain("inline code");
            +    expect(html).toContain("plain text");
            +    expect(html).not.toContain("
            ");
            +  });
            +
            +  it("reparses inline math in recovered content", () => {
            +    // Given
            +    const markdown = "-       formula $x^2 + y^2$";
            +
            +    // When
            +    const html = renderMarkdown(markdown);
            +
            +    // Then
            +    expect(html).toContain('class="katex"');
            +    expect(html).not.toContain("$x^2 + y^2$");
            +    expect(html).not.toContain("
            ");
            +  });
            +
            +  it("preserves recovered blocks separated by a blank line", () => {
            +    // Given
            +    const markdown = `-       **first block**
            +
            +        [second block](https://example.com)`;
            +
            +    // When
            +    const html = renderMarkdown(markdown);
            +
            +    // Then
            +    expect(html.match(/

            /g) ?? []).toHaveLength(2); + expect(html).toContain("first block"); + expect(html).toContain('second block'); + expect(html).not.toContain("

            ");
            +  });
            +
            +  it("recursively recovers a nested list in a tail block", () => {
            +    // Given
            +    const markdown = `-       parent item
            +
            +        -       nested item`;
            +
            +    // When
            +    const html = renderMarkdown(markdown);
            +
            +    // Then
            +    expect(html.match(/
              /g) ?? []).toHaveLength(2); + expect(html).toContain("
            • nested item
            • "); + expect(html).not.toContain("
              ");
              +  });
              +});
              diff --git a/apps/web/src/markdown-list-indentation.ts b/apps/web/src/markdown-list-indentation.ts
              new file mode 100644
              index 00000000..1826f1d9
              --- /dev/null
              +++ b/apps/web/src/markdown-list-indentation.ts
              @@ -0,0 +1,197 @@
              +interface MarkdownPoint {
              +  readonly line?: number;
              +  readonly offset?: number;
              +}
              +
              +interface MarkdownPosition {
              +  readonly start?: MarkdownPoint;
              +}
              +
              +interface MarkdownAstNode {
              +  readonly type: string;
              +  readonly value?: unknown;
              +  readonly position?: MarkdownPosition;
              +  children?: MarkdownAstNode[];
              +}
              +
              +interface MarkdownCodeNode extends MarkdownAstNode {
              +  readonly type: "code";
              +  readonly value: string;
              +}
              +
              +interface MarkdownListNode extends MarkdownAstNode {
              +  readonly type: "list";
              +  spread?: boolean;
              +}
              +
              +interface MarkdownListItemNode extends MarkdownAstNode {
              +  readonly type: "listItem";
              +  spread?: boolean;
              +}
              +
              +interface MarkdownFile {
              +  readonly value?: unknown;
              +}
              +
              +interface MarkdownParser {
              +  parse(markdown: string): unknown;
              +}
              +
              +interface RecoveredMarkdown {
              +  readonly blocks: MarkdownAstNode[];
              +  readonly source: string;
              +}
              +
              +const INLINE_PARSE_PREFIX = "jcode-markdown-inline-prefix:";
              +const MAX_LIST_INDENTATION_RECOVERY_DEPTH = 32;
              +
              +function isMarkdownAstNode(value: unknown): value is MarkdownAstNode {
              +  return (
              +    typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"
              +  );
              +}
              +
              +function isMarkdownListNode(node: MarkdownAstNode | undefined): node is MarkdownListNode {
              +  return node?.type === "list";
              +}
              +
              +function isMarkdownListItemNode(node: MarkdownAstNode): node is MarkdownListItemNode {
              +  return node.type === "listItem";
              +}
              +
              +function isSameLineOverIndentedCode(
              +  node: MarkdownAstNode,
              +  parent: MarkdownAstNode | undefined,
              +  markdown: string,
              +): node is MarkdownCodeNode {
              +  if (
              +    node.type !== "code" ||
              +    parent?.type !== "listItem" ||
              +    typeof node.value !== "string" ||
              +    !/^[\t ]/.test(node.value)
              +  ) {
              +    return false;
              +  }
              +
              +  const nodeStart = node.position?.start;
              +  const parentStart = parent.position?.start;
              +  if (
              +    nodeStart?.line === undefined ||
              +    nodeStart.offset === undefined ||
              +    parentStart?.line === undefined ||
              +    nodeStart.line !== parentStart.line
              +  ) {
              +    return false;
              +  }
              +
              +  const sourceCharacter = markdown[nodeStart.offset];
              +  return sourceCharacter !== "`" && sourceCharacter !== "~";
              +}
              +
              +function fallbackRecoveredMarkdown(value: string, source: string): RecoveredMarkdown {
              +  return {
              +    blocks: [{ type: "paragraph", children: [{ type: "text", value }] }],
              +    source,
              +  };
              +}
              +
              +function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown {
              +  // A text prefix keeps the first block inline while the active processor
              +  // reparses GFM, math, blank-line tail blocks, and nested list syntax.
              +  const source = `${INLINE_PARSE_PREFIX}${value}`;
              +  const document = parser.parse(source);
              +  if (!isMarkdownAstNode(document)) {
              +    return fallbackRecoveredMarkdown(value, source);
              +  }
              +
              +  const blocks = document.children;
              +  const paragraph = blocks?.[0];
              +  if (!blocks || !paragraph || paragraph.type !== "paragraph" || !paragraph.children) {
              +    return fallbackRecoveredMarkdown(value, source);
              +  }
              +
              +  const children = paragraph.children;
              +  const first = children?.[0];
              +  if (
              +    first?.type !== "text" ||
              +    typeof first.value !== "string" ||
              +    !first.value.startsWith(INLINE_PARSE_PREFIX)
              +  ) {
              +    return fallbackRecoveredMarkdown(value, source);
              +  }
              +
              +  const firstValue = first.value.slice(INLINE_PARSE_PREFIX.length);
              +  const restoredFirstChildren: MarkdownAstNode[] = firstValue
              +    ? [{ ...first, value: firstValue }, ...children.slice(1)]
              +    : children.slice(1);
              +  return {
              +    blocks: [{ ...paragraph, children: restoredFirstChildren }, ...blocks.slice(1)],
              +    source,
              +  };
              +}
              +
              +function blocksFromIndentedCode(
              +  node: MarkdownCodeNode,
              +  parser: MarkdownParser,
              +  recoveryDepth: number,
              +): RecoveredMarkdown {
              +  if (recoveryDepth >= MAX_LIST_INDENTATION_RECOVERY_DEPTH) {
              +    return { blocks: [node], source: node.value };
              +  }
              +  const recovered = parseRecoveredMarkdown(node.value.trim(), parser);
              +  const first = recovered.blocks[0];
              +  return {
              +    ...recovered,
              +    blocks:
              +      first && node.position
              +        ? [{ ...first, position: node.position }, ...recovered.blocks.slice(1)]
              +        : recovered.blocks,
              +  };
              +}
              +
              +/**
              + * CommonMark interprets four or more spaces after a list marker as code.
              + * Chat output sometimes contains additional alignment whitespace on that same
              + * line. Recover only those excess-indented nodes; intentional code remains.
              + */
              +function attachListItemIndentationNormalizer(this: MarkdownParser) {
              +  return (tree: MarkdownAstNode, file: MarkdownFile): void => {
              +    if (typeof file.value !== "string") {
              +      return;
              +    }
              +
              +    const visit = (
              +      node: MarkdownAstNode,
              +      source: string,
              +      parent: MarkdownAstNode | undefined,
              +      recoveryDepth: number,
              +    ): void => {
              +      if (!node.children) {
              +        return;
              +      }
              +
              +      node.children = node.children.flatMap((child) => {
              +        if (isSameLineOverIndentedCode(child, node, source)) {
              +          const recovered = blocksFromIndentedCode(child, this, recoveryDepth);
              +          if (recovered.blocks.length > 1 && isMarkdownListItemNode(node)) {
              +            node.spread = true;
              +            if (isMarkdownListNode(parent)) {
              +              parent.spread = true;
              +            }
              +          }
              +          for (const block of recovered.blocks) {
              +            visit(block, recovered.source, node, recoveryDepth + 1);
              +          }
              +          return recovered.blocks;
              +        }
              +
              +        visit(child, source, node, recoveryDepth);
              +        return [child];
              +      });
              +    };
              +
              +    visit(tree, file.value, undefined, 0);
              +  };
              +}
              +
              +export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer;
              diff --git a/apps/web/src/statusMotion.structure.test.tsx b/apps/web/src/statusMotion.structure.test.tsx
              new file mode 100644
              index 00000000..b946953b
              --- /dev/null
              +++ b/apps/web/src/statusMotion.structure.test.tsx
              @@ -0,0 +1,113 @@
              +import { readFileSync } from "node:fs";
              +import { renderToStaticMarkup } from "react-dom/server";
              +import { describe, expect, it } from "vitest";
              +
              +import TerminalActivityIndicator from "./components/terminal/TerminalActivityIndicator";
              +
              +const indexCss = readFileSync(new URL("./index.css", import.meta.url), "utf8");
              +const terminalSource = readFileSync(
              +  new URL("./components/terminal/TerminalActivityIndicator.tsx", import.meta.url),
              +  "utf8",
              +);
              +const sidebarSource = readFileSync(new URL("./components/Sidebar.tsx", import.meta.url), "utf8");
              +const timelineSource = readFileSync(
              +  new URL("./components/chat/MessagesTimeline.tsx", import.meta.url),
              +  "utf8",
              +);
              +const spinnerSource = readFileSync(
              +  new URL("./components/ThreadRunningSpinner.tsx", import.meta.url),
              +  "utf8",
              +);
              +
              +function sourceRegion(source: string, start: string, end: string | null): string {
              +  const startIndex = source.indexOf(start);
              +  const endIndex = end === null ? source.length : source.indexOf(end, startIndex);
              +  if (startIndex < 0 || endIndex < 0) {
              +    throw new Error(`Missing source region marker: ${start} -> ${end ?? ""}`);
              +  }
              +  return source.slice(startIndex, endIndex);
              +}
              +
              +function occurrenceCount(source: string, value: string): number {
              +  return source.split(value).length - 1;
              +}
              +
              +describe("persistent status motion structure", () => {
              +  it("routes only terminal, Sidebar project, and timeline working pulses through status-pulse", () => {
              +    expect(occurrenceCount(terminalSource, "status-pulse")).toBe(1);
              +    expect(occurrenceCount(sidebarSource, "status-pulse")).toBe(1);
              +    expect(occurrenceCount(timelineSource, "status-pulse")).toBe(3);
              +
              +    expect(indexCss).toContain(".status-pulse");
              +    expect(indexCss).not.toContain("status-ping");
              +    expect(terminalSource).not.toContain("terminal-running-indicator__dot");
              +  });
              +
              +  it("uses the measured two-second hold and stepped-ramp keyframes", () => {
              +    expect(indexCss).toMatch(/\.status-pulse\s*\{[^}]*animation:\s*status-pulse 2s infinite;/su);
              +    expect(indexCss).toMatch(
              +      /@keyframes status-pulse\s*\{[\s\S]*?0%,\s*40%\s*\{[\s\S]*?opacity:\s*1;[\s\S]*?animation-timing-function:\s*steps\(6\);[\s\S]*?50%,\s*90%\s*\{[\s\S]*?opacity:\s*0\.5;[\s\S]*?100%\s*\{[\s\S]*?opacity:\s*1;/u,
              +    );
              +  });
              +
              +  it("disables semantic status motion while leaving a stable visible state", () => {
              +    const reducedMotion = sourceRegion(
              +      indexCss,
              +      "@media (prefers-reduced-motion: reduce)",
              +      "/* Suppress all transitions during theme changes */",
              +    );
              +    expect(reducedMotion).toMatch(
              +      /\.status-pulse\s*\{[^}]*animation-name:\s*none;[^}]*opacity:\s*1;[^}]*transform:\s*none;/su,
              +    );
              +  });
              +
              +  it("renders semantic motion only for the running terminal state and preserves delays", () => {
              +    const running = renderToStaticMarkup();
              +    const attention = renderToStaticMarkup();
              +    const review = renderToStaticMarkup();
              +
              +    expect(occurrenceCount(running, "status-pulse")).toBe(4);
              +    for (const delayMs of [0, 160, 320, 480]) {
              +      expect(running).toContain(`animation-delay:${delayMs}ms`);
              +    }
              +    expect(attention).not.toContain("status-pulse");
              +    expect(review).not.toContain("status-pulse");
              +  });
              +
              +  it("preserves finite spinner, skeleton, shimmer, and ultrathink exclusions", () => {
              +    const sidebarSkeleton = sourceRegion(
              +      sidebarSource,
              +      '{projectEmptyState === "loading" && (',
              +      '{projectEmptyState === "empty" && (',
              +    );
              +    const generatedImageShimmer = sourceRegion(
              +      indexCss,
              +      '.chat-generated-image[data-status="loading"] .chat-generated-image__frame',
              +      ".chat-generated-image__overlay {",
              +    );
              +    const ultrathinkMotion = sourceRegion(indexCss, "@keyframes ultrathink-rainbow", null);
              +
              +    expect(spinnerSource).toMatch(/animate-spin[\s\S]*\[animation-duration:1\.6s\]/u);
              +    expect(spinnerSource).toContain("conic-gradient");
              +    expect(spinnerSource).toContain("radial-gradient");
              +    expect(occurrenceCount(spinnerSource, "animate-spin")).toBe(1);
              +    expect(sidebarSkeleton).toMatch(
              +      /aria-label="Loading projects"[\s\S]*Loading projects…[\s\S]*animate-pulse/u,
              +    );
              +    expect(occurrenceCount(sidebarSkeleton, "animate-pulse")).toBe(3);
              +    expect(generatedImageShimmer).toMatch(
              +      /chat-generated-image-shimmer 1\.6s ease-in-out infinite/u,
              +    );
              +    expect(ultrathinkMotion).toMatch(/@keyframes ultrathink-rainbow/u);
              +    expect(ultrathinkMotion).toMatch(
              +      /\.ultrathink-frame[\s\S]*animation:\s*ultrathink-rainbow 10s linear infinite/u,
              +    );
              +    expect(ultrathinkMotion).toMatch(
              +      /\.ultrathink-chroma[\s\S]*animation:\s*ultrathink-chroma-shift 10s linear infinite/u,
              +    );
              +    expect(ultrathinkMotion).toMatch(
              +      /\.ultrathink-word[\s\S]*animation:\s*ultrathink-rainbow 10s linear infinite/u,
              +    );
              +    expect(ultrathinkMotion).not.toContain("status-pulse");
              +  });
              +});
              diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
              index 906c6a7b..4b02ef6b 100644
              --- a/packages/contracts/src/ipc.ts
              +++ b/packages/contracts/src/ipc.ts
              @@ -321,6 +321,8 @@ export interface DesktopAdvertisedEndpoint {
               
               export interface DesktopBridge {
                 getWsUrl: () => string | null;
              +  getIsFullscreen?: () => boolean;
              +  onFullscreenChange?: (listener: (isFullscreen: boolean) => void) => () => void;
                 getLocalEnvironmentBootstrap?: () => Promise;
                 getServerExposureState?: () => Promise;
                 setServerExposureMode?: (mode: DesktopServerExposureMode) => Promise;
              diff --git a/packages/contracts/src/ipc.typecheck.ts b/packages/contracts/src/ipc.typecheck.ts
              index 35bf967c..8e65f0e9 100644
              --- a/packages/contracts/src/ipc.typecheck.ts
              +++ b/packages/contracts/src/ipc.typecheck.ts
              @@ -1,5 +1,5 @@
               import type { FirstRunState } from "./firstRunWizard";
              -import type { NativeApi } from "./ipc";
              +import type { DesktopBridge, NativeApi } from "./ipc";
               
               type Assert = T;
               
              @@ -23,3 +23,14 @@ export type _CompleteFirstRunWizardReturnsState = Assert<
               export type _SkipFirstRunWizardReturnsState = Assert<
                 IsExact
               >;
              +
              +export type _DesktopFullscreenSyncRead = Assert<
              +  IsExact, () => boolean>
              +>;
              +
              +export type _DesktopFullscreenSubscription = Assert<
              +  IsExact<
              +    NonNullable,
              +    (listener: (isFullscreen: boolean) => void) => () => void
              +  >
              +>;
              diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts
              new file mode 100644
              index 00000000..d2c3f7ab
              --- /dev/null
              +++ b/packages/contracts/src/model.test.ts
              @@ -0,0 +1,51 @@
              +import { describe, expect, it } from "vitest";
              +
              +import {
              +  DEFAULT_MODEL_BY_PROVIDER,
              +  MODEL_OPTIONS_BY_PROVIDER,
              +  MODEL_SLUG_ALIASES_BY_PROVIDER,
              +} from "./model";
              +
              +describe("Claude Sonnet 5 built-in metadata", () => {
              +  it("exposes the authoritative intrinsic capabilities", () => {
              +    const model = MODEL_OPTIONS_BY_PROVIDER.claudeAgent.find(
              +      (option) => option.slug === "claude-sonnet-5",
              +    );
              +
              +    expect(model).toEqual({
              +      slug: "claude-sonnet-5",
              +      name: "Claude Sonnet 5",
              +      capabilities: {
              +        contextWindowTokens: 1_000_000,
              +        maxOutputTokens: 128_000,
              +        thinkingMode: "adaptive",
              +        minimumProviderVersion: "2.1.197",
              +        reasoningEffortLevels: [
              +          { value: "low", label: "Low" },
              +          { value: "medium", label: "Medium" },
              +          { value: "high", label: "High", isDefault: true },
              +          { value: "xhigh", label: "Extra High" },
              +          { value: "max", label: "Max" },
              +        ],
              +        supportsFastMode: false,
              +        supportsThinkingToggle: false,
              +        promptInjectedEffortLevels: [],
              +        contextWindowOptions: [],
              +      },
              +    });
              +  });
              +
              +  it("keeps Sonnet 4.6 as the Claude default and generic alias", () => {
              +    expect(DEFAULT_MODEL_BY_PROVIDER.claudeAgent).toBe("claude-sonnet-4-6");
              +    expect(MODEL_SLUG_ALIASES_BY_PROVIDER.claudeAgent.sonnet).toBe("claude-sonnet-4-6");
              +    expect(
              +      MODEL_OPTIONS_BY_PROVIDER.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"),
              +    ).toBe(true);
              +  });
              +
              +  it("adds only explicit Sonnet 5 aliases for the canonical ID", () => {
              +    expect(MODEL_SLUG_ALIASES_BY_PROVIDER.claudeAgent["sonnet-5"]).toBe("claude-sonnet-5");
              +    expect(MODEL_SLUG_ALIASES_BY_PROVIDER.claudeAgent["claude-sonnet-5"]).toBe("claude-sonnet-5");
              +    expect(MODEL_SLUG_ALIASES_BY_PROVIDER.claudeAgent["claude-sonnet-5-0"]).toBeUndefined();
              +  });
              +});
              diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
              index 60641c67..1cd728d7 100644
              --- a/packages/contracts/src/model.ts
              +++ b/packages/contracts/src/model.ts
              @@ -150,7 +150,13 @@ export type ContextWindowOption = {
                 readonly isDefault?: true;
               };
               
              +export type ModelThinkingMode = "adaptive" | "extended";
              +
               export type ModelCapabilities = {
              +  readonly contextWindowTokens?: number;
              +  readonly maxOutputTokens?: number;
              +  readonly thinkingMode?: ModelThinkingMode;
              +  readonly minimumProviderVersion?: string;
                 readonly optionDescriptors?: readonly ProviderOptionDescriptor[];
                 readonly reasoningEffortLevels: readonly EffortOption[];
                 readonly supportsFastMode: boolean;
              @@ -244,6 +250,27 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
                   },
                 ],
                 claudeAgent: [
              +    {
              +      slug: "claude-sonnet-5",
              +      name: "Claude Sonnet 5",
              +      capabilities: {
              +        contextWindowTokens: 1_000_000,
              +        maxOutputTokens: 128_000,
              +        thinkingMode: "adaptive",
              +        minimumProviderVersion: "2.1.197",
              +        reasoningEffortLevels: [
              +          { value: "low", label: "Low" },
              +          { value: "medium", label: "Medium" },
              +          { value: "high", label: "High", isDefault: true },
              +          { value: "xhigh", label: "Extra High" },
              +          { value: "max", label: "Max" },
              +        ],
              +        supportsFastMode: false,
              +        supportsThinkingToggle: false,
              +        promptInjectedEffortLevels: [],
              +        contextWindowOptions: [],
              +      },
              +    },
                   {
                     slug: "claude-opus-4-8",
                     name: "Claude Opus 4.8",
              @@ -577,6 +604,8 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record {
               
                 it("uses provider-specific aliases", () => {
                   expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6");
              +    expect(normalizeModelSlug("sonnet-5", "claudeAgent")).toBe("claude-sonnet-5");
              +    expect(normalizeModelSlug("claude-sonnet-5", "claudeAgent")).toBe("claude-sonnet-5");
                   expect(normalizeModelSlug("opus", "claudeAgent")).toBe("claude-opus-4-8");
                   expect(normalizeModelSlug("opus-4.8", "claudeAgent")).toBe("claude-opus-4-8");
                   expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6");
              @@ -221,6 +223,16 @@ describe("getModelCapabilities reasoningEffortLevels", () => {
                   ]);
                 });
               
              +  it("returns authoritative effort options for Sonnet 5", () => {
              +    expect(values("claudeAgent", "claude-sonnet-5")).toEqual([
              +      "low",
              +      "medium",
              +      "high",
              +      "xhigh",
              +      "max",
              +    ]);
              +  });
              +
                 it("returns no claude effort options for Haiku 4.5", () => {
                   expect(values("claudeAgent", "claude-haiku-4-5")).toEqual([]);
                 });
              @@ -252,6 +264,7 @@ describe("getDefaultEffort", () => {
                   expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high");
                   expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-8"))).toBe("high");
                   expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high");
              +    expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-sonnet-5"))).toBe("high");
                   expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull();
                   expect(getDefaultEffort(getModelCapabilities("gemini", "gemini-2.5-flash-lite"))).toBe("-1");
                 });
              @@ -367,6 +380,13 @@ describe("context window helpers", () => {
                   expect(getDefaultContextWindow(getModelCapabilities("codex", "gpt-5.4"))).toBeNull();
                 });
               
              +  it("does not expose a selectable context suffix for intrinsic 1M models", () => {
              +    const sonnetCaps = getModelCapabilities("claudeAgent", "claude-sonnet-5");
              +    expect(sonnetCaps.contextWindowTokens).toBe(1_000_000);
              +    expect(sonnetCaps.contextWindowOptions).toEqual([]);
              +    expect(getDefaultContextWindow(sonnetCaps)).toBeNull();
              +  });
              +
                 it("validates context window against model capabilities", () => {
                   const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6");
                   expect(hasContextWindowOption(opusCaps, "200k")).toBe(true);
              @@ -475,6 +495,16 @@ describe("resolveApiModelId", () => {
                     }),
                   ).toBe("claude-opus-4-6");
                 });
              +
              +  it("keeps Sonnet 5 canonical even when stale options request a 1m suffix", () => {
              +    expect(
              +      resolveApiModelId({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        options: { contextWindow: "1m" },
              +      }),
              +    ).toBe("claude-sonnet-5");
              +  });
               });
               
               describe("normalizeClaudeModelOptions", () => {
              @@ -489,6 +519,17 @@ describe("normalizeClaudeModelOptions", () => {
                   });
                 });
               
              +  it("preserves xhigh effort for Sonnet 5 without unrelated options", () => {
              +    expect(
              +      normalizeClaudeModelOptions("claude-sonnet-5", {
              +        effort: "xhigh",
              +        fastMode: true,
              +        thinking: false,
              +        contextWindow: "1m",
              +      }),
              +    ).toEqual({ effort: "xhigh" });
              +  });
              +
                 it("keeps the Haiku thinking toggle and removes unsupported effort", () => {
                   expect(
                     normalizeClaudeModelOptions("claude-haiku-4-5", {
              diff --git a/packages/shared/src/modelCompatibility.test.ts b/packages/shared/src/modelCompatibility.test.ts
              new file mode 100644
              index 00000000..d3b16e00
              --- /dev/null
              +++ b/packages/shared/src/modelCompatibility.test.ts
              @@ -0,0 +1,151 @@
              +import { describe, expect, it } from "vitest";
              +
              +import { resolveModelCompatibility } from "./modelCompatibility";
              +
              +describe("resolveModelCompatibility", () => {
              +  it("disables Sonnet 5 when a known Claude Code version is below the support floor", () => {
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        modelDisplayName: "Sonnet Next",
              +        providerVersion: "2.1.196",
              +      }),
              +    ).toEqual({
              +      selectable: false,
              +      reason: "Update Claude to 2.1.197 or newer to use Sonnet Next.",
              +    });
              +  });
              +
              +  it("uses the supplied provider display name in generic update guidance", () => {
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        providerDisplayName: "Claude CLI",
              +        model: "claude-sonnet-5",
              +        modelDisplayName: "Sonnet Next",
              +        providerVersion: "2.1.196",
              +      }),
              +    ).toEqual({
              +      selectable: false,
              +      reason: "Update Claude CLI to 2.1.197 or newer to use Sonnet Next.",
              +    });
              +  });
              +
              +  it("keeps Sonnet 5 selectable at and above the support floor", () => {
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: "2.1.197",
              +      }),
              +    ).toEqual({ selectable: true });
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: "2.2.0",
              +      }),
              +    ).toEqual({ selectable: true });
              +  });
              +
              +  it("handles release prefixes, prereleases, and build metadata at the support floor", () => {
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: "v2.1.197+local.1",
              +      }),
              +    ).toEqual({ selectable: true });
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: "2.1.197-beta.1",
              +      }),
              +    ).toEqual({
              +      selectable: false,
              +      reason: "Update Claude to 2.1.197 or newer to use Claude Sonnet 5.",
              +    });
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: "2.1.198-beta.1",
              +      }),
              +    ).toEqual({ selectable: true });
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: "2.1.196+001",
              +      }),
              +    ).toEqual({
              +      selectable: false,
              +      reason: "Update Claude to 2.1.197 or newer to use Claude Sonnet 5.",
              +    });
              +  });
              +
              +  it.each([
              +    "2.1.197-alpha..1",
              +    "2.1.197-.",
              +    "02.1.196",
              +    "2.01.196",
              +    "2.1.0196",
              +    "2.1.197-01",
              +    "2.1.197-alpha.01",
              +    "2.1.196+build..1",
              +    "2.1.196+.build",
              +    "2.1.196+build.",
              +    "2.1.196+build_1",
              +    "2.1.196+",
              +    " 2.1.196",
              +    "2.1.196 ",
              +    `${"0".repeat(400)}.0.0`,
              +  ])("fails open for malformed provider version %s", (providerVersion) => {
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion,
              +      }),
              +    ).toEqual({ selectable: true });
              +  });
              +
              +  it("accepts and safely orders arbitrarily large valid core identifiers", () => {
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: `${"9".repeat(400)}.0.0`,
              +      }),
              +    ).toEqual({ selectable: true });
              +  });
              +
              +  it("keeps unknown and unparseable provider versions selectable", () => {
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: null,
              +      }),
              +    ).toEqual({ selectable: true });
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-5",
              +        providerVersion: "development-build",
              +      }),
              +    ).toEqual({ selectable: true });
              +  });
              +
              +  it("does not apply the Sonnet 5 version policy to other models", () => {
              +    expect(
              +      resolveModelCompatibility({
              +        provider: "claudeAgent",
              +        model: "claude-sonnet-4-6",
              +        providerVersion: "2.1.196",
              +      }),
              +    ).toEqual({ selectable: true });
              +  });
              +});
              diff --git a/packages/shared/src/modelCompatibility.ts b/packages/shared/src/modelCompatibility.ts
              new file mode 100644
              index 00000000..a1d919ff
              --- /dev/null
              +++ b/packages/shared/src/modelCompatibility.ts
              @@ -0,0 +1,98 @@
              +import { PROVIDER_DISPLAY_NAMES, type ProviderKind } from "@jcode/contracts";
              +
              +import { formatModelDisplayName, getModelCapabilities, normalizeModelSlug } from "./model";
              +
              +export type ModelCompatibility =
              +  | { readonly selectable: true }
              +  | { readonly selectable: false; readonly reason: string };
              +
              +export type ModelCompatibilityInput = {
              +  readonly provider: ProviderKind;
              +  readonly providerDisplayName?: string;
              +  readonly model: string;
              +  readonly modelDisplayName?: string;
              +  readonly providerVersion: string | null | undefined;
              +};
              +
              +type ParsedProviderVersion = {
              +  readonly major: string;
              +  readonly minor: string;
              +  readonly patch: string;
              +  readonly prerelease: boolean;
              +};
              +
              +const PROVIDER_VERSION_PATTERN =
              +  /^v?(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?[0-9A-Za-z.-]+))?(?:\+(?[0-9A-Za-z.-]+))?$/u;
              +const VERSION_IDENTIFIER_PATTERN = /^[0-9A-Za-z-]+$/u;
              +const NUMERIC_IDENTIFIER_PATTERN = /^\d+$/u;
              +
              +function hasValidIdentifiers(value: string | undefined, rejectLeadingZero: boolean): boolean {
              +  if (value === undefined) return true;
              +  return value.split(".").every((identifier) => {
              +    if (!VERSION_IDENTIFIER_PATTERN.test(identifier)) return false;
              +    return !(
              +      rejectLeadingZero &&
              +      identifier.length > 1 &&
              +      identifier.startsWith("0") &&
              +      NUMERIC_IDENTIFIER_PATTERN.test(identifier)
              +    );
              +  });
              +}
              +
              +function parseProviderVersion(value: string | null | undefined): ParsedProviderVersion | null {
              +  const groups = typeof value === "string" ? PROVIDER_VERSION_PATTERN.exec(value)?.groups : null;
              +  const major = groups?.["major"];
              +  const minor = groups?.["minor"];
              +  const patch = groups?.["patch"];
              +  if (major === undefined || minor === undefined || patch === undefined) {
              +    return null;
              +  }
              +  if (
              +    !hasValidIdentifiers(groups?.["prerelease"], true) ||
              +    !hasValidIdentifiers(groups?.["build"], false)
              +  ) {
              +    return null;
              +  }
              +
              +  return {
              +    major,
              +    minor,
              +    patch,
              +    prerelease: groups?.["prerelease"] !== undefined,
              +  };
              +}
              +
              +function compareNumericIdentifier(left: string, right: string): number {
              +  if (left.length !== right.length) return left.length < right.length ? -1 : 1;
              +  if (left === right) return 0;
              +  return left < right ? -1 : 1;
              +}
              +
              +function isVersionBelow(current: ParsedProviderVersion, minimum: ParsedProviderVersion): boolean {
              +  const majorComparison = compareNumericIdentifier(current.major, minimum.major);
              +  if (majorComparison !== 0) return majorComparison < 0;
              +  const minorComparison = compareNumericIdentifier(current.minor, minimum.minor);
              +  if (minorComparison !== 0) return minorComparison < 0;
              +  const patchComparison = compareNumericIdentifier(current.patch, minimum.patch);
              +  if (patchComparison !== 0) return patchComparison < 0;
              +  return current.prerelease && !minimum.prerelease;
              +}
              +
              +export function resolveModelCompatibility(input: ModelCompatibilityInput): ModelCompatibility {
              +  const model = normalizeModelSlug(input.model, input.provider);
              +  const minimumProviderVersion = getModelCapabilities(input.provider, model).minimumProviderVersion;
              +  if (minimumProviderVersion === undefined) {
              +    return { selectable: true };
              +  }
              +
              +  const current = parseProviderVersion(input.providerVersion);
              +  const minimum = parseProviderVersion(minimumProviderVersion);
              +  if (current === null || minimum === null || !isVersionBelow(current, minimum)) {
              +    return { selectable: true };
              +  }
              +
              +  return {
              +    selectable: false,
              +    reason: `Update ${input.providerDisplayName ?? PROVIDER_DISPLAY_NAMES[input.provider]} to ${minimumProviderVersion} or newer to use ${input.modelDisplayName ?? formatModelDisplayName(model) ?? input.model}.`,
              +  };
              +}