);
}
-/** 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);
-
+ // 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]");
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 (!open) return;
+ const menu = document.querySelector("[data-composer-overflow-menu]");
+ const preferred = menu?.querySelector(
+ '[role="menuitemcheckbox"][aria-checked="true"]:not(:disabled)',
+ );
+ (preferred ?? (menu ? enabledMenuItems(menu)[0] : null))?.focus();
+ }, [open]);
- if (!machineName) return null;
+ if (items.length === 0) return null;
- if (!selectable) {
+ // 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 (
-
-
+
+ {only.icon}
+ {only.badge ? (
+
+ {only.badge}
+
+ ) : null}
+
);
}
+ const activeCount = items.filter((item) => item.active).length;
+
return (
-
-
) : null}
- {machineSwitchError ? (
-
- {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}
+
+ {/* Inline composer for empty state (only when sim drawer closed) */}
+ {!appPanelOpen ? (
+
+ {composerWithTypographyRoot}
+
+ ) : null}
- {/* Lane selector pill */}
+ {/* 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 ? (
-
+ );
+}
diff --git a/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts b/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts
index 00632e262..8f8582e33 100644
--- a/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts
+++ b/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts
@@ -315,25 +315,68 @@ 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);
- const nextMachineId = machineIdFromAutoCreateLaneOptionId(nextLaneId)
- ?? machineOptions[0]?.id
- ?? boundMachineId;
+ // 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 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);
@@ -350,8 +393,8 @@ export function useDraftMachineRouting({
setDraftLaunchTargetId(null);
onLaneChange?.(actualLaneId);
}, [
- boundMachineId,
chooseMachine,
+ boundMachineId,
lanesByMachineId,
machineId,
machineOptions,
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx
index b8bbb0d48..2676b06b1 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,39 @@ export const ModelListRow = memo(function ModelListRow({
) : null}
+ {showFastChip ? (
+
+
+ Fast
+
+ ) : null}
+
{!isAvailable && onSignIn ? (
({
sortModelItems: (items: T[]): T[] => [...items],
}));
-import { ModelPicker } from "./ModelPicker";
+import { composeModelPickerTriggerLabel, ModelPicker } from "./ModelPicker";
import {
rememberRuntimeCatalog,
resetModelPickerRuntimeCatalogForTests,
@@ -218,6 +218,28 @@ const GPT: ModelDescriptor = {
isCliWrapped: true,
};
+const FAST_GPT: ModelDescriptor = {
+ ...GPT,
+ serviceTiers: ["fast"],
+};
+
+const FAST_GPT_ALT: ModelDescriptor = {
+ ...GPT,
+ id: "openai/gpt-5.4-mini",
+ shortId: "gpt-5.4-mini",
+ displayName: "GPT-5.4 Mini",
+ providerModelId: "gpt-5.4-mini",
+ serviceTiers: ["fast"],
+};
+
+const SLOW_GPT: ModelDescriptor = {
+ ...GPT,
+ id: "openai/gpt-5.4-thinking",
+ shortId: "gpt-5.4-thinking",
+ displayName: "GPT-5.4 Thinking",
+ providerModelId: "gpt-5.4-thinking",
+};
+
const OPENCODE_MODEL: ModelDescriptor = {
id: "opencode/anthropic/claude-sonnet-5",
shortId: "claude-sonnet-5",
@@ -273,6 +295,49 @@ function renderPicker(overrides: Partial {
+ return await waitFor(() => {
+ const row = document.querySelector(`[data-model-id="${modelId}"]`);
+ if (!row) throw new Error(`row not rendered for ${modelId}`);
+ return row;
+ });
+}
+
+describe("composeModelPickerTriggerLabel", () => {
+ it("appends Fast only when fast mode is on and the model supports it", () => {
+ expect(composeModelPickerTriggerLabel({ model: FAST_GPT, value: FAST_GPT.id, fastMode: true }))
+ .toBe(`${FAST_GPT.displayName} Fast`);
+ expect(composeModelPickerTriggerLabel({ model: FAST_GPT, value: FAST_GPT.id, fastMode: false }))
+ .toBe(FAST_GPT.displayName);
+ expect(composeModelPickerTriggerLabel({ model: OPUS, value: OPUS.id, fastMode: true }))
+ .toBe(OPUS.displayName);
+ });
+
+ it("honours an explicit capability override", () => {
+ expect(composeModelPickerTriggerLabel({
+ model: OPUS,
+ value: OPUS.id,
+ fastMode: true,
+ fastModeSupported: true,
+ })).toBe(`${OPUS.displayName} Fast`);
+ expect(composeModelPickerTriggerLabel({
+ model: FAST_GPT,
+ value: FAST_GPT.id,
+ fastMode: true,
+ fastModeSupported: false,
+ })).toBe(FAST_GPT.displayName);
+ });
+
+ it("falls back to the raw value, then to a placeholder, and never suffixes either", () => {
+ expect(composeModelPickerTriggerLabel({ model: undefined, value: "vendor/mystery", fastMode: true }))
+ .toBe("vendor/mystery");
+ expect(composeModelPickerTriggerLabel({ model: undefined, value: " ", fastMode: true }))
+ .toBe("Select model");
+ });
+});
+
describe("ModelPicker", () => {
it("renders the active model on the trigger and opens the popover on click", async () => {
const user = userEvent.setup();
@@ -531,33 +596,272 @@ describe("ModelPicker", () => {
expect(chip).toBeNull();
});
- it("renders the fast-mode toggle outside the trigger when supported", async () => {
- const onToggle = vi.fn();
- const FAST: ModelDescriptor = {
- ...GPT,
- serviceTiers: ["fast"],
+ it("renders a fast affordance only on fast-capable rows, and only when the surface opts in", async () => {
+ const user = userEvent.setup();
+ const onFastModeChange = vi.fn();
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /Select model/i }));
+
+ const chip = await within(await findModelRow(FAST_GPT.id))
+ .findByRole("button", { name: /Fast mode for/i });
+ expect(chip.getAttribute("aria-label")).toBe(`Fast mode for ${FAST_GPT.displayName}`);
+ expect(chip.getAttribute("aria-pressed")).toBe("false");
+ // Same provider rail, no fast service tier — that row stays chip-free.
+ expect(
+ within(await findModelRow(SLOW_GPT.id)).queryByRole("button", { name: /Fast mode for/i }),
+ ).toBeNull();
+ });
+
+ it("omits the fast affordance entirely when onFastModeChange is not supplied", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /Select model/i }));
+
+ await screen.findAllByRole("option");
+ expect(screen.queryByRole("button", { name: /Fast mode for/i })).toBeNull();
+ expect(document.querySelector('[data-model-picker-fast-toggle="true"]')).toBeNull();
+ });
+
+ it("toggles fast mode without closing the picker or changing the selected model", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const onFastModeChange = vi.fn();
+ render(
+ ,
+ );
+ const trigger = screen.getByRole("button", { name: /Select model/i });
+ await user.click(trigger);
+
+ const chip = await within(await findModelRow(FAST_GPT.id))
+ .findByRole("button", { name: /Fast mode for/i });
+ await user.click(chip);
+
+ expect(onFastModeChange).toHaveBeenCalledWith(true);
+ expect(onChange).not.toHaveBeenCalled();
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("toggles fast mode from the keyboard", async () => {
+ const onFastModeChange = vi.fn();
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: /Select model/i }));
+
+ const chip = await within(await findModelRow(FAST_GPT.id))
+ .findByRole("button", { name: /Fast mode for/i });
+ expect(chip.tabIndex).toBe(0);
+ chip.focus();
+ expect(document.activeElement).toBe(chip);
+ expect(chip.getAttribute("aria-pressed")).toBe("true");
+ fireEvent.keyDown(chip, { key: "Enter" });
+ expect(onFastModeChange).toHaveBeenCalledWith(false);
+ });
+
+ it("lights the fast chip only on the selected row", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /Select model/i }));
+
+ const selectedChip = await within(await findModelRow(FAST_GPT.id))
+ .findByRole("button", { name: /Fast mode for/i });
+ const otherChip = await within(await findModelRow(FAST_GPT_ALT.id))
+ .findByRole("button", { name: /Fast mode for/i });
+ expect(selectedChip.getAttribute("aria-pressed")).toBe("true");
+ expect(otherChip.getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("selects the model and enables fast when the chip is clicked on a non-selected row", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const onFastModeChange = vi.fn();
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /Select model/i }));
+
+ const otherChip = await within(await findModelRow(FAST_GPT_ALT.id))
+ .findByRole("button", { name: /Fast mode for/i });
+ await user.click(otherChip);
+
+ 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 () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const onFastModeChange = vi.fn();
+ const props = {
+ value: FAST_GPT.id,
+ onChange,
+ surfaceKey: "test",
+ models: [FAST_GPT, FAST_GPT_ALT],
+ onFastModeChange,
};
+ const { rerender } = render();
+ const trigger = screen.getByRole("button", { name: /Select model/i });
+ await user.click(trigger);
+
+ const chip = await within(await findModelRow(FAST_GPT.id))
+ .findByRole("button", { name: /Fast mode for/i });
+ await user.click(chip);
+ expect(onFastModeChange).toHaveBeenLastCalledWith(true);
+
+ rerender();
+ await user.click(
+ await within(await findModelRow(FAST_GPT.id)).findByRole("button", { name: /Fast mode for/i }),
+ );
+
+ expect(onFastModeChange).toHaveBeenLastCalledWith(false);
+ expect(onChange).not.toHaveBeenCalled();
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("clears a stale fast bit when a different model is picked by a plain row click", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const onFastModeChange = vi.fn();
render(
,
+ );
+ await user.click(screen.getByRole("button", { name: /Select model/i }));
+
+ // A model without a fast service tier has no chip at all, and inherits nothing.
+ const slowRow = await findModelRow(SLOW_GPT.id);
+ expect(within(slowRow).queryByRole("button", { name: /Fast mode for/i })).toBeNull();
+ await user.click(slowRow);
+
+ 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 () => {
+ const { rerender } = render(
+ ,
+ );
+ const trigger = screen.getByRole("button", { name: /Select model/i });
+ expect(trigger.querySelector('[data-model-picker-fast-glyph="true"]')).toBeNull();
+
+ rerender(
+ ,
+ );
+ const glyph = trigger.querySelector('[data-model-picker-fast-glyph="true"]');
+ expect(glyph).not.toBeNull();
+ expect(glyph?.getAttribute("aria-hidden")).toBe("true");
+ // The accessible name stays text-only.
+ expect(trigger.getAttribute("aria-label")).toBe(
+ `Select model (current: ${FAST_GPT.displayName} Fast)`,
+ );
+ });
+
+ it("keeps the deprecated sibling chip for surfaces still on onFastModeToggle", async () => {
+ const onToggle = vi.fn();
+ render(
+ ,
);
- const fastButton = screen.getByRole("button", { name: /Fast mode/i });
- expect(fastButton.getAttribute("data-model-picker-fast-toggle")).toBe("true");
const trigger = screen.getByRole("button", { name: /Select model/i });
- expect(trigger.contains(fastButton)).toBe(false);
- await userEvent.click(fastButton);
- expect(onToggle).toHaveBeenCalledWith(true);
- // Clicking fast did NOT open the popover
- expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ const legacyChip = screen.getByRole("button", { name: "Fast mode" });
+ expect(trigger.contains(legacyChip)).toBe(false);
+ // The legacy chip already carries the state, so the label stays unsuffixed.
+ expect(trigger.textContent).not.toContain("Fast");
+ await userEvent.click(legacyChip);
+ expect(onToggle).toHaveBeenCalledWith(false);
});
- it("renders the fast-mode toggle for Cursor runtime models that advertise fast service", async () => {
+ it("suffixes the trigger label with Fast while fast mode is on", () => {
+ render(
+ ,
+ );
+ const trigger = screen.getByRole("button", { name: /Select model/i });
+ expect(trigger.textContent).toContain(`${FAST_GPT.displayName} Fast`);
+ });
+
+ it("renders the fast affordance for Cursor runtime models that advertise fast service", async () => {
providerAuthStatusInternal = { cursor: "ok" };
const onToggle = vi.fn();
const cursorFast = createDynamicCursorCliModelDescriptor("composer-2.5", "Composer 2.5", {
@@ -599,12 +903,13 @@ describe("ModelPicker", () => {
onChange={vi.fn()}
surfaceKey="test"
models={[cursorFast]}
- fastModeActive={true}
- onFastModeToggle={onToggle}
+ fastMode
+ onFastModeChange={onToggle}
/>,
);
+ await userEvent.click(screen.getByRole("button", { name: /Select model/i }));
- const fastButton = screen.getByRole("button", { name: /Fast mode/i });
+ const fastButton = await screen.findByRole("button", { name: /Fast mode for/i });
expect(fastButton.getAttribute("aria-pressed")).toBe("true");
await userEvent.click(fastButton);
expect(onToggle).toHaveBeenCalledWith(false);
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx
index 37802457e..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;
@@ -42,8 +42,23 @@ export type ModelPickerProps = {
onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void;
onRuntimeCatalogRefreshed?: (provider: AgentChatModelCatalogRefreshProvider) => void;
constrainToAvailableModelIds?: boolean;
+ /**
+ * Fast mode lives inside the picker (a per-row affordance plus a " Fast"
+ * suffix on the trigger) so every surface that mounts a ModelPicker gets it
+ * without building its own chip. Omitting `onFastModeChange` renders no fast
+ * affordance at all.
+ */
+ fastMode?: boolean;
+ onFastModeChange?: (next: boolean) => void;
+ /** @deprecated Older alias for {@link ModelPickerProps.fastMode}. */
fastModeActive?: boolean;
+ /** @deprecated Older alias for {@link ModelPickerProps.onFastModeChange}. */
onFastModeToggle?: (next: boolean) => void;
+ /**
+ * Overrides the descriptor-derived capability for the trigger suffix only —
+ * callers that resolve models outside the registry (batch launch) still know
+ * better than `modelSupportsFastMode` for the *selected* model.
+ */
fastModeSupported?: boolean;
allowCliOnlyModels?: boolean;
cursorAvailabilityMode?: "chat" | "cli" | "all";
@@ -75,7 +90,9 @@ export const ModelPicker = memo(function ModelPicker({
onOpenSignIn,
onRuntimeCatalogRefreshed,
constrainToAvailableModelIds = false,
- fastModeActive = false,
+ fastMode,
+ onFastModeChange,
+ fastModeActive,
onFastModeToggle,
fastModeSupported,
allowCliOnlyModels = false,
@@ -267,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],
@@ -287,11 +308,13 @@ export const ModelPicker = memo(function ModelPicker({
onOpenSignIn?.(family, authTypes);
}, [onOpenSignIn]);
- const triggerFastSupported =
- typeof fastModeSupported === "boolean"
- ? fastModeSupported
- : modelSupportsFastMode(selectedModel);
- const showFastToggle = triggerFastSupported && typeof onFastModeToggle === "function";
+ // Two modes, one bit. `onFastModeChange` means the picker owns fast mode
+ // (per-row affordance + trigger suffix); the deprecated `onFastModeToggle`
+ // keeps the old sibling chip alive for surfaces that have not migrated yet.
+ const fastModeOn = fastMode ?? fastModeActive ?? false;
+ const legacyFastChip = !onFastModeChange && typeof onFastModeToggle === "function";
+ const legacyFastSupported = legacyFastChip
+ && (fastModeSupported ?? modelSupportsFastMode(selectedModel));
return (
@@ -316,6 +339,8 @@ export const ModelPicker = memo(function ModelPicker({
compact={compact}
disabled={disabled}
open={open}
+ fastMode={fastModeOn && !legacyFastChip}
+ {...(typeof fastModeSupported === "boolean" ? { fastModeSupported } : {})}
className={triggerClassName}
/>
@@ -346,6 +371,9 @@ export const ModelPicker = memo(function ModelPicker({
allowCliOnlyModels={allowCliOnlyModels}
cursorAvailabilityMode={cursorAvailabilityMode}
allowRegistryExpansion={!constrainToAvailableModelIds}
+ fastMode={fastModeOn}
+ {...(typeof fastModeSupported === "boolean" ? { fastModeSupported } : {})}
+ {...(onFastModeChange ? { onFastModeChange } : {})}
{...(filter ? { registryFilter: filter } : {})}
{...(onOpenSignIn ? { onOpenSignIn: handleOpenSignIn } : {})}
/>
@@ -353,9 +381,9 @@ export const ModelPicker = memo(function ModelPicker({
- {showFastToggle ? (
- void;
+}) {
+ return (
+ onToggle?.(!active)}
+ className={cn(
+ "inline-flex shrink-0 items-center justify-center rounded-md border font-sans font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45",
+ compact
+ ? "ade-chat-composer-fast-toggle h-6 gap-0.5 px-1.5 text-[9px]"
+ : "h-8 gap-1 px-2 text-[11px]",
+ active
+ ? "border-amber-300/30 bg-amber-400/12 text-amber-100 shadow-[0_0_0_1px_rgba(251,191,36,0.08)]"
+ : "border-white/[0.07] bg-white/[0.025] text-muted-fg/60 hover:bg-white/[0.06] hover:text-fg/80",
+ )}
+ >
+
+ Fast
+
+ );
+});
+
+/**
+ * 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 (
) : null}
+ {showFastGlyph ? (
+ // Presentational only — the accessible name already says "Fast".
+
+ ) : null}
{label} void;
-}) {
- return (
- onToggle?.(!active)}
- className={cn(
- "inline-flex shrink-0 items-center justify-center rounded-md border font-sans font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45",
- compact
- ? "ade-chat-composer-fast-toggle h-6 gap-0.5 px-1.5 text-[9px]"
- : "h-8 gap-1 px-2 text-[11px]",
- active
- ? "border-amber-300/30 bg-amber-400/12 text-amber-100 shadow-[0_0_0_1px_rgba(251,191,36,0.08)]"
- : "border-white/[0.07] bg-white/[0.025] text-muted-fg/60 hover:bg-white/[0.06] hover:text-fg/80",
- )}
- >
-
- Fast
-
- );
-});
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx
index a583f080c..ed5f53913 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,
@@ -125,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;
/**
@@ -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,18 @@ 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`).
+ onSelect(modelId, modelId !== value && fastMode ? { fastMode: false } : undefined);
+ },
+ [fastMode, onSelect, recordUsage, value],
+ );
+
const handleListKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape") {
@@ -514,29 +537,44 @@ 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);
- 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 });
},
- [onSelect, recordUsage],
+ [expandedModels, isAvailableForUse, onFastModeChange, onOpenSignIn, onSelect, recordUsage, value],
);
const handleSetSurfaceDefault = useCallback(
@@ -643,7 +681,13 @@ 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",
+ "border-b border-transparent focus-visible:border-white/20",
)}
/>
onOpenSignIn(m.family, m.authTypes) } : {})}
/>
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx
index 4a91130c2..84a00d735 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..5b644383a 100644
--- a/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx
+++ b/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx
@@ -288,12 +288,15 @@ export function SessionLaunchModelControls({
) : null}
onChange({ modelId })}
+ onChange={(modelId, options) => onChange({
+ modelId,
+ ...(options ? { fastMode: options.fastMode } : {}),
+ })}
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..66026537b
--- /dev/null
+++ b/apps/desktop/src/renderer/components/terminals/LaneCombobox.test.tsx
@@ -0,0 +1,222 @@
+/* @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 {
+ AUTO_CREATE_LANE_OPTION_ID,
+ 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("keeps aria-selected on the value while keyboard highlight moves", () => {
+ render();
+ const popover = openList();
+ const selected = screen.getByRole("option", { name: /auth-refresh/ });
+ const other = screen.getByRole("option", { name: /render-perf/ });
+
+ fireEvent.keyDown(popover, { key: "ArrowDown" });
+
+ expect(selected.getAttribute("aria-selected")).toBe("true");
+ expect(other.getAttribute("aria-selected")).toBe("false");
+ });
+
+ it("reports a selected auto-create option independently of highlight", () => {
+ render(
+ ,
+ );
+ const popover = openList();
+ const autoCreate = screen.getByRole("option", { name: "Auto-create lane" });
+
+ fireEvent.keyDown(popover, { key: "ArrowDown" });
+
+ expect(autoCreate.getAttribute("aria-selected")).toBe("true");
+ });
+
+ 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..5355b7075 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 { useCallback, useEffect, useLayoutEffect, 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,65 @@ 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 };
+ width?: { min: number; max: number };
+}): LanePopoverPlacement {
+ const { trigger, viewport } = input;
+ const minWidth = input.width?.min ?? POPOVER_MIN_WIDTH;
+ const maxWidth = input.width?.max ?? POPOVER_MAX_WIDTH;
+
+ const width = Math.min(
+ Math.min(maxWidth, Math.max(trigger.width, minWidth)),
+ 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 +303,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 +364,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,41 +424,16 @@ 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(() => {
+ useLayoutEffect(() => {
if (!open) return;
updatePosition();
window.addEventListener("scroll", updatePosition, true);
@@ -408,167 +457,105 @@ 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 (
<>
setOpen(!open)}
- style={triggerStyle}
>
- {displayColor ? (
-
- ) : null}
+ {displayColor ? : null}
+ {displayLabel}
{selectedBranchLabel ? (
-
-
- {displayLabel}
-
-
-
-
- {selectedBranchLabel}
-
-
-
- ) : (
-
- {displayLabel}
+ // Shrink factor puts every pixel of squeeze on the branch first: the
+ // lane name is the label, the branch is only context.
+
+
+ {selectedBranchLabel}
- )}
+ ) : null}
+ {/*
+ 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(
-
= 0 ? `ade-lane-option-${highlightedIndex}` : undefined
+ }
className="ade-lane-popover ade-liquid-glass-menu"
style={popoverStyle}
onKeyDown={handleKeyDown}
+ // Height is content-driven under `maxHeight`, so `layout` is what
+ // makes filtering read as the list collapsing rather than snapping.
+ layout={reducedMotion ? false : "size"}
+ initial={reducedMotion ? false : { opacity: 0, scale: 0.96, y: placement?.openAbove ? 4 : -4 }}
+ animate={{ opacity: 1, scale: 1, y: 0 }}
+ transition={
+ reducedMotion
+ ? { duration: 0 }
+ : { type: "spring", stiffness: 520, damping: 34, mass: 0.7 }
+ }
>
setSearch(e.target.value)}
@@ -576,14 +563,7 @@ export function LaneCombobox({
{items.length === 0 ? (
-
+
No lanes found
) : (
@@ -593,119 +573,71 @@ export function LaneCombobox({
- );
+/** 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;
}
/** Muted, centered hint for a tab whose own series is empty while the module
@@ -601,7 +522,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 (
@@ -610,10 +539,10 @@ function TabRow({ tab, onTabChange }: { tab: ActivityTab; onTabChange: (tab: Act
type="button"
role="tab"
aria-selected={active}
- tabIndex={active ? 0 : -1}
+ tabIndex={0}
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 +570,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 +650,14 @@ function FooterChip({
return (
- {chip.icon === "trophy" ? : }
+ {chip.icon === "trophy" ? : }
{chip.label}
);
@@ -763,16 +692,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 +734,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 +746,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 +756,60 @@ export function ActivityModule({
}
return (
-
-
);
}
@@ -905,6 +866,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..0a2787e72 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,
+ readActivityPersisted,
+} from "./ActivityModule";
+import { computeHeatmapLayout } from "./ActivityHeatmap";
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,
+ };
+}
+
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,134 @@ 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) =>
+ makeActivityDay(`2026-06-${String(i + 1).padStart(2, "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 makeActivityDay(date, {
+ inputTokens: 100,
+ outputTokens: 50,
+ totalTokens: 150,
+ 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 +1207,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]],
+ [[], []],
+ ])("maps $0 to $1", (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..e1ae73eb1 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.
+ */
+[data-chat-composer-wrapper]: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,86 @@ 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 :is([data-draft-open-shell], [data-draft-import-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). |