From cef4db368b102d48fbca8a17d428144216010f76 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:35:13 -0400 Subject: [PATCH 1/6] Redesign the Work new-chat composer --- .../app/LinearQuickViewButton.test.tsx | 12 +- .../chat/AgentChatComposer.test.tsx | 182 ++-- .../components/chat/AgentChatComposer.tsx | 854 ++++++++---------- .../components/chat/AgentChatPane.test.tsx | 99 +- .../components/chat/AgentChatPane.tsx | 141 ++- .../chat/CrossMachineHandoffModal.tsx | 18 +- .../components/chat/DraftMachinePicker.tsx | 149 +++ .../components/chat/useDraftMachineRouting.ts | 49 +- .../shared/ModelPicker/ModelListRow.tsx | 75 +- .../shared/ModelPicker/ModelPicker.test.tsx | 347 ++++++- .../shared/ModelPicker/ModelPicker.tsx | 183 +++- .../shared/ModelPicker/ModelPickerContent.tsx | 63 +- .../ModelPicker/ReasoningEffortPicker.tsx | 17 +- .../shared/PermissionModePicker.tsx | 23 +- .../shared/SessionLaunchModelControls.tsx | 4 +- .../terminals/LaneCombobox.test.tsx | 190 ++++ .../components/terminals/LaneCombobox.tsx | 443 ++++----- .../components/usage/ActivityHeatmap.tsx | 127 +++ .../components/usage/ActivityModule.tsx | 302 +++---- .../components/usage/activityIntensity.ts | 78 ++ .../renderer/components/usage/usage.test.tsx | 221 ++++- .../renderer/hooks/usePrefersReducedMotion.ts | 23 + apps/desktop/src/renderer/index.css | 118 +++ docs/features/chat/README.md | 4 +- docs/features/chat/composer-and-ui.md | 162 +++- 25 files changed, 2681 insertions(+), 1203 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/LaneCombobox.test.tsx create mode 100644 apps/desktop/src/renderer/components/usage/ActivityHeatmap.tsx create mode 100644 apps/desktop/src/renderer/components/usage/activityIntensity.ts create mode 100644 apps/desktop/src/renderer/hooks/usePrefersReducedMotion.ts diff --git a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.test.tsx b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.test.tsx index c8e275e80..32140dd61 100644 --- a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.test.tsx +++ b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.test.tsx @@ -11,19 +11,19 @@ import { BatchLaunchStatusToast } from "./BatchLaunchStatusToast"; vi.mock("../shared/ModelPicker/ModelPicker", () => ({ ModelPicker: ({ - fastModeActive, + fastMode, fastModeSupported, - onFastModeToggle, + onFastModeChange, }: { - fastModeActive: boolean; + fastMode: boolean; fastModeSupported: boolean; - onFastModeToggle: (next: boolean) => void; + onFastModeChange: (next: boolean) => void; }) => fastModeSupported ? ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 00f7f5e54..ffbd49ea3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -234,6 +234,21 @@ function makeLinearIssue(overrides: Partial = {}): Normal }; } +/** + * Issue context lives in the composer's overflow control, which renders as a + * plain button when it holds a single entry and as a menu when it holds more. + * Tests care about reaching it, not about which form it took. + */ +function openIssueContext() { + const inline = screen.queryByRole("button", { name: "Issue context" }); + if (inline) { + fireEvent.click(inline); + return; + } + fireEvent.click(screen.getByRole("button", { name: "More composer controls" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: /Issue context/ })); +} + describe("AgentChatComposer", () => { it("re-resolves credentialed smart-link previews when the composer is reused", async () => { const url = "https://github.com/arul28/ADE/pull/835"; @@ -1092,30 +1107,73 @@ describe("AgentChatComposer", () => { }); }); - it("toggles Codex fast mode for supported models", () => { - const onFastModeChange = vi.fn(); + it("renders the overflow entry directly when only one control survives gating", () => { + // A "⋯" that opens onto a single row is a menu pretending to be a button. + // Surfaces gate these entries independently, so on a CLI draft only one may + // survive — it should be reachable in one click, not two. + renderComposer({ turnActive: false, draft: "" }); + + expect(screen.queryByRole("button", { name: "More composer controls" })).toBeNull(); + expect(screen.getByRole("button", { name: "Issue context" })).toBeTruthy(); + }); + + it("collapses into a real menu once a second control is available", () => { + renderComposer({ + turnActive: false, + draft: "", + showParallelChatToggle: true, + onParallelChatModeChange: vi.fn(), + }); + + fireEvent.click(screen.getByRole("button", { name: "More composer controls" })); + expect(screen.getByRole("menuitemcheckbox", { name: /Issue context/ })).toBeTruthy(); + expect(screen.getByRole("menuitemcheckbox", { name: /Parallel models/i })).toBeTruthy(); + }); + + it("pairs background launch with Send as one split control", () => { + // Two adjacent circular buttons both carrying an arrow read as one control + // duplicated, so background launch is a row on Send's caret instead. + renderComposer({ + turnActive: false, + draft: "Launch this.", + onSubmitInBackground: vi.fn(), + }); + + const send = screen.getByRole("button", { name: "Send" }); + const caret = screen.getByRole("button", { name: "Send options" }); + expect(send.parentElement?.parentElement).toBe(caret.parentElement?.parentElement); + + fireEvent.click(caret); + expect(screen.getByRole("menuitem", { name: /Launch in background/ })).toBeTruthy(); + }); + + it("no longer owns a standalone fast-mode toolbar control", () => { + // Fast mode is a property of the model, so it moved onto the model row in + // the shared ModelPicker (toggle behaviour is covered by ModelPicker.test). + // What this suite owns is that the composer stopped rendering a fourth pill + // beside the model name. renderComposer({ sessionProvider: "codex", modelId: "openai/gpt-5.5", availableModelIds: ["openai/gpt-5.5"], fastMode: false, - onFastModeChange, + onFastModeChange: vi.fn(), }); - const fastButton = screen.getByRole("button", { name: "Fast mode" }); - expect(fastButton.getAttribute("aria-pressed")).toBe("false"); - - const toolbarButtons = screen.getAllByRole("button"); - expect(toolbarButtons.indexOf(screen.getByRole("button", { name: /Select model/i }))).toBeLessThan( - toolbarButtons.indexOf(screen.getByRole("button", { name: "Reasoning effort" })), - ); - expect(toolbarButtons.indexOf(screen.getByRole("button", { name: "Reasoning effort" }))).toBeLessThan( - toolbarButtons.indexOf(fastButton), - ); + expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull(); + expect(document.querySelector("[data-chat-composer-fast-toggle]")).toBeNull(); + }); - fireEvent.click(fastButton); + it("names fast mode on the collapsed model trigger", () => { + renderComposer({ + sessionProvider: "codex", + modelId: "openai/gpt-5.5", + availableModelIds: ["openai/gpt-5.5"], + fastMode: true, + onFastModeChange: vi.fn(), + }); - expect(onFastModeChange).toHaveBeenCalledWith(true); + expect(screen.getByRole("button", { name: /Select model/i }).textContent).toMatch(/Fast/); }); it("hides Codex fast mode for unsupported models", () => { @@ -1378,7 +1436,10 @@ describe("AgentChatComposer", () => { }); expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); - expect((screen.getByRole("button", { name: "Launch in background" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Send options" })); + expect( + (screen.getByRole("menuitem", { name: /Launch in background/ }) as HTMLButtonElement).disabled, + ).toBe(true); fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); @@ -1566,8 +1627,8 @@ describe("AgentChatComposer", () => { onStartOrchestratorChat, }); - const button = screen.getByRole("button", { name: "Start orchestrator mode" }); - fireEvent.click(button); + fireEvent.click(screen.getByRole("button", { name: "More composer controls" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: /Start orchestrator mode/ })); expect(onStartOrchestratorChat).toHaveBeenCalledTimes(1); }); @@ -1582,11 +1643,16 @@ describe("AgentChatComposer", () => { orchestratorModeActive: true, }); - const button = screen.getByRole("button", { name: "Orchestrator mode active" }); - expect(button.getAttribute("aria-pressed")).toBe("true"); + // Active state has to survive being folded away, so the collapsed trigger + // carries a dot and the row itself reports aria-checked. + const trigger = screen.getByRole("button", { name: "More composer controls" }); + fireEvent.click(trigger); + + const row = screen.getByRole("menuitemcheckbox", { name: /Orchestrator mode/ }); + expect(row.getAttribute("aria-checked")).toBe("true"); expect(container.querySelector("[data-chat-composer-orchestrator-glow]")).toBeTruthy(); - fireEvent.click(button); + fireEvent.click(row); expect(onStopOrchestratorChat).toHaveBeenCalledTimes(1); }); @@ -1620,7 +1686,7 @@ describe("AgentChatComposer", () => { onAddContextAttachment: vi.fn(), }); - fireEvent.click(screen.getByRole("button", { name: "Attach issue context" })); + openIssueContext(); const menu = document.body.querySelector("[data-issue-context-menu]"); const composerShell = container.querySelector("[data-chat-composer-mode]"); @@ -1653,7 +1719,7 @@ describe("AgentChatComposer", () => { onOpenLinearSettings, }); - fireEvent.click(screen.getByRole("button", { name: "Attach issue context" })); + openIssueContext(); fireEvent.click(screen.getByRole("button", { name: /Linear issue/i })); await screen.findByText(/Linear token missing/i); @@ -1689,7 +1755,7 @@ describe("AgentChatComposer", () => { onAddContextAttachment, }); - fireEvent.click(screen.getByRole("button", { name: "Attach issue context" })); + openIssueContext(); fireEvent.click(screen.getByRole("button", { name: /Linear issue/i })); await waitFor(() => expect(searchLinearIssues).toHaveBeenCalled()); @@ -1755,7 +1821,7 @@ describe("AgentChatComposer", () => { onAddContextAttachment: vi.fn(), }); - fireEvent.click(screen.getByRole("button", { name: "Attach issue context" })); + openIssueContext(); fireEvent.click(screen.getByRole("button", { name: /Linear issue/i })); await waitFor(() => expect(screen.getAllByText("ADE-123").length).toBeGreaterThan(0)); @@ -2201,7 +2267,8 @@ describe("AgentChatComposer", () => { onParallelChatModeChange, }); - fireEvent.click(screen.getByRole("button", { name: /Parallel models/i })); + fireEvent.click(screen.getByRole("button", { name: "More composer controls" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: /Parallel models/i })); expect(onParallelChatModeChange).toHaveBeenCalledWith(true); }); @@ -2318,68 +2385,5 @@ describe("AgentChatComposer", () => { } }); - describe("machine chip", () => { - const REMOTE_TAB = { - kind: "remote" as const, - key: "remote:target-1:project-1", - targetId: "target-1", - runtimeName: "MacBook Pro (97)", - projectId: "project-1", - rootPath: "/remote/ADE", - displayName: "ADE", - }; - - afterEach(() => { - useAppStore.setState({ - projectBinding: null, - openRemoteProjectTabs: [], - } as any); - }); - - it("states the machine as read-only fact once a lane is selected", () => { - useAppStore.setState({ - projectBinding: REMOTE_TAB, - openRemoteProjectTabs: [REMOTE_TAB], - } as any); - - renderComposer({ turnActive: false, draft: "", laneSelectionId: "lane-7" }); - - const chip = document.querySelector('[data-chat-composer-machine-chip="readonly"]'); - expect(chip).toBeTruthy(); - expect(chip?.textContent).toContain("MacBook Pro (97)"); - expect(document.querySelector('[data-chat-composer-machine-chip="picker"]')).toBeNull(); - }); - - it("names This Mac absolutely when the tab is bound locally", () => { - renderComposer({ turnActive: false, draft: "", laneSelectionId: "lane-7" }); - - const chip = document.querySelector('[data-chat-composer-machine-chip="readonly"]'); - expect(chip?.textContent).toContain("This Mac"); - expect(chip?.textContent).not.toMatch(/remote/i); - }); - - it("becomes a picker while the lane is still auto-create", () => { - const onMachineChange = vi.fn(); - useAppStore.setState({ openRemoteProjectTabs: [REMOTE_TAB] } as any); - - renderComposer({ - turnActive: false, - draft: "", - laneSelectionId: "__ade_auto_create_lane__", - onMachineChange, - }); - - const trigger = screen.getByRole("button", { - name: "Choose machine, currently This Mac", - }); - expect(trigger.textContent).toContain("This Mac"); - expect(trigger.textContent).not.toContain("Run on"); - - fireEvent.click(trigger); - fireEvent.click(screen.getByRole("menuitem", { name: /MacBook Pro \(97\)/ })); - - expect(onMachineChange).toHaveBeenCalledWith("target-1"); - }); - }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 7fa011926..94053121c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DesktopTower, DeviceMobile, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; +import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DeviceMobile, DotsThree, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; import { inferAttachmentType, @@ -74,8 +74,6 @@ import { CURSOR_MODE_LABELS } from "../../../shared/cursorModes"; import { ChatProposedPlanCard } from "./ChatProposedPlanCard"; import { ChatModelSelectionPendingCard } from "./ChatModelSelectionPendingCard"; import { ChatCommandMenu, type ChatCommandMenuItem, type ChatCommandMenuHandle } from "./ChatCommandMenu"; -import { isAutoCreateLaneOptionId } from "../terminals/LaneCombobox"; -import { switchToThisMachineProject } from "./thisMachineProjectRoot"; import { modifierKeyLabel } from "../../lib/platform"; import { canOpenInAdeBrowser, openUrlInAdeBrowser } from "../../lib/openExternal"; import { @@ -524,186 +522,328 @@ const COMPOSER_TOOLBAR_PICKER_TRIGGER = "max-w-[min(9.5rem,34vw)] shrink min-w-0 // has given up its width. const COMPOSER_MODEL_TRIGGER = "max-w-[min(9.5rem,34vw)] shrink min-w-[4.5rem]"; -function ComposerFastModeButton({ - active, - disabled = false, - supported, - onToggle, +const COMPOSER_PERMISSION_TRIGGER_CLASS = cn( + "ade-chat-composer-permission-trigger", + "inline-flex h-6 min-w-0 shrink-0 items-center justify-start gap-1 rounded-md border px-1.5", + "font-sans text-[length:calc(var(--chat-font-size)*9/14)] leading-none transition-colors duration-150", + "border-white/[0.06] bg-white/[0.03] text-fg/80", + "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", +); + +const COMPOSER_COMPACT_MENU_WIDTH = 240; + +/** + * Idle-state Send, as one split control rather than two buttons. + * + * Background launch used to be a second filled circle immediately right of Send + * — two adjacent round buttons, both carrying an arrow, both meaning "go". It + * read as one control accidentally duplicated, and the extra circle overflowed + * the composer's padding and clipped against its rounded edge. + * + * This is deliberately the same shape as `ActiveTurnSendButton` above: one + * `rounded-full` body, the primary action on the left, a hairline divider, and + * a caret sharing the same fill. The composer now uses one send idiom whether a + * turn is running or not, and the caret cannot be mistaken for a second arrow. + */ +function ComposerIdleSendButton({ + label, + description, + effect, + icon, + sendEnabled, + backgroundLabel, + backgroundEnabled, + backgroundBusy, + onSend, + onSendInBackground, }: { - active: boolean; - disabled?: boolean; - supported: boolean; - onToggle?: (next: boolean) => void; + label: string; + description: string; + effect?: string | undefined; + icon: React.ReactNode; + sendEnabled: boolean; + backgroundLabel: string; + backgroundEnabled: boolean; + backgroundBusy: boolean; + onSend: () => void; + onSendInBackground: () => void; }) { - if (!supported) return null; + const { caretRef, menuOpen, setMenuOpen } = useComposerSplitMenu("[data-idle-send-menu]"); + + const rows = [ + { + id: "send", + label, + detail: description, + icon: , + enabled: sendEnabled, + onSelect: onSend, + }, + { + id: "background", + label: backgroundBusy ? "Launching…" : backgroundLabel, + detail: "Start this chat without leaving the new chat pane.", + icon: , + enabled: backgroundEnabled, + onSelect: onSendInBackground, + }, + ]; return ( - +
+
+ + + + + + +
+ {menuOpen && caretRef.current + ? createPortal( +
+ {rows.map((row, index) => ( + + ))} +
, + document.body, + ) + : null} +
); } -/** One selectable machine in the composer's machine chip. */ -export type ComposerMachineOption = { - /** `"local"` for this Mac, otherwise the remote target id. */ +/** One row in the composer's overflow menu. */ +export type ComposerOverflowItem = { id: string; - name: string; + label: string; + icon: React.ReactNode; + /** Reflected as `aria-checked`; toggles read as on/off rather than commands. */ + active?: boolean; + disabled?: boolean; + /** Count shown beside the label, and on the inline button when collapsed. */ + badge?: number | undefined; + onSelect: () => void; }; -import { - THIS_MACHINE_ID as COMPOSER_LOCAL_MACHINE_ID, - THIS_MACHINE_NAME as COMPOSER_LOCAL_MACHINE_NAME, -} from "../../../shared/machineIdentity"; -export { COMPOSER_LOCAL_MACHINE_ID, COMPOSER_LOCAL_MACHINE_NAME }; - -const EMPTY_REMOTE_PROJECT_TABS: Extract[] = []; -const EMPTY_PROJECT_TAB_ROOTS: string[] = []; - /** - * States a fact when the chat already has a lane (the machine is settled and - * cannot change), and becomes a picker while the lane is still "Auto-create - * lane" (the machine is still a choice). Same slot and position either way — - * only the affordance changes. + * Secondary composer toggles, folded behind one control. + * + * These are real features, but they are opened rarely and each one was carrying + * its own bordered, tinted button in the toolbar — so the row spent most of its + * width on controls nobody was reaching for, and the send button had to compete + * with four other coloured affordances to read as the primary action. Behind a + * single glyph they cost one slot instead of four and keep their labels, which + * icon-only buttons never had. + * + * Items that would not have rendered before are simply not passed in; with none + * left the trigger itself disappears rather than opening an empty menu. */ -function ComposerMachineChip({ - machineId, - machineName, - selectable, - options, - onChange, - disabled = false, +function ComposerOverflowMenu({ + items, + triggerRef, }: { - machineId: string; - machineName: string; - selectable: boolean; - options: ComposerMachineOption[]; - onChange?: (machineId: string) => void; - disabled?: boolean; + items: ComposerOverflowItem[]; + /** + * Anchor for popovers owned by a row rather than by the menu (issue context + * opens its own portal). Those popovers position against a live element, and + * the row that opened them unmounts with the menu — so they anchor to the + * trigger, which is always mounted. + */ + triggerRef?: React.MutableRefObject; }) { - const [menuOpen, setMenuOpen] = useState(false); - - useEffect(() => { - if (!menuOpen) return; - const close = () => setMenuOpen(false); - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") setMenuOpen(false); - }; - document.addEventListener("mousedown", close); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", close); - document.removeEventListener("keydown", onKey); - }; - }, [menuOpen]); - - if (!machineName) return null; - - if (!selectable) { + // The composer shell clips its overflow, so an inline-absolute menu is cut off + // at the prompt box edge and simply cannot be read. Every other composer + // popover portals to the body for this reason; this one now does too. + const { caretRef, menuOpen: open, setMenuOpen: setOpen } = useComposerSplitMenu("[data-composer-overflow-menu]"); + + if (items.length === 0) return null; + + // A "⋯" that opens onto a single row is a menu pretending to be a button. + // Surfaces gate these entries independently, so how many survive is + // contextual — when only one does, show it directly instead. + const [only] = items; + if (items.length === 1 && only) { return ( - - + ); } + const activeCount = items.filter((item) => item.active).length; + return ( - - - {menuOpen ? ( - event.stopPropagation()} - className="absolute bottom-7 left-0 z-50 flex min-w-[10rem] flex-col rounded-lg border border-white/[0.08] bg-[var(--color-popup-bg)] p-1 shadow-2xl" + + + {open && caretRef.current + ? createPortal( +
- - {option.name} - {option.id === machineId ? : null} - - ))} - - ) : null} - + {items.map((item) => ( + + ))} +
, + document.body, + ) + : null} + ); } -const COMPOSER_PERMISSION_TRIGGER_CLASS = cn( - "ade-chat-composer-permission-trigger", - "inline-flex h-6 min-w-0 shrink-0 items-center justify-start gap-1 rounded-md border px-1.5", - "font-sans text-[length:calc(var(--chat-font-size)*9/14)] leading-none transition-colors duration-150", - "border-white/[0.06] bg-white/[0.03] text-fg/80", - "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", -); - -const COMPOSER_COMPACT_MENU_WIDTH = 240; - const CLAUDE_MODE_OPTIONS: Array> = [ @@ -1317,12 +1457,6 @@ export function AgentChatComposer({ showAppControlToggle = false, appControlOpen = false, onToggleAppControl, - laneSelectionId = null, - machineSelectable, - machineId: machineIdOverride, - machineName: machineNameOverride, - machineOptions: machineOptionsOverride, - onMachineChange, }: { surfaceMode?: ChatSurfaceMode; layoutVariant?: "standard" | "grid-tile"; @@ -1515,23 +1649,6 @@ export function AgentChatComposer({ showAppControlToggle?: boolean; appControlOpen?: boolean; onToggleAppControl?: () => void; - /** - * The lane the composer will launch into. When it is the synthetic - * "Auto-create lane" option the machine is still a choice, so the machine - * chip becomes a picker; any real lane id settles the machine and the chip - * degrades to a read-only statement of fact. - */ - laneSelectionId?: string | null; - /** Explicitly controls the draft-only picker affordance. */ - machineSelectable?: boolean; - /** Current machine id, used to mark the selected menu row. */ - machineId?: string; - /** Overrides the machine name derived from the active project binding. */ - machineName?: string; - /** Overrides the machine list derived from the open project tabs. */ - machineOptions?: ComposerMachineOption[]; - /** Overrides the default rebind-this-tab behaviour of the machine picker. */ - onMachineChange?: (machineId: string) => void; }) { const promptStashRef = useRef(null); const promptStashButtonEnabled = useRootAppStore((state) => state.promptStashButtonEnabled); @@ -1651,75 +1768,6 @@ export function AgentChatComposer({ && !composerInputLocked && draft.trim().length > 0; - // ── Machine chip ───────────────────────────────────────────────────────── - // Machines are named absolutely in the Work tab: the tab's machine is a - // switchable dimension, so "remote" has no fixed referent here. Everything - // below reads renderer state that is already loaded — no extra IPC. - const projectBinding = useAppStore((s) => s.projectBinding); - // Stable empty fallbacks: surfaces that seed only part of the app store must - // not crash the machine chip. - const openRemoteProjectTabs = useAppStore((s) => s.openRemoteProjectTabs) ?? EMPTY_REMOTE_PROJECT_TABS; - const openProjectTabRoots = useAppStore((s) => s.openProjectTabRoots) ?? EMPTY_PROJECT_TAB_ROOTS; - const localProjectRootPath = useAppStore((s) => s.project?.rootPath ?? null); - const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); - const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); - - const machineName = machineNameOverride - ?? (projectBinding?.kind === "remote" ? projectBinding.runtimeName : COMPOSER_LOCAL_MACHINE_NAME); - const machineId = machineIdOverride - ?? (projectBinding?.kind === "remote" ? projectBinding.targetId : COMPOSER_LOCAL_MACHINE_ID); - const machineOptions = useMemo(() => { - if (machineOptionsOverride) return machineOptionsOverride; - const options: ComposerMachineOption[] = [ - { id: COMPOSER_LOCAL_MACHINE_ID, name: COMPOSER_LOCAL_MACHINE_NAME }, - ]; - for (const tab of openRemoteProjectTabs) { - if (options.some((option) => option.id === tab.targetId)) continue; - options.push({ id: tab.targetId, name: tab.runtimeName }); - } - return options; - }, [machineOptionsOverride, openRemoteProjectTabs]); - // Opt-in: an unknown lane selection is treated as settled, so the chip only - // claims to be a control when the host says the lane is still auto-create. - const machineChipSelectable = machineSelectable ?? isAutoCreateLaneOptionId(laneSelectionId); - const [machineSwitchError, setMachineSwitchError] = useState(null); - const handleMachineChange = useCallback( - (machineId: string) => { - if (onMachineChange) { - onMachineChange(machineId); - return; - } - setMachineSwitchError(null); - // Default: the machine IS the tab binding, so choosing one rebinds this - // tab rather than opening a second one. Which means it must rebind to - // THIS repo's checkout on that machine — switching repositories behind - // the user's back is not a machine switch. - if (machineId === COMPOSER_LOCAL_MACHINE_ID) { - void switchToThisMachineProject({ - projectBinding, - openProjectTabRoots, - localProjectRootPath, - switchProjectToPath, - }).then(setMachineSwitchError); - return; - } - const tab = openRemoteProjectTabs.find((entry) => entry.targetId === machineId); - if (!tab) return; - void switchRemoteProject(tab.targetId, tab.projectId).catch((reason: unknown) => { - setMachineSwitchError(reason instanceof Error ? reason.message : String(reason)); - }); - }, - [ - localProjectRootPath, - onMachineChange, - openProjectTabRoots, - openRemoteProjectTabs, - projectBinding, - switchProjectToPath, - switchRemoteProject, - ], - ); - // ── Voice dictation ────────────────────────────────────────────────────── const voiceInputEnabled = useAppStore((s) => s.voiceInputEnabled); const voiceModelInstalled = useVoiceModelInstalled(voiceInputEnabled); @@ -3995,21 +4043,6 @@ export function AgentChatComposer({ ) : null} - {machineSwitchError ? ( -
- {machineSwitchError} - -
- ) : null} onParallelSlotFastModeChange?.(parallelConfiguringIndex, next)} /> - onParallelSlotFastModeChange?.(parallelConfiguringIndex, next)} - /> ) : null} {!hideModelControls && !parallelChatMode && (orchestrationRole !== "lead" || !sessionId) ? ( @@ -4628,6 +4658,9 @@ export function AgentChatComposer({ disabled={modelSelectionLocked} compact triggerClassName={COMPOSER_MODEL_TRIGGER} + fastMode={fastModeActive} + fastModeSupported={fastModeSupported} + {...(onFastModeChange ? { onFastModeChange } : {})} /> - ) : null} - {!hideModelControls ? ( - - ) : null} - - - - - {showOrchestratorModeButton ? ( - - - - ) : null} - - {showParallelChatToggle && !parallelChatMode ? ( - - - - ) : null} - - {cursorCloudAvailable && (onOpenCloudLaunchMode || onOpenCloudBringToLocal) ? ( + {cursorCloudAvailable && (onOpenCloudLaunchMode || onOpenCloudBringToLocal) ? ( ) : null} - {showIosSimulatorToggle && onToggleIosSimulator ? ( - - - - ) : null} - - {showAppControlToggle && onToggleAppControl ? ( - - - - ) : null} + {/* Secondary toggles, folded behind one glyph. Each entry is still + gated by exactly the condition that used to gate its button, so + a control that would not have rendered does not become a row. */} + , + // Reads as "on" while issues are attached, so the collapsed + // trigger's dot reports them without needing its own badge. + active: contextAttachmentCount > 0, + disabled: !canAttachIssueContext, + badge: contextAttachmentCount || undefined, + onSelect: () => { + if (!canAttachIssueContext) return; + setAttachmentPickerOpen(false); + setIssueContextMenuOpen((open) => !open); + }, + }, + ...(showOrchestratorModeButton + ? [{ + id: "orchestrator", + label: orchestratorModeActive ? "Orchestrator mode" : "Start orchestrator mode", + icon: , + active: orchestratorModeActive, + disabled: orchestratorModeButtonDisabled, + onSelect: () => { + if (orchestratorModeButtonDisabled) return; + if (orchestratorModeActive) { + onStopOrchestratorChat?.(); + return; + } + onStartOrchestratorChat?.(); + }, + }] + : []), + ...(showParallelChatToggle && !parallelChatMode + ? [{ + id: "parallel", + label: "Parallel models", + icon: , + disabled: turnActive || busy, + onSelect: () => onParallelChatModeChange?.(true), + }] + : []), + ...(showIosSimulatorToggle && onToggleIosSimulator + ? [{ + id: "ios-simulator", + label: iosSimulatorOpen ? "Close iOS simulator" : "Open iOS simulator", + icon: , + active: iosSimulatorOpen, + onSelect: onToggleIosSimulator, + }] + : []), + ...(showAppControlToggle && onToggleAppControl + ? [{ + id: "app-control", + label: appControlOpen ? "Close App Control" : "Open App Control", + icon: , + active: appControlOpen, + onSelect: onToggleAppControl, + }] + : []), + ]} + /> {/* Voice dictation — paired just left of the send control. */} {voiceInputEnabled && !composerInputLocked && !parallelChatMode ? ( @@ -4976,8 +4907,14 @@ export function AgentChatComposer({ : cloudMode ? "Launch a Cursor Cloud agent with this prompt and the panel's settings." : "Send this prompt to the selected model."; - return ( - <> + const backgroundAvailable = Boolean(onSubmitInBackground) && !parallelChatMode && !cloudMode; + const sendIcon = cloudMode + ? + : ; + + // Without a background option this is a plain circular Send. + if (!backgroundAvailable) { + return ( - {onSubmitInBackground && !parallelChatMode && !cloudMode ? ( - - - - ) : null} - + ); + } + + return ( + ); })() )} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index f5db66bb6..b902b74ea 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1252,6 +1252,27 @@ async function clickEnabledModelOption(name: RegExp | string) { fireEvent.click(enabledOption!); } +/** + * Background launch is a row on the send split now, not a sibling button. + * Opens the menu only when it is closed so repeated lookups inside one test do + * not toggle it shut. + */ +async function findBackgroundLaunchRow(container?: HTMLElement): Promise { + // The caret belongs to the pane under test, but the menu it opens is + // portalled to document.body — so scoping the row lookup to `container` finds + // nothing. Scope only the trigger; always read the row off `screen`. + const triggerScope = container ? within(container) : screen; + if (!screen.queryByRole("menuitem", { name: /in background/i })) { + fireEvent.click(await triggerScope.findByRole("button", { name: "Send options" })); + } + return (await screen.findByRole("menuitem", { name: /in background/i })) as HTMLButtonElement; +} + +/** The collapsed model trigger, which now carries the fast-mode suffix. */ +async function findModelTrigger(): Promise { + return await screen.findByRole("button", { name: /current: /i }); +} + function sessionTabTitles(expectedTitles: string[]) { const tabs = screen.getAllByRole("button") .filter((button) => expectedTitles.includes(button.textContent?.trim() ?? "")); @@ -1446,7 +1467,7 @@ describe("AgentChatPane pane reserve", () => { , ); - expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + expect(await screen.findByAltText("ADE")).toBeTruthy(); expect(readLeftReserve(container)).toBe("0px"); }); }); @@ -2126,7 +2147,7 @@ describe("AgentChatPane submit recovery", () => { availableModelIdsOverride: ["anthropic/claude-sonnet-5"], }); - expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + expect(await screen.findByAltText("ADE")).toBeTruthy(); const includedModelLabel = getModelById("anthropic/claude-sonnet-5")?.displayName ?? "Claude Sonnet 5"; const trigger = await screen.findByRole("button", { name: /^Select model/ }); fireEvent.pointerDown(trigger, { button: 0 }); @@ -2247,7 +2268,7 @@ describe("AgentChatPane submit recovery", () => { const modelLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; expect(await screen.findByRole("button", { name: new RegExp(`current: ${escapeRegExp(modelLabel)}`, "i") })).toBeTruthy(); - expect((screen.getByRole("button", { name: "Fast mode" })).getAttribute("aria-pressed")).toBe("true"); + expect((await findModelTrigger()).textContent).toMatch(/Fast/); expect(screen.getByLabelText("Reasoning effort").textContent).toContain("XH"); expect(screen.getByRole("button", { name: "Codex permission mode" }).textContent).toContain("Full"); @@ -2311,9 +2332,9 @@ describe("AgentChatPane submit recovery", () => { }); const approvalButton = await screen.findByRole("button", { name: "Codex permission mode" }); - await waitFor(() => { + await waitFor(async () => { expect(approvalButton.textContent).toContain("Full"); - expect((screen.getByRole("button", { name: "Fast mode" })).getAttribute("aria-pressed")).toBe("true"); + expect((await findModelTrigger()).textContent).toMatch(/Fast/); expect(screen.getByLabelText("Reasoning effort").textContent).toContain("HI"); }); @@ -3778,7 +3799,14 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - fireEvent.click(await screen.findByRole("button", { name: "Fast mode" })); + // Fast mode lives on the model row now, so reach it the same way every + // other model interaction in this suite does. + const modelTrigger = await findModelTrigger(); + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + fireEvent.click((await screen.findAllByRole("button", { name: /Fast mode for/i }))[0]!); + fireEvent.keyDown(document, { key: "Escape" }); await waitFor(() => { expect(updateSession).toHaveBeenCalledWith(expect.objectContaining({ @@ -3890,8 +3918,7 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - const fastModeButton = await screen.findByRole("button", { name: "Fast mode" }); - expect(fastModeButton.getAttribute("aria-pressed")).toBe("false"); + expect((await findModelTrigger()).textContent).not.toMatch(/Fast/); sessions[0] = { ...session, @@ -3910,8 +3937,8 @@ describe("AgentChatPane submit recovery", () => { }, }); - await waitFor(() => { - expect(fastModeButton.getAttribute("aria-pressed")).toBe("true"); + await waitFor(async () => { + expect((await findModelTrigger()).textContent).toMatch(/Fast/); expect(screen.getByLabelText("Reasoning effort").textContent).toContain("XH"); }); }); @@ -4060,7 +4087,7 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(screen.queryByText("Loading sessions")).toBeNull(); }); - expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + expect(await screen.findByAltText("ADE")).toBeTruthy(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(window.ade.agentChat.models).not.toHaveBeenCalledWith( expect.objectContaining({ provider: "cursor" }), @@ -4110,7 +4137,7 @@ describe("AgentChatPane submit recovery", () => { , ); - expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + expect(await screen.findByAltText("ADE")).toBeTruthy(); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalled(); }); @@ -5454,9 +5481,13 @@ describe("AgentChatPane submit recovery", () => { fireEvent.click(modelTrigger); fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + // Machine and lane are separate shelf controls now, so routing an + // auto-create launch onto This Mac is two choices rather than one + // machine-qualified row inside the lane list. + fireEvent.click(await screen.findByRole("button", { name: /^Choose machine, currently/ })); + fireEvent.click(await screen.findByRole("menuitemradio", { name: /This Mac/ })); fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); - const machineRows = await screen.findAllByText("Auto-create lane here"); - fireEvent.click(machineRows.at(-1)!); + fireEvent.click(await screen.findByText("Auto-create lane")); expect(switchProjectToPath).not.toHaveBeenCalled(); expect(switchRemoteProject).not.toHaveBeenCalled(); @@ -5626,7 +5657,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Launch this in the background." } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); await waitFor(() => { expect(onSessionCreated).toHaveBeenCalledWith( @@ -6076,13 +6107,13 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Launch this and let me keep typing." } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); - await waitFor(() => { + await waitFor(async () => { expect(createLane).toHaveBeenCalled(); expect(screen.getByText(/Creating lane for chat/i)).toBeTruthy(); expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); - expect((screen.getByRole("button", { name: "Auto-create in background" }) as HTMLButtonElement).disabled).toBe(true); + expect((await findBackgroundLaunchRow()).disabled).toBe(true); }); expect((textbox as HTMLTextAreaElement).disabled).toBe(false); expect((textbox as HTMLTextAreaElement).value).toBe(""); @@ -6090,7 +6121,7 @@ describe("AgentChatPane submit recovery", () => { fireEvent.change(textbox, { target: { value: "Next thought while it launches." } }); expect((textbox as HTMLTextAreaElement).value).toBe("Next thought while it launches."); expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(false); - expect((screen.getByRole("button", { name: "Auto-create in background" }) as HTMLButtonElement).disabled).toBe(false); + expect((await findBackgroundLaunchRow()).disabled).toBe(false); resolveCreateLane(); await waitFor(() => { @@ -6129,7 +6160,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Keep this launch visible." } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); await waitFor(() => { expect(createLane).toHaveBeenCalledTimes(1); @@ -6232,7 +6263,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Launch in the background, then leave it hidden." } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); await waitFor(() => { expect(createLane).toHaveBeenCalledTimes(1); @@ -6294,7 +6325,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Surface the failure after remount." } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); await waitFor(() => { expect(send).toHaveBeenCalled(); @@ -6334,7 +6365,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Launch once even if clicked twice." } }); - const launchButton = await screen.findByRole("button", { name: "Auto-create in background" }); + const launchButton = await findBackgroundLaunchRow(); await act(async () => { launchButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); launchButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); @@ -6420,7 +6451,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await within(paneOne).findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Only lane one should show this launch." } }); - fireEvent.click(await within(paneOne).findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow(paneOne)); await waitFor(() => { expect(createLane).toHaveBeenCalledTimes(1); @@ -6451,7 +6482,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); for (let index = 1; index <= 9; index += 1) { fireEvent.change(textbox, { target: { value: `Launch background chat ${index}.` } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); await waitFor(() => { expect(createLane).toHaveBeenCalledTimes(index); }); @@ -6490,14 +6521,14 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "First auto lane." } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); await waitFor(() => { expect(createLane).toHaveBeenCalledTimes(1); expect((textbox as HTMLTextAreaElement).value).toBe(""); }); fireEvent.change(textbox, { target: { value: "Second auto lane." } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); await waitFor(() => { expect(createLane).toHaveBeenCalledTimes(2); expect(screen.getAllByText(/Creating lane for chat/i)).toHaveLength(2); @@ -6928,7 +6959,7 @@ describe("AgentChatPane submit recovery", () => { , ); - expect((await screen.findByRole("button", { name: /Fast mode/i })).getAttribute("aria-pressed")).toBe("true"); + expect((await findModelTrigger()).textContent).toMatch(/Fast/); const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Run Cursor in fast mode." } }); @@ -6995,7 +7026,7 @@ describe("AgentChatPane submit recovery", () => { , ); - expect((await screen.findByRole("button", { name: /Fast mode/i })).getAttribute("aria-pressed")).toBe("true"); + expect((await findModelTrigger()).textContent).toMatch(/Fast/); const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Run OpenCode in fast mode." } }); @@ -7060,7 +7091,7 @@ describe("AgentChatPane submit recovery", () => { ); await screen.findByRole("button", { name: /current: Claude Sonnet 5/i }); - expect(screen.queryByRole("button", { name: /Fast mode/i })).toBeNull(); + expect((await findModelTrigger()).textContent).not.toMatch(/Fast/); const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Run Claude without stale fast mode." } }); @@ -7229,7 +7260,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Launch this CLI session in the background." } }); - fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + fireEvent.click(await findBackgroundLaunchRow()); await waitFor(() => { expect(onLaunchCliSession).toHaveBeenCalledWith(expect.objectContaining({ @@ -8070,7 +8101,8 @@ describe("AgentChatPane submit recovery", () => { fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); - fireEvent.click(await screen.findByRole("button", { name: /Parallel models/i })); + fireEvent.click(await screen.findByRole("button", { name: "More composer controls" })); + fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: /Parallel models/i })); fireEvent.click(screen.getAllByRole("button", { name: "Configure" })[1]!); const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); @@ -8249,7 +8281,8 @@ describe("AgentChatPane submit recovery", () => { fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); - fireEvent.click(await screen.findByRole("button", { name: /Parallel models/i })); + fireEvent.click(await screen.findByRole("button", { name: "More composer controls" })); + fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: /Parallel models/i })); fireEvent.click(screen.getAllByRole("button", { name: "Configure" })[1]!); fireEvent.click(await screen.findByRole("button", { name: /^Select model/ })); fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 94299c81c..66bf73259 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -221,6 +221,7 @@ import { useDraftMachineRouting, type RoutedDraftLane, } from "./useDraftMachineRouting"; +import { DraftMachinePicker } from "./DraftMachinePicker"; import { buildTrackedCliLaunchCommand, LAUNCH_PROFILE_TITLE, @@ -3469,6 +3470,20 @@ export function AgentChatPane({ const [runtimeCatalogVersion, setRuntimeCatalogVersion] = useState(0); const [reasoningEffort, setReasoningEffort] = useState(null); const [fastMode, setFastMode] = useState(false); + /** + * Synchronous mirror of `fastMode`. + * + * The ModelPicker's fast chip can enable fast mode *and* change the model in + * one click, firing both handlers in the same tick. The model-change persist + * below reads the fast bit to include it in `updateSession`, and a render + * closure there still holds the pre-click value — so the write would send the + * stale bit and the reconcile would then clobber the freshly enabled one back + * off. Reading the ref makes that path see the user's actual intent. + */ + const fastModeRef = useRef(false); + useEffect(() => { + fastModeRef.current = fastMode; + }, [fastMode]); const [executionMode, setExecutionMode] = useState("focused"); const [interactionMode, setInteractionMode] = useState(initialNativeControls.interactionMode); // Seed availableModelIds, aiStatus, and providerConnections synchronously @@ -10649,6 +10664,10 @@ export function AgentChatPane({ if (!selectedSessionId) { draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; } + // Written synchronously, not left to the mirroring effect: the picker's + // fast chip can enable fast mode and change the model in the same tick, and + // the model-change persist reads this ref in that same tick. + fastModeRef.current = enabled; setFastMode(enabled); if (!selectedSessionId) return; if (isPersistentIdentitySurface && sessionMutationKind) return; @@ -10729,15 +10748,12 @@ export function AgentChatPane({ && (workDraftKind === "chat" || isWorkCliLaunchDraft); const { machineOptions: laneMachineOptions, - selectorMachines: draftLaneSelectorMachines, selectorLanes: draftLaneSelectorLanes, boundMachineId: boundLaneMachineId, executionLanes: draftExecutionLanes, executionBinding: draftExecutionBinding, selectedMachine: selectedDraftMachine, - selectedLaneIsPrimary: selectedDraftLaneIsPrimary, machineUnavailable: draftMachineUnavailable, - selectorValue: draftLaneSelectorValue, handleMachineChange: handleDraftMachineChange, handleLaneSelectionChange: handleDraftLaneSelectionChange, } = useDraftMachineRouting({ @@ -10755,6 +10771,16 @@ export function AgentChatPane({ setDraftLaunchTargetId, setError, }); + // The shelf picks the machine separately, so its lane list is already scoped + // to one machine — a flat list of bare lane ids rather than the grouped, + // machine-qualified option ids the combined selector needed. + const draftShelfLanes = useMemo( + () => [AUTO_CREATE_LANE_OPTION, ...draftExecutionLanes], + [draftExecutionLanes], + ); + const draftShelfLaneValue = draftLaunchTargetIsAutoCreate + ? AUTO_CREATE_LANE_OPTION.id + : (laneId ?? ""); draftExecutionLanesRef.current = draftExecutionLanes; draftExecutionBindingRef.current = draftExecutionBinding; draftExecutionBindingRequiredRef.current = showDraftLaunchControls; @@ -11782,33 +11808,10 @@ export function AgentChatPane({ ) : null; const composerMachineBinding = activeComposerRuntimeBinding; - const composerMachineId = selectedSessionId - ? (composerMachineBinding?.kind === "remote" ? composerMachineBinding.targetId : "this-mac") - : (selectedDraftMachine?.id ?? boundLaneMachineId); - const composerMachineName = selectedSessionId - ? (composerMachineBinding?.kind === "remote" ? composerMachineBinding.runtimeName : "This Mac") - : (selectedDraftMachine?.name ?? "This Mac"); - const composerMachineSelectable = Boolean( - showDraftLaunchControls - && (draftLaunchTargetIsAutoCreate || selectedDraftLaneIsPrimary), - ); const composerElement = ( ({ - id: machine.id, - name: machine.name, - }))} - onMachineChange={handleDraftMachineChange} layoutVariant={layoutVariant} composerMaxHeightPx={composerMaxHeightPx} isActive={isTileActive} @@ -11972,7 +11975,7 @@ export function AgentChatPane({ sessionId: selectedSessionId, modelId: nextModelId, reasoningEffort: snapshot.nextReasoningEffort, - ...(modelSupportsFastMode(snapshot.nextDesc) ? { fastMode } : {}), + ...(modelSupportsFastMode(snapshot.nextDesc) ? { fastMode: fastModeRef.current } : {}), ...nextNativeControlPayload, }, ...chatPinArgsFor(chatRuntimePinRef)).then((updatedSession) => { applyModelSelectionSnapshot(snapshot); @@ -12933,7 +12936,7 @@ export function AgentChatPane({ )}>
-

- {isOrchestratorDraft ? "Orchestrate a swarm of agents" : "Start a new conversation"} -

+ {/* Only a non-default mode earns a line here. The wordmark + above already says which app this is, so a generic + "Start a new conversation" was a caption on a thing + that needs no caption — and a whole band of vertical + space spent saying nothing the user did not know. */} + {isOrchestratorDraft ? ( +

+ Orchestrate a swarm of agents +

+ ) : null} - {/* Lane selector pill */} + {/* Inline composer for empty state (only when sim drawer closed) */} + {!appPanelOpen ? ( +
+ {composerWithTypographyRoot} +
+ ) : null} + + {/* Launch shelf — everything that answers "where does this + run". It is drawn as a recessed drawer tucked under the + composer rather than as another centered band: the lane + is a setting on the prompt above it, and Shell / Import + act on whatever lane is selected here, so nesting them + under it encodes the dependency the old stacked layout + inverted. */} {showWorkspaceChrome && draftLaneSelectorLanes.length > 0 && onLaneChange ? ( -
+
+ {/* Machine first, then that machine's lanes. Splitting + the two keeps the lane list flat and one machine + long instead of growing with machine count. */} + {onOpenShellSession || onImportedSession ? ( -
+
{onOpenShellSession ? ( ) : null} @@ -13022,8 +13066,12 @@ export function AgentChatPane({ ) : showWorkspaceChrome && laneDisplayLabel ? ( {laneAccentColor ? ( @@ -13040,13 +13088,6 @@ export function AgentChatPane({ ) : null} - {/* Inline composer for empty state (only when sim drawer closed) */} - {!appPanelOpen ? ( -
- {composerWithTypographyRoot} -
- ) : null} - {isWorkDraftComposer && !appPanelOpen ? ( {onReasoningEffortChange ? ( ) : null} - {destinationFastModeSupported && onFastModeChange ? ( - - ) : null} {destinationPermissionPicker}
{mode === "fork" ? ( diff --git a/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx b/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx new file mode 100644 index 000000000..36b9e00ed --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx @@ -0,0 +1,149 @@ +import { CaretDown, Check, DesktopTower } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "../ui/cn"; +import { SmartTooltip } from "../ui/SmartTooltip"; + +export type DraftMachineOption = { + id: string; + name: string; +}; + +const MENU_WIDTH = 220; + +/** + * Machine half of the launch shelf's "where does this run" pair. + * + * Machine and lane are two orthogonal choices, and folding them into one list + * made that list carry both — every lane row had to name its machine, and the + * list grew by machine count rather than staying the length of one machine's + * lanes. Choosing the machine first means the lane list beside it is always + * flat, short, and unambiguous. + * + * Renders nothing with fewer than two machines: there is no choice to make, and + * the shelf should not spend a control saying so. + */ +export function DraftMachinePicker({ + machines, + selectedMachineId, + onChange, + disabled = false, +}: { + machines: readonly DraftMachineOption[]; + selectedMachineId: string | null; + onChange: (machineId: string) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handleDown = (event: MouseEvent) => { + const target = event.target as Element | null; + if (triggerRef.current?.contains(target as Node)) return; + if (target?.closest?.("[data-draft-machine-menu]")) return; + setOpen(false); + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + window.addEventListener("mousedown", handleDown); + window.addEventListener("keydown", handleKey); + return () => { + window.removeEventListener("mousedown", handleDown); + window.removeEventListener("keydown", handleKey); + }; + }, [open]); + + if (machines.length < 2) return null; + + const selected = machines.find((machine) => machine.id === selectedMachineId) ?? machines[0]; + if (!selected) return null; + + return ( +
+ + + + {open && triggerRef.current + ? createPortal( + (() => { + const rect = triggerRef.current.getBoundingClientRect(); + return ( +
+ {machines.map((machine) => { + const active = machine.id === selected.id; + return ( + + ); + })} +
+ ); + })(), + document.body, + ) + : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts b/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts index 00632e262..99a0d20f6 100644 --- a/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts +++ b/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts @@ -315,23 +315,58 @@ export function useDraftMachineRouting({ : (laneId ?? "") ); + /** + * Switching machines re-points the lane, but how far depends on what was + * selected. Auto-create and the primary lane are the two targets ADE + * guarantees exist on every machine running it, so a selection sitting on + * either is still meaningful after the switch and is preserved — primary + * re-points to the new machine's own primary (same role, different lane id), + * and auto-create needs no re-pointing at all. Any other lane is specific to + * the machine it was created on and cannot follow, so it lands on that + * machine's primary instead of leaving the picker pointing at a lane that + * does not exist there. + */ const handleMachineChange = useCallback((nextMachineId: string) => { const nextMachine = machineOptions.find((candidate) => candidate.id === nextMachineId); if (!nextMachine) return; - const primary = primaryLaneForMachine(nextMachineId); - if (!primary) { - setError(`${nextMachine.name} has no primary lane for this repository.`); - return; - } setError(null); + const wasAutoCreate = draftLaunchTargetIsAutoCreate; chooseMachine(nextMachineId); - onLaneChange?.(primary.id); - }, [chooseMachine, machineOptions, onLaneChange, primaryLaneForMachine, setError]); + + const fallback = primaryLaneForMachine(nextMachineId); + if (fallback) { + onLaneChange?.(fallback.id); + } + // Re-assert auto-create *after* re-pointing the lane. `onLaneChange` moves + // the lane pointer to the new machine's primary, and a bare lane pointer + // reads as "a specific lane is selected" — which would silently convert an + // auto-create draft into a primary-lane draft, change the composer's draft + // key, and discard whatever the user had typed. A machine with no lanes at + // all lands here too: auto-create is the one target always launchable. + if (wasAutoCreate || !fallback) { + setDraftLaunchTargetId(AUTO_CREATE_LANE_OPTION_ID); + } + }, [ + chooseMachine, + draftLaunchTargetIsAutoCreate, + machineOptions, + onLaneChange, + primaryLaneForMachine, + setDraftLaunchTargetId, + setError, + ]); const handleLaneSelectionChange = useCallback((nextLaneId: string) => { if (isAutoCreateLaneOptionId(nextLaneId)) { setDraftLaunchTargetId(AUTO_CREATE_LANE_OPTION_ID); + // A bare auto-create id carries no machine. That used to mean "the first + // machine", which was right when one grouped list owned both choices — + // every auto-create row was machine-qualified. The shelf now picks the + // machine in its own control and passes bare ids, so defaulting here + // would silently drag the selection back to the bound machine. Keep + // whatever the machine picker already chose. const nextMachineId = machineIdFromAutoCreateLaneOptionId(nextLaneId) + ?? machineId ?? machineOptions[0]?.id ?? boundMachineId; chooseMachine(nextMachineId); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx index b8bbb0d48..127e4872c 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx @@ -1,9 +1,10 @@ import { memo, useCallback } from "react"; import * as ContextMenu from "@radix-ui/react-context-menu"; import { Star, Lightning } from "@phosphor-icons/react"; -import type { ModelDescriptor } from "../../../../shared/modelRegistry"; +import { modelSupportsFastMode, type ModelDescriptor } from "../../../../shared/modelRegistry"; import { ModelRowLogo } from "../ProviderLogos"; import { cn } from "../../ui/cn"; +import { usePrefersReducedMotion } from "../../../hooks/usePrefersReducedMotion"; const LOCAL_FAMILIES = new Set(["ollama", "lmstudio"]); @@ -41,6 +42,19 @@ export type ModelListRowProps = { onViewDocs?: (modelId: string) => void; onSignIn?: () => void; inlineReasoningChip?: InlineReasoningChipState; + /** + * Whether *this row's* chip reads as on. Fast mode is one bit on the surface, + * but it only applies to the model it was enabled for — the parent derives + * this as `fastMode && isActive` so a toggle never lights every fast-capable + * row at once. + */ + fastModeOn?: boolean; + /** + * Absent when the surface has not opted into fast mode — no chip is drawn. + * Takes the model id because the handler's behaviour differs for the selected + * row (plain toggle) and a non-selected one (select + enable). + */ + onFastModeChange?: (modelId: string, next: boolean) => void; }; const REASONING_LABELS: Record = { @@ -72,9 +86,13 @@ export const ModelListRow = memo(function ModelListRow({ onViewDocs, onSignIn, inlineReasoningChip, + fastModeOn = false, + onFastModeChange, }: ModelListRowProps) { const sub = subProviderLabel(model); const localBadge = isLocalModel(model); + const showFastChip = Boolean(onFastModeChange) && modelSupportsFastMode(model); + const reducedMotion = usePrefersReducedMotion(); const handleSelect = useCallback(() => { if (!isAvailable) { @@ -143,6 +161,29 @@ export const ModelListRow = memo(function ModelListRow({ [reasoningCycleCb], ); + // The chip never falls through to the row's own click handler; whether the + // press also commits a model selection is the parent's call (see + // `ModelPickerContent`), because only it knows which row is selected. + const handleFastChipClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + onFastModeChange?.(model.id, !fastModeOn); + }, + [fastModeOn, model.id, onFastModeChange], + ); + + const handleFastChipKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + onFastModeChange?.(model.id, !fastModeOn); + } + }, + [fastModeOn, model.id, onFastModeChange], + ); + const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { @@ -250,6 +291,38 @@ export const ModelListRow = memo(function ModelListRow({ ) : null} + {showFastChip ? ( + + ) : null} + {!isAvailable && onSignIn ? ( + ); +}); + +/** + * Whether the trigger is showing a fast-mode selection — drives both the + * " Fast" suffix and the lightning glyph so the two can never disagree. + */ +export function modelPickerTriggerIsFast({ + model, + fastMode = false, + fastModeSupported, +}: { + model: ModelDescriptor | undefined; + fastMode?: boolean; + fastModeSupported?: boolean; +}): boolean { + if (!model || !fastMode) return false; + return fastModeSupported ?? modelSupportsFastMode(model); +} + +/** + * Trigger label for a picker. Kept pure (and glyph-free — the lightning is + * rendered separately and is presentational) so the " Fast" suffix rule stays + * testable without mounting the popover. + */ +export function composeModelPickerTriggerLabel({ + model, + value, + fastMode = false, + fastModeSupported, +}: { + model: ModelDescriptor | undefined; + value: string; + fastMode?: boolean; + fastModeSupported?: boolean; +}): string { + const base = model?.displayName ?? (value.trim() || "Select model"); + const fast = modelPickerTriggerIsFast({ + model, + fastMode, + ...(typeof fastModeSupported === "boolean" ? { fastModeSupported } : {}), + }); + return fast ? `${base} Fast` : base; +} + type TriggerProps = { model: ModelDescriptor | undefined; value: string; compact: boolean; disabled: boolean; open: boolean; + fastMode: boolean; + fastModeSupported?: boolean; className?: string; }; const ModelPickerTrigger = memo( forwardRef>( function ModelPickerTrigger( - { model, value, compact, disabled, open, className, ...rest }, + { model, value, compact, disabled, open, fastMode, fastModeSupported, className, ...rest }, ref, ) { - const label = model?.displayName ?? (value.trim() || "Select model"); + const label = composeModelPickerTriggerLabel({ + model, + value, + fastMode, + ...(typeof fastModeSupported === "boolean" ? { fastModeSupported } : {}), + }); + const showFastGlyph = modelPickerTriggerIsFast({ + model, + fastMode, + ...(typeof fastModeSupported === "boolean" ? { fastModeSupported } : {}), + }); return ( - ); -}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index a583f080c..9bf0171fd 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -11,6 +11,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { MagnifyingGlass } from "@phosphor-icons/react"; import { MODEL_REGISTRY, + modelSupportsFastMode, resolveCliProviderForModel, type AuthType, type ModelDescriptor, @@ -147,6 +148,14 @@ export type ModelPickerContentProps = { cursorAvailabilityMode?: "chat" | "cli" | "all"; allowRegistryExpansion?: boolean; registryFilter?: (model: ModelDescriptor) => boolean; + /** + * Fast mode is a single bit on the surface, and it belongs to whichever model + * is selected — so only the selected row ever draws an "on" chip. Without + * `onFastModeChange` the surface has not opted in and no row shows a fast + * affordance. + */ + fastMode?: boolean; + onFastModeChange?: (next: boolean) => void; }; export const ModelPickerContent = memo(function ModelPickerContent({ @@ -165,6 +174,8 @@ export const ModelPickerContent = memo(function ModelPickerContent({ cursorAvailabilityMode = allowCliOnlyModels ? "cli" : "chat", allowRegistryExpansion = true, registryFilter, + fastMode = false, + onFastModeChange, }: ModelPickerContentProps) { // hidePermissionRail is currently a forward-compat hook (see prop docs). // Reference it so unused-var lint stays quiet, and so future code paths @@ -481,6 +492,19 @@ export const ModelPickerContent = memo(function ModelPickerContent({ [cursorAvailabilityMode, effectiveAuth, familyIsReady, isAvailable, matchesCursorAvailabilityMode], ); + const handleRowSelect = useCallback( + (modelId: string) => { + recordUsage(modelId); + // Fast mode belongs to the model it was enabled for, so a plain switch to + // a different model starts clean instead of inheriting the previous + // model's bit. The fast chip re-enables it explicitly (see + // `handleFastChipChange`). + if (fastMode && modelId !== value) onFastModeChange?.(false); + onSelect(modelId); + }, + [fastMode, onFastModeChange, onSelect, recordUsage, value], + ); + const handleListKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Escape") { @@ -514,29 +538,45 @@ export const ModelPickerContent = memo(function ModelPickerContent({ onOpenSignIn?.(target.family); return; } - recordUsage(target.id); - onSelect(target.id); + handleRowSelect(target.id); } }, [ flatVisibleIds, focusedIndex, + handleRowSelect, isAvailableForUse, modelListVirtualizer, onOpenSignIn, onRequestClose, - onSelect, - recordUsage, visibleModels, ], ); - const handleRowSelect = useCallback( - (modelId: string) => { + /** + * One press on a non-selected row's chip means "use this model, fast" — it + * commits the selection and turns fast on. On the selected row it is a plain + * toggle that leaves both the selection and the open popover alone. + */ + const handleFastChipChange = useCallback( + (modelId: string, next: boolean) => { + const model = expandedModels.find((m) => m.id === modelId); + if (!model || !modelSupportsFastMode(model)) return; + if (modelId === value) { + onFastModeChange?.(next); + return; + } + if (!isAvailableForUse(model)) { + onOpenSignIn?.(model.family, model.authTypes); + return; + } recordUsage(modelId); + // Fast first: the host persists the model change with whatever fast bit it + // sees, so the intent has to land before the selection commits. + onFastModeChange?.(true); onSelect(modelId); }, - [onSelect, recordUsage], + [expandedModels, isAvailableForUse, onFastModeChange, onOpenSignIn, onSelect, recordUsage, value], ); const handleSetSurfaceDefault = useCallback( @@ -643,7 +683,12 @@ export const ModelPickerContent = memo(function ModelPickerContent({ aria-label="Search models" className={cn( "min-w-0 flex-1 bg-transparent text-[12px] font-medium leading-tight", - "text-fg placeholder:text-muted-fg/45 outline-none", + // The field is auto-focused on open, which is right — you opened + // it to search. The focus *ring* is the part that reads as an + // error box around the whole row, so suppress the shadow-based + // ring too; `outline-none` alone does not cover it. + "text-fg placeholder:text-muted-fg/45 outline-none focus:outline-none focus-visible:outline-none", + "shadow-none focus:shadow-none focus-visible:shadow-none focus:ring-0 focus-visible:ring-0", )} />
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx index 4a91130c2..eda0ebbf5 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx @@ -406,7 +406,7 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ if (tiers.length === 0) return null; - const label = reasoningChipLabel(displayedEffort, useCodex56Labels) ?? "AUTO"; + const shortLabel = reasoningChipLabel(displayedEffort, useCodex56Labels) ?? "AUTO"; return ( >( function ReasoningEffortTrigger( - { label, tone, compact, disabled, open, className, ...rest }, + { label, shortLabel, tone, compact, disabled, open, className, ...rest }, ref, ) { return ( @@ -591,12 +593,13 @@ const ReasoningEffortTrigger = memo( - {label} + {label} + {shortLabel} = { green: { dot: "bg-emerald-400", - trigger: "border-emerald-400/24 bg-emerald-500/[0.08] text-emerald-100", + trigger: "", iconSurface: "border-emerald-300/20 bg-emerald-500/[0.12] text-emerald-200", rowActive: "bg-emerald-500/[0.12] text-emerald-50", rowHover: "hover:bg-emerald-500/[0.08] hover:text-emerald-50", }, amber: { dot: "bg-amber-400", - trigger: "border-amber-300/22 bg-amber-500/[0.08] text-amber-100", + trigger: "", iconSurface: "border-amber-300/20 bg-amber-500/[0.12] text-amber-200", rowActive: "bg-amber-500/[0.12] text-amber-50", rowHover: "hover:bg-amber-500/[0.08] hover:text-amber-50", }, blue: { dot: "bg-sky-400", - trigger: "border-sky-300/22 bg-sky-500/[0.08] text-sky-100", + trigger: "", iconSurface: "border-sky-300/20 bg-sky-500/[0.12] text-sky-200", rowActive: "bg-sky-500/[0.12] text-sky-50", rowHover: "hover:bg-sky-500/[0.08] hover:text-sky-50", }, purple: { dot: "bg-violet-400", - trigger: "border-violet-300/24 bg-violet-500/[0.09] text-violet-100", + trigger: "", iconSurface: "border-violet-300/20 bg-violet-500/[0.14] text-violet-200", rowActive: "bg-violet-500/[0.14] text-violet-50", rowHover: "hover:bg-violet-500/[0.08] hover:text-violet-50", }, red: { dot: "bg-red-400", - trigger: "border-red-300/24 bg-red-500/[0.09] text-red-100", + // The one tone that keeps colour in the toolbar — bypassed permissions are + // the only setting here that can do damage. It is a tint, not a filled + // pill: enough to catch the eye without shouting over the send button. + trigger: "border-red-400/25 text-red-100/90", iconSurface: "border-red-300/20 bg-red-500/[0.14] text-red-200", rowActive: "bg-red-500/[0.14] text-red-50", rowHover: "hover:bg-red-500/[0.08] hover:text-red-50", }, slate: { dot: "bg-slate-300", - trigger: "border-white/[0.08] bg-white/[0.045] text-fg/80", + trigger: "", iconSurface: "border-white/[0.08] bg-white/[0.06] text-fg/72", rowActive: "bg-white/[0.08] text-fg/90", rowHover: "hover:bg-white/[0.055] hover:text-fg/90", diff --git a/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx b/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx index f47d1b0eb..ac2258a18 100644 --- a/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx +++ b/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx @@ -292,8 +292,8 @@ export function SessionLaunchModelControls({ surfaceKey={surfaceKey} compact triggerClassName={COMPOSER_MODEL_TRIGGER} - fastModeActive={config.fastMode} - onFastModeToggle={(fastMode) => onChange({ fastMode })} + fastMode={config.fastMode} + onFastModeChange={(fastMode) => onChange({ fastMode })} fastModeSupported={batchLaunchSupportsFastMode(config.modelId)} disabled={disabled} /> diff --git a/apps/desktop/src/renderer/components/terminals/LaneCombobox.test.tsx b/apps/desktop/src/renderer/components/terminals/LaneCombobox.test.tsx new file mode 100644 index 000000000..63ad196ea --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LaneCombobox.test.tsx @@ -0,0 +1,190 @@ +/* @vitest-environment jsdom */ + +/* + * Covers the sleek single-line `LaneCombobox` trigger, its search predicate, and + * the two measured invariants the Work perf pass recorded for this component: + * a `fullWidth` trigger must fill a narrow parent without overflowing it, and + * the popover must clamp to the renderer viewport on both axes. + */ + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + LaneCombobox, + computeLanePopoverPlacement, + laneMatchesSearch, +} from "./LaneCombobox"; + +afterEach(cleanup); + +const lanes = [ + { id: "lane-auth", name: "auth-refresh", color: "#7C5CFF", branchRef: "refs/heads/feat/auth-refresh" }, + { id: "lane-perf", name: "render-perf", color: null, branchRef: "refs/heads/perf/render" }, +]; + +function trigger(): HTMLElement { + return screen.getByRole("button", { name: "Select lane" }); +} + +describe("laneMatchesSearch", () => { + const lane = { name: "auth-refresh", branchLabel: "feat/auth-token" }; + + it("keeps every row for an empty or whitespace query", () => { + expect(laneMatchesSearch(lane, "")).toBe(true); + expect(laneMatchesSearch(lane, " ")).toBe(true); + }); + + it("matches on lane name and on branch, case-insensitively", () => { + expect(laneMatchesSearch(lane, "REFRESH")).toBe(true); + expect(laneMatchesSearch(lane, " token ")).toBe(true); + expect(laneMatchesSearch({ name: "primary", branchLabel: null }, "main")).toBe(false); + }); + + it("still matches when the caller passes a full ref instead of a short label", () => { + expect(laneMatchesSearch({ name: "x", branchLabel: "refs/heads/feat/auth" }, "feat/auth")).toBe(true); + }); +}); + +describe("computeLanePopoverPlacement", () => { + it("clamps a right-edge trigger back inside the viewport", () => { + const placement = computeLanePopoverPlacement({ + trigger: { top: 40, bottom: 68, left: 534, width: 40 }, + viewport: { width: 582, height: 745 }, + }); + expect(placement.left + placement.width).toBeLessThanOrEqual(582 - 10); + expect(placement.left).toBeGreaterThanOrEqual(10); + }); + + it("opens upward and stays off the top edge when the trigger sits low", () => { + const placement = computeLanePopoverPlacement({ + trigger: { top: 672, bottom: 700, left: 20, width: 200 }, + viewport: { width: 900, height: 745 }, + }); + expect(placement.openAbove).toBe(true); + expect(placement.bottom).toBeDefined(); + const top = 745 - (placement.bottom ?? 0) - placement.maxHeight; + expect(top).toBeGreaterThanOrEqual(10); + }); + + it("detaches from the anchor rather than overflowing when neither side fits", () => { + const placement = computeLanePopoverPlacement({ + trigger: { top: 90, bottom: 110, left: 20, width: 200 }, + viewport: { width: 400, height: 200 }, + }); + expect(placement.top).toBeDefined(); + expect(placement.top ?? 0).toBeGreaterThanOrEqual(10); + expect((placement.top ?? 0) + placement.maxHeight).toBeLessThanOrEqual(200 - 10); + }); +}); + +describe("LaneCombobox trigger", () => { + it("renders lane and branch on one line with the branch giving way first", () => { + render(); + + const button = trigger(); + expect(button.textContent).toContain("auth-refresh"); + expect(button.textContent).toContain("feat/auth-refresh"); + // Single line: fixed height, no auto-growing column. + expect(button.className).toContain("h-[30px]"); + expect(button.className).not.toContain("flex-col"); + + const branch = button.querySelector(".shrink-\\[9999\\]"); + expect(branch?.textContent).toContain("feat/auth-refresh"); + }); + + it("uses the compact height when asked", () => { + render(); + expect(trigger().className).toContain("h-7"); + }); + + it("fills a narrow parent without a width cap or intrinsic floor", () => { + render( +
+ +
, + ); + + const button = trigger(); + expect(button.className).toContain("w-full"); + expect(button.className).toContain("min-w-0"); + // A max-width below the parent is exactly what overflowed the 120px filter + // panel in the measured Work run; `fullWidth` must not reintroduce one. + expect(button.className).not.toMatch(/\bmax-w-/); + expect(button.style.width).toBe(""); + expect(button.style.minWidth).toBe(""); + // Every text run inside can collapse, so nothing establishes a min-content floor. + for (const span of Array.from(button.querySelectorAll("span"))) { + if (span.className.includes("truncate") && !span.className.includes("min-w-0")) { + expect(span.parentElement?.className).toContain("min-w-0"); + } + } + }); + + it("caps its own width when it is not asked to fill the parent", () => { + render(); + expect(trigger().className).toContain("max-w-[320px]"); + }); +}); + +describe("LaneCombobox machine chrome", () => { + const machines = [ + { id: "this-mac", name: "This Mac" }, + { id: "studio", name: "Studio" }, + ]; + + function openList(): HTMLElement { + fireEvent.click(trigger()); + return screen.getByPlaceholderText("Search lanes...").closest(".ade-lane-popover") as HTMLElement; + } + + it("renders no machine chrome at all for a single machine", () => { + render( + , + ); + expect(openList().querySelectorAll("[data-machine-header]")).toHaveLength(0); + }); + + it("opens without auto-focusing the search field", async () => { + render(); + const popover = openList(); + + await waitFor(() => { + expect(document.activeElement).toBe(popover); + }); + expect(document.activeElement).not.toBe(screen.getByPlaceholderText("Search lanes...")); + }); + + it("promotes machines to section headers once there is more than one", () => { + render( + , + ); + const headers = Array.from(openList().querySelectorAll("[data-machine-header]")); + expect(headers.map((node) => node.textContent)).toEqual(["This Mac", "Studio"]); + }); + + it("filters rows by branch as well as name", () => { + render(); + const popover = openList(); + + fireEvent.change(screen.getByPlaceholderText("Search lanes..."), { + target: { value: "perf/render" }, + }); + + const rows = Array.from(popover.querySelectorAll(".ade-lane-popover-item")); + expect(rows).toHaveLength(1); + expect(rows[0]?.textContent).toContain("render-perf"); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx b/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx index e31de0ea2..8acf3fa2b 100644 --- a/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx +++ b/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx @@ -1,11 +1,14 @@ import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { CaretUpDown, Check, MagnifyingGlass } from "@phosphor-icons/react"; +import { motion } from "motion/react"; +import { CaretUpDown, Check, DesktopTower, MagnifyingGlass } from "@phosphor-icons/react"; import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; import { LaneLogoMark, laneDisplayColor } from "./LaneChip"; import { branchNameFromRef } from "../prs/shared/laneBranchTargets"; -import { COLORS, laneSurfaceTint } from "../lanes/laneDesignTokens"; +import { COLORS } from "../lanes/laneDesignTokens"; +import { cn } from "../ui/cn"; +import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; /** * Synthetic lane id for the draft-composer “auto-create lane” row. @@ -93,12 +96,32 @@ const POPOVER_GAP = 4; const VIEWPORT_PAD = 10; const POPOVER_PREFERRED_MAX_HEIGHT = 320; const POPOVER_MIN_HEIGHT = 160; +const POPOVER_MIN_WIDTH = 240; +/** Matches the `.ade-lane-popover` stylesheet cap so the two can't disagree. */ +const POPOVER_MAX_WIDTH = 280; function resolveBranchLabel(ref: string | null | undefined): string | null { if (!ref) return null; return branchNameFromRef(ref) || null; } +/** + * Row filter for the popover search box. + * + * Exported because it is the one piece of the combobox worth testing without a + * DOM: everything else about filtering is rendering. Callers may hand it either + * a short branch label or a full ref — a substring match covers both. + */ +export function laneMatchesSearch( + candidate: { name: string; branchLabel?: string | null }, + query: string, +): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + if (candidate.name.toLowerCase().includes(needle)) return true; + return candidate.branchLabel?.toLowerCase().includes(needle) ?? false; +} + function laneListIcon(item: LaneListItem) { const color = item.color ? laneDisplayColor(item.color) : "var(--color-muted-fg)"; return item.color ? ( @@ -120,12 +143,6 @@ function laneListItemFromLane( }; } -function matchesLaneSearch(item: LaneListItem, query: string): boolean { - if (!query) return true; - return item.name.toLowerCase().includes(query) - || (item.branchLabel?.toLowerCase().includes(query) ?? false); -} - /** * Flat list (today's shape) when there is at most one machine, grouped with a * header + its own auto-create row per machine when lanes span machines. @@ -137,11 +154,10 @@ function buildLaneListEntries(input: { allLabel: string; search: string; }): LaneListEntry[] { - const query = input.search.trim().toLowerCase(); const entries: LaneListEntry[] = []; let nextItemIndex = 0; const pushItem = (key: string, item: LaneListItem) => { - if (!matchesLaneSearch(item, query)) return false; + if (!laneMatchesSearch(item, input.search)) return false; entries.push({ kind: "item", key, index: nextItemIndex++, item }); return true; }; @@ -189,6 +205,62 @@ function buildLaneListEntries(input: { return entries; } +export type LanePopoverPlacement = { + left: number; + width: number; + maxHeight: number; + /** Exactly one of these is set; `bottom` anchors an upward-opening popover. */ + top?: number; + bottom?: number; + openAbove: boolean; +}; + +/** + * Pure placement so the viewport-clamp invariant is testable without layout. + * + * The popover is `position: fixed`, so an unclamped anchor happily renders off + * the renderer edge. Both axes are clamped here, including the case where + * neither side of the trigger has room for the minimum useful height — there we + * abandon the anchor rather than overflow. + */ +export function computeLanePopoverPlacement(input: { + trigger: { top: number; bottom: number; left: number; width: number }; + viewport: { width: number; height: number }; +}): LanePopoverPlacement { + const { trigger, viewport } = input; + + const width = Math.min( + Math.min(POPOVER_MAX_WIDTH, Math.max(trigger.width, POPOVER_MIN_WIDTH)), + Math.max(0, viewport.width - VIEWPORT_PAD * 2), + ); + const left = Math.max( + VIEWPORT_PAD, + Math.min(trigger.left, viewport.width - width - VIEWPORT_PAD), + ); + + const spaceBelow = viewport.height - trigger.bottom - VIEWPORT_PAD - POPOVER_GAP; + const spaceAbove = trigger.top - VIEWPORT_PAD - POPOVER_GAP; + const openAbove = spaceBelow < spaceAbove; + const available = Math.max(0, openAbove ? spaceAbove : spaceBelow); + const cap = Math.min(POPOVER_PREFERRED_MAX_HEIGHT, Math.max(0, viewport.height - VIEWPORT_PAD * 2)); + const fitted = Math.min(cap, available); + + if (fitted < Math.min(POPOVER_MIN_HEIGHT, cap)) { + // Neither side can host a usable list. Detach from the trigger and clamp to + // the viewport instead of letting the menu run off-screen. + const maxHeight = Math.min(cap, POPOVER_MIN_HEIGHT); + const top = Math.max( + VIEWPORT_PAD, + Math.min(trigger.bottom + POPOVER_GAP, viewport.height - maxHeight - VIEWPORT_PAD), + ); + return { left, width, maxHeight, top, openAbove: false }; + } + + return openAbove + ? { left, width, maxHeight: fitted, bottom: viewport.height - trigger.top + POPOVER_GAP, openAbove } + : { left, width, maxHeight: fitted, top: trigger.bottom + POPOVER_GAP, openAbove }; +} + type LaneComboboxProps = { lanes: LaneComboboxLane[]; /** @@ -228,9 +300,10 @@ export function LaneCombobox({ const [search, setSearch] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); const triggerRef = useRef(null); - const popoverRef = useRef(null); const searchInputRef = useRef(null); + const popoverRef = useRef(null); const listRef = useRef(null); + const reducedMotion = usePrefersReducedMotion(); const selectedLane = useMemo(() => { const routed = machineLaneFromOptionId(value); @@ -288,23 +361,21 @@ export function LaneCombobox({ useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if ( - popoverRef.current && - !popoverRef.current.contains(e.target as Node) && - triggerRef.current && - !triggerRef.current.contains(e.target as Node) - ) { - close(); - } + const target = e.target as Node | null; + if (popoverRef.current?.contains(target)) return; + if (triggerRef.current?.contains(target)) return; + close(); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open, close]); - // Focus search on open and sync keyboard highlight to the current selection. + // Keep keyboard navigation active without auto-selecting the search field. + // Search should only acquire its highlighted border after the user clicks or + // tabs into it. useEffect(() => { if (!open) return; - requestAnimationFrame(() => searchInputRef.current?.focus()); + requestAnimationFrame(() => popoverRef.current?.focus({ preventScroll: true })); const selectedIdx = items.findIndex((item) => item.id === value); setHighlightedIndex(selectedIdx >= 0 ? selectedIdx : 0); // Only re-sync when the menu opens — search filtering keeps its own highlight reset. @@ -350,39 +421,14 @@ export function LaneCombobox({ } }, [open, highlightedIndex]); - // Position popover below trigger - const [popoverStyle, setPopoverStyle] = useState({}); + const [placement, setPlacement] = useState(null); const updatePosition = useCallback(() => { if (!triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_PAD; - const spaceAbove = rect.top - VIEWPORT_PAD; - const openAbove = spaceBelow < spaceAbove; - - const available = (openAbove ? spaceAbove : spaceBelow) - POPOVER_GAP; - const maxHeight = Math.max( - POPOVER_MIN_HEIGHT, - Math.min(POPOVER_PREFERRED_MAX_HEIGHT, available), - ); - - const width = Math.min(280, Math.max(rect.width, 260)); - let left = rect.left; - if (left + width > viewportWidth - VIEWPORT_PAD) { - left = viewportWidth - width - VIEWPORT_PAD; - } - left = Math.max(VIEWPORT_PAD, left); - - setPopoverStyle({ - left, - width, - maxHeight, - ...(openAbove - ? { bottom: viewportHeight - rect.top + POPOVER_GAP } - : { top: rect.bottom + POPOVER_GAP }), - }); + setPlacement(computeLanePopoverPlacement({ + trigger: { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + })); }, []); useEffect(() => { if (!open) return; @@ -408,167 +454,101 @@ export function LaneCombobox({ value === "all" || !selectedLane ? null : (customLaneColor ?? COLORS.accent); - const pillSurface = variant === "pill" && value !== "all" && selectedLane && customLaneColor - ? laneSurfaceTint(customLaneColor, "default") - : null; - const defaultVariantSurface = variant === "default" && value !== "all" && customLaneColor - ? laneSurfaceTint(customLaneColor, "soft") - : null; - const triggerStyle: React.CSSProperties = - variant === "pill" - ? { - display: "inline-flex", - alignItems: "center", - gap: 6, - minHeight: selectedBranchLabel ? 40 : 31, - padding: selectedBranchLabel - ? "5px 10px 5px 14px" - : "6px 10px 6px 14px", - borderRadius: 9999, - border: pillSurface?.text ? pillSurface.border : "1px solid rgba(255,255,255,0.08)", - background: pillSurface?.text - ? pillSurface.background - : "rgba(255,255,255,0.04)", - boxShadow: pillSurface?.text - ? `inset 0 0 0 1px color-mix(in srgb, ${pillSurface.text} 10%, transparent)` - : undefined, - color: pillSurface?.text ? "rgba(255,255,255,0.85)" : "rgba(255,255,255,0.7)", - fontSize: 11, - fontWeight: 500, - cursor: "pointer", - minWidth: 0, - width: fullWidth ? "100%" : undefined, - // fullWidth means "fill the container" — don't cap it below the parent. - maxWidth: fullWidth ? undefined : 320, - transition: "border-color 100ms ease, background 100ms ease, box-shadow 100ms ease", - } - : { - display: "inline-flex", - alignItems: "center", - gap: 6, - ...( - selectedBranchLabel - ? { - height: "auto" as const, - minHeight: compact ? 32 : 40, - padding: compact ? "3px 6px" : "4px 8px", - } - : { - height: compact ? 24 : 28, - padding: compact ? "0 6px" : "0 8px", - } - ), - borderRadius: 6, - border: defaultVariantSurface?.text - ? defaultVariantSurface.border - : "1px solid var(--work-pane-border)", - background: defaultVariantSurface?.text - ? defaultVariantSurface.background - : "rgba(255,255,255,0.02)", - boxShadow: defaultVariantSurface?.text - ? `inset 0 0 0 1px color-mix(in srgb, ${defaultVariantSurface.text} 8%, transparent)` - : undefined, - color: "var(--color-fg)", - fontSize: 11, - fontWeight: 400, - cursor: "pointer", - minWidth: 0, - width: fullWidth ? "100%" : undefined, - // fullWidth means "fill the container" — don't cap it below the parent. - maxWidth: fullWidth ? undefined : 200, - transition: "border-color 100ms ease, background 100ms ease, box-shadow 100ms ease", - }; + const popoverStyle: React.CSSProperties = { + left: placement?.left ?? 0, + width: placement?.width ?? POPOVER_MIN_WIDTH, + maxHeight: placement?.maxHeight ?? POPOVER_PREFERRED_MAX_HEIGHT, + ...(placement?.bottom !== undefined + ? { bottom: placement.bottom } + : { top: placement?.top ?? 0 }), + // The stylesheet ships a CSS keyframe entrance for this class; framer owns + // the entrance now, and running both double-animates the open. + animation: "none", + transformOrigin: placement?.openAbove ? "bottom left" : "top left", + }; + + // Sits in composer shelves next to 24-28px ghost pills, so the trigger is a + // single line at that scale in both variants — never a two-line block. + const triggerClass = cn( + "ade-lane-trigger group inline-flex min-w-0 shrink items-center gap-1.5", + "border border-white/[0.07] bg-white/[0.03] text-[11px] font-normal text-fg/80", + "transition-colors duration-100 hover:border-white/[0.13] hover:bg-white/[0.06]", + "data-[open=true]:border-white/[0.16] data-[open=true]:bg-white/[0.07]", + variant === "pill" ? "rounded-full" : "rounded-md", + compact ? "h-7 px-2" : "h-[30px] px-2.5", + fullWidth + ? "w-full" + // fullWidth means "fill the container" — only the free-standing form caps. + : variant === "pill" ? "max-w-[320px]" : "max-w-[200px]", + ); return ( <> + {/* + No `AnimatePresence` / exit animation here on purpose: the popover lives + in a body portal, and a node that outlives `open` by an animation frame + leaks into whatever renders next (including other test cases). The + entrance spring is the part users actually see. + */} {open ? createPortal( -
setSearch(e.target.value)} @@ -576,14 +556,7 @@ export function LaneCombobox({
{items.length === 0 ? ( -
+
No lanes found
) : ( @@ -593,119 +566,65 @@ export function LaneCombobox({
- {entry.label} + + {entry.label}
); } const item = entry.item; - const currentIndex = entry.index; const isSelected = item.id === value; - const isAutoCreate = isAutoCreateLaneOptionId(item.id); + const isHighlighted = entry.index === highlightedIndex; - if (isAutoCreate) { + if (isAutoCreateLaneOptionId(item.id)) { return ( ); } - const titleRow = ( -
- {laneListIcon(item)} - - {item.name} - - {isSelected ? ( - - ) : null} -
- ); return ( ); }) )}
-
, + , document.body, ) : null} diff --git a/apps/desktop/src/renderer/components/usage/ActivityHeatmap.tsx b/apps/desktop/src/renderer/components/usage/ActivityHeatmap.tsx new file mode 100644 index 000000000..f2fd413bd --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/ActivityHeatmap.tsx @@ -0,0 +1,127 @@ +import { useMemo } from "react"; +import type { AdeUsageDailyPoint } from "../../../shared/types"; +import { + bucketActivityIntensity, + dayActivityScore, + trimLeadingInactiveDays, +} from "./activityIntensity"; + +const HEATMAP_GAP = 3; +const HEATMAP_MIN_CELL = 6; +const HEATMAP_HUE = "#5B93F5"; +const HEATMAP_LEVEL_MIX = [0, 28, 47, 68, 92] as const; + +export type HeatmapLayout = { + rows: number; + cols: number; + cell: number; + width: number; + visible: number; +}; + +export function computeHeatmapLayout({ + cellCount, + maxCell, + availableWidth, +}: { + cellCount: number; + maxCell: number; + availableWidth: number; +}): HeatmapLayout { + if (cellCount <= 0) return { rows: 1, cols: 0, cell: maxCell, width: 0, visible: 0 }; + + const rows = cellCount <= 7 ? 1 : 7; + const cols = Math.max(1, Math.ceil(cellCount / rows)); + const spanOf = (columns: number, cell: number) => columns * cell + (columns - 1) * HEATMAP_GAP; + + if (availableWidth <= 0) { + return { rows, cols, cell: maxCell, width: spanOf(cols, maxCell), visible: cellCount }; + } + + const fitted = Math.floor((availableWidth - (cols - 1) * HEATMAP_GAP) / cols); + const cell = Math.max(HEATMAP_MIN_CELL, Math.min(maxCell, fitted)); + if (spanOf(cols, cell) <= availableWidth) { + return { rows, cols, cell, width: spanOf(cols, cell), visible: cellCount }; + } + + const visibleCols = Math.max(1, Math.floor((availableWidth + HEATMAP_GAP) / (cell + HEATMAP_GAP))); + return { + rows, + cols: visibleCols, + cell, + width: spanOf(visibleCols, cell), + visible: Math.min(cellCount, visibleCols * rows), + }; +} + +type HeatmapCell = { point: AdeUsageDailyPoint; level: number }; + +export function useHeatmapCells(points: AdeUsageDailyPoint[]): HeatmapCell[] { + return useMemo(() => { + const ordered = trimLeadingInactiveDays( + [...points].sort((a, b) => a.date.localeCompare(b.date)), + ); + const levels = bucketActivityIntensity(ordered.map(dayActivityScore)); + return ordered.map((point, index) => ({ point, level: levels[index] ?? 0 })); + }, [points]); +} + +type HeatmapTooltip = { + show: (point: AdeUsageDailyPoint, target: HTMLElement) => void; + hide: () => void; + toggle: (point: AdeUsageDailyPoint, target: HTMLElement) => void; +}; + +export function ActivityHeatmap({ + cells, + layout, + reduced, + tooltip, +}: { + cells: HeatmapCell[]; + layout: HeatmapLayout; + reduced: boolean; + tooltip: HeatmapTooltip; +}) { + const { rows, cell } = layout; + const shown = layout.visible >= cells.length ? cells : cells.slice(cells.length - layout.visible); + + return ( +
+
+ {shown.map(({ point, level }) => ( + tooltip.show(point, event.currentTarget)} + onPointerLeave={tooltip.hide} + onClick={(event) => tooltip.toggle(point, event.currentTarget)} + /> + ))} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/usage/ActivityModule.tsx b/apps/desktop/src/renderer/components/usage/ActivityModule.tsx index ed6244f07..7c8bc5d6d 100644 --- a/apps/desktop/src/renderer/components/usage/ActivityModule.tsx +++ b/apps/desktop/src/renderer/components/usage/ActivityModule.tsx @@ -15,6 +15,15 @@ import type { } from "../../../shared/types"; import { formatTokens } from "../../lib/format"; import { useAppStore } from "../../state/appStore"; +import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; +import { + dayHasActivity, +} from "./activityIntensity"; +import { + ActivityHeatmap, + computeHeatmapLayout, + useHeatmapCells, +} from "./ActivityHeatmap"; // --------------------------------------------------------------------------- // Persistence @@ -89,25 +98,6 @@ function persistActivityPatch(patch: Partial): void { } } -// --------------------------------------------------------------------------- -// Reduced-motion -// --------------------------------------------------------------------------- - -function usePrefersReducedMotion(): boolean { - const [reduced, setReduced] = useState(() => { - if (typeof window === "undefined" || !window.matchMedia) return false; - return window.matchMedia("(prefers-reduced-motion: reduce)").matches; - }); - useEffect(() => { - if (typeof window === "undefined" || !window.matchMedia) return; - const query = window.matchMedia("(prefers-reduced-motion: reduce)"); - const onChange = () => setReduced(query.matches); - query.addEventListener?.("change", onChange); - return () => query.removeEventListener?.("change", onChange); - }, []); - return reduced; -} - // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- @@ -126,40 +116,6 @@ function formatDay(date: string): string { return parsed.toLocaleDateString([], { month: "short", day: "numeric" }); } -/** - * Single source of truth for a day's activity magnitude. Both the heatmap - * intensity (dayValue) and the has-activity predicate (dayHasActivity) derive - * from this, so the two can never drift over which daily-point dimensions - * count. Every activity dimension is covered: tokens, sessions, interactions, - * local git commits/PRs/files/lines, and the GitHub-reported counterparts. - * Counts (sessions, commits, PRs, files) carry heavier weights than raw line - * counts, which are additive. - */ -function dayActivityScore(point: AdeUsageDailyPoint): number { - return ( - point.totalTokens - + point.sessions * 4_000 - + (point.interactions ?? 0) * 1_500 - + point.commits * 3_000 - + point.prs * 5_000 - + point.filesChanged * 500 - + point.insertions - + point.deletions - + (point.githubCommits ?? 0) * 3_000 - + (point.githubPrs ?? 0) * 5_000 - + (point.githubAdditions ?? 0) - + (point.githubDeletions ?? 0) - ); -} - -function dayValue(point: AdeUsageDailyPoint): number { - return dayActivityScore(point); -} - -function dayHasActivity(point: AdeUsageDailyPoint): boolean { - return dayActivityScore(point) > 0; -} - function sessionsTotal(stats: AdeUsageStats): number { return (stats.summary.chatSessions ?? 0) + (stats.summary.terminalSessions ?? 0); } @@ -170,7 +126,6 @@ function sessionsTotal(stats: AdeUsageStats): number { const TOKEN_COLORS = { input: "#5B93F5", output: "#E0A82E", cache: "#8892A6" } as const; const CODE_COLORS = { insertions: "#3FB950", deletions: "#E5595C", github: "#8892A6" } as const; -const HEATMAP_HUE = "#5B93F5"; const CLIENT_COLORS: Record = { desktop: "#5B93F5", @@ -315,64 +270,32 @@ function ChartFrame({ ); } -function ActivityHeatmap({ - points, - height, - reduced, - tooltip, -}: { - points: AdeUsageDailyPoint[]; - height: number; - reduced: boolean; - tooltip: ReturnType; -}) { - const cells = useMemo(() => { - const ordered = [...points].sort((a, b) => a.date.localeCompare(b.date)); - const max = Math.max(1, ...ordered.map(dayValue)); - return ordered.map((point) => ({ point, intensity: Math.max(0, Math.min(1, dayValue(point) / max)) })); - }, [points]); - - // Fill the chart box instead of leaving it mostly blank: a short range lays out - // as one tall row of large cells; longer ranges use a 7-row calendar-style grid - // (columns = weeks) whose cells stretch to fill the available width and height. - const rows = cells.length <= 7 ? 1 : 7; - const cols = Math.max(1, Math.ceil(cells.length / rows)); - - return ( -
- {cells.map(({ point, intensity }) => ( - tooltip.show(point, event.currentTarget)} - onPointerLeave={tooltip.hide} - onClick={(event) => tooltip.toggle(point, event.currentTarget)} - /> - ))} -
- ); +/** Horizontal padding of the card, per variant — subtracted from the measured + * slot to get the width the grid actually has, and added back to turn the + * grid's natural width into a card width. */ +const CARD_PADDING_X_COMPACT = 20; +const CARD_PADDING_X_FULL = 24; +/** Below this the tab row and the footer line start colliding, so a very short + * range widens the card past its grid rather than squeezing the chrome. */ +const MIN_CARD_WIDTH = 380; + +/** Tracks a container's width so the heatmap can be sized from it. */ +function useMeasuredWidth(ref: React.RefObject): number { + const [width, setWidth] = useState(0); + useEffect(() => { + const node = ref.current; + if (!node || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(([entry]) => { + setWidth(entry?.contentRect.width ?? 0); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [ref]); + return width; } +export { computeHeatmapLayout } from "./ActivityHeatmap"; + /** Muted, centered hint for a tab whose own series is empty while the module * has data on other tabs (so the global warm-empty state does not apply). */ function TabEmptyHint({ message }: { message: string }) { @@ -601,7 +524,15 @@ function WarmEmpty({ height }: { height: number }) { function TabRow({ tab, onTabChange }: { tab: ActivityTab; onTabChange: (tab: ActivityTab) => void }) { return ( -
+ // A quiet segmented control: one recessed track, only the active segment + // lifted. Four equally prominent buttons read as a toolbar and pulled focus + // away from the composer this module sits under. +
{TABS.map((value) => { const active = value === tab; return ( @@ -612,8 +543,8 @@ function TabRow({ tab, onTabChange }: { tab: ActivityTab; onTabChange: (tab: Act aria-selected={active} tabIndex={active ? 0 : -1} onClick={() => onTabChange(value)} - className={`rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${ - active ? "bg-white/[0.08] text-fg" : "text-muted-fg hover:text-fg/85" + className={`rounded-[5px] px-1.5 py-[3px] text-[10px] font-medium transition-colors ${ + active ? "bg-white/[0.07] text-fg/90" : "text-muted-fg/70 hover:text-fg/75" }`} > {TAB_LABELS[value]} @@ -641,7 +572,7 @@ function RangeControl({ value={preset} onChange={(event) => onPresetChange(event.target.value as AdeUsageRangePreset)} aria-label="Time range" - className="cursor-pointer appearance-none rounded-md border border-white/[0.08] bg-white/[0.03] py-1 pl-2 pr-6 text-[11px] font-medium text-fg/85 outline-none hover:bg-white/[0.06] focus-visible:ring-1 focus-visible:ring-white/20" + className="cursor-pointer appearance-none rounded-md border border-white/[0.06] bg-transparent py-[3px] pl-1.5 pr-5 text-[10px] font-medium text-muted-fg/80 outline-none hover:bg-white/[0.05] hover:text-fg/80 focus-visible:ring-1 focus-visible:ring-white/20" > {RANGE_OPTIONS.map((option) => ( ))} - + ); } @@ -721,14 +652,14 @@ function FooterChip({ return ( - {chip.icon === "trophy" ? : } + {chip.icon === "trophy" ? : } {chip.label} ); @@ -763,16 +694,39 @@ export function ActivityModule({ }) { const reduced = usePrefersReducedMotion(); const [tab, setTab] = useState(() => readActivityPersisted().tab); + const slotRef = useRef(null); const cardRef = useRef(null); const tooltip = useDayTooltip(cardRef); const chip = useFooterChip(stats); const compactMode = variant === "compact"; - const chartHeight = compactMode ? 84 : 132; + const chartHeight = compactMode ? 76 : 124; + const heatmapMaxCell = compactMode ? 13 : 16; const maxBars = compactMode ? 40 : 64; const chartPoints = useChartPoints(stats?.daily ?? [], maxBars); const hasActivity = (stats?.daily ?? []).some(dayHasActivity); + // The card is sized to the heatmap rather than stretched to the slot: a + // ~53-column grid of 13px cells simply does not fill 820px, and the leftover + // was showing up as dead space along the card's right edge. Measuring the + // slot (always full width) instead of the card keeps this off a resize loop. + const cardPaddingX = compactMode ? CARD_PADDING_X_COMPACT : CARD_PADDING_X_FULL; + const slotWidth = useMeasuredWidth(slotRef); + const heatmapCells = useHeatmapCells(stats?.daily ?? []); + const heatmapLayout = useMemo( + () => computeHeatmapLayout({ + cellCount: heatmapCells.length, + maxCell: heatmapMaxCell, + availableWidth: slotWidth > 0 ? Math.max(0, slotWidth - cardPaddingX) : 0, + }), + [heatmapCells.length, heatmapMaxCell, slotWidth, cardPaddingX], + ); + // Held across tabs so switching to Tokens does not resize the card underneath + // the pointer; the bar charts just fill whatever width the heatmap earned. + const cardWidth = slotWidth > 0 && heatmapLayout.width > 0 + ? Math.min(slotWidth, Math.max(MIN_CARD_WIDTH, heatmapLayout.width + cardPaddingX)) + : undefined; + const changeTab = useCallback((next: ActivityTab) => { setTab(next); tooltip.hide(); @@ -782,6 +736,10 @@ export function ActivityModule({ const summary = stats?.summary; const activeDays = summary?.activeDays; + // The heatmap is the one view whose height is content-derived, so it opts out + // of the reserved chart band the fixed-height bar charts still need. + const heatmapView = tab === "activity" && stats != null && hasActivity; + let chart: React.ReactNode; if (loading && !stats) { chart = ; @@ -790,7 +748,7 @@ export function ActivityModule({ } else if (!hasActivity) { chart = ; } else if (tab === "activity") { - chart = ; + chart = ; } else if (tab === "tokens") { chart = ; } else if (tab === "code") { @@ -800,55 +758,60 @@ export function ActivityModule({ } return ( -
-
- - {showRangeControl && onPresetChange ? ( - - ) : null} -
- -
+
- {chart} - {tooltip.tip ? : null} -
- -
- - {stats ? ( - <> - {formatTokens(stats.summary.totalTokens)} tokens - {" · "} - {compact(sessionsTotal(stats))} sessions - {activeDays != null ? ( - <> - {" · "} - {activeDays} active {activeDays === 1 ? "day" : "days"} - - ) : null} - - ) : loading ? ( - "Loading activity…" - ) : ( - "No activity yet" - )} - - {chip ? : null} -
-
+
+ + {showRangeControl && onPresetChange ? ( + + ) : null} +
+ +
+ {chart} + {tooltip.tip ? : null} +
+ +
+ + {stats ? ( + <> + {formatTokens(stats.summary.totalTokens)} tokens + {" · "} + {compact(sessionsTotal(stats))} sessions + {activeDays != null ? ( + <> + {" · "} + {activeDays} active {activeDays === 1 ? "day" : "days"} + + ) : null} + + ) : loading ? ( + "Loading activity…" + ) : ( + "No activity yet" + )} + + {chip ? : null} +
+ +
); } @@ -905,6 +868,9 @@ export function WorkActivityModule() { persistActivityPatch({ preset: next }); }, []); + // max-w bounds the module to the composer's column; the card itself is sized + // to its own content inside that. mt- settles it further from the composer so + // it reads as an ambient footer rather than a second panel stacked on it. return ( ); } diff --git a/apps/desktop/src/renderer/components/usage/activityIntensity.ts b/apps/desktop/src/renderer/components/usage/activityIntensity.ts new file mode 100644 index 000000000..adaf1023e --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/activityIntensity.ts @@ -0,0 +1,78 @@ +import type { AdeUsageDailyPoint } from "../../../shared/types"; + +/** + * Single source of truth for a day's activity magnitude. Both the heatmap + * intensity and the has-activity predicate (dayHasActivity) derive from this, + * so the two can never drift over which daily-point dimensions count. Every + * activity dimension is covered: tokens, sessions, interactions, local git + * commits/PRs/files/lines, and the GitHub-reported counterparts. Counts + * (sessions, commits, PRs, files) carry heavier weights than raw line counts, + * which are additive. + */ +export function dayActivityScore(point: AdeUsageDailyPoint): number { + return ( + point.totalTokens + + point.sessions * 4_000 + + (point.interactions ?? 0) * 1_500 + + point.commits * 3_000 + + point.prs * 5_000 + + point.filesChanged * 500 + + point.insertions + + point.deletions + + (point.githubCommits ?? 0) * 3_000 + + (point.githubPrs ?? 0) * 5_000 + + (point.githubAdditions ?? 0) + + (point.githubDeletions ?? 0) + ); +} + +export function dayHasActivity(point: AdeUsageDailyPoint): boolean { + return dayActivityScore(point) > 0; +} + +/** 0 = no activity; 1-4 = quartile of the non-zero distribution. */ +export type ActivityLevel = 0 | 1 | 2 | 3 | 4; + +/** Nearest-rank percentile over an ascending-sorted, non-empty array. */ +function percentile(sorted: number[], fraction: number): number { + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1)); + return sorted[index] as number; +} + +/** + * Buckets daily activity scores into 5 discrete shades (GitHub's contribution + * graph approach). A linear value/max ramp is useless here because a single + * outlier day — one 35.9B-token session is entirely normal — pushes every other + * day into the same near-floor tone. Quartiles are computed over the NON-ZERO + * days only, so empty days never dilute the distribution, and the busiest day + * always lands at level 4 even when the range is flat or has a single spike. + */ +export function bucketActivityIntensity(values: number[]): ActivityLevel[] { + const active = values.filter((value) => value > 0).sort((a, b) => a - b); + if (active.length === 0) return values.map(() => 0); + + const max = active[active.length - 1] as number; + const thresholds = [percentile(active, 0.25), percentile(active, 0.5), percentile(active, 0.75)]; + + return values.map((value) => { + if (value <= 0) return 0; + if (value >= max) return 4; + let level = 1; + for (const threshold of thresholds) { + if (value > threshold) level += 1; + } + return Math.min(4, level) as ActivityLevel; + }); +} + +/** + * Drops leading empty days so the grid sizes to the data instead of the preset. + * A project with 19 active days would otherwise render a full year of dead + * cells on the all-time range. Trailing and interior gaps are preserved: today + * must stay the last cell, and an idle week between two active weeks is signal. + * Expects date-ascending input; an all-empty series is returned untouched. + */ +export function trimLeadingInactiveDays(points: AdeUsageDailyPoint[]): AdeUsageDailyPoint[] { + const firstActive = points.findIndex(dayHasActivity); + return firstActive <= 0 ? points : points.slice(firstActive); +} diff --git a/apps/desktop/src/renderer/components/usage/usage.test.tsx b/apps/desktop/src/renderer/components/usage/usage.test.tsx index 2b59b67ba..7ecb402c9 100644 --- a/apps/desktop/src/renderer/components/usage/usage.test.tsx +++ b/apps/desktop/src/renderer/components/usage/usage.test.tsx @@ -13,9 +13,15 @@ import type { UsageSnapshot, } from "../../../shared/types"; import { HeaderUsageControl } from "./HeaderUsageControl"; -import { ActivityModule, WorkActivityModule, readActivityPersisted } from "./ActivityModule"; +import { + ActivityModule, + WorkActivityModule, + computeHeatmapLayout, + readActivityPersisted, +} from "./ActivityModule"; import { AdeUsageSection } from "../settings/AdeUsageSection"; import { UsageQuotaPanel } from "./UsageQuotaPanel"; +import { bucketActivityIntensity, trimLeadingInactiveDays } from "./activityIntensity"; import { ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, @@ -30,6 +36,27 @@ type UsageComponentTestBridge = { ai: Pick; }; +function makeActivityDay( + date: string, + overrides: Partial = {}, +): AdeUsageDailyPoint { + return { + date, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedTokens: 0, + commits: 0, + prs: 0, + insertions: 0, + deletions: 0, + filesChanged: 0, + sessions: 0, + interactions: 0, + ...overrides, + } as AdeUsageDailyPoint; +} + function makeEmptySnapshot(): UsageSnapshot { return { windows: [], @@ -864,7 +891,7 @@ describe("usage components", () => { expect(screen.queryByText("Your activity will appear here after your first chat.")).toBeNull(); const cell = container.querySelector('[aria-label="Daily activity heatmap"]')!.children[0] as HTMLElement; - expect(Number(cell.getAttribute("data-intensity"))).toBeGreaterThan(0); + expect(Number(cell.getAttribute("data-level"))).toBeGreaterThan(0); unmount(); } }); @@ -916,12 +943,15 @@ describe("usage components", () => { expect(screen.getByRole("button", { name: "30d" })).toBeTruthy(); }); - it("scales the heatmap to fill the box: one row for short ranges, seven for long", () => { + it("lays the heatmap out as square cells: one row for short ranges, seven for long", () => { const short = makeActivityStats(); const { rerender, container } = render(); - const grid1 = container.querySelector('[aria-label="Daily activity heatmap"]')!; + const grid1 = container.querySelector('[aria-label="Daily activity heatmap"]')! as HTMLElement; expect(grid1.getAttribute("data-heatmap-rows")).toBe("1"); expect(grid1.children.length).toBe(short.daily.length); + // Height derives from the square cell size, not from a fixed chart band. + expect(grid1.getAttribute("data-heatmap-cell")).toBe("16"); + expect(grid1.style.height).toBe("16px"); const long = makeActivityStats({ daily: Array.from({ length: 30 }, (_, i) => ({ @@ -931,9 +961,133 @@ describe("usage components", () => { })), } as unknown as Partial); rerender(); - const grid2 = container.querySelector('[aria-label="Daily activity heatmap"]')!; + const grid2 = container.querySelector('[aria-label="Daily activity heatmap"]')! as HTMLElement; expect(grid2.getAttribute("data-heatmap-rows")).toBe("7"); expect(grid2.children.length).toBe(30); + // 7 rows of 16px cells plus six 3px gaps. + expect(grid2.style.height).toBe("130px"); + }); + + it("clamps the heatmap window to the first active day", () => { + const daily = Array.from({ length: 20 }, (_, i) => ({ + date: `2026-06-${String(i + 1).padStart(2, "0")}`, + inputTokens: 0, outputTokens: 0, totalTokens: 0, cachedTokens: 0, + commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 0, interactions: 0, + })); + // Active on the 16th and the 20th; the 17th-19th gap must survive. + daily[15] = { ...daily[15]!, totalTokens: 1_500, sessions: 1, interactions: 2 }; + daily[19] = { ...daily[19]!, totalTokens: 900_000, sessions: 4, interactions: 30 }; + const { container } = render( + )} preset="30d" onPresetChange={vi.fn()} />, + ); + + const grid = container.querySelector('[aria-label="Daily activity heatmap"]')!; + expect(grid.children.length).toBe(5); + expect(Array.from(grid.children, (cell) => cell.getAttribute("data-level"))).toEqual(["1", "0", "0", "0", "4"]); + }); + + it("computes a content-sized heatmap layout, shrinking cells when the natural width does not fit", () => { + // Unmeasured: natural size at the max cell, nothing dropped. + expect(computeHeatmapLayout({ cellCount: 30, maxCell: 13, availableWidth: 0 })).toEqual({ + rows: 7, cols: 5, cell: 13, width: 5 * 13 + 4 * 3, visible: 30, + }); + + // Fits with room to spare: cells stay at max and the grid stops short of + // the available width — the card is what shrinks, not the cells. + const roomy = computeHeatmapLayout({ cellCount: 30, maxCell: 13, availableWidth: 800 }); + expect(roomy).toMatchObject({ cell: 13, cols: 5, visible: 30 }); + expect(roomy.width).toBeLessThan(800); + + // Natural width exceeds available: cells shrink toward the floor, every + // day still renders, and the grid fits. + const tight = computeHeatmapLayout({ cellCount: 371, maxCell: 13, availableWidth: 800 }); + expect(tight.cell).toBeLessThan(13); + expect(tight.cell).toBeGreaterThanOrEqual(6); + expect(tight.visible).toBe(371); + expect(tight.width).toBeLessThanOrEqual(800); + + // Past the floor: oldest columns are dropped rather than overflowing. + const overflowing = computeHeatmapLayout({ cellCount: 900, maxCell: 13, availableWidth: 800 }); + expect(overflowing.cell).toBe(6); + expect(overflowing.visible).toBeLessThan(900); + expect(overflowing.visible % 7).toBe(0); + expect(overflowing.width).toBeLessThanOrEqual(800); + }); + + it("sizes the card to the heatmap grid instead of stretching it across the slot", () => { + const observers: Array<{ callback: ResizeObserverCallback; targets: Element[] }> = []; + class TestResizeObserver implements ResizeObserver { + private readonly entry: { callback: ResizeObserverCallback; targets: Element[] }; + constructor(callback: ResizeObserverCallback) { + this.entry = { callback, targets: [] }; + observers.push(this.entry); + } + observe(target: Element): void { this.entry.targets.push(target); } + unobserve(): void {} + disconnect(): void {} + } + const original = globalThis.ResizeObserver; + Object.assign(globalThis, { ResizeObserver: TestResizeObserver }); + const emit = (width: number) => { + for (const observer of observers) { + observer.callback( + observer.targets.map((target) => ({ target, contentRect: { width } }) as unknown as ResizeObserverEntry), + {} as ResizeObserver, + ); + } + }; + const dailyFrom = (days: number) => + Array.from({ length: days }, (_, index) => { + const date = new Date(Date.UTC(2024, 0, 1 + index)).toISOString().slice(0, 10); + return { + date, inputTokens: 100, outputTokens: 50, totalTokens: 150, cachedTokens: 0, + commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 1, interactions: 1, + }; + }); + + try { + const { container, rerender } = render( + )} + variant="compact" + preset="all" + onPresetChange={vi.fn()} + className="w-full max-w-[820px]" + />, + ); + act(() => emit(820)); + + const card = container.querySelector("[data-activity-module]") as HTMLElement; + const grid = container.querySelector('[aria-label="Daily activity heatmap"]') as HTMLElement; + const cardWidth = Number.parseFloat(card.style.width); + const gridWidth = Number.parseFloat(grid.style.width); + // Card = grid + its own padding, and it stops short of the 820px slot: + // the leftover becomes centring margin, not dead space inside the card. + expect(gridWidth).toBeGreaterThan(0); + expect(cardWidth).toBe(gridWidth + 20); + expect(cardWidth).toBeLessThan(820); + expect(Number(grid.getAttribute("data-heatmap-cell"))).toBeLessThanOrEqual(13); + expect(grid.children.length).toBe(400); + + // Far past the natural fit: cells bottom out at the floor, the grid + // still fits the slot, and the oldest columns are dropped. + rerender( + )} + variant="compact" + preset="all" + onPresetChange={vi.fn()} + className="w-full max-w-[820px]" + />, + ); + const wide = container.querySelector('[aria-label="Daily activity heatmap"]') as HTMLElement; + expect(wide.getAttribute("data-heatmap-cell")).toBe("6"); + expect(Number.parseFloat(wide.style.width)).toBeLessThanOrEqual(800); + expect(wide.children.length).toBeLessThan(900); + } finally { + if (original) Object.assign(globalThis, { ResizeObserver: original }); + else Reflect.deleteProperty(globalThis, "ResizeObserver"); + } }); it("shows a per-tab hint when the active tab is empty but the module has data", () => { @@ -1052,3 +1206,60 @@ describe("usage components", () => { }); }); }); + +describe("activity heatmap intensity", () => { + it("spreads an outlier-dominated distribution across all four levels", () => { + const levels = bucketActivityIntensity([ + 1_000, 4_000, 9_000, 20_000, 60_000, 150_000, 400_000, 35_900_000_000, + ]); + + expect(levels).toEqual([1, 1, 2, 2, 3, 3, 4, 4]); + expect(new Set(levels).size).toBe(4); + }); + + it.each([ + [[500, 500, 500, 500], [4, 4, 4, 4]], + [[0, 0, 7, 0], [0, 0, 4, 0]], + [[0, 0, 0], [0, 0, 0]], + [[], []], + ])("handles flat, sparse, and empty distributions", (values, expected) => { + expect(bucketActivityIntensity(values)).toEqual(expected); + }); + + it("ignores empty days when computing quartiles and keeps the busiest day at level 4", () => { + const dense = bucketActivityIntensity([10, 20, 30, 40]); + const sparse = bucketActivityIntensity([0, 10, 0, 20, 0, 30, 0, 40, 0]); + + expect(sparse.filter((level) => level > 0)).toEqual(dense); + expect(bucketActivityIntensity([1, 100]).at(-1)).toBe(4); + expect(bucketActivityIntensity([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]).at(-1)).toBe(4); + }); + + it("trims only leading inactive days and preserves interior and trailing gaps", () => { + const points = [ + makeActivityDay("2026-01-01"), + makeActivityDay("2026-01-02"), + makeActivityDay("2026-01-03", { totalTokens: 1_000 }), + makeActivityDay("2026-01-04"), + makeActivityDay("2026-01-05", { commits: 2 }), + makeActivityDay("2026-01-06"), + ]; + + expect(trimLeadingInactiveDays(points).map((point) => point.date)).toEqual([ + "2026-01-03", + "2026-01-04", + "2026-01-05", + "2026-01-06", + ]); + }); + + it("keeps empty/already-active series stable and counts GitHub-only activity", () => { + const empty = [makeActivityDay("2026-01-01"), makeActivityDay("2026-01-02")]; + const active = [makeActivityDay("2026-01-01", { sessions: 1 }), makeActivityDay("2026-01-02")]; + const github = [makeActivityDay("2026-01-01"), makeActivityDay("2026-01-02", { githubCommits: 3 })]; + + expect(trimLeadingInactiveDays(empty)).toEqual(empty); + expect(trimLeadingInactiveDays(active)).toBe(active); + expect(trimLeadingInactiveDays(github).map((point) => point.date)).toEqual(["2026-01-02"]); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/usePrefersReducedMotion.ts b/apps/desktop/src/renderer/hooks/usePrefersReducedMotion.ts new file mode 100644 index 000000000..00dec34b8 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +/** + * Lightweight matchMedia hook that does not depend on motion/react. Several + * renderer suites install partial motion mocks, so the shared hook stays + * usable by controls outside animation-library boundaries. + */ +export function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(() => + typeof window !== "undefined" + && typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches); + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const onChange = () => setReduced(query.matches); + query.addEventListener?.("change", onChange); + return () => query.removeEventListener?.("change", onChange); + }, []); + + return reduced; +} diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 742451035..7645e8db7 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -2503,6 +2503,34 @@ button:active, [role="button"]:active { box-shadow: var(--shadow-card-hover); } +/* + * Launch shelf beneath the new-chat composer. + * + * The parent stack contributes a 12px gap and the composer wrapper contributes + * another 12px bottom margin. Cancel both and overlap the painted composer by + * one pixel so there is no background-colored seam at any scale factor. + * The shelf has no top border: the composer's bottom edge is the shared edge, + * while the shelf supplies only the continuing sides and bottom. + * + * Keep this geometry in CSS rather than Tailwind arbitrary values carrying + * variables; those can disappear from the generated stylesheet silently. + */ +.ade-chat-launch-shelf { + position: relative; + margin-top: -25px; + padding: 5px 12px 8px; + border: 1px solid var(--chat-glass-border); + border-top: 0; + border-radius: 0 0 var(--chat-radius-shell) var(--chat-radius-shell); + background: + linear-gradient( + to bottom, + color-mix(in srgb, var(--chat-glass-bg) 78%, transparent) 0%, + color-mix(in srgb, var(--chat-panel-bg) 42%, transparent) 100% + ); + box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); +} + .ade-liquid-glass { position: relative; isolation: isolate; @@ -2554,6 +2582,15 @@ button:active, [role="button"]:active { 0 18px 44px -30px rgba(0, 0, 0, 0.72); } +/* + * The empty-state launch shelf sits immediately behind the composer. Its + * shared edge must stay visible; the normal downward composer shadow otherwise + * paints a black band over the shelf and looks exactly like layout spacing. + */ +.relative:has(+ .ade-chat-launch-shelf) [data-chat-composer-mode] { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + /* Rich composer chips are contentEditable="false", so browsers skip them when painting the native selection and a drag across a chip looks like it breaks in half. AgentChatComposer marks the chips the selection intersects with @@ -3369,6 +3406,87 @@ button:active, [role="button"]:active { container-name: chat-composer; } +/* + * Composer selectors share the composer surface instead of nesting individual + * bordered pills inside it. The popovers retain their normal menu chrome. + */ +.ade-chat-composer-footer :is( + [data-model-picker-trigger], + [data-reasoning-effort-picker-trigger], + .ade-chat-composer-permission-trigger +) { + height: 28px; + font-size: 11px; + line-height: 1; + border-color: transparent; + border-radius: 6px; + background: transparent; + box-shadow: none; + padding-inline: 6px; +} + +.ade-chat-composer-footer [data-reasoning-chip] { + font-size: 11px; + line-height: 1; +} + +.ade-chat-composer-footer :is( + [data-model-picker-trigger], + [data-reasoning-effort-picker-trigger], + .ade-chat-composer-permission-trigger +):hover:not(:disabled), +.ade-chat-composer-footer :is( + [data-model-picker-trigger], + [data-reasoning-effort-picker-trigger], + .ade-chat-composer-permission-trigger +)[data-state="open"] { + border-color: transparent; + background: rgb(255 255 255 / 5%); + box-shadow: none; +} + +.ade-chat-composer-footer .ade-chat-composer-permission-trigger > span:first-child { + width: 13px; + height: 13px; + border-color: transparent; + border-radius: 0; + background: transparent; +} + +.ade-chat-composer-footer .ade-chat-composer-permission-trigger > span:first-child svg { + width: 11px; + height: 11px; +} + +.ade-chat-composer-footer .ade-chat-composer-permission-label { + font-size: 11px; + line-height: 1; +} + +/* The launch shelf is already the containing surface, so machine and lane are + inline selectors rather than pills nested inside another drawer. */ +.ade-chat-launch-shelf :is([data-draft-machine-picker], .ade-lane-trigger) { + font-size: 9px; + line-height: 1; + border-color: transparent; + border-radius: 6px; + background: transparent; + box-shadow: none; +} + +.ade-chat-launch-shelf button[aria-label="Open shell in selected lane"], +.ade-chat-launch-shelf button[aria-label="Import an external CLI session"] { + font-size: 9px; + line-height: 1; +} + +.ade-chat-launch-shelf :is([data-draft-machine-picker], .ade-lane-trigger):hover:not(:disabled), +.ade-chat-launch-shelf :is([data-draft-machine-picker], .ade-lane-trigger)[data-open="true"], +.ade-chat-launch-shelf [data-draft-machine-picker][aria-expanded="true"] { + border-color: transparent; + background: rgb(255 255 255 / 5%); +} + @container chat-composer (max-width: 560px) { .ade-chat-composer-permission-trigger { min-width: 1.5rem; diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index b729d3ae4..ca97ce7f9 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -106,9 +106,9 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/chatTurnState.ts` | Shared renderer turn-state invariant used by cache hydration, history snapshots, live event flushes, and locked-session summary refreshes. A terminal `status`/`done` at the end of the transcript outranks an eventually consistent `status: "active"` session summary, so failed/interrupted turns restore an idle composer. Also resolves the user message associated with a failed turn, including Codex optimistic user rows that predate assignment of a provider `turnId`. | | `apps/desktop/src/renderer/lib/claudeAuthPrompt.ts` | Renderer-side classifier for Claude logged-out / `/login`-required error text. Drives the header and sticky login CTAs; matches both Claude-first wording and ADE's own "Authentication failed for <model>" classified message. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | -| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, parallel launch slot configuration, and inline smart-link chips. Completed GitHub, Linear, ADE, and generic web URLs become atomic violet chips while their literal URL remains the serialized prompt text; click/keyboard actions offer Copy link and Remove link, hover exposes the canonical URL, and Backspace/Delete removes the whole token. During an active Claude turn, the split Send caret selects inline, after-turn, or interrupt delivery without sending; the primary button and Enter execute the chosen mode. Staged messages expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted, dropped, and native-path attachments are copied through `ade.agentChat.saveTempAttachment` on the draft's selected runtime, so a MacBook chat never receives a Studio-only path (and vice versa). The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. The machine chip next to the lane selector picks which machine owns an auto-created lane and draft without rebinding the project tab. Attachment storage, model/auth discovery, slash commands, file search, parallel launch state, creation, rollback, and recovery all carry that captured `OpenProjectBinding`; unresolved or disconnected bindings fail closed instead of falling back to the tab's machine. Changing machines clears machine-owned attachments and tool context so paths cannot cross runtimes. The **This Mac** option resolves to *this repository's* local checkout through `thisMachineProjectRoot.ts` rather than to the first open local tab; when no matching local checkout exists, the composer shows an inline dismissible amber notice. | +| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx`, `DraftMachinePicker.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, parallel launch slot configuration, and inline smart-link chips. Completed GitHub, Linear, ADE, and generic web URLs become atomic violet chips while their literal URL remains the serialized prompt text; click/keyboard actions offer Copy link and Remove link, hover exposes the canonical URL, and Backspace/Delete removes the whole token. During an active Claude turn, the split Send caret selects inline, after-turn, or interrupt delivery without sending; the primary button and Enter execute the chosen mode. Staged messages expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted, dropped, and native-path attachments are copied through `ade.agentChat.saveTempAttachment` on the draft's selected runtime, so a MacBook chat never receives a Studio-only path (and vice versa). The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. The empty-draft launch shelf separates machine selection (`DraftMachinePicker`) from the lane list, scopes lanes to the chosen machine, and keeps Shell and Import beside the resulting target. It hides the machine control when there is only one choice and preserves Auto-create across machine changes. Attachment storage, model/auth discovery, slash commands, file search, parallel launch state, creation, rollback, and recovery all carry that captured `OpenProjectBinding`; unresolved or disconnected bindings fail closed instead of falling back to the tab's machine. Changing machines clears machine-owned attachments and tool context so paths cannot cross runtimes. The **This Mac** option resolves to *this repository's* local checkout through `thisMachineProjectRoot.ts` rather than to the first open local tab; when no matching local checkout exists, the composer shows an inline dismissible amber notice. | | `apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx` | Desktop prompt-stash control mounted immediately left of the context meter. Cmd/Ctrl+S and the bookmark share one path: non-empty text is persisted before the exact saved draft is cleared, while an empty draft opens the keyboard-navigable stash menu. Restore is a take operation, but it puts text into the composer before waiting for a remote delete so edits cannot be overwritten; delete failure intentionally favors a duplicate over lost text. Attachments and context items never enter the stash. | -| `apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx` | Shared reasoning slider. Supports pointer drag with nearest-tick snap, keyboard arrows/Home/End, a progressive filled gradient, and a directional roll transition for the active tier label, GPT-5.6 labels (Light, Medium, High, Extra High, Max, and Ultra where supported), and an Ultra multi-agent usage note. Choosing or dragging to a tier leaves the popover open; outside click or Escape closes it. | +| `apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx` | Shared reasoning slider. Supports pointer drag with nearest-tick snap, keyboard arrows/Home/End, a progressive filled gradient, and a directional roll transition for the active tier label, GPT-5.6 labels (Light, Medium, High, Extra High, Max, and Ultra where supported), and an Ultra multi-agent usage note. The collapsed trigger uses full tier names on desktop, keeps abbreviations for narrow/mobile layouts, and does not add a second border around the label. Choosing or dragging to a tier leaves the popover open; outside click or Escape closes it. | | `apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx` | Pending-input card used when ADE asks the user to choose a model for a new or rerouted agent. It renders the agent briefing, touched files, run-after dependencies, provider/model controls, cancel/confirm states, and leaves the model unset until the user chooses one. | | `apps/desktop/src/renderer/components/chat/ChatCursorCloudPanel.tsx` | Side panel for Cursor Cloud (background agents): lists existing cloud agents and runs for the lane, lets the user open an existing cloud chat in ADE, archive/unarchive/cancel, and stream run output. Backed by `ade.ai.cursorCloud.*` IPC. | | `apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx` | Inline composer affordance for "Send to Cursor Cloud": picks repo + branch + Cursor Cloud-eligible model, optionally targeting a detected PR, and dispatches the prompt to a fresh cloud agent. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 2b7adf630..21b9b977e 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -12,7 +12,7 @@ subagents, computer use). The pane derives all visible state from the | Path | Role | |---|---| | `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. It persists a per-session `ade.chat.lastViewed.v1:` timestamp in renderer `localStorage`; scheduled turns that fired since that timestamp produce a dismissible while-you-were-away strip above the composer, with the latest outcome preview and jump requests for up to three wake dividers. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional `draftContextTargetId` prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists; window event handlers match on either `sessionId` or `draftTargetId`. When auto-creating a lane the draft resolves the primary lane for the `onLaneChange` callback so the sidebar lane context stays in sync. Composer draft state (text, model, reasoning, attachments, context items) is persisted to `localStorage` under the `ade.chat.composerDraft.v1` key family and restored on scope change through `ComposerDraftStorageSnapshot`. Pending-steer Edit uses `cancelSteer({ requireQueued: true })`, then merges the queued text, file attachments, and context attachments into the captured composer draft; if the message already left the queue, the cancel fails and the draft is left unchanged. Draft launches are tracked through **root**-store-backed `DraftLaunchJob` state machines with multi-step progress (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` / `failed`; auto-create names the lane deterministically up front and renames to the AI name in the background, so there is no blocking `naming-lane` phase); jobs live in the root store (not the per-project store) so an in-flight launch survives a remote project switch that tears down the originating project surface. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback to that binding, and caps each step at 90 s (`withDraftLaunchTimeout`). The composer is cleared optimistically at job start, stale active rows gain a hide-status escape hatch, failed jobs expose Restore in the job strip and matching error banner, and the `DraftLaunchSnapshot` captures the full control state so the async launch uses frozen settings. It also owns the transcript-resilience rules described in [Transcript and turns](transcript-and-turns.md#history-snapshots-scroll-back-and-misses): `resolveChatHistoryMissAction` (a history miss never blanks a rendered transcript), `resolveSnapshotHistoryCursor` (`hasOlderHistory` is authoritative over `tailStartOffset`), the bounded silent retry ladder `OLDER_HISTORY_RETRY_DELAYS_MS = [800, 2400]`, the `syncPendingBySession` flag that surfaces as `data-chat-sync-pending` + a 2 px catch-up hairline under the header (a fading static rule, never a continuous animation; the fade is `motion-safe:`), and a minimal static cold-chat skeleton (`data-chat-cold-skeleton`) so a chat with no cached view reads as loading rather than empty. `resolveRenderedChatSessionId` picks the session to paint from the incoming props rather than the effect-synced `selectedSessionId`, which otherwise paints the outgoing chat's transcript for one frame after the pane is pointed elsewhere. The module-level view cache holds 8 entries / 128 MB total (32 MB per session, matching the resident ceiling) and stores a reference to the array the pane already holds; a **detached** view — an older transcript prefix whose live tail was dropped to stay under the resident cap after paging back — is skipped rather than evicted, so a later restore can never render an old slice as if it were current. The active-turn recovery loop is a **stall detector**, not the transport: it re-reads the transcript on a jittered `ACTIVE_TURN_RECOVERY_INTERVAL_MS` (10 s) tick and skips entirely when the live subscription delivered anything inside that window. Subscription ownership is handed to `chatSessionRetention.ts` when the pane hides. Left/right floating-pane reserve is applied only while a selected session surface renders those panes; an empty draft never reserves a hidden PR or Chat Actions pane, so its hero composer remains centered. | -| `apps/desktop/src/renderer/components/usage/ActivityModule.tsx` | Tabbed cross-client activity/tokens/code/clients module. `AgentChatPane` mounts the self-fetching `WorkActivityModule` (compact variant) beneath the empty Work draft composer when no app panel is open; the component persists the chosen tab and day/week/month/year range under `ade.activity.module.v1`. | +| `apps/desktop/src/renderer/components/usage/ActivityModule.tsx`, `ActivityHeatmap.tsx`, `activityIntensity.ts` | Tabbed cross-client activity/tokens/code/clients module. `AgentChatPane` mounts the self-fetching `WorkActivityModule` (compact variant) beneath the empty Work draft composer when no app panel is open; the component persists the chosen tab and day/week/month/year range under `ade.activity.module.v1`. `ActivityHeatmap` owns the responsive seven-row grid and viewport fitting, while `activityIntensity` provides the shared daily activity score, non-zero quartile buckets, and leading-inactive-day trimming used by the grid and summary counts. | | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout` (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Pure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. `AgentChatPane` writes these jobs into the root store while `TerminalsPage` passes matching jobs into the Work session sidebar. The local handoff surface offers a brief summarized handoff or a full-history fork whenever the source provider is fork-capable (`providerSupportsHandoffFork`: Claude, Codex, OpenCode, Droid); Cursor is brief-only. Fork keeps the new chat on the same provider and lane while allowing the target model to change; Claude forks the SDK session pointer, Codex the app-server thread (`thread/fork`), OpenCode `session.fork`, and Droid `forkSession()`. | | `apps/desktop/src/renderer/lib/aiDiscoveryCache.ts` | Runtime-binding-scoped AI integration-status and provider-model cache shared across renderer surfaces. Local and remote checkouts with the same project identity cannot share model/auth state. `getAiStatusCached` uses a 10-second freshness window and deduplicates concurrent `ade.ai.getStatus` requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops. | @@ -66,7 +66,7 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Prompt formatting for visual/tool context from attachments, iOS Simulator, App Control, and built-in browser selections. | | `apps/desktop/src/shared/types/chat.ts` | Shared composer/session DTOs, including `PARALLEL_CHAT_MAX_ATTACHMENTS`, parallel launch state types, the `AgentChatModelCatalog*` set, `AgentChatModelCatalogRefreshProvider` (`opencode` / `cursor` / `droid` / `lmstudio` / `ollama`), and `AgentChatModelCatalogArgs` (`mode`, `refreshProvider`). | | `apps/desktop/src/renderer/components/shared/ModelPicker/` | Modular ModelPicker (see [ModelPicker structure](#modelpicker-structure)): `ModelPicker.tsx`, `ModelPickerContent.tsx`, `ModelPickerRail.tsx`, `ModelListRow.tsx`, `ReasoningEffortPicker.tsx` (draggable/snapping gradient slider that stays open on selection), `modelCatalog.ts`, `modelOrdering.ts`, `modelPickerSearch.ts`, `providerEmptyState.tsx`, `runtimeCatalogCache.ts`, plus the `useProviderAuthStatus` / `useAuthOnlyFilter` / `useModelFavorites` / `useModelRecents` / `usePerSurfaceModelDefaults` / `useReasoningByFamily` hooks. | -| `apps/desktop/src/renderer/components/shared/PermissionModePicker.tsx` | The permission-mode pill itself, shared by every surface that lets a user choose how a chat starts: the composer's per-provider controls, `SessionLaunchModelControls`, and the cross-machine handoff modal. Exports the generic `PermissionModePicker`, `PermissionModeGlyph`, the tone/icon enums the provider option tables map into, and `PERMISSION_TRIGGER_CLASS` — the one definition of the trigger chrome, previously hand-copied per surface. That class scales with `calc(var(--chat-font-size,14px)*9/14)`; the fallback is load-bearing, because `--chat-font-size` only exists on a chat appearance root and a bare token would leave the launch and handoff pills inheriting the ambient size. Anything offering permission modes renders this, not a lookalike. | +| `apps/desktop/src/renderer/components/shared/PermissionModePicker.tsx` | The permission-mode pill itself, shared by every surface that lets a user choose how a chat starts: the composer's per-provider controls, `SessionLaunchModelControls`, and the cross-machine handoff modal. Exports the generic `PermissionModePicker`, `PermissionModeGlyph`, the tone/icon enums the provider option tables map into, and `PERMISSION_TRIGGER_CLASS` — the one definition of the trigger chrome, previously hand-copied per surface. That class scales with `calc(var(--chat-font-size,14px)*9/14)`; the fallback is load-bearing, because `--chat-font-size` only exists on a chat appearance root and a bare token would leave the launch and handoff pills inheriting the ambient size. Anything offering permission modes renders this, not a lookalike. Tone colour is deliberately asymmetric between the collapsed trigger and the open popover: only the `red` tone (bypassed permissions — the one mode here that can do damage) keeps colour on the resting trigger, and as a border/text tint rather than a filled pill. Every safe tone renders neutral trigger chrome and lets its tone read from the glyph alone. A toolbar where each control is a saturated pill has no way left to say "this one is different"; the full palette still applies to the popover rows, where there is room. | | `apps/desktop/src/renderer/components/shared/BlockedAction.tsx` | The blocked-action primitive: `BlockedActionReason` (id, title, detail, and the optional fix that clears it), `BlockedReasons` to render them inline, `describeBlockedReasons` for tooltip/a11y text, and `BlockedActionButton`, which takes the reasons themselves rather than a `disabled` boolean so a caller cannot disable a control without handing over the explanation. Exists because ADE keeps regrowing the same bug — a surface computes blockers, disables the primary button, and renders none of them. | Cross-machine Work drafts do not rebind the project tab. `AgentChatPane` freezes @@ -109,6 +109,76 @@ to the selected-session branch that mounts those panes. The empty/draft branch uses a zero reserve even when the lane's persisted PR-pane preference is open, so a new-chat composer is centered rather than shifted for invisible chrome. +### Empty draft surface + +The draft (new-chat) branch is three elements, not seven: the wordmark, the +composer, and a **launch shelf** tucked under the composer, with the activity +module below. There is no standing "Start a new conversation" caption — the +wordmark already identifies the app, so the line was a band of vertical space +spent restating what the user could see. Only a non-default mode still writes a +line there (`isOrchestratorDraft` renders "Orchestrate a swarm of agents"), +because that names something the surface does not otherwise show. + +The shelf holds everything that answers **where this runs**, as two adjacent +dropdowns plus two labelled actions: `DraftMachinePicker`, then a `LaneCombobox` +(mounted `compact`, so its 28px trigger matches the composer pills above it), +then Shell and Import. The composer sits above it at `z-10`. + +Machine and lane are **separate controls on purpose**. Folding them into one +list made that list carry two orthogonal choices: every lane row had to name its +machine, and the list grew by machine count rather than staying the length of one +machine's lanes. Choosing the machine first keeps the lane list flat, short, and +scoped — `DraftMachinePicker` renders nothing below two machines, and +`AgentChatPane` passes `draftShelfLanes` (bare lane ids for the selected machine) +rather than the machine-qualified option ids the combined selector needed. This +also retired the composer toolbar's machine chip, which only ever existed because +machine had nowhere else to live. + +`handleMachineChange` in `useDraftMachineRouting.ts` re-points the lane to the +target machine's primary **without touching `draftLaunchTargetId`**, so a draft +sitting on "Auto-create lane" keeps that target and only its underlying machine +moves — auto-create and primary are the two targets ADE guarantees on every +machine running it, so neither needs the user to re-choose. A machine with no +lanes at all falls back to `AUTO_CREATE_LANE_OPTION_ID` rather than erroring or +leaving the picker blank. + +Shell and Import keep text labels. As icon-only buttons they were unreadable — +that was a symptom of solving the wrong problem (compressing controls to fit a +shelf that was simply too tall). + +`LaneCombobox` itself was reworked for this: a single-line trigger (lane dot, +name, branch, caret) instead of a 40px two-line block, lane colour reduced to +the dot so the control sits in the same neutral ghost family as its neighbours, +and a search field. Branch text truncates before the lane name via flex shrink +factors — the branch is context, the name is the label. Two invariants are pinned +by tests: `fullWidth` emits no `max-w-*` and nothing inside establishes a +min-content floor (the measured narrow-pane overflow this component regressed +before), and `computeLanePopoverPlacement` clamps on both axes including the +case where neither side fits. The popover has no exit animation on purpose — a +body-portal node that outlives `open` by a frame leaks into whatever renders +next, which is how its search field started colliding with unrelated +`getByRole("textbox")` queries in the chat suites. Its machine-grouping support +still exists for other callers; the shelf simply never triggers it. + +Its chrome is the `.ade-chat-launch-shelf` class in `renderer/index.css`. The +shelf cancels both the parent stack's 12px gap and the composer's 12px wrapper +margin, overlaps the painted composer by one pixel, and omits its top border. +The composer's bottom edge is therefore the shared edge while the shelf +supplies the continuing sides and lower corners; no background-colored seam +sits between the two surfaces. + +**Keep the margin and padding in that CSS rule, not on the element.** They lived +briefly as Tailwind arbitrary values carrying CSS variables +(`-mt-[var(--chat-radius-shell)]`, `pt-[calc(var(--chat-radius-shell)+4px)]`) — +exactly the kind of class that can fail to compile with no error. When the +negative margin silently vanishes, the shelf detaches from the composer. +Nothing in the type system or test suite catches that geometry. + +The nesting is also load-bearing: Shell and Import both act on whichever lane is +selected in this shelf, so presenting them *inside* it encodes a dependency the +earlier stacked layout inverted — lane selection used to sit below two buttons +that could not work without it. + ### Header - Session title from `chatSessionTitle()`; falls back to "New chat". @@ -335,6 +405,10 @@ so a new-chat composer is centered rather than shifted for invisible chrome. progressive low-to-high gradient and the active tier keeps the existing pulse/colour treatment. A tier choice does **not** close the popover — the user can compare levels until clicking outside or pressing Escape. + The collapsed trigger uses the full tier label on normal-width composers + (for example, `Medium` rather than `MED`) and removes the nested label + outline because the trigger already supplies the interactive boundary. + Narrow/mobile layouts retain the abbreviated label to preserve space. GPT-5.6 displays Light / Medium / High / Extra High / Ultra; ordinary Max is hidden for that family, and Ultra explains that it can delegate to multiple agents and use limits faster. @@ -348,16 +422,78 @@ so a new-chat composer is centered rather than shifted for invisible chrome. text to the clipboard as a recovery path. The composer pill and top bar pill both observe the root-store dictation slice, so their timer and waveform stay in sync. -- **Fast mode.** A yellow Lightning chip next to the model selector - toggles the legacy-named `codexFastMode` bit for the selected - session. It renders whenever the selected descriptor advertises - `serviceTiers: ["fast"]`, including dynamic Cursor SDK/CLI rows and - GPT-5.6 and older fast-capable Codex entries. Codex state flows into the next - `thread/start` / `turn/start` as `serviceTier: "fast"`; Cursor SDK - state flows through the discovered model-parameter selection, and +- **Fast mode.** Toggles the legacy-named `codexFastMode` bit for the + selected session. Fast mode is a property of a *model*, so the toggle + lives on the model row inside the shared `ModelPicker` rather than as a + separate composer chip — every surface that mounts the picker gets it, + and the composer toolbar keeps one control where it used to spend two. + The row chip renders whenever that descriptor advertises + `serviceTiers: ["fast"]` (dynamic Cursor SDK/CLI rows, GPT-5.6, and + older fast-capable Codex entries) *and* the caller supplied + `onFastModeChange`. Fast is one bit per surface but it belongs to the + model it was enabled for, so a chip reads as on only on the *selected* + row (`fastModeOn={fastMode && isActive}`) — never on every fast-capable + row at once. Clicking the selected row's chip is a plain on/off toggle + that neither closes the picker nor re-fires selection; clicking a + non-selected row's chip means "use this model, fast" — one press + commits the model selection and turns fast on. A plain row click onto a + different model clears the previous model's fast bit rather than + inheriting it. The chip's states are rest (muted outline, outline + lightning) → hover (darker fill, still off) → press (`active:scale`, + suppressed under `prefers-reduced-motion`) → on (violet fill, filled + lightning). The collapsed trigger names the state rather than showing a + separate indicator — `composeModelPickerTriggerLabel()` in + `ModelPicker.tsx` renders "GPT-5.6 Terra Fast", with a filled lightning + glyph rendered before the model name (`aria-hidden`, so the accessible + name stays text-only). Codex state flows into + the next `thread/start` / `turn/start` as `serviceTier: "fast"`; Cursor + SDK state flows through the discovered model-parameter selection, and Work CLI launches resolve fast Cursor rows to the matching `*-fast` - alias. The toggle is also exposed per-slot in parallel mode through - `onParallelSlotCodexFastModeChange`. + alias. Parallel mode passes the per-slot setter through the slot's own + picker (`onParallelSlotCodexFastModeChange`). + + Surfaces not yet migrated (`ModelSelector`, `ReviewLaunchModelControls`, + `CtoSettingsPanel`, `ChatModelSelectionPendingCard`, `ProjectlessComposer`) + still pass the deprecated `fastModeActive` / `onFastModeToggle` pair, + which keeps rendering the old sibling chip. Migrating them is a prop + rename with nothing else to unwind. +- **Overflow control.** Issue context, orchestrator mode, parallel models, + and the iOS Simulator / App Control drawer toggles are folded behind one + `⋯` trigger (`ComposerOverflowMenu`). Each entry is gated by exactly the + condition that used to gate its standalone button, so a control that + would not have rendered does not become a row, and with no entries left + the trigger disappears entirely. + + How many entries survive is **contextual**, not fixed — a Work CLI draft + hides the lane tool drawers (`hideLaneToolDrawers`) and has no + orchestrator, so it can be left with one. A `⋯` that opens onto a single + row is a menu pretending to be a button, so at `items.length === 1` the + control renders that entry directly as an icon button instead. Callers + must therefore not assume either form; tests reach it through a helper + that accepts both. + + Because folding hides active state, the collapsed trigger carries an + accent dot whenever any entry is on, rows report `aria-checked`, and an + entry may carry a `badge` count (issue context uses it for attached + issues) which surfaces on the inline button too. Rows that open their + own portal — issue context — position against the `triggerRef` rather + than against the row, because the row unmounts with the menu while the + trigger stays mounted. + + The menu itself **portals to `document.body`** via + `composerSplitMenuPosition`, like every other composer popover. The + composer shell clips its overflow, so an inline-absolute menu is cut off + at the prompt-box edge and simply cannot be read. +- **Send options.** Background launch is the second row on Send's caret, + and Send is a **split control**: one `rounded-full` body, the arrow on + the left, a hairline divider, a caret sharing the same fill. This is + deliberately the same shape as `ActiveTurnSendButton`, so the composer + uses one send idiom whether or not a turn is running. The earlier + arrangement — a second filled circle beside Send, also carrying an arrow + — read as one control accidentally duplicated and overflowed the + composer's padding, clipping against its rounded edge. The split renders + only when `onSubmitInBackground` is supplied and the surface is neither + parallel nor Cursor-Cloud mode; otherwise Send stays a plain circle. - **Attachments.** Allows the user to attach files and artifacts to the next turn. - **Permission controls.** Inline with the composer: @@ -521,10 +657,10 @@ power the TUI picker (`apps/ade-cli/src/tuiClient/components/ModelPicker/`). | Module | Role | |---|---| -| `ModelPicker.tsx` | Trigger + popover entry point. Owns runtime-catalog loading via `runtimeCatalogCache`, fast-mode chip, and the favorites/recents fan-out. | +| `ModelPicker.tsx` | Trigger + popover entry point. Owns runtime-catalog loading via `runtimeCatalogCache`, fast mode, and the favorites/recents fan-out. Pass `fastMode` + `onFastModeChange` and the picker owns the affordance: a per-row Fast chip inside the popover plus a ` Fast` trigger suffix composed by the pure `composeModelPickerTriggerLabel` helper. Surfaces that pass neither render no fast affordance at all; the deprecated `fastModeActive` / `onFastModeToggle` / `fastModeSupported` props still render the old sibling chip for call sites that have not migrated. | | `ModelPickerContent.tsx` | The popover body: search bar, rail, virtualized list (`@tanstack/react-virtual`), empty state. Props include `hidePermissionRail` (forward-compat hook for orchestrated surfaces that suppress permission-related affordances), `allowCliOnlyModels` (switch Cursor filtering from SDK chat models to CLI launch models), `allowRegistryExpansion` (when false, skip merging `MODEL_REGISTRY` entries into the runtime catalog), and `registryFilter` (restrict registry expansion by descriptor, used by fork handoffs to keep the provider fixed without freezing the picker to a stale concrete-id list). When the authenticated-only filter is active, authenticated CLI-backed providers (Claude, Codex, Droid) may expand from the static registry even if the last discovered model-id list is incomplete. Estimated row height `MODEL_ROW_ESTIMATED_HEIGHT = 44`. | | `ModelPickerRail.tsx` | Left-rail tabs (Favorites / Recents / per-provider groups). Reads `AuthStatus` per family to render auth gates and the OpenCode "Install OpenCode" CTA from `providerEmptyState`. | -| `ModelListRow.tsx` | A single model row (favorite star, brand logo, display name, sub-provider chip, availability tone). | +| `ModelListRow.tsx` | A single model row (favorite star, brand logo, display name, sub-provider chip, availability tone). Also renders the muted Fast chip when the surface supplied `onFastModeChange` and `modelSupportsFastMode()` holds for that row's descriptor; toggling it changes neither the selection nor the popover's open state. | | `ReasoningEffortPicker.tsx` | Standalone reasoning-effort dropdown, mounted next to the model trigger and inside per-slot parallel-launch controls. | | `modelCatalog.ts` | `descriptorsFromAgentChatModelCatalog`, `mergeSelectorModels`, `resolveModelDescriptorWithRuntimeCatalog`, `createUnknownModelPlaceholder` — pure helpers that flatten the IPC catalog into a `ModelDescriptor[]` and reconcile it with the static registry while preserving runtime metadata such as `serviceTiers` and Cursor `cursorAvailability`. | | `modelOrdering.ts` | `sortModelItems` — provider/group ordering and intra-group ranking (favorites first, then recents, then default registry order). | From f1de9f7a0abd89ab4dec892683cde4eacdadaa53 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:13:56 -0400 Subject: [PATCH 2/6] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20address?= =?UTF-8?q?=20review=20accessibility=20and=20layout=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/AgentChatComposer.test.tsx | 13 ++-- .../components/chat/AgentChatComposer.tsx | 9 ++- .../components/chat/AgentChatPane.test.tsx | 60 ++++++++++--------- .../components/chat/AgentChatPane.tsx | 35 +++++++---- .../components/chat/DraftMachinePicker.tsx | 43 +++++++++++-- .../components/chat/useDraftMachineRouting.ts | 6 +- .../shared/ModelPicker/ModelListRow.tsx | 1 + .../shared/ModelPicker/ModelPicker.tsx | 1 + .../shared/ModelPicker/ModelPickerContent.tsx | 1 + .../ModelPicker/ReasoningEffortPicker.tsx | 4 +- .../components/terminals/LaneCombobox.tsx | 19 +++++- .../components/usage/ActivityHeatmap.tsx | 3 +- .../components/usage/ActivityModule.tsx | 4 +- .../renderer/components/usage/usage.test.tsx | 25 ++++---- apps/desktop/src/renderer/index.css | 5 +- 15 files changed, 144 insertions(+), 85 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index ffbd49ea3..efc861cc2 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1139,9 +1139,10 @@ describe("AgentChatComposer", () => { onSubmitInBackground: vi.fn(), }); - const send = screen.getByRole("button", { name: "Send" }); - const caret = screen.getByRole("button", { name: "Send options" }); - expect(send.parentElement?.parentElement).toBe(caret.parentElement?.parentElement); + const splitControl = document.querySelector("[data-composer-idle-send-control]"); + expect(splitControl).toBeTruthy(); + expect(within(splitControl as HTMLElement).getByRole("button", { name: "Send" })).toBeTruthy(); + const caret = within(splitControl as HTMLElement).getByRole("button", { name: "Send options" }); fireEvent.click(caret); expect(screen.getByRole("menuitem", { name: /Launch in background/ })).toBeTruthy(); @@ -1173,7 +1174,7 @@ describe("AgentChatComposer", () => { onFastModeChange: vi.fn(), }); - expect(screen.getByRole("button", { name: /Select model/i }).textContent).toMatch(/Fast/); + expect(document.querySelector("[data-model-picker-trigger]")?.textContent).toMatch(/Fast/); }); it("hides Codex fast mode for unsupported models", () => { @@ -1196,7 +1197,7 @@ describe("AgentChatComposer", () => { hideModelControls: true, }); - expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull(); + expect(document.querySelector("[data-model-picker-trigger]")).toBeNull(); expect(screen.queryByRole("button", { name: "Reasoning effort" })).toBeNull(); expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull(); }); @@ -1214,7 +1215,7 @@ describe("AgentChatComposer", () => { ], }); - expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull(); + expect(document.querySelector("[data-model-picker-trigger]")).toBeNull(); expect(screen.queryByRole("button", { name: "Reasoning effort" })).toBeNull(); expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull(); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 94053121c..ffcf0db95 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -591,7 +591,7 @@ function ComposerIdleSendButton({ return (
-
+
- ) : null, + onChange: (modelId: string, options?: { fastMode: boolean }) => void; + }) => ( + <> + {fastModeSupported ? ( + + ) : null} + + + ), })); vi.mock("../shared/ModelPicker/ReasoningEffortPicker", () => ({ @@ -96,6 +111,31 @@ describe("LinearQuickViewButton batch-launch UI", () => { expect(onChange).toHaveBeenCalledWith({ fastMode: true }); }); + it("routes a model and Fast selection as one atomic patch", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Choose another model in Fast mode" })); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + modelId: "openai/gpt-5.5", + fastMode: true, + }); + }); + it("keeps created sessions with kickoff errors openable and out of Retry failed", () => { const onDismiss = vi.fn(); render( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ffcf0db95..67abe2a21 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1527,7 +1527,7 @@ export function AgentChatComposer({ orchestrationRole?: OrchestrationRole | null; messagePlaceholder?: string; inputLockMessage?: string | null; - onModelChange: (modelId: string) => void; + onModelChange: (modelId: string, options?: { fastMode: boolean }) => void; onReasoningEffortChange: (reasoningEffort: string | null) => void; onFastModeChange?: (enabled: boolean) => void; onDraftChange: (value: string) => void; @@ -1615,7 +1615,11 @@ export function AgentChatComposer({ onParallelConfiguringIndexChange?: (index: number | null) => void; onParallelAddModel?: () => void; onParallelRemoveModel?: (index: number) => void; - onParallelSlotModelChange?: (index: number, modelId: string) => void; + onParallelSlotModelChange?: ( + index: number, + modelId: string, + options?: { fastMode: boolean }, + ) => void; onParallelSlotReasoningChange?: (index: number, effort: string | null) => void; onParallelSlotFastModeChange?: (index: number, enabled: boolean) => void; parallelLaunchBusy?: boolean; @@ -4616,7 +4620,8 @@ export function AgentChatComposer({ <> onParallelSlotModelChange?.(parallelConfiguringIndex, next)} + onChange={(next, options) => + onParallelSlotModelChange?.(parallelConfiguringIndex, next, options)} surfaceKey={`chat-composer-parallel-${parallelConfiguringIndex}`} {...(availableModelIds ? { availableModelIds } : {})} constrainToAvailableModelIds={constrainModelSelection} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 6ef7d58ff..57cc9c9be 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1161,6 +1161,8 @@ function renderAutoCreateDraftPane(args?: { orchestratorEnabled?: boolean; onLaunchCliSession?: React.ComponentProps["onLaunchCliSession"]; onLaneChange?: React.ComponentProps["onLaneChange"]; + onDraftMachineChange?: React.ComponentProps["onDraftMachineChange"]; + initialDraftMachineId?: string | null; lanes?: any[]; project?: { rootPath: string; displayName?: string }; projectBinding?: OpenProjectBinding; @@ -1204,6 +1206,8 @@ function renderAutoCreateDraftPane(args?: { orchestratorEnabled={args?.orchestratorEnabled} availableLanes={lanes} onLaneChange={args?.onLaneChange ?? vi.fn()} + onDraftMachineChange={args?.onDraftMachineChange} + initialDraftMachineId={args?.initialDraftMachineId} onSessionCreated={args?.onSessionCreated} onLaunchCliSession={args?.onLaunchCliSession} /> @@ -5372,6 +5376,24 @@ describe("AgentChatPane submit recovery", () => { expect(onLaneChange).toHaveBeenCalledWith("lane-worktree"); }); + it("recovers a bare Auto-create selection from an unavailable persisted machine", async () => { + installAdeMocks({ sessions: [] }); + const onDraftMachineChange = vi.fn(); + const onLaneChange = vi.fn(); + renderAutoCreateDraftPane({ + initialDraftMachineId: "disconnected-studio", + onDraftMachineChange, + onLaneChange, + }); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("option", { name: /Auto-create lane/i })); + + expect(onDraftMachineChange).toHaveBeenCalledWith(null); + expect(onLaneChange).toHaveBeenCalledWith("lane-primary"); + expect(screen.queryByText(/selected machine is not currently available/i)).toBeNull(); + }); + it("auto-creates on This Mac from a remote-bound tab without rebinding the project", async () => { const { create } = installAdeMocks({ sessions: [] }); const onSessionCreated = vi.fn(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index d2d8b23c8..87063bc8f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11931,7 +11931,7 @@ export function AgentChatPane({ }} orchestratorModeActive={isOrchestratorDraft || isOrchestratorLead} orchestrationRole={isOrchestratorDraft ? "lead" : activeOrchestrationRole} - onModelChange={(nextModelId) => { + onModelChange={(nextModelId, options) => { const modelAllowed = modelSelectionConstrained ? effectiveAvailableModelIds.includes(nextModelId) @@ -11950,6 +11950,9 @@ export function AgentChatPane({ if (!selectedSessionId) { draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; } + if (options) { + setFastModeState(options.fastMode); + } const snapshot = buildModelSelectionSnapshot(nextModelId); if (!selectedSessionId || turnActive) { applyModelSelectionSnapshot(snapshot); @@ -12250,7 +12253,7 @@ export function AgentChatPane({ return cur; }); }} - onParallelSlotModelChange={(index, nextModelId) => { + onParallelSlotModelChange={(index, nextModelId, options) => { if (modelSelectionConstrained && !effectiveAvailableModelIds.includes(nextModelId)) return; const desc = resolveModelDescriptorWithRuntimeCatalog(nextModelId) ?? getModelById(nextModelId); const tiers = desc?.reasoningTiers ?? []; @@ -12267,6 +12270,7 @@ export function AgentChatPane({ patchParallelSlot(index, { modelId: nextModelId, reasoningEffort: nextEffort, + ...(options ? { fastMode: options.fastMode } : {}), executionMode: nextExecOpts.some((o) => o.value === parallelModelSlots[index]?.executionMode) ? parallelModelSlots[index]!.executionMode : (nextExecOpts[0]?.value ?? "focused"), diff --git a/apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx b/apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx index 0d8f08db8..b5702c9a2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx @@ -122,8 +122,9 @@ export const ChatModelSelectionPendingCard = memo(function ChatModelSelectionPen // When the user picks a different model, infer the new provider from the // model registry or runtime-catalog prefix so the dispatched ModelSelection // stays internally consistent (provider + modelId always agree). - const handleModelChange = useCallback((nextModelId: string) => { + const handleModelChange = useCallback((nextModelId: string, options?: { fastMode: boolean }) => { setModelId(nextModelId); + if (options) setFastMode(options.fastMode); setProvider(resolveSelectionProvider(nextModelId, fallbackProvider)); }, [fallbackProvider]); diff --git a/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx b/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx index cdc484e5e..a54a9cd12 100644 --- a/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx +++ b/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx @@ -1079,7 +1079,10 @@ export function CrossMachineHandoffModal({
{ + if (options) onFastModeChange?.(options.fastMode); + onModelChange(nextModelId); + }} surfaceKey="cross-machine-handoff" compact {...(modelIdsForMode ? { availableModelIds: modelIdsForMode } : {})} diff --git a/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts b/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts index 3255173fc..8f8582e33 100644 --- a/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts +++ b/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts @@ -365,8 +365,18 @@ export function useDraftMachineRouting({ // machine in its own control and passes bare ids, so defaulting here // would silently drag the selection back to the bound machine. Keep // whatever the machine picker already chose. - const nextMachineId = machineIdFromAutoCreateLaneOptionId(nextLaneId) - ?? machineId; + const explicitMachineId = machineIdFromAutoCreateLaneOptionId(nextLaneId); + const machineIsAvailable = (candidate: string | null | undefined) => + Boolean(candidate && machineOptions.some((option) => option.id === candidate)); + const nextMachineId = machineIsAvailable(explicitMachineId) + ? explicitMachineId! + : machineIsAvailable(machineId) + ? machineId + : ( + machineOptions.find((option) => option.isBound)?.id + ?? machineOptions[0]?.id + ?? boundMachineId + ); chooseMachine(nextMachineId); const primary = primaryLaneForMachine(nextMachineId); if (primary) onLaneChange?.(primary.id); @@ -384,8 +394,10 @@ export function useDraftMachineRouting({ onLaneChange?.(actualLaneId); }, [ chooseMachine, + boundMachineId, lanesByMachineId, machineId, + machineOptions, onLaneChange, primaryLaneForMachine, setDraftLaunchTargetId, diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 2ff3318ba..edd4a9258 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -728,13 +728,9 @@ describe("ModelPicker", () => { .findByRole("button", { name: /Fast mode for/i }); await user.click(otherChip); - expect(onChange).toHaveBeenCalledWith(FAST_GPT_ALT.id); - expect(onFastModeChange).toHaveBeenCalledTimes(1); - expect(onFastModeChange).toHaveBeenCalledWith(true); - // Fast intent has to land before the selection commits, because hosts - // persist the model change with whatever fast bit they can see. - expect(onFastModeChange.mock.invocationCallOrder[0]) - .toBeLessThan(onChange.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(FAST_GPT_ALT.id, { fastMode: true }); + expect(onFastModeChange).not.toHaveBeenCalled(); }); it("turns fast off again when the selected row's chip is clicked twice", async () => { @@ -788,8 +784,9 @@ describe("ModelPicker", () => { expect(within(slowRow).queryByRole("button", { name: /Fast mode for/i })).toBeNull(); await user.click(slowRow); - expect(onChange).toHaveBeenCalledWith(SLOW_GPT.id); - expect(onFastModeChange).toHaveBeenCalledWith(false); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(SLOW_GPT.id, { fastMode: false }); + expect(onFastModeChange).not.toHaveBeenCalled(); }); it("renders a presentational lightning glyph on the trigger when fast mode is on", async () => { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 553c7b67f..4db370e0b 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -30,7 +30,7 @@ import { export type ModelPickerProps = { value: string; - onChange: (modelId: string) => void; + onChange: (modelId: string, options?: { fastMode: boolean }) => void; surfaceKey: string; compact?: boolean; disabled?: boolean; @@ -284,8 +284,12 @@ export const ModelPicker = memo(function ModelPicker({ ); const handleSelect = useCallback( - (modelId: string) => { - onChange(modelId); + (modelId: string, options?: { fastMode: boolean }) => { + if (options) { + onChange(modelId, options); + } else { + onChange(modelId); + } setOpen(false); }, [onChange], diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index 79c46162f..ed5f53913 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -126,7 +126,7 @@ export type ModelPickerContentProps = { models: readonly ModelDescriptor[]; isAvailable: (modelId: string) => boolean; providerAuthStatus?: Partial>; - onSelect: (modelId: string) => void; + onSelect: (modelId: string, options?: { fastMode: boolean }) => void; onRequestClose: () => void; onProviderRailSelect?: (family: ProviderFamily) => void; /** @@ -499,10 +499,9 @@ export const ModelPickerContent = memo(function ModelPickerContent({ // a different model starts clean instead of inheriting the previous // model's bit. The fast chip re-enables it explicitly (see // `handleFastChipChange`). - if (fastMode && modelId !== value) onFastModeChange?.(false); - onSelect(modelId); + onSelect(modelId, modelId !== value && fastMode ? { fastMode: false } : undefined); }, - [fastMode, onFastModeChange, onSelect, recordUsage, value], + [fastMode, onSelect, recordUsage, value], ); const handleListKeyDown = useCallback( @@ -571,10 +570,9 @@ export const ModelPickerContent = memo(function ModelPickerContent({ return; } recordUsage(modelId); - // Fast first: the host persists the model change with whatever fast bit it - // sees, so the intent has to land before the selection commits. - onFastModeChange?.(true); - onSelect(modelId); + // Model + service tier are one selection. Emitting a single change keeps + // controlled consumers from rebuilding two patches from the same render. + onSelect(modelId, { fastMode: true }); }, [expandedModels, isAvailableForUse, onFastModeChange, onOpenSignIn, onSelect, recordUsage, value], ); diff --git a/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx b/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx index ac2258a18..5b644383a 100644 --- a/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx +++ b/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx @@ -288,7 +288,10 @@ export function SessionLaunchModelControls({ ) : null} onChange({ modelId })} + onChange={(modelId, options) => onChange({ + modelId, + ...(options ? { fastMode: options.fastMode } : {}), + })} surfaceKey={surfaceKey} compact triggerClassName={COMPOSER_MODEL_TRIGGER} From 8ea9db08f0e707188e68eebf400949c19d86ef5b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:54:18 -0400 Subject: [PATCH 4/6] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20fix=20p?= =?UTF-8?q?icker=20accessibility=20and=20rollback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/chat/AgentChatPane.test.tsx | 16 ++++++++++++++-- .../renderer/components/chat/AgentChatPane.tsx | 6 +++++- .../components/chat/DraftMachinePicker.tsx | 14 +++++++++----- .../shared/ModelPicker/ModelListRow.tsx | 2 +- .../shared/ModelPicker/ModelPicker.test.tsx | 3 +++ 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 57cc9c9be..76ce315dd 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -4317,7 +4317,7 @@ describe("AgentChatPane submit recovery", () => { }); it("keeps the committed model visible when the backend rejects a switch", async () => { - const session = buildSession("session-1", { status: "idle" }); + const session = buildSession("session-1", { status: "idle", fastMode: true }); const updateSession = vi.fn().mockRejectedValue(new Error("switch failed")); const warmupModel = vi.fn().mockResolvedValue(undefined); installAdeMocks({ @@ -4334,6 +4334,7 @@ describe("AgentChatPane submit recovery", () => { const nextLabel = getModelById("anthropic/claude-sonnet-5")?.displayName ?? "Claude Sonnet 5"; const nextLabelPattern = new RegExp(escapeRegExp(nextLabel), "i"); expect(trigger.textContent ?? "").toContain(currentLabel); + expect(trigger.textContent ?? "").toContain("Fast"); fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click(trigger); @@ -4349,6 +4350,7 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(currentLabel); }); + expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain("Fast"); expect(warmupModel).not.toHaveBeenCalled(); }); @@ -5416,6 +5418,7 @@ describe("AgentChatPane submit recovery", () => { }; const switchProjectToPath = vi.fn(); const switchRemoteProject = vi.fn(); + const onDraftMachineChange = vi.fn(); const remoteLanes = [{ // Primary lane ids are intentionally duplicated across machines. The // machine-qualified picker value must still route creation to This Mac. @@ -5490,6 +5493,8 @@ describe("AgentChatPane submit recovery", () => { renderAutoCreateDraftPane({ lanes: remoteLanes, onSessionCreated, + initialDraftMachineId: "disconnected-studio", + onDraftMachineChange, project: { rootPath: remoteBinding.rootPath, displayName: remoteBinding.displayName, @@ -5507,7 +5512,14 @@ describe("AgentChatPane submit recovery", () => { // Machine and lane are separate shelf controls now, so routing an // auto-create launch onto This Mac is two choices rather than one // machine-qualified row inside the lane list. - fireEvent.click(await screen.findByRole("button", { name: /^Choose machine, currently/ })); + const unavailableTrigger = await screen.findByRole("button", { + name: /current machine unavailable; fallback Mac Studio/i, + }); + fireEvent.click(unavailableTrigger); + fireEvent.click(await screen.findByRole("menuitemradio", { name: /Mac Studio/ })); + expect(onDraftMachineChange).toHaveBeenCalledWith(null); + + fireEvent.click(await screen.findByRole("button", { name: /currently Mac Studio/i })); fireEvent.click(await screen.findByRole("menuitemradio", { name: /This Mac/ })); fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); fireEvent.click(await screen.findByText("Auto-create lane")); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 87063bc8f..1145dca15 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11950,6 +11950,8 @@ export function AgentChatPane({ if (!selectedSessionId) { draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; } + const previousModelId = modelId; + const previousFastMode = fastModeRef.current; if (options) { setFastModeState(options.fastMode); } @@ -12023,6 +12025,8 @@ export function AgentChatPane({ } void refreshSessions().catch(() => {}); }).catch((err) => { + setModelId(previousModelId); + setFastModeState(previousFastMode); void refreshSessions().catch(() => {}); setError(err instanceof Error ? err.message : String(err)); }).finally(() => { @@ -13013,7 +13017,7 @@ export function AgentChatPane({ long instead of growing with machine count. */} diff --git a/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx b/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx index 82509c6e1..ce1d1d51c 100644 --- a/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx +++ b/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx @@ -87,8 +87,12 @@ export function DraftMachinePicker({ if (machines.length < 2) return null; - const selected = machines.find((machine) => machine.id === selectedMachineId) ?? machines[0]; - if (!selected) return null; + const selected = machines.find((machine) => machine.id === selectedMachineId) ?? null; + const displayed = selected ?? machines[0]; + if (!displayed) return null; + const triggerLabel = selected + ? `Choose machine, currently ${selected.name}` + : `Choose machine, current machine unavailable; fallback ${displayed.name}`; return (
@@ -105,7 +109,7 @@ export function DraftMachinePicker({ data-draft-machine-picker aria-haspopup="menu" aria-expanded={open} - aria-label={`Choose machine, currently ${selected.name}`} + aria-label={triggerLabel} disabled={disabled} onClick={() => setOpen((current) => !current)} className={cn( @@ -118,7 +122,7 @@ export function DraftMachinePicker({ )} > - {selected.name} + {displayed.name} {machines.map((machine) => { - const active = machine.id === selected.id; + const active = machine.id === selectedMachineId; return (