e.stopPropagation()}
- onInput={() => setDirty(true)}
- onChange={() => setDirty(true)}
- >
-
{editing ? "Edit automation" : "New automation"}
- {!editing ? (
-
- When an event fires, run a prompt on a guest and keep the history.
+ setDirty(true)}
+ onChange={() => setDirty(true)}
+ >
+ {editing ? "Edit automation" : "New automation"}
+ {!editing ? (
+
+ When an event fires, run a prompt on a guest and keep the history.
+
+ ) : null}
+ {!editing ? (
+
+
+ Start from a template, or duplicate an existing job from the detail pane.
- ) : null}
- {!editing ? (
-
-
- Start from a template, or duplicate an existing job from the detail pane.
+
+ {AUTOMATION_STARTERS.map((row) => (
+ {
+ const current = automationStarterById(state.automationStarterId);
+ const untouched =
+ (!name.trim() && !prompt.trim()) ||
+ (current != null && name === current.name && prompt === current.prompt);
+ if (!untouched && state.automationStarterId !== row.id) {
+ setConfirmStarterId(row.id);
+ return;
+ }
+ dispatch({ type: "newAutomation", open: true, starterId: row.id });
+ }}
+ >
+ {row.title}
+
+ ))}
+
+
+ ) : null}
+
+ When
+
+ {AUTOMATION_TRIGGER_FAMILIES.map((row) => {
+ const selected = automationTriggerFamily(kind) === row.id;
+ return (
+ {
+ setDirty(true);
+ setKind(defaultKindForTriggerFamily(row.id, kind));
+ }}
+ >
+ {row.title}
+ {row.blurb}
+
+ );
+ })}
+
+ {isClockScheduleKind(kind) ? (
+
+
+ Schedule
+ ({
+ value: row,
+ label:
+ row === "interval"
+ ? "Every N minutes"
+ : row === "adaptive"
+ ? "Adaptive (by time of day)"
+ : row === "daily"
+ ? "Daily"
+ : row === "weekly"
+ ? "Weekly"
+ : "Cron",
+ }))}
+ onChange={(value) => setKind(value as AutomationSchedule["kind"])}
+ />
+
+ {kind === "interval" ? (
+
+ Minutes
+ setEveryMinutes(Number(e.target.value))}
+ />
+
+ ) : kind === "adaptive" ? (
+
+ Faster inside a daily window, slower the rest of the day.
+
+ ) : kind === "cron" ? (
+
+ Expression
+ setCronExpr(e.target.value)}
+ placeholder="0 9 * * 1"
+ />
+
+ ) : (
+
+ Time
+ setTime(e.target.value)} />
+
+ )}
+
+ ) : kind === "forge" ? (
+ Webhook plus polling for a new commit. Attach a repo below.
+ ) : kind === "pull_request" ? (
+ Webhook plus polling for a new pull request. Attach a repo below.
+ ) : (
+
+ POST /api/hooks with X-Locus-Secret or a Slack signing secret. No git repo required.
+
+ )}
+ {kind === "daily" || kind === "weekly" || kind === "adaptive" || kind === "cron" ? (
+
+
Timezone
+
setTimeZone(e.target.value)}
+ placeholder="America/Los_Angeles"
+ />
+
+ Clock fires use this IANA zone. This machine is {formatLocalTimezone()}.
-
- {AUTOMATION_STARTERS.map((row) => (
- {
- const current = automationStarterById(state.automationStarterId);
- const untouched =
- (!name.trim() && !prompt.trim()) ||
- (current != null && name === current.name && prompt === current.prompt);
- if (!untouched && state.automationStarterId !== row.id) {
- setConfirmStarterId(row.id);
- return;
- }
- dispatch({ type: "newAutomation", open: true, starterId: row.id });
- }}
- >
- {row.title}
-
+
+ ) : null}
+ {kind === "adaptive" ? (
+
+
Days
+
+ {SCHEDULE_WEEKDAYS.map((day, index) => (
+
+ {
+ const current = weekdays.length
+ ? weekdays
+ : SCHEDULE_WEEKDAYS.map((_, i) => i);
+ setWeekdays(
+ e.target.checked
+ ? [...new Set([...current, index])]
+ : current.filter((value) => value !== index),
+ );
+ }}
+ />
+ {day.slice(0, 3)}
+
))}
+
All checked means every day. Uncheck a day to skip it.
) : null}
-
- When
-
- {AUTOMATION_TRIGGER_FAMILIES.map((row) => {
- const selected = automationTriggerFamily(kind) === row.id;
+ {(() => {
+ try {
+ const sched = schedule();
+ if (validateSchedule(sched)) return null;
+ if (sched.kind === "webhook") {
return (
-
{
- setDirty(true);
- setKind(defaultKindForTriggerFamily(row.id, kind));
- }}
- >
- {row.title}
- {row.blurb}
-
+
+ Fires on each accepted webhook POST.
+
);
- })}
-
- {isClockScheduleKind(kind) ? (
+ }
+ if (!isClockSchedule(sched)) return null;
+ const nextAt = computeNextRunAt(sched).toISOString();
+ const label = formatNextFire({
+ enabled: true,
+ schedule: sched,
+ nextRunAt: nextAt,
+ });
+ if (!label) return null;
+ return (
+
+ Next fire ≈{" "}
+ {new Date(label).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ );
+ } catch {
+ return null;
+ }
+ })()}
+ {kind === "adaptive" && (
+ <>
- ) : kind === "forge" ? (
- Webhook plus polling for a new commit. Attach a repo below.
- ) : kind === "pull_request" ? (
-
- Webhook plus polling for a new pull request. Attach a repo below.
-
- ) : (
-
- POST /api/hooks with X-Locus-Secret or a Slack signing secret. No git repo required.
-
- )}
- {kind === "daily" || kind === "weekly" || kind === "adaptive" || kind === "cron" ? (
-
-
Timezone
-
setTimeZone(e.target.value)}
- placeholder="America/Los_Angeles"
- />
-
- Clock fires use this IANA zone. This machine is {formatLocalTimezone()}.
-
-
- ) : null}
- {kind === "adaptive" ? (
-
-
Days
-
- ) : null}
- {(() => {
- try {
- const sched = schedule();
- if (validateSchedule(sched)) return null;
- if (sched.kind === "webhook") {
- return (
-
- Fires on each accepted webhook POST.
-
- );
- }
- if (!isClockSchedule(sched)) return null;
- const nextAt = computeNextRunAt(sched).toISOString();
- const label = formatNextFire({
- enabled: true,
- schedule: sched,
- nextRunAt: nextAt,
- });
- if (!label) return null;
- return (
-
- Next fire ≈{" "}
- {new Date(label).toLocaleString(undefined, {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- })}
-
- );
- } catch {
- return null;
- }
- })()}
- {kind === "adaptive" && (
- <>
-
-
- From
- setWindowStart(e.target.value)}
- />
-
-
- To
- setWindowEnd(e.target.value)}
- />
-
+
+
+ Minutes during window
+ setEveryMinutes(Number(e.target.value))}
+ />
-
-
- Minutes during window
- setEveryMinutes(Number(e.target.value))}
- />
-
-
- Minutes otherwise
- setOtherwiseEveryMinutes(Number(e.target.value))}
- />
-
+
+ Minutes otherwise
+ setOtherwiseEveryMinutes(Number(e.target.value))}
+ />
- >
- )}
- {kind === "weekly" && (
-
- Weekday
- setWeekday(Number(e.target.value))}>
- {SCHEDULE_WEEKDAYS.map((day, i) => (
-
- {day}
-
- ))}
-
- )}
-
-
-
- {parsedRepo || repo.trim() || repoId ? (
-
- Git ref (optional)
- setRef(e.target.value)} placeholder="main" />
-
- ) : null}
-
{
- setProviderId(next.providerId);
- setModel(next.model);
- }}
+ )}
+
+
+
+ {parsedRepo || repo.trim() || repoId ? (
+
+ Git ref (optional)
+ setRef(e.target.value)} placeholder="main" />
+
+ ) : null}
+ {
+ setProviderId(next.providerId);
+ setModel(next.model);
+ }}
+ />
+
+ More options
+
+ Named environment, placement, runtime, OS, hardware, jail, spend, autonomy, notify, MCP,
+ and reuse. When, Then, repo, and model stay above.
+
+
-
- More options
-
- Named environment, placement, runtime, OS, hardware, jail, spend, autonomy, notify, MCP,
- and reuse. When, Then, repo, and model stay above.
-
+
{
setBackendId(id);
- if (id && runtime === "vm") setRuntime("docker");
}}
/>
{
setRuntime(next);
- if (next === "vm") setBackendId("");
const nextOs =
next !== "vm" && (os === "windows-11" || os === "windows-10") ? "ubuntu" : os;
if (nextOs !== os) setOs(nextOs);
@@ -889,7 +898,7 @@ export function NewAutomationModal() {
/>
{backendId
- ? `${runtime === "host" ? "The host workspace" : "The container"} runs on ${selectedWorker ? workerWhereLabel(selectedWorker) : "the worker"}. Chat and keys stay on this machine.${
+ ? `${runtime === "host" ? "The host workspace" : runtime === "vm" ? "The VM" : "The container"} runs on ${selectedWorker ? workerWhereLabel(selectedWorker) : "the worker"}. Chat and keys stay on this machine.${
selectedWorker && workerIsMetered(selectedWorker)
? " Stop destroys the guest. Idle TTL applies on the metered VM."
: ""
@@ -921,13 +930,12 @@ export function NewAutomationModal() {
{runtime === "host" ? null : (
Template
- setTemplateId(e.target.value)}>
- {osTemplates.map((t) => (
-
- {t.name}
-
- ))}
-
+ ({ value: t.id, label: t.name }))}
+ onChange={setTemplateId}
+ />
)}
{runtime === "vm" ? (
@@ -953,37 +961,41 @@ export function NewAutomationModal() {
{runtime === "docker" ? "Workspace image (optional)" : "Golden image (optional)"}
- setImageId(e.target.value)}>
-
- {runtime === "docker" ? "Empty /workspace" : "Fresh disk from ISO / cloud image"}
-
- {images
- .filter((img) =>
- runtime === "docker"
- ? img.runtime === "docker"
- : img.os === os && img.runtime !== "docker",
- )
- .map((img) => (
-
- {img.name}
-
- ))}
-
+
+ runtime === "docker"
+ ? img.runtime === "docker"
+ : img.os === os && img.runtime !== "docker",
+ )
+ .map((img) => ({ value: img.id, label: img.name })),
+ ]}
+ onChange={setImageId}
+ />
)}
{runtime === "host" ? null : (
Jail
-
setNetwork(e.target.value as NetworkJail)}
- >
- {formatNetworkJail("open")}
- {formatNetworkJail("restricted")}
- {formatNetworkJail("none")}
-
+ options={JAIL_SELECT_OPTIONS}
+ onChange={(value) => {
+ if (isNetworkJail(value)) setNetwork(value);
+ }}
+ />
Adaptive and webhook jobs reuse this jail on the named machine.
@@ -1001,6 +1013,8 @@ export function NewAutomationModal() {
: "Start the VM for each run"}
)}
+
+
) : null}
@@ -1055,42 +1066,16 @@ export function NewAutomationModal() {
opens a new chat. Reuse keeps the full thread; memory keeps a distilled list. Do not use
this for secrets.
-
-
- {runtime === "host" ? null : (
-
-
Extra allow hosts
-
setAllowHosts(e.target.value)}
- placeholder="your-domain.atlassian.net, grafana.example.com, api.incident.io"
- />
-
Added to the restricted jail allowlist for this automation.
-
- )}
Autonomy
- setAutonomy(parseAutonomyTier(e.target.value))}
- >
- {AUTONOMY_TIERS.map((tier) => (
-
- {formatAutonomy(tier)}
-
- ))}
-
+ options={AUTONOMY_SELECT_OPTIONS}
+ onChange={(value) => setAutonomy(parseAutonomyTier(value))}
+ />
Spend cap (USD)
@@ -1120,15 +1105,12 @@ export function NewAutomationModal() {
Overlap
- setOverlap(e.target.value as "skip" | "coalesce" | "queue")}
- >
- Skip if busy
- Run latest when free
- Queue one
-
+ options={OVERLAP_SELECT_OPTIONS}
+ onChange={(value) => setOverlap(value as "skip" | "coalesce" | "queue")}
+ />
Blackout dates
@@ -1139,6 +1121,30 @@ export function NewAutomationModal() {
onChange={(e) => setBlackout(e.target.value)}
/>
+
+
+
+
+ {runtime === "host" ? null : (
+
+
Extra allow hosts
+
setAllowHosts(e.target.value)}
+ placeholder="your-domain.atlassian.net, grafana.example.com, api.incident.io"
+ />
+
Added to the restricted jail allowlist for this automation.
+
+ )}
Notify
@@ -1166,58 +1172,54 @@ export function NewAutomationModal() {
Needs you
-
- {error && (
-
- {error}
-
- )}
-
-
- Cancel
-
- void submit()}>
- {busy ? "Saving…" : editing ? "Save" : "Create automation"}
-
- {confirmHost ? (
-
setConfirmHost(false)}
- onConfirm={() => void submit({ hostConfirmed: true })}
- />
- ) : null}
- {confirmStarterId ? (
- setConfirmStarterId(null)}
- onConfirm={() => {
- const id = confirmStarterId;
- setConfirmStarterId(null);
- dispatch({ type: "newAutomation", open: true, starterId: id });
- }}
- />
- ) : null}
- {confirmDiscard ? (
- setConfirmDiscard(false)}
- onConfirm={closeModal}
- />
- ) : null}
+
+ {error && {error}
}
+
+
+ Cancel
+
+ void submit()}>
+ {busy ? "Saving…" : editing ? "Save" : "Create automation"}
+
-
+ {confirmHost ? (
+ setConfirmHost(false)}
+ onConfirm={() => void submit({ hostConfirmed: true })}
+ />
+ ) : null}
+ {confirmStarterId ? (
+ setConfirmStarterId(null)}
+ onConfirm={() => {
+ const id = confirmStarterId;
+ setConfirmStarterId(null);
+ dispatch({ type: "newAutomation", open: true, starterId: id });
+ }}
+ />
+ ) : null}
+ {confirmDiscard ? (
+ setConfirmDiscard(false)}
+ onConfirm={closeModal}
+ />
+ ) : null}
+
);
}
diff --git a/apps/desktop/src/components/board/BoardCanvas.test.tsx b/apps/desktop/src/components/board/BoardCanvas.test.tsx
index 741c4fc..4029ade 100644
--- a/apps/desktop/src/components/board/BoardCanvas.test.tsx
+++ b/apps/desktop/src/components/board/BoardCanvas.test.tsx
@@ -172,7 +172,7 @@ describe("BoardCanvas", () => {
);
const frameNav = host.querySelector(".board-frames");
expect(frameNav?.textContent).toContain("Context");
- expect(host.querySelector('button[title="Zoom to fit (Ctrl/Cmd+0)"]')).toBeTruthy();
+ expect(host.querySelector('button[aria-label="Zoom to fit"]')).toBeTruthy();
});
it("switches tools from keyboard and Escape returns to select", () => {
diff --git a/apps/desktop/src/components/board/BoardCanvas.tsx b/apps/desktop/src/components/board/BoardCanvas.tsx
index 96e24a7..20bc0ba 100644
--- a/apps/desktop/src/components/board/BoardCanvas.tsx
+++ b/apps/desktop/src/components/board/BoardCanvas.tsx
@@ -95,6 +95,10 @@ import {
MenuSep,
placePointerMenu,
} from "@/components/layout/OverflowMenu";
+import { Select } from "@/components/layout/Select";
+import { Tooltip } from "@/components/layout/Tooltip";
+import { shortcutHint } from "@/lib/shortcuts";
+import { showNotice } from "@/lib/notice";
import { BoardShapeFace, BoardShapePicker } from "./BoardShapePicker";
import { BoardTableObject } from "./BoardTableObject";
import { BoardTimelineObject } from "./BoardTimelineObject";
@@ -614,7 +618,7 @@ export function BoardCanvas(props: {
x += BOARD_GRID;
y += BOARD_GRID;
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not add image");
+ showNotice(err instanceof Error ? err.message : "Could not add image");
}
}
if (!ids.length) return false;
@@ -1308,33 +1312,33 @@ export function BoardCanvas(props: {
) : null}
- stepHistory(-1)}
- >
- Undo
-
- stepHistory(1)}
- >
- Redo
-
-
-
zoomTo(cam.z * 0.92)}>
- −
+
+ stepHistory(-1)}>
+ Undo
- {Math.round(cam.z * 100)}%
- zoomTo(cam.z * 1.08)}>
- +
-
- fitTo()}>
- Fit
+
+
+ stepHistory(1)}>
+ Redo
+
+
+
+ zoomTo(cam.z * 0.92)}>
+ −
+
+
+ {Math.round(cam.z * 100)}%
+
+ zoomTo(cam.z * 1.08)}>
+ +
+
+
+
+ fitTo()}>
+ Fit
+
+
void; onPick: (cite: BoardCite) => v
))}
{kind === "pull" && state.repos.length ? (
- setRepoId(event.target.value)}
- >
- {state.repos.map((row) => (
-
- {row.name}
-
- ))}
-
+ options={state.repos.map((row) => ({ value: row.id, label: row.name }))}
+ onChange={setRepoId}
+ />
) : null}
{options.map((row) => (
diff --git a/apps/desktop/src/components/board/BoardEmpty.tsx b/apps/desktop/src/components/board/BoardEmpty.tsx
index 642964f..fbc7d14 100644
--- a/apps/desktop/src/components/board/BoardEmpty.tsx
+++ b/apps/desktop/src/components/board/BoardEmpty.tsx
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { resolveAgentDefaults } from "@/lib/lastAgent";
import { useStore } from "@/state/store";
-import { Mascot } from "@/components/layout/Mascot";
+import { EmptyHero } from "@/components/layout/EmptyHero";
import { IconPlus } from "@/components/layout/Icons";
import { FeatureHintRow } from "@/components/layout/FeatureHint";
import { SECTION_FEATURE_HINTS } from "@/lib/discoverability";
@@ -40,14 +40,10 @@ export function BoardEmpty() {
return (
-
-
-
Board
-
- A local planning canvas. Stickies, frames, diagrams, live cards, and Locus on the canvas
- — no guest.
-
-
+
{SECTION_FEATURE_HINTS.board ?
: null}
);
}
if (!cached) {
return (
);
}
@@ -336,48 +337,36 @@ function BrainBriefDialog(props: {
onClose: () => void;
onApprove: () => void;
}) {
- const dialogRef = useRef
(null);
- useDialogFocus(true, props.onClose, dialogRef);
return (
-
-
+
Brain briefing for {props.draft.issue}
+
Edit, then Approve. Locus will not post until you do.
+ {props.draft.busy && !props.draft.body ? (
+
Drafting from the brain…
+ ) : null}
+
+
);
}
diff --git a/apps/desktop/src/components/board/BoardShapePicker.tsx b/apps/desktop/src/components/board/BoardShapePicker.tsx
index 6e1f102..b0ea095 100644
--- a/apps/desktop/src/components/board/BoardShapePicker.tsx
+++ b/apps/desktop/src/components/board/BoardShapePicker.tsx
@@ -1,5 +1,6 @@
import { BOARD_SHAPE_GROUPS, boardShapePath, type BoardShapeKind } from "@locus/shared";
-import { type ReactNode } from "react";
+import { useRef, type ReactNode } from "react";
+import { useDialogFocus } from "@/lib/useDialogFocus";
export function BoardShapeGlyph(props: { kind: BoardShapeKind; title?: string }) {
return (
@@ -31,8 +32,16 @@ export function BoardShapePicker(props: {
onPick: (kind: BoardShapeKind) => void;
onClose: () => void;
}) {
+ const dialogRef = useRef
(null);
+ useDialogFocus(true, props.onClose, dialogRef);
return (
-
+
Shapes
diff --git a/apps/desktop/src/components/board/BoardTimelineObject.tsx b/apps/desktop/src/components/board/BoardTimelineObject.tsx
index ac212ad..d40da69 100644
--- a/apps/desktop/src/components/board/BoardTimelineObject.tsx
+++ b/apps/desktop/src/components/board/BoardTimelineObject.tsx
@@ -9,6 +9,7 @@ import {
type BoardTimelineScale,
} from "@locus/shared";
import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
+import { Select } from "@/components/layout/Select";
function monthLabel(day: string): string {
const t = boardDayUtc(day);
@@ -126,19 +127,21 @@ export function BoardTimelineObject(props: {
{props.obj.text || "Timeline view"}
)}
-
- event.stopPropagation()}
+ >
+ event.stopPropagation()}
- onChange={(event) =>
- patch({ ...timeline, scale: event.target.value as BoardTimelineScale })
- }
- >
- Days
- Weeks
- Months
-
+ options={[
+ { value: "days", label: "Days" },
+ { value: "weeks", label: "Weeks" },
+ { value: "months", label: "Months" },
+ ]}
+ onChange={(value) => patch({ ...timeline, scale: value as BoardTimelineScale })}
+ />
diff --git a/apps/desktop/src/components/brain/Brain.tsx b/apps/desktop/src/components/brain/Brain.tsx
index 95ef966..1df08f9 100644
--- a/apps/desktop/src/components/brain/Brain.tsx
+++ b/apps/desktop/src/components/brain/Brain.tsx
@@ -13,7 +13,7 @@ import {
} from "@locus/shared";
import { api } from "@/lib/api";
import { useStore } from "@/state/store";
-import { Mascot } from "@/components/layout/Mascot";
+import { EmptyHero } from "@/components/layout/EmptyHero";
import { IconFolder } from "@/components/layout/Icons";
import { FeatureHintRow } from "@/components/layout/FeatureHint";
import { SECTION_FEATURE_HINTS } from "@/lib/discoverability";
@@ -170,12 +170,10 @@ export function BrainPane() {
{showEmptyHero ? (
-
-
Brain
-
- Index a repo folder to learn services and APIs. Remember durable conclusions. Coding
- agents orient on this automatically — grep still wins for the tree.
-
+
) : null}
diff --git a/apps/desktop/src/components/brain/BrainGraph.tsx b/apps/desktop/src/components/brain/BrainGraph.tsx
index b6df498..f0d0b89 100644
--- a/apps/desktop/src/components/brain/BrainGraph.tsx
+++ b/apps/desktop/src/components/brain/BrainGraph.tsx
@@ -7,6 +7,7 @@ import {
type RefObject,
} from "react";
import type { BrainEdge, BrainNode } from "@locus/shared";
+import { Select } from "@/components/layout/Select";
import {
BRAIN_GRAPH_FAMILIES,
BRAIN_GRAPH_LAYOUTS,
@@ -255,21 +256,20 @@ export function BrainGraph(props: {
Layout
- {
- const next = BRAIN_GRAPH_LAYOUTS.find((row) => row.id === event.target.value)?.id;
+ options={BRAIN_GRAPH_LAYOUTS.map((row) => ({
+ value: row.id,
+ label: row.label,
+ }))}
+ onChange={(value) => {
+ const next = BRAIN_GRAPH_LAYOUTS.find((row) => row.id === value)?.id;
if (next) props.onLayout(next);
}}
- >
- {BRAIN_GRAPH_LAYOUTS.map((row) => (
-
- {row.label}
-
- ))}
-
+ />
- zoomAt(0.92)}>
+ zoomAt(0.92)}>
−
{Math.round(camera.z * 100)}%
- zoomAt(1.08)}>
+ zoomAt(1.08)}>
+
-
+
Fit
diff --git a/apps/desktop/src/components/chat/ArtifactDrawer.tsx b/apps/desktop/src/components/chat/ArtifactDrawer.tsx
index 75bd1ad..2da7785 100644
--- a/apps/desktop/src/components/chat/ArtifactDrawer.tsx
+++ b/apps/desktop/src/components/chat/ArtifactDrawer.tsx
@@ -1,4 +1,6 @@
-import { useMemo, useState } from "react";
+import { useMemo, useRef, useState } from "react";
+import { useDialogFocus } from "@/lib/useDialogFocus";
+import { Skeleton } from "@/components/layout/Skeleton";
import { canShowTimeline, parseUnifiedDiff, type AgentSession } from "@locus/shared";
import {
artifactSummary,
@@ -106,7 +108,7 @@ export function TurnDiffChip({
/>
) : null}
- {loading && !text ? "Loading…" : text}
+ {loading && !text ? : text}
>
) : null}
@@ -118,8 +120,10 @@ export function ArtifactDrawer({ session }: { session: AgentSession }) {
const { dispatch } = useStore();
const [open, setOpen] = useState(false);
const [diff, setDiff] = useState<{ id: string; text: string } | null>(null);
+ const drawerRef = useRef(null);
const items = useMemo(() => sessionArtifacts(session.messages), [session.messages]);
const gitTurns = turnDiffCheckpoints(session);
+ useDialogFocus(open, () => setOpen(false), drawerRef, { trap: false });
if (!items.length && !gitTurns.length) return null;
async function loadDiff(checkpointId: string) {
@@ -133,7 +137,11 @@ export function ArtifactDrawer({ session }: { session: AgentSession }) {
}
return (
-
+
{
expect(host.textContent).toMatch(/Queue paused after Stop/);
expect(host.textContent).toMatch(/1 queued/);
- const remove = queued!.querySelector("button[title='Remove from queue']") as HTMLButtonElement;
+ const remove = queued!.querySelector(
+ "button[aria-label='Remove from queue']",
+ ) as HTMLButtonElement;
await act(async () => {
remove.click();
await Promise.resolve();
@@ -795,9 +797,9 @@ describe("ChatPane", () => {
});
const queued = host.querySelector("[aria-label='Queued prompts']");
- const sendNow = queued!.querySelector("button[aria-label='Send now']") as HTMLButtonElement;
+ const sendNow = queued!.querySelector("button[aria-label^='Send now']") as HTMLButtonElement;
expect(sendNow).toBeTruthy();
- expect(sendNow.title).toMatch(/interrupt this turn/i);
+ expect(sendNow.getAttribute("aria-label")).toMatch(/interrupt this turn/i);
await act(async () => {
sendNow.click();
await Promise.resolve();
@@ -819,7 +821,7 @@ describe("ChatPane", () => {
vms: [vm],
});
- const sendNow = host.querySelector("button[aria-label='Send now']") as HTMLButtonElement;
+ const sendNow = host.querySelector("button[aria-label^='Send now']") as HTMLButtonElement;
await act(async () => {
sendNow.click();
await Promise.resolve();
@@ -2772,11 +2774,16 @@ describe("ChatPane", () => {
sessions: [session],
vms: [{ ...vm, status: "ready" }],
});
- const select = host.querySelector("[aria-label='Default subagent']") as HTMLSelectElement;
- expect(select?.value).toBe("explore");
+ const picker = host.querySelector("[aria-label='Default subagent']") as HTMLElement;
+ expect(host.querySelector(".ui-select-value")?.textContent).toMatch(/Explore/i);
await act(async () => {
- select.value = "review";
- select.dispatchEvent(new Event("change", { bubbles: true }));
+ picker.click();
+ });
+ const review = [...host.querySelectorAll("[role='menuitem']")].find((el) =>
+ el.textContent?.includes("Review"),
+ );
+ await act(async () => {
+ review?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(api.patchSession).toHaveBeenCalledWith("s1", { defaultSubagent: "review" });
@@ -2821,11 +2828,11 @@ describe("ChatPane", () => {
sessions: [{ ...session, runtime: "host", status: "idle", vmId: undefined }],
vms: [],
});
- expect(host.textContent).toContain("no VM or container");
+ expect(host.textContent).toContain("This chat can read and edit files on this machine");
expect(host.querySelector(".vision-toggle")).toBeNull();
expect(host.querySelector(".composer-add")).toBeTruthy();
expect(host.querySelector(".websearch-toggle")).toBeNull();
- expect(host.textContent).toContain("Host agent · /workspace on this machine");
+ expect(host.querySelector(".chat-runtime-badge")?.textContent).toBe("This machine");
});
it("lays out the composer with model and Send on the footer", () => {
@@ -2858,9 +2865,9 @@ describe("ChatPane", () => {
},
});
expect(host.querySelector(".composer-bar .model-picker")).toBeTruthy();
- expect(host.querySelector(".model-picker-provider.on")).toBeTruthy();
+ expect(host.querySelector(".model-picker-combined.on")).toBeTruthy();
expect(host.querySelector(".model-cost-chip")).toBeNull();
- expect(host.querySelector("[aria-label='Context usage']")?.textContent).toMatch(/0% context/);
+ expect(host.querySelector("[aria-label='Context usage']")).toBeNull();
expect(host.querySelector(".composer-meta")?.textContent).toMatch(/7,484 in · 400 out · \$0/);
expect(host.querySelector(".composer-add")).toBeTruthy();
expect(host.querySelector("[data-testid='composer-commands']")).toBeTruthy();
@@ -2931,10 +2938,10 @@ describe("ChatPane", () => {
expect(css).toMatch(/\.composer-bar \{[\s\S]*?flex-wrap:\s*wrap/);
expect(css).toMatch(/\.composer-actions \{[\s\S]*?flex:\s*1 1 12\.5rem/);
expect(css).toMatch(
- /@container composer \(max-width: 520px\) \{[\s\S]*?\.composer-actions,[\s\S]*?flex:\s*1 1 100%/,
+ /@container composer \(max-width: 560px\) \{[\s\S]*?\.composer-actions,[\s\S]*?flex:\s*1 1 100%/,
);
expect(css).toMatch(
- /@container composer \(max-width: 520px\) \{[\s\S]*?\.composer-send-label \{[\s\S]*?display:\s*none/,
+ /@container composer \(max-width: 560px\) \{[\s\S]*?\.composer-send-label \{[\s\S]*?display:\s*none/,
);
expect(css).toMatch(
/\.model-picker\.compact \.model-picker-provider \{[\s\S]*?overflow:\s*hidden/,
@@ -2954,7 +2961,7 @@ describe("ChatPane", () => {
"utf8",
);
expect(css).toMatch(/:root,[\s\S]*?--chat-thread-pad: 16px 20px 8px;/);
- expect(css).toMatch(/:root,[\s\S]*?--chat-font: 13px;/);
+ expect(css).toMatch(/:root,[\s\S]*?--chat-font: 14px;/);
expect(css).toMatch(/\.thread \{[\s\S]*?padding: var\(--chat-thread-pad\)/);
expect(css).toMatch(/\.thread \{[\s\S]*?scrollbar-width:\s*none/);
expect(css).toMatch(/\.thread-scroll-thumb \{[\s\S]*?repeating-linear-gradient/);
diff --git a/apps/desktop/src/components/chat/ChatPane.tsx b/apps/desktop/src/components/chat/ChatPane.tsx
index 1e18f1c..d74c3a5 100644
--- a/apps/desktop/src/components/chat/ChatPane.tsx
+++ b/apps/desktop/src/components/chat/ChatPane.tsx
@@ -5,9 +5,7 @@ import {
useMemo,
useRef,
useState,
- memo,
type ClipboardEvent,
- type RefObject,
type UIEvent,
} from "react";
import {
@@ -21,20 +19,17 @@ import {
CLEAR_CHAT_CONFIRM,
clusterChatThread,
configuredGuestOsOptions,
- formatCompactNotice,
formatUsageHint,
guestLabel,
workerComputeUsd,
isCanvasSession,
isGameSession,
isGuestReady,
- isGuestStartNote,
isGuestStarting,
isHiddenChatMessage,
isHumanDriving,
isWaitingOnGuestStart,
supersededGuestLifecycleIds,
- formatWaitElapsed,
localModelWaitHint,
localModelWaitLabel,
chatInputError,
@@ -55,7 +50,6 @@ import {
composerOverflowCommands,
matchSlashMenu,
mergeSkills,
- operatorVisibleText,
pendingPromptQueue,
queueItemBadge,
rewindTargets,
@@ -66,53 +60,36 @@ import {
sessionWebSearch,
sessionLiveChrome,
skillSlashName,
- summarizeLogBlob,
- turnToolCalls,
subagentViewMessages,
subagentStatusLabel,
findLiveSubagent,
sessionDefaultSubagent,
formatSubagentKind,
- thinkingTraceText,
showThoughtTraces,
parseAssetMentions,
parseProductMode,
parseSessionSurface,
sessionDisplayName,
- sessionAvatarId,
- sessionAvatarPose,
- sessionAvatarAccessory,
- isProjectSession,
validateSkill,
workspaceRelFromToolPath,
BUILTIN_SLASH_NAMES,
type AgentSkill,
- type AgentSession,
type AssetMentionItem,
- type ChatCluster,
type ChatMessage,
- type CloneProgress,
- type ImageBuildProgress,
type SlashCommand,
type SlashMenuItem,
type SubagentLive,
- type TeamEventKind,
- type TeamSpeaker,
} from "@locus/shared";
import { api, retryEvents } from "@/lib/api";
-import { showNotice } from "@/lib/notice";
+import { showErrorNotice, showNotice } from "@/lib/notice";
+import { askPrompt } from "@/lib/dialog";
import { configForSave } from "@/lib/configPersist";
import {
filesToAttachments,
filesFromDataTransfer,
composerFilesFromClipboard,
} from "@/lib/attachments";
-import {
- applyComposerChipKey,
- applyComposerPaste,
- leftoverAttachChips,
- splitComposerFiles,
-} from "@/lib/composerPaste";
+import { applyComposerChipKey, applyComposerPaste, splitComposerFiles } from "@/lib/composerPaste";
import {
chatPostBody,
DROPPED_CHAT_ERROR,
@@ -124,35 +101,21 @@ import {
import { CHAT_RECOVERY_TIMEOUT_MS } from "@/lib/net";
import { turnDiffByAssistantId } from "@/lib/artifacts";
import { saveLastAgent } from "@/lib/lastAgent";
-import { formatMarkdown } from "@/lib/markdown";
import { chatFindHits, stepChatFindIndex } from "@/lib/chatFind";
import { isStuckToBottom, pinThreadToLatest, schedulePinThreadToLatest } from "@/lib/threadScroll";
-import {
- listPreserveScrollTop,
- listSizePrefix,
- listWindowFromSizes,
- sameListWindow,
- shouldUpdateListWindow,
- type ListWindow,
-} from "@/lib/listWindow";
import { visitMarkerId } from "@/lib/chatNewMarker";
import { openSettings, useStore } from "@/state/store";
-import { TurnDiffChip } from "./ArtifactDrawer";
-import { ExpandableLog } from "./ExpandableLog";
import { CompareBanner } from "./CompareBanner";
import { FeatureNudge } from "@/components/layout/FeatureNudge";
import { ConfirmModal } from "@/components/layout/ConfirmModal";
+import { Tooltip } from "@/components/layout/Tooltip";
import {
readLiveComposerDraft as readDraft,
resetComposerDrafts,
writeLiveComposerDraft as writeDraft,
} from "@/lib/composerDrafts";
-import { screenshotSrc } from "./GuestTimeline";
-import { ShotCard, ToolActivity } from "./ChatToolActivity";
-import { SubagentFold } from "./SubagentCard";
import {
IconClose,
- IconCopy,
IconPlay,
IconReturn,
IconSend,
@@ -181,90 +144,7 @@ import {
teammateMentionItems,
type TeammateMentionItem,
} from "@/lib/teammateMention";
-import { TaskListCard } from "./TaskListCard";
-import { SubscriptionCard } from "./SubscriptionCard";
-import { Mascot } from "@/components/layout/Mascot";
-import { PersonaAvatar } from "@/components/layout/PersonaAvatar";
-import { TrustActions } from "./TrustActions";
-import { CloneProgressCard, ImageBuildCard } from "./CloneProgressCard";
-import { ThinkingTrace } from "./ThinkingTrace";
-import { ChatScrollIndicator } from "./ChatScrollIndicator";
-import { NewMessagesDivider } from "./NewMessagesDivider";
-
-const TEAM_EVENT_LABELS: Record = {
- spawn: "Spawned",
- join: "Joined",
- handoff: "Handoff",
- blocked: "Blocked",
- done: "Done",
- needs_you: "Needs you",
- address: "Addressed",
-};
-
-type ChatWho = {
- name: string;
- speaker?: Pick<
- TeamSpeaker,
- "avatarId" | "avatarPose" | "avatarAccessory" | "avatarAccent" | "sessionId"
- >;
-};
-
-/** Prefer a running teammate, then the live team name, then the session identity. */
-function swarmChatWho(input: {
- session: AgentSession;
- teamName?: string;
- members: AgentSession[];
- speaker?: TeamSpeaker;
- preferRunning?: boolean;
-}): ChatWho {
- if (input.speaker) {
- const live =
- input.members.find((row) => row.id === input.speaker!.sessionId) ??
- (input.session.id === input.speaker.sessionId ? input.session : undefined);
- if (live) {
- const name =
- isProjectSession(live) && input.teamName?.trim()
- ? input.teamName.trim()
- : sessionDisplayName(live);
- return {
- name,
- speaker: {
- sessionId: live.id,
- avatarId: sessionAvatarId(live),
- avatarPose: sessionAvatarPose(live),
- avatarAccessory: sessionAvatarAccessory(live),
- avatarAccent: live.persona?.avatarAccent,
- },
- };
- }
- return { name: input.speaker.displayName, speaker: input.speaker };
- }
- if (input.preferRunning) {
- const running = input.members.filter(
- (row) => row.status === "running" || row.status === "provisioning",
- );
- if (running.length === 1) {
- const live = running[0]!;
- return {
- name: sessionDisplayName(live),
- speaker: {
- sessionId: live.id,
- avatarId: sessionAvatarId(live),
- avatarPose: sessionAvatarPose(live),
- avatarAccessory: sessionAvatarAccessory(live),
- avatarAccent: live.persona?.avatarAccent,
- },
- };
- }
- if (running.length > 1) {
- return { name: running.map((row) => sessionDisplayName(row)).join(" · ") };
- }
- }
- if (isProjectSession(input.session) && input.teamName?.trim()) {
- return { name: input.teamName.trim() };
- }
- return { name: sessionDisplayName(input.session) };
-}
+import { ChatThread, hasOpenTool, isBlankAssistant } from "./ChatThread";
function forgetDraft(id: string | undefined) {
writeDraft(id, { text: "", files: [] }, "clear");
@@ -886,7 +766,9 @@ export function ChatPane() {
async function stop() {
if (!session) return;
- await api.abortChat(session.id).catch(() => undefined);
+ await api.abortChat(session.id).catch((err: unknown) => {
+ showErrorNotice(err, "Could not stop");
+ });
setBusy(false);
sending.current = false;
}
@@ -948,14 +830,18 @@ export function ChatPane() {
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-"),
) ?? "workflow";
- const rawName = window.prompt("Skill slash name (letters, numbers, dashes)", suggested);
+ const rawName = await askPrompt({
+ title: "Skill slash name",
+ body: "Letters, numbers, and dashes.",
+ value: suggested,
+ });
if (rawName == null) return;
const name = skillSlashName(rawName);
if (!name) {
showNotice("Skill slash name must be /name (letters, numbers, dashes).");
return;
}
- const title = window.prompt("Skill title", name)?.trim() || name;
+ const title = (await askPrompt({ title: "Skill title", value: name }))?.trim() || name;
const body = [
assistant?.content.trim() ? `## How we did it\n\n${assistant.content.trim()}` : "",
`## Task\n\n${user.content.trim()}`,
@@ -983,7 +869,7 @@ export function ChatPane() {
dispatch({ type: "config", config: saved });
showNotice(`Saved /${name}. Type /${name} in the composer to run it.`);
} catch (err) {
- showNotice(err instanceof Error ? err.message : "Could not save skill");
+ showErrorNotice(err, "Could not save skill");
}
}
@@ -1321,52 +1207,60 @@ export function ChatPane() {
{queueItemBadge(item) ? {queueItemBadge(item)} : null}
{item.text}
- {
- void api
- .sendQueueNow(session.id, item.id)
- .then((updated) => {
- setSendError(null);
- dispatch({ type: "session", session: updated });
- })
- .catch((err: unknown) => {
- setSendError(
- err instanceof Error
- ? err.message
- : "Couldn't send the queued prompt now.",
- );
- });
- }}
- >
-
-
- {
- const next = queue.filter((row) => row.id !== item.id);
- void api
- .patchSession(session.id, { queue: next.length ? next : null })
- .then((updated) => dispatch({ type: "session", session: updated }))
- .catch((err: unknown) => {
- setSendError(
- err instanceof Error
- ? err.message
- : "Couldn't remove the queued prompt.",
- );
- });
- }}
>
-
-
+ {
+ void api
+ .sendQueueNow(session.id, item.id)
+ .then((updated) => {
+ setSendError(null);
+ dispatch({ type: "session", session: updated });
+ })
+ .catch((err: unknown) => {
+ setSendError(
+ err instanceof Error
+ ? err.message
+ : "Couldn't send the queued prompt now.",
+ );
+ });
+ }}
+ >
+
+
+
+
+ {
+ const next = queue.filter((row) => row.id !== item.id);
+ void api
+ .patchSession(session.id, { queue: next.length ? next : null })
+ .then((updated) => dispatch({ type: "session", session: updated }))
+ .catch((err: unknown) => {
+ setSendError(
+ err instanceof Error
+ ? err.message
+ : "Couldn't remove the queued prompt.",
+ );
+ });
+ }}
+ >
+
+
+
))}
@@ -1545,10 +1439,10 @@ export function ChatPane() {
: isCanvasSession(session)
? "Describe a change, or click Comment on the canvas…"
: swarmMode
- ? "Message the team… @ to mention, / for commands"
+ ? "Message the team…"
: sessionRuntime(session) === "host"
- ? "Message the agent… @game / @asset / @agent to reuse other chats, / for commands"
- : "Message the agent… / for commands"
+ ? "Message the agent… @game / @asset / @agent to reuse other chats"
+ : "Message the agent…"
}
onChange={(value) => {
setText(value);
@@ -1819,27 +1713,39 @@ export function ChatPane() {
) : null}
{turning ? (
+
+ void queueFollowup()}
+ >
+ Queue
+
+
+ ) : null}
+
void queueFollowup()}
+ aria-label={turning ? "Send — steer this turn" : "Send"}
+ onClick={() => void send()}
>
- Queue
+ Send
+
- ) : null}
- void send()}
- >
- Send
-
-
+
@@ -1850,11 +1756,11 @@ export function ChatPane() {
) : null}
{hostChat ? (
- Host agent · /workspace on this machine
+ This machine
) : docker ? (
- Using {session.dockerImage ?? "Docker"} · /workspace
+ {session.dockerImage ?? "Docker"}
) : configuredGuestOsOptions(state.config?.templates).length <= 1 ? (
- Using {guestLabel(session.os)}
+ {guestLabel(session.os)}
) : null}
)}
@@ -1880,624 +1786,3 @@ export function ChatPane() {
);
}
-
-function isBlankAssistant(message: ChatMessage): boolean {
- return (
- message.role === "assistant" &&
- !message.content.trim() &&
- !message.toolCalls?.length &&
- !message.attachments?.length &&
- !message.screenshotBase64 &&
- !message.screenshotName &&
- !message.artifactName &&
- !message.recordingName
- );
-}
-
-function hasOpenTool(messages: ChatMessage[]): boolean {
- let lastUser = -1;
- for (let i = messages.length - 1; i >= 0; i--) {
- if (messages[i]?.role === "user") {
- lastUser = i;
- break;
- }
- }
- return messages
- .slice(lastUser + 1)
- .some((message) => message.toolCalls?.some((call) => !call.finishedAt));
-}
-
-function useTurnElapsed(active: boolean, resetKey: string): number {
- const [elapsed, setElapsed] = useState(0);
- useEffect(() => {
- if (!active) {
- setElapsed(0);
- return;
- }
- const started = Date.now();
- setElapsed(0);
- const id = setInterval(() => {
- setElapsed(Math.floor((Date.now() - started) / 1000));
- }, 1000);
- return () => clearInterval(id);
- }, [active, resetKey]);
- return elapsed;
-}
-
-function WorkingIndicator({
- label,
- thought,
- ticking = false,
- resetKey = "",
-}: {
- label: string;
- thought?: string;
- ticking?: boolean;
- resetKey?: string;
-}) {
- const elapsedSec = useTurnElapsed(ticking, resetKey);
- const elapsed = elapsedSec >= 3 ? formatWaitElapsed(elapsedSec) : "";
- return (
-
-
-
-
-
-
-
-
-
- {label}
- {elapsed ? (
-
- {" "}
- · {elapsed}
-
- ) : null}
-
- {thought ? (
-
- {thought}
-
- ) : null}
-
-
- );
-}
-
-function SystemOrMarkdown(props: { role: string; content: string; liveWait?: boolean }) {
- if (props.role === "system" && summarizeLogBlob(props.content).detail) {
- return
;
- }
- if (props.liveWait) {
- return (
-
-
-
-
-
-
- {props.content}
-
- );
- }
- return
;
-}
-
-const MarkdownBubble = memo(function MarkdownBubble({ content }: { content: string }) {
- return (
-
{
- const btn = (event.target as HTMLElement | null)?.closest(".md-copy");
- if (!btn) return;
- const pre = btn.parentElement?.querySelector("pre");
- const text = pre?.textContent ?? "";
- if (!text) return;
- void navigator.clipboard.writeText(text).catch(() => undefined);
- }}
- />
- );
-});
-
-type TurnDiff = { checkpointId: string; fileCount: number; path?: string; line?: number };
-
-const CHAT_THREAD_ROW_PX = 140;
-
-const ChatThread = memo(function ChatThread({
- threadRef,
- onScroll,
- followLatest,
- session,
- clusters,
- messages,
- viewing,
- swarmMode,
- thoughtTraces,
- hideLifecycleIds,
- newMarkerId,
- findHits,
- findActiveId,
- turnDiffs,
- cloneStartId,
- cloneProgress,
- buildStartId,
- imageProgress,
- waitingGuest,
- waitingStartNote,
- workingOnBlank,
- showTailWorking,
- workingLabel,
- thinkingSnippet,
- lastUserId,
- turning,
- outboundId,
- teamName,
- teamMembers,
- hostChat,
- docker,
- onOpenPath,
- onOpenSubagent,
- onEditLast,
- onRetryLast,
- onCopyText,
- onRemoveSubscription,
-}: {
- threadRef: RefObject
;
- onScroll: (event: UIEvent) => void;
- followLatest: boolean;
- session: AgentSession;
- clusters: ChatCluster[];
- messages: ChatMessage[];
- viewing: SubagentLive | null;
- swarmMode: boolean;
- thoughtTraces: boolean;
- hideLifecycleIds: ReadonlySet;
- newMarkerId?: string;
- findHits: string[];
- findActiveId?: string;
- turnDiffs: ReadonlyMap;
- cloneStartId?: string;
- cloneProgress?: CloneProgress;
- buildStartId?: string;
- imageProgress?: ImageBuildProgress;
- waitingGuest: boolean;
- waitingStartNote: boolean;
- workingOnBlank?: string;
- showTailWorking: boolean;
- workingLabel: string;
- thinkingSnippet: string;
- lastUserId?: string;
- turning: boolean;
- outboundId?: string;
- teamName?: string;
- teamMembers: AgentSession[];
- hostChat: boolean;
- docker: boolean;
- onOpenPath: (path: string, line?: number) => void;
- onOpenSubagent: (agent: SubagentLive) => void;
- onEditLast: () => void;
- onRetryLast: () => void;
- onCopyText: (value: string) => void;
- onRemoveSubscription: (id: string) => void;
-}) {
- const swarmWhoBase = { session, teamName, members: teamMembers };
- const workingWho = swarmMode
- ? swarmChatWho({ ...swarmWhoBase, preferRunning: true })
- : { name: "Agent" as string };
- const findHitSet = useMemo(() => new Set(findHits), [findHits]);
- const heightsRef = useRef(new Map());
- const offsetsRef = useRef([0]);
- const [sizeTick, setSizeTick] = useState(0);
- const sizes = useMemo(
- () => clusters.map((block) => heightsRef.current.get(block.message.id) ?? CHAT_THREAD_ROW_PX),
- // sizeTick re-reads measured heights after paint; clusters identity covers new rows.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [clusters, sizeTick],
- );
- const [chatWin, setChatWin] = useState(() =>
- listWindowFromSizes({
- scrollTop: 0,
- clientHeight: 0,
- sizes: clusters.map(() => CHAT_THREAD_ROW_PX),
- stickToEnd: true,
- }),
- );
-
- function measurePainted(el: HTMLDivElement | null): boolean {
- if (!el) return false;
- const heights = heightsRef.current;
- let changed = false;
- for (const node of el.querySelectorAll("[data-thread-row]")) {
- const id = node.getAttribute("data-thread-row");
- if (!id) continue;
- const h = Math.round(node.getBoundingClientRect().height);
- if (h <= 0) continue;
- if (heights.get(id) !== h) {
- heights.set(id, h);
- changed = true;
- }
- }
- return changed;
- }
-
- function syncChatWindow(el: HTMLDivElement | null, stickToEnd?: boolean) {
- if (!el) return;
- const next = listWindowFromSizes({
- scrollTop: el.scrollTop,
- clientHeight: el.clientHeight,
- sizes,
- stickToEnd: stickToEnd ?? (followLatest || isStuckToBottom(el) || el.clientHeight <= 0),
- });
- setChatWin((current) => {
- if (sameListWindow(current, next)) return current;
- if (current.start === next.start && current.end === next.end) return next;
- return shouldUpdateListWindow(current, next) ? next : current;
- });
- }
-
- useLayoutEffect(() => {
- heightsRef.current.clear();
- offsetsRef.current = [0];
- setSizeTick((n) => n + 1);
- setChatWin(
- listWindowFromSizes({
- scrollTop: 0,
- clientHeight: 0,
- sizes: clusters.map(() => CHAT_THREAD_ROW_PX),
- stickToEnd: true,
- }),
- );
- // Only reset measurements when switching chats, not on every cluster append.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [session.id]);
-
- useLayoutEffect(() => {
- const el = threadRef.current;
- if (findActiveId) {
- const idx = clusters.findIndex((block) => block.message.id === findActiveId);
- if (el && idx >= 0) {
- const offsets = listSizePrefix(sizes);
- el.scrollTop = Math.max(0, (offsets[idx] ?? 0) - el.clientHeight / 3);
- }
- }
- const prevOffsets = offsetsRef.current;
- const nextOffsets = listSizePrefix(sizes);
- if (
- el &&
- el.clientHeight > 0 &&
- prevOffsets.length > 1 &&
- !followLatest &&
- !isStuckToBottom(el)
- ) {
- const preserved = listPreserveScrollTop(prevOffsets, nextOffsets, el.scrollTop);
- if (Math.abs(preserved - el.scrollTop) > 0.5) el.scrollTop = preserved;
- }
- offsetsRef.current = nextOffsets;
- syncChatWindow(el);
- if (followLatest && !findActiveId) pinThreadToLatest(el);
- if (measurePainted(el)) setSizeTick((n) => n + 1);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [clusters, sizes, messages.length, session.id, findActiveId, followLatest]);
-
- useLayoutEffect(() => {
- if (!followLatest || findActiveId) return;
- pinThreadToLatest(threadRef.current);
- }, [followLatest, findActiveId, chatWin.start, chatWin.end, chatWin.height, threadRef]);
-
- useEffect(() => {
- const el = threadRef.current;
- if (!el || typeof ResizeObserver === "undefined") return;
- const ro = new ResizeObserver(() => {
- if (measurePainted(el)) setSizeTick((n) => n + 1);
- });
- ro.observe(el);
- return () => ro.disconnect();
- }, [session.id, threadRef]);
-
- const painted = clusters.slice(chatWin.start, chatWin.end);
-
- return (
-
-
{
- const node = event.currentTarget;
- syncChatWindow(node, isStuckToBottom(node) || node.clientHeight <= 0);
- onScroll(event);
- }}
- >
- {messages.length === 0 && !viewing && (
-
-
{swarmMode ? workingWho.name : "Agent"}
-
- {swarmMode
- ? "Say hi — ask what they should own on the team, or give them a first task."
- : hostChat
- ? "This is a host agent — no VM or container. It can read and edit files in /workspace on this machine, run a shell, and use git. There is no desktop."
- : `This chat owns a ${docker ? `${session.dockerImage ?? "Docker"} container` : `${guestLabel(session.os)} VM`}. Ask it to ${docker ? "run commands, click through Chromium, or clone a repo" : "use the desktop, run commands, or clone a repo"} — tools stay on the ${docker ? "container" : "guest"}, not your host.`}
-
-
- )}
-
- {painted.map((block, paintedIndex) => {
- const index = chatWin.start + paintedIndex;
- const blocks = clusters;
- const m = block.message;
- const showNew = !viewing && m.id === newMarkerId;
- if (hideLifecycleIds.has(m.id)) {
- return showNew ? (
-
-
-
- ) : null;
- }
- const tools = block.kind === "turn" ? block.tools : [];
- if (
- m.role === "assistant" &&
- isBlankAssistant(m) &&
- !(thoughtTraces && thinkingTraceText(m)) &&
- !tools.length &&
- m.id !== workingOnBlank
- )
- return showNew ? (
-
-
-
- ) : null;
- const calls = m.role === "assistant" || m.role === "tool" ? turnToolCalls(m, tools) : [];
- const turnDiff =
- m.role === "assistant" && m.id !== workingOnBlank && !viewing
- ? turnDiffs.get(m.id)
- : undefined;
- const visible = operatorVisibleText(m.role, m.content);
- const leftoverAttaches =
- m.role !== "tool" && m.attachments?.length
- ? leftoverAttachChips(visible || m.content, m.attachments)
- : [];
- const whoInfo =
- m.role === "user"
- ? { name: "You" }
- : m.role === "system"
- ? { name: "Locus" }
- : swarmMode
- ? swarmChatWho({
- ...swarmWhoBase,
- speaker: m.speaker,
- preferRunning: m.id === workingOnBlank,
- })
- : { name: "Agent" };
- const who = whoInfo.name;
- const teamEventLabel = m.teamEvent ? TEAM_EVENT_LABELS[m.teamEvent] : undefined;
- const cloneCard = m.id === cloneStartId && cloneProgress ? cloneProgress : undefined;
- const buildCard = m.id === buildStartId && imageProgress ? imageProgress : undefined;
- const prev = index > 0 ? blocks[index - 1]?.message : undefined;
- const continued =
- swarmMode &&
- prev &&
- !m.compact &&
- !prev.compact &&
- (prev.role === "tool" ? "assistant" : prev.role) ===
- (m.role === "tool" ? "assistant" : m.role) &&
- (prev.speaker?.displayName ?? "") === (m.speaker?.displayName ?? "");
- return (
-
- {showNew ?
: null}
-
- {m.compact || continued ? null : (
-
- {whoInfo.speaker ? (
-
- ) : m.speaker ? (
-
- ) : null}
- {who}
- {teamEventLabel ? (
-
{teamEventLabel}
- ) : null}
- {m.steer ?
Steer : null}
- {m.role === "user" && visible.trim() ? (
-
- {m.id === lastUserId && !turning && !viewing ? (
- <>
-
- Edit
-
-
- Retry
-
- >
- ) : null}
- onCopyText(visible)}
- >
- Copy
-
-
- ) : null}
- {m.role === "assistant" && visible.trim() ? (
-
- onCopyText(visible)}
- >
- Copy
-
-
- ) : null}
-
- )}
- {m.id === workingOnBlank ? (
- <>
-
- {thoughtTraces ?
: null}
- {viewing ? null : (
-
row.status === "running")}
- onOpen={onOpenSubagent}
- />
- )}
- >
- ) : m.role === "tool" ? null : buildCard ? (
-
- ) : cloneCard ? (
-
- ) : m.compact ? (
-
- {formatCompactNotice(m.compact)}
-
- ) : (
- <>
- {thoughtTraces ? : null}
- {visible ? (
-
- ) : null}
- >
- )}
- {calls.length ? (
-
- ) : null}
- {m.role !== "tool" && screenshotSrc(session.id, m) ? (
-
- ) : null}
- {leftoverAttaches.length ? (
-
- {leftoverAttaches.map((item) => (
-
- {item.name}
-
- ))}
-
- ) : null}
- {m.role !== "tool" && m.artifactName ? (
-
- {m.artifactName}
-
- ) : null}
- {m.role !== "tool" && m.recordingName ? (
-
- Guest recording
-
- ) : null}
- {turnDiff ? (
-
- ) : null}
-
-
- );
- })}
-
- {session.lastCompact &&
- !messages.some((row) => row.compact && !isHiddenChatMessage(row)) ? (
-
-
- {formatCompactNotice(session.lastCompact)}
-
-
- ) : null}
- {showTailWorking ? (
-
-
- {workingWho.speaker ? (
-
- ) : null}
- {workingWho.name}
-
-
- {viewing ? null : (
-
row.status === "running")}
- onOpen={onOpenSubagent}
- />
- )}
-
- ) : null}
- {!turning && !viewing ?
: null}
- {session.taskList?.length && !viewing ? (
-
- ) : null}
- {session.subscriptions?.length && !viewing ? (
-
- ) : null}
-
-
-
- );
-});
diff --git a/apps/desktop/src/components/chat/ChatThread.test.tsx b/apps/desktop/src/components/chat/ChatThread.test.tsx
new file mode 100644
index 0000000..1ced89d
--- /dev/null
+++ b/apps/desktop/src/components/chat/ChatThread.test.tsx
@@ -0,0 +1,99 @@
+import { act, useRef, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import type { AgentSession } from "@locus/shared";
+import { ChatThread } from "./ChatThread";
+import { Store, initialState } from "@/state/store";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const session = {
+ id: "s1",
+ title: "Fix login",
+ os: "ubuntu" as const,
+ templateId: "ubuntu-24.04",
+ providerId: "p1",
+ model: "gpt-4.1",
+ status: "idle" as const,
+ vmId: "v1",
+ messages: [],
+ createdAt: "2020-01-01T00:00:00.000Z",
+ updatedAt: "2020-01-01T00:00:00.000Z",
+} as AgentSession;
+
+const noop = () => undefined;
+
+function withStore(node: ReactNode) {
+ return (
+ undefined }}>
+ {node}
+
+ );
+}
+
+function Shell(props: { hostChat?: boolean; docker?: boolean; swarmMode?: boolean }) {
+ const threadRef = useRef(null);
+ return withStore(
+ ,
+ );
+}
+
+describe("ChatThread", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("shows the host empty hint", () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render( );
+ });
+ expect(host.querySelector(".chat-empty-hint")?.textContent).toContain(
+ "read and edit files on this machine",
+ );
+ });
+
+ it("shows the container empty hint", () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render( );
+ });
+ expect(host.querySelector(".chat-empty-hint")?.textContent).toContain("runs in a container");
+ });
+});
diff --git a/apps/desktop/src/components/chat/ChatThread.tsx b/apps/desktop/src/components/chat/ChatThread.tsx
new file mode 100644
index 0000000..2df9026
--- /dev/null
+++ b/apps/desktop/src/components/chat/ChatThread.tsx
@@ -0,0 +1,755 @@
+import {
+ memo,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type RefObject,
+ type UIEvent,
+} from "react";
+import {
+ formatCompactNotice,
+ formatWaitElapsed,
+ guestLabel,
+ isGuestStartNote,
+ isHiddenChatMessage,
+ isProjectSession,
+ operatorVisibleText,
+ sessionAvatarAccessory,
+ sessionAvatarId,
+ sessionAvatarPose,
+ sessionDisplayName,
+ summarizeLogBlob,
+ thinkingTraceText,
+ turnToolCalls,
+ type AgentSession,
+ type ChatCluster,
+ type ChatMessage,
+ type CloneProgress,
+ type ImageBuildProgress,
+ type SubagentLive,
+ type TeamEventKind,
+ type TeamSpeaker,
+} from "@locus/shared";
+import { leftoverAttachChips } from "@/lib/composerPaste";
+import { formatMarkdown } from "@/lib/markdown";
+import { isStuckToBottom, pinThreadToLatest } from "@/lib/threadScroll";
+import {
+ listPreserveScrollTop,
+ listSizePrefix,
+ listWindowFromSizes,
+ sameListWindow,
+ shouldUpdateListWindow,
+ type ListWindow,
+} from "@/lib/listWindow";
+import { TurnDiffChip } from "./ArtifactDrawer";
+import { ExpandableLog } from "./ExpandableLog";
+import { ShotCard, ToolActivity } from "./ChatToolActivity";
+import { SubagentFold } from "./SubagentCard";
+import { screenshotSrc } from "./GuestTimeline";
+import { IconCopy } from "@/components/layout/Icons";
+import { Mascot } from "@/components/layout/Mascot";
+import { PersonaAvatar } from "@/components/layout/PersonaAvatar";
+import { TrustActions } from "./TrustActions";
+import { CloneProgressCard, ImageBuildCard } from "./CloneProgressCard";
+import { ThinkingTrace } from "./ThinkingTrace";
+import { ChatScrollIndicator } from "./ChatScrollIndicator";
+import { NewMessagesDivider } from "./NewMessagesDivider";
+import { TaskListCard } from "./TaskListCard";
+import { SubscriptionCard } from "./SubscriptionCard";
+
+const TEAM_EVENT_LABELS: Record = {
+ spawn: "Spawned",
+ join: "Joined",
+ handoff: "Handoff",
+ blocked: "Blocked",
+ done: "Done",
+ needs_you: "Needs you",
+ address: "Addressed",
+};
+
+type ChatWho = {
+ name: string;
+ speaker?: Pick<
+ TeamSpeaker,
+ "avatarId" | "avatarPose" | "avatarAccessory" | "avatarAccent" | "sessionId"
+ >;
+};
+
+/** Prefer a running teammate, then the live team name, then the session identity. */
+function swarmChatWho(input: {
+ session: AgentSession;
+ teamName?: string;
+ members: AgentSession[];
+ speaker?: TeamSpeaker;
+ preferRunning?: boolean;
+}): ChatWho {
+ if (input.speaker) {
+ const live =
+ input.members.find((row) => row.id === input.speaker!.sessionId) ??
+ (input.session.id === input.speaker.sessionId ? input.session : undefined);
+ if (live) {
+ const name =
+ isProjectSession(live) && input.teamName?.trim()
+ ? input.teamName.trim()
+ : sessionDisplayName(live);
+ return {
+ name,
+ speaker: {
+ sessionId: live.id,
+ avatarId: sessionAvatarId(live),
+ avatarPose: sessionAvatarPose(live),
+ avatarAccessory: sessionAvatarAccessory(live),
+ avatarAccent: live.persona?.avatarAccent,
+ },
+ };
+ }
+ return { name: input.speaker.displayName, speaker: input.speaker };
+ }
+ if (input.preferRunning) {
+ const running = input.members.filter(
+ (row) => row.status === "running" || row.status === "provisioning",
+ );
+ if (running.length === 1) {
+ const live = running[0]!;
+ return {
+ name: sessionDisplayName(live),
+ speaker: {
+ sessionId: live.id,
+ avatarId: sessionAvatarId(live),
+ avatarPose: sessionAvatarPose(live),
+ avatarAccessory: sessionAvatarAccessory(live),
+ avatarAccent: live.persona?.avatarAccent,
+ },
+ };
+ }
+ if (running.length > 1) {
+ return { name: running.map((row) => sessionDisplayName(row)).join(" · ") };
+ }
+ }
+ if (isProjectSession(input.session) && input.teamName?.trim()) {
+ return { name: input.teamName.trim() };
+ }
+ return { name: sessionDisplayName(input.session) };
+}
+
+export function isBlankAssistant(message: ChatMessage): boolean {
+ return (
+ message.role === "assistant" &&
+ !message.content.trim() &&
+ !message.toolCalls?.length &&
+ !message.attachments?.length &&
+ !message.screenshotBase64 &&
+ !message.screenshotName &&
+ !message.artifactName &&
+ !message.recordingName
+ );
+}
+
+export function hasOpenTool(messages: ChatMessage[]): boolean {
+ let lastUser = -1;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i]?.role === "user") {
+ lastUser = i;
+ break;
+ }
+ }
+ return messages
+ .slice(lastUser + 1)
+ .some((message) => message.toolCalls?.some((call) => !call.finishedAt));
+}
+
+function useTurnElapsed(active: boolean, resetKey: string): number {
+ const [elapsed, setElapsed] = useState(0);
+ useEffect(() => {
+ if (!active) {
+ setElapsed(0);
+ return;
+ }
+ const started = Date.now();
+ setElapsed(0);
+ const id = setInterval(() => {
+ setElapsed(Math.floor((Date.now() - started) / 1000));
+ }, 1000);
+ return () => clearInterval(id);
+ }, [active, resetKey]);
+ return elapsed;
+}
+
+function WorkingIndicator({
+ label,
+ thought,
+ ticking = false,
+ resetKey = "",
+}: {
+ label: string;
+ thought?: string;
+ ticking?: boolean;
+ resetKey?: string;
+}) {
+ const elapsedSec = useTurnElapsed(ticking, resetKey);
+ const elapsed = elapsedSec >= 3 ? formatWaitElapsed(elapsedSec) : "";
+ return (
+
+
+
+
+
+
+
+
+
+ {label}
+ {elapsed ? (
+
+ {" "}
+ · {elapsed}
+
+ ) : null}
+
+ {thought ? (
+
+ {thought}
+
+ ) : null}
+
+
+ );
+}
+
+function SystemOrMarkdown(props: { role: string; content: string; liveWait?: boolean }) {
+ if (props.role === "system" && summarizeLogBlob(props.content).detail) {
+ return ;
+ }
+ if (props.liveWait) {
+ return (
+
+
+
+
+
+
+ {props.content}
+
+ );
+ }
+ return ;
+}
+
+const MarkdownBubble = memo(function MarkdownBubble({ content }: { content: string }) {
+ return (
+ {
+ const btn = (event.target as HTMLElement | null)?.closest(".md-copy");
+ if (!btn) return;
+ const pre = btn.parentElement?.querySelector("pre");
+ const text = pre?.textContent ?? "";
+ if (!text) return;
+ void navigator.clipboard.writeText(text).catch(() => undefined);
+ }}
+ />
+ );
+});
+
+type TurnDiff = { checkpointId: string; fileCount: number; path?: string; line?: number };
+
+const CHAT_THREAD_ROW_PX = 140;
+
+export const ChatThread = memo(function ChatThread({
+ threadRef,
+ onScroll,
+ followLatest,
+ session,
+ clusters,
+ messages,
+ viewing,
+ swarmMode,
+ thoughtTraces,
+ hideLifecycleIds,
+ newMarkerId,
+ findHits,
+ findActiveId,
+ turnDiffs,
+ cloneStartId,
+ cloneProgress,
+ buildStartId,
+ imageProgress,
+ waitingGuest,
+ waitingStartNote,
+ workingOnBlank,
+ showTailWorking,
+ workingLabel,
+ thinkingSnippet,
+ lastUserId,
+ turning,
+ outboundId,
+ teamName,
+ teamMembers,
+ hostChat,
+ docker,
+ onOpenPath,
+ onOpenSubagent,
+ onEditLast,
+ onRetryLast,
+ onCopyText,
+ onRemoveSubscription,
+}: {
+ threadRef: RefObject
;
+ onScroll: (event: UIEvent) => void;
+ followLatest: boolean;
+ session: AgentSession;
+ clusters: ChatCluster[];
+ messages: ChatMessage[];
+ viewing: SubagentLive | null;
+ swarmMode: boolean;
+ thoughtTraces: boolean;
+ hideLifecycleIds: ReadonlySet;
+ newMarkerId?: string;
+ findHits: string[];
+ findActiveId?: string;
+ turnDiffs: ReadonlyMap;
+ cloneStartId?: string;
+ cloneProgress?: CloneProgress;
+ buildStartId?: string;
+ imageProgress?: ImageBuildProgress;
+ waitingGuest: boolean;
+ waitingStartNote: boolean;
+ workingOnBlank?: string;
+ showTailWorking: boolean;
+ workingLabel: string;
+ thinkingSnippet: string;
+ lastUserId?: string;
+ turning: boolean;
+ outboundId?: string;
+ teamName?: string;
+ teamMembers: AgentSession[];
+ hostChat: boolean;
+ docker: boolean;
+ onOpenPath: (path: string, line?: number) => void;
+ onOpenSubagent: (agent: SubagentLive) => void;
+ onEditLast: () => void;
+ onRetryLast: () => void;
+ onCopyText: (value: string) => void;
+ onRemoveSubscription: (id: string) => void;
+}) {
+ const swarmWhoBase = { session, teamName, members: teamMembers };
+ const workingWho = swarmMode
+ ? swarmChatWho({ ...swarmWhoBase, preferRunning: true })
+ : { name: "Agent" as string };
+ const findHitSet = useMemo(() => new Set(findHits), [findHits]);
+ const heightsRef = useRef(new Map());
+ const offsetsRef = useRef([0]);
+ const [sizeTick, setSizeTick] = useState(0);
+ const sizes = useMemo(
+ () => clusters.map((block) => heightsRef.current.get(block.message.id) ?? CHAT_THREAD_ROW_PX),
+ // sizeTick re-reads measured heights after paint; clusters identity covers new rows.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [clusters, sizeTick],
+ );
+ const [chatWin, setChatWin] = useState(() =>
+ listWindowFromSizes({
+ scrollTop: 0,
+ clientHeight: 0,
+ sizes: clusters.map(() => CHAT_THREAD_ROW_PX),
+ stickToEnd: true,
+ }),
+ );
+
+ function measurePainted(el: HTMLDivElement | null): boolean {
+ if (!el) return false;
+ const heights = heightsRef.current;
+ let changed = false;
+ for (const node of el.querySelectorAll("[data-thread-row]")) {
+ const id = node.getAttribute("data-thread-row");
+ if (!id) continue;
+ const h = Math.round(node.getBoundingClientRect().height);
+ if (h <= 0) continue;
+ if (heights.get(id) !== h) {
+ heights.set(id, h);
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
+ function syncChatWindow(el: HTMLDivElement | null, stickToEnd?: boolean) {
+ if (!el) return;
+ const next = listWindowFromSizes({
+ scrollTop: el.scrollTop,
+ clientHeight: el.clientHeight,
+ sizes,
+ stickToEnd: stickToEnd ?? (followLatest || isStuckToBottom(el) || el.clientHeight <= 0),
+ });
+ setChatWin((current) => {
+ if (sameListWindow(current, next)) return current;
+ if (current.start === next.start && current.end === next.end) return next;
+ return shouldUpdateListWindow(current, next) ? next : current;
+ });
+ }
+
+ useLayoutEffect(() => {
+ heightsRef.current.clear();
+ offsetsRef.current = [0];
+ setSizeTick((n) => n + 1);
+ setChatWin(
+ listWindowFromSizes({
+ scrollTop: 0,
+ clientHeight: 0,
+ sizes: clusters.map(() => CHAT_THREAD_ROW_PX),
+ stickToEnd: true,
+ }),
+ );
+ // Only reset measurements when switching chats, not on every cluster append.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [session.id]);
+
+ useLayoutEffect(() => {
+ const el = threadRef.current;
+ if (findActiveId) {
+ const idx = clusters.findIndex((block) => block.message.id === findActiveId);
+ if (el && idx >= 0) {
+ const offsets = listSizePrefix(sizes);
+ el.scrollTop = Math.max(0, (offsets[idx] ?? 0) - el.clientHeight / 3);
+ }
+ }
+ const prevOffsets = offsetsRef.current;
+ const nextOffsets = listSizePrefix(sizes);
+ if (
+ el &&
+ el.clientHeight > 0 &&
+ prevOffsets.length > 1 &&
+ !followLatest &&
+ !isStuckToBottom(el)
+ ) {
+ const preserved = listPreserveScrollTop(prevOffsets, nextOffsets, el.scrollTop);
+ if (Math.abs(preserved - el.scrollTop) > 0.5) el.scrollTop = preserved;
+ }
+ offsetsRef.current = nextOffsets;
+ syncChatWindow(el);
+ if (followLatest && !findActiveId) pinThreadToLatest(el);
+ if (measurePainted(el)) setSizeTick((n) => n + 1);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [clusters, sizes, messages.length, session.id, findActiveId, followLatest]);
+
+ useLayoutEffect(() => {
+ if (!followLatest || findActiveId) return;
+ pinThreadToLatest(threadRef.current);
+ }, [followLatest, findActiveId, chatWin.start, chatWin.end, chatWin.height, threadRef]);
+
+ useEffect(() => {
+ const el = threadRef.current;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const ro = new ResizeObserver(() => {
+ if (measurePainted(el)) setSizeTick((n) => n + 1);
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [session.id, threadRef]);
+
+ const painted = clusters.slice(chatWin.start, chatWin.end);
+
+ return (
+
+
{
+ const node = event.currentTarget;
+ syncChatWindow(node, isStuckToBottom(node) || node.clientHeight <= 0);
+ onScroll(event);
+ }}
+ >
+ {messages.length === 0 && !viewing && (
+
+ {swarmMode
+ ? "Say hi — ask what they should own, or give them a first task."
+ : hostChat
+ ? "This chat can read and edit files on this machine."
+ : docker
+ ? "This chat runs in a container. Ask it to clone a repo or run commands."
+ : `This chat owns a ${guestLabel(session.os)} desktop.`}
+
+ )}
+
+ {painted.map((block, paintedIndex) => {
+ const index = chatWin.start + paintedIndex;
+ const blocks = clusters;
+ const m = block.message;
+ const showNew = !viewing && m.id === newMarkerId;
+ if (hideLifecycleIds.has(m.id)) {
+ return showNew ? (
+
+
+
+ ) : null;
+ }
+ const tools = block.kind === "turn" ? block.tools : [];
+ if (
+ m.role === "assistant" &&
+ isBlankAssistant(m) &&
+ !(thoughtTraces && thinkingTraceText(m)) &&
+ !tools.length &&
+ m.id !== workingOnBlank
+ )
+ return showNew ? (
+
+
+
+ ) : null;
+ const calls = m.role === "assistant" || m.role === "tool" ? turnToolCalls(m, tools) : [];
+ const turnDiff =
+ m.role === "assistant" && m.id !== workingOnBlank && !viewing
+ ? turnDiffs.get(m.id)
+ : undefined;
+ const visible = operatorVisibleText(m.role, m.content);
+ const leftoverAttaches =
+ m.role !== "tool" && m.attachments?.length
+ ? leftoverAttachChips(visible || m.content, m.attachments)
+ : [];
+ const whoInfo =
+ m.role === "user"
+ ? { name: "You" }
+ : m.role === "system"
+ ? { name: "Locus" }
+ : swarmMode
+ ? swarmChatWho({
+ ...swarmWhoBase,
+ speaker: m.speaker,
+ preferRunning: m.id === workingOnBlank,
+ })
+ : { name: "Agent" };
+ const who = whoInfo.name;
+ const teamEventLabel = m.teamEvent ? TEAM_EVENT_LABELS[m.teamEvent] : undefined;
+ const cloneCard = m.id === cloneStartId && cloneProgress ? cloneProgress : undefined;
+ const buildCard = m.id === buildStartId && imageProgress ? imageProgress : undefined;
+ const prev = index > 0 ? blocks[index - 1]?.message : undefined;
+ const continued =
+ swarmMode &&
+ prev &&
+ !m.compact &&
+ !prev.compact &&
+ (prev.role === "tool" ? "assistant" : prev.role) ===
+ (m.role === "tool" ? "assistant" : m.role) &&
+ (prev.speaker?.displayName ?? "") === (m.speaker?.displayName ?? "");
+ return (
+
+ {showNew ?
: null}
+
+ {m.compact || continued ? null : (
+
+ {whoInfo.speaker ? (
+
+ ) : m.speaker ? (
+
+ ) : null}
+ {who}
+ {teamEventLabel ? (
+
{teamEventLabel}
+ ) : null}
+ {m.steer ?
Steer : null}
+ {m.role === "user" && visible.trim() ? (
+
+ {m.id === lastUserId && !turning && !viewing ? (
+ <>
+
+ Edit
+
+
+ Retry
+
+ >
+ ) : null}
+ onCopyText(visible)}
+ >
+ Copy
+
+
+ ) : null}
+ {m.role === "assistant" && visible.trim() ? (
+
+ onCopyText(visible)}
+ >
+ Copy
+
+
+ ) : null}
+
+ )}
+ {m.id === workingOnBlank ? (
+ <>
+
+ {thoughtTraces ?
: null}
+ {viewing ? null : (
+
row.status === "running")}
+ onOpen={onOpenSubagent}
+ />
+ )}
+ >
+ ) : m.role === "tool" ? null : buildCard ? (
+
+ ) : cloneCard ? (
+
+ ) : m.compact ? (
+
+ {formatCompactNotice(m.compact)}
+
+ ) : (
+ <>
+ {thoughtTraces ? : null}
+ {visible ? (
+
+ ) : null}
+ >
+ )}
+ {calls.length ? (
+
+ ) : null}
+ {m.role !== "tool" && screenshotSrc(session.id, m) ? (
+
+ ) : null}
+ {leftoverAttaches.length ? (
+
+ {leftoverAttaches.map((item) => (
+
+ {item.name}
+
+ ))}
+
+ ) : null}
+ {m.role !== "tool" && m.artifactName ? (
+
+ {m.artifactName}
+
+ ) : null}
+ {m.role !== "tool" && m.recordingName ? (
+
+ Guest recording
+
+ ) : null}
+ {turnDiff ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+ {session.lastCompact &&
+ !messages.some((row) => row.compact && !isHiddenChatMessage(row)) ? (
+
+
+ {formatCompactNotice(session.lastCompact)}
+
+
+ ) : null}
+ {showTailWorking ? (
+
+
+ {workingWho.speaker ? (
+
+ ) : null}
+ {workingWho.name}
+
+
+ {viewing ? null : (
+
row.status === "running")}
+ onOpen={onOpenSubagent}
+ />
+ )}
+
+ ) : null}
+ {!turning && !viewing ?
: null}
+ {session.taskList?.length && !viewing ? (
+
+ ) : null}
+ {session.subscriptions?.length && !viewing ? (
+
+ ) : null}
+
+
+
+ );
+});
diff --git a/apps/desktop/src/components/chat/ChatToolActivity.tsx b/apps/desktop/src/components/chat/ChatToolActivity.tsx
index a6ee8ef..3a46272 100644
--- a/apps/desktop/src/components/chat/ChatToolActivity.tsx
+++ b/apps/desktop/src/components/chat/ChatToolActivity.tsx
@@ -28,7 +28,9 @@ import { api } from "@/lib/api";
import { prUrlFromToolOutput } from "@/lib/markdown";
import { screenshotSrc } from "./GuestTimeline";
import { useDialogFocus } from "@/lib/useDialogFocus";
+import { showNotice } from "@/lib/notice";
import { IconClose } from "@/components/layout/Icons";
+import { BusyButton, InlineError } from "@/components/layout/Skeleton";
import { ToolPatch } from "./PatchCard";
import { SubagentCard } from "./SubagentCard";
@@ -246,32 +248,34 @@ function ApprovalRow({ sessionId, callId }: { sessionId: string; callId: string
return (
-
void resolve("approved")}
>
- {working === "approved" ? "Approving…" : "Approve"}
-
-
+ void resolve("approved", true)}
>
Always allow
-
-
+ void resolve("denied")}
>
- {working === "denied" ? "Denying…" : "Deny"}
-
- {error ?
{error}
: null}
+ Deny
+
+ {error ?
: null}
);
}
@@ -417,7 +421,7 @@ function SlackPostDraft({ sessionId, call }: { sessionId: string; call: ToolCall
decision === "approved" ? text : undefined,
);
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not resolve draft");
+ showNotice(err instanceof Error ? err.message : "Could not resolve draft");
} finally {
setWorking(false);
}
@@ -481,7 +485,7 @@ function BrainBriefDraft({ sessionId, call }: { sessionId: string; call: ToolCal
decision === "approved" ? text : undefined,
);
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not resolve briefing");
+ showNotice(err instanceof Error ? err.message : "Could not resolve briefing");
} finally {
setWorking(false);
}
diff --git a/apps/desktop/src/components/chat/CompareBanner.tsx b/apps/desktop/src/components/chat/CompareBanner.tsx
index 22d6b51..5846555 100644
--- a/apps/desktop/src/components/chat/CompareBanner.tsx
+++ b/apps/desktop/src/components/chat/CompareBanner.tsx
@@ -3,6 +3,8 @@ import { useStore } from "@/state/store";
import { pickCompareWinner } from "@/lib/compare";
import { sessionNeedsYou } from "@/lib/mobileShell";
import { api } from "@/lib/api";
+import { askAlert } from "@/lib/dialog";
+import { showUndoNotice } from "@/lib/notice";
export function CompareBanner({ session }: { session: AgentSession }) {
const { state, dispatch } = useStore();
@@ -12,11 +14,34 @@ export function CompareBanner({ session }: { session: AgentSession }) {
async function keep(winner: AgentSession, losers: AgentSession[]) {
try {
const kept = await pickCompareWinner(winner, ...losers);
- for (const loser of losers) {
+ const archived = losers.filter((row) => row.id !== winner.id);
+ for (const loser of archived) {
dispatch({ type: "session", session: { ...loser, archived: true, compare: undefined } });
}
dispatch({ type: "session", session: kept });
dispatch({ type: "select", id: kept.id });
+ if (archived.length) {
+ showUndoNotice(
+ archived.length === 1
+ ? `Archived ${archived[0]!.title}`
+ : `Archived ${archived.length} chats`,
+ () => {
+ void (async () => {
+ for (const loser of archived) {
+ try {
+ const restored = await api.patchSession(loser.id, {
+ archived: false,
+ compare: loser.compare ?? null,
+ });
+ dispatch({ type: "session", session: restored });
+ } catch {
+ dispatch({ type: "session", session: { ...loser, archived: false } });
+ }
+ }
+ })();
+ },
+ );
+ }
} catch {
/* stay in compare */
}
@@ -54,7 +79,10 @@ export function CompareBanner({ session }: { session: AgentSession }) {
const text = rows
.map((row) => `## ${row.role} ${row.branch ?? ""}\n${row.diff || "(empty)"}`)
.join("\n\n");
- window.alert(text.slice(0, 4000) || "No diffs yet");
+ void askAlert({
+ title: "Worker diffs",
+ body: text.slice(0, 4000) || "No diffs yet",
+ });
});
}}
>
@@ -121,7 +149,10 @@ export function CompareBanner({ session }: { session: AgentSession }) {
const text = rows
.map((row) => `## ${row.role} ${row.branch ?? ""}\n${row.diff || "(empty)"}`)
.join("\n\n");
- window.alert(text.slice(0, 4000) || "No diffs yet");
+ void askAlert({
+ title: "Worker diffs",
+ body: text.slice(0, 4000) || "No diffs yet",
+ });
});
}}
>
@@ -170,7 +201,10 @@ export function CompareBanner({ session }: { session: AgentSession }) {
const text = rows
.map((row) => `## ${row.role} ${row.branch ?? row.id}\n${row.diff || "(empty)"}`)
.join("\n\n");
- window.alert(text.slice(0, 4000) || "No diffs yet");
+ void askAlert({
+ title: "Worker diffs",
+ body: text.slice(0, 4000) || "No diffs yet",
+ });
});
}}
>
diff --git a/apps/desktop/src/components/chat/CompareModal.tsx b/apps/desktop/src/components/chat/CompareModal.tsx
index efd69d1..e133120 100644
--- a/apps/desktop/src/components/chat/CompareModal.tsx
+++ b/apps/desktop/src/components/chat/CompareModal.tsx
@@ -1,8 +1,8 @@
import { otherCompareModel, type AgentSession } from "@locus/shared";
-import { useRef, useState } from "react";
+import { useState } from "react";
import { useStore } from "@/state/store";
import { startModelCompare } from "@/lib/compare";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { Modal } from "@/components/layout/Modal";
import { ModelPicker } from "@/components/settings/ModelPicker";
export function CompareModal({ session, onClose }: { session: AgentSession; onClose: () => void }) {
@@ -13,8 +13,6 @@ export function CompareModal({ session, onClose }: { session: AgentSession; onCl
const [model, setModel] = useState(suggested.model);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
- const dialogRef = useRef(null);
- useDialogFocus(true, onClose, dialogRef);
async function start() {
setBusy(true);
@@ -33,47 +31,34 @@ export function CompareModal({ session, onClose }: { session: AgentSession; onCl
}
return (
-
-
e.stopPropagation()}
- >
-
Compare models
-
- Fork this guest, run the last prompt on a second model, then pick a winner.
-
-
{
- setProviderId(next.providerId);
- setModel(next.model);
- }}
- />
- {error && (
-
- {error}
-
- )}
-
-
- Cancel
-
- void start()}
- >
- {busy ? "Starting…" : "Start compare"}
-
-
+
+ Compare models
+
+ Fork this guest, run the last prompt on a second model, then pick a winner.
+
+ {
+ setProviderId(next.providerId);
+ setModel(next.model);
+ }}
+ />
+ {error && {error}
}
+
+
+ Cancel
+
+ void start()}
+ >
+ {busy ? "Starting…" : "Start compare"}
+
-
+
);
}
diff --git a/apps/desktop/src/components/chat/ComposerFiles.tsx b/apps/desktop/src/components/chat/ComposerFiles.tsx
index 08d51fb..74781e9 100644
--- a/apps/desktop/src/components/chat/ComposerFiles.tsx
+++ b/apps/desktop/src/components/chat/ComposerFiles.tsx
@@ -23,6 +23,7 @@ import {
pointInRects,
} from "@/lib/composerPaste";
import { IconClose, IconPlus } from "@/components/layout/Icons";
+import { Tooltip } from "@/components/layout/Tooltip";
import { placeMenuPop } from "@/components/layout/OverflowMenu";
export const COMPOSER_FILE_CAP = 4;
@@ -63,14 +64,15 @@ export function ComposerAddButton(props: {
props.onPick(picked);
}}
/>
-
input.current?.click()}
- >
-
-
+
+ input.current?.click()}
+ >
+
+
+
>
);
}
diff --git a/apps/desktop/src/components/chat/ComposerMoreOptions.tsx b/apps/desktop/src/components/chat/ComposerMoreOptions.tsx
index c2fe8b6..5d19d8b 100644
--- a/apps/desktop/src/components/chat/ComposerMoreOptions.tsx
+++ b/apps/desktop/src/components/chat/ComposerMoreOptions.tsx
@@ -1,5 +1,6 @@
import type { NamedEnvironment, NetworkJail } from "@locus/shared";
import { formatNetworkJail } from "@locus/shared";
+import { Select } from "@/components/layout/Select";
export type ComposerMoreOptionsProps = {
hostChat: boolean;
@@ -69,24 +70,21 @@ export function ComposerMoreOptions(props: ComposerMoreOptionsProps) {
Named environment
-
{
- const id = e.target.value;
+ options={[
+ { value: "", label: "Default" },
+ ...props.environments.map((env) => ({ value: env.id, label: env.name })),
+ ]}
+ onChange={(id) => {
props.onNamedEnvChange(
id,
props.environments.find((row) => row.id === id),
);
}}
- >
- Default
- {props.environments.map((env) => (
-
- {env.name}
-
- ))}
-
+ />
{envLabel}
diff --git a/apps/desktop/src/components/chat/ContextMeter.test.tsx b/apps/desktop/src/components/chat/ContextMeter.test.tsx
index a7e30ad..1b9843b 100644
--- a/apps/desktop/src/components/chat/ContextMeter.test.tsx
+++ b/apps/desktop/src/components/chat/ContextMeter.test.tsx
@@ -111,6 +111,25 @@ describe("ContextMeter", () => {
});
}
+ it("hides the chip when occupancy is still 0%", () => {
+ render(
+
,
+ );
+ expect(host.querySelector("[aria-label='Context usage']")).toBeNull();
+ });
+
it("opens a card with a stable width and unclipped token counts", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
diff --git a/apps/desktop/src/components/chat/ContextMeter.tsx b/apps/desktop/src/components/chat/ContextMeter.tsx
index d4f7ee4..872093d 100644
--- a/apps/desktop/src/components/chat/ContextMeter.tsx
+++ b/apps/desktop/src/components/chat/ContextMeter.tsx
@@ -157,6 +157,8 @@ export function ContextMeter(props: {
const width =
place?.width ??
contextPopWidth(typeof window === "undefined" ? CONTEXT_POP_WIDTH : window.innerWidth);
+ if (percent === "0%") return null;
+
const popStyle: CSSProperties = {
position: "fixed",
width,
diff --git a/apps/desktop/src/components/chat/NewSession.tsx b/apps/desktop/src/components/chat/NewSession.tsx
index 77f2840..6cf7314 100644
--- a/apps/desktop/src/components/chat/NewSession.tsx
+++ b/apps/desktop/src/components/chat/NewSession.tsx
@@ -12,11 +12,10 @@ import type {
VisionMode,
} from "@locus/shared";
import {
- AUTONOMY_TIERS,
DEFAULT_DOCKER_IMAGE,
applyNamedEnvironment,
defaultNetworkJail,
- formatAutonomy,
+ isNetworkJail,
latestGoldenImage,
matchRepoHardware,
parseAutonomyTier,
@@ -31,8 +30,16 @@ import {
workerIsMetered,
workerWhereLabel,
} from "@locus/shared";
-import { useEffect, useMemo, useRef, useState } from "react";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { useEffect, useMemo, useState } from "react";
+import { FormTabs, Select } from "@/components/layout/Select";
+import {
+ AUTONOMY_SELECT_OPTIONS,
+ JAIL_SELECT_OPTIONS,
+ LIVE_CHROME_SELECT_OPTIONS,
+ VISION_SELECT_OPTIONS,
+ WEB_SEARCH_SELECT_OPTIONS,
+} from "@/lib/formOptions";
+import { Modal } from "@/components/layout/Modal";
import { suggestedHardware } from "@/lib/hardware";
import {
loadLastAgent,
@@ -80,8 +87,6 @@ export function NewSessionModal() {
const [startVm, setStartVm] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState
(null);
- const dialogRef = useRef(null);
- useDialogFocus(true, () => dispatch({ type: "new", open: false }), dialogRef);
const [hwDirty, setHwDirty] = useState(false);
const [cpuCores, setCpuCores] = useState(4);
const [memoryMb, setMemoryMb] = useState(4096);
@@ -99,6 +104,7 @@ export function NewSessionModal() {
const [netDirty, setNetDirty] = useState(false);
const [namedEnvId, setNamedEnvId] = useState("");
const [branchLock, setBranchLock] = useState(false);
+ const [advancedTab, setAdvancedTab] = useState<"agent" | "tools" | "runtime">("agent");
const [images, setImages] = useState([]);
const selectedWorker = workers.find((row) => row.id === backendId);
@@ -138,7 +144,7 @@ export function NewSessionModal() {
if (nextRuntime === "host" && !policyAllowsHostAgents(state.config?.managedPolicy)) {
nextRuntime = "docker";
}
- const workerId = nextRuntime === "vm" ? "" : nextWorker;
+ const workerId = nextWorker;
const nextOs =
nextRuntime === "vm"
? pickConfiguredOs(state.config?.templates, state.newOs ?? last.os)
@@ -234,7 +240,7 @@ export function NewSessionModal() {
async function submit() {
setError(null);
if (repo.trim() && !parsed) {
- setError("Enter a GitHub, GitLab, or Bitbucket URL");
+ setError("Enter a GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps URL");
return;
}
const override: HardwareOverride | undefined =
@@ -313,229 +319,353 @@ export function NewSessionModal() {
}
return (
- dispatch({ type: "new", open: false })}>
-
e.stopPropagation()}
- >
-
New agent
-
applyEnv(env)}
- snapshot={{
- runtime,
- os,
- templateId: templateId || undefined,
- dockerImage: runtime === "docker" ? dockerImage : undefined,
- imageId: imageId || undefined,
- network: runtime === "host" ? undefined : network,
- maxUsd: parseSpendUsd(maxUsd),
- placement: backendId ? "worker" : "local",
- backendId: backendId || undefined,
- }}
- />
- {
- setBackendId(id);
- if (id) {
- if (runtime === "vm") setRuntime("docker");
- setVision("off");
- setStartVm(true);
- setImageId("");
- }
- }}
- />
- {
- setRuntime(next);
- if (next === "vm") setBackendId("");
- const nextOs =
- next !== "vm" && (os === "windows-11" || os === "windows-10") ? "ubuntu" : os;
- if (nextOs !== os) setOs(nextOs);
- setVision(next === "vm" ? "latest" : "off");
- setStartVm(next !== "host" || Boolean(backendId));
- if (next === "host") setAllowedTools("");
- setImageId(
- next === "host" || backendId
- ? ""
- : (latestGoldenImage(images, nextOs, next === "docker" ? "docker" : "vm")?.id ??
- ""),
- );
- }}
- />
-
- {backendId
- ? `${runtime === "host" ? "The host workspace" : "The container"} runs on ${selectedWorker ? workerWhereLabel(selectedWorker) : "the worker"}. Chat and keys stay on this machine.${
- selectedWorker && workerIsMetered(selectedWorker)
- ? " Stop destroys the guest. Idle TTL applies on the metered VM."
- : ""
- }`
- : runtime === "host"
- ? "A coding agent on this machine. No VM or container — files, shell, and git run here (a security exception)."
+ dispatch({ type: "new", open: false })}>
+ New agent
+ applyEnv(env)}
+ snapshot={{
+ runtime,
+ os,
+ templateId: templateId || undefined,
+ dockerImage: runtime === "docker" ? dockerImage : undefined,
+ imageId: imageId || undefined,
+ network: runtime === "host" ? undefined : network,
+ maxUsd: parseSpendUsd(maxUsd),
+ placement: backendId ? "worker" : "local",
+ backendId: backendId || undefined,
+ }}
+ />
+ {
+ setBackendId(id);
+ if (id) {
+ setVision("off");
+ setStartVm(true);
+ setImageId("");
+ }
+ }}
+ />
+ {
+ setRuntime(next);
+ const nextOs =
+ next !== "vm" && (os === "windows-11" || os === "windows-10") ? "ubuntu" : os;
+ if (nextOs !== os) setOs(nextOs);
+ setVision(next === "vm" ? "latest" : "off");
+ setStartVm(next !== "host" || Boolean(backendId));
+ if (next === "host") setAllowedTools("");
+ setImageId(
+ next === "host" || backendId
+ ? ""
+ : (latestGoldenImage(images, nextOs, next === "docker" ? "docker" : "vm")?.id ?? ""),
+ );
+ }}
+ />
+
+ {backendId
+ ? `${runtime === "host" ? "The host workspace" : runtime === "vm" ? "The VM" : "The container"} runs on ${selectedWorker ? workerWhereLabel(selectedWorker) : "the worker"}. Chat and keys stay on this machine.${
+ selectedWorker && workerIsMetered(selectedWorker)
+ ? " Stop destroys the guest. Idle TTL applies on the metered VM."
+ : ""
+ }`
+ : runtime === "host"
+ ? "A coding agent on this machine. No VM or container — files, shell, and git run here (a security exception)."
+ : runtime === "docker"
+ ? "Daily path: Docker + locus/agent. Windows guests need a QEMU VM."
+ : "QEMU is the Windows-desktop / installer-QA path. Hardware overrides are VM-only."}
+
+
+
Task
+
setPrompt(e.target.value)}
+ placeholder={
+ runtime === "host"
+ ? "What should we talk about? Sent as the first message."
: runtime === "docker"
- ? "Daily path: Docker + locus/agent. Windows guests need a QEMU VM."
- : "QEMU is the Windows-desktop / installer-QA path. Hardware overrides are VM-only."}
-
-
- Task
- setPrompt(e.target.value)}
- placeholder={
- runtime === "host"
- ? "What should we talk about? Sent as the first message."
- : runtime === "docker"
- ? "What should this container do? Sent as the first message after the container starts."
- : "What should this machine do? Sent as the first message after the VM starts."
- }
- />
-
- {runtime === "host" ? (
-
- ) : null}
- {runtime === "docker" ? (
- <>
-
- {images.some((img) => img.runtime === "docker") && !backendId ? (
-
- Workspace image
- setImageId(e.target.value)}>
- Empty /workspace
- {images
- .filter((img) => img.runtime === "docker")
- .map((img) => (
-
- {img.name}
-
- ))}
-
-
- ) : null}
- >
- ) : runtime === "vm" ? (
- <>
- {
- setOs(next);
- const defaults = resolveAgentDefaults(state.config, next, loadLastAgent());
- setTemplateId(defaults.templateId);
- setHwDirty(false);
- setImageId(latestGoldenImage(images, next)?.id ?? "");
- }}
- />
-
- >
- ) : null}
- {
- setProviderId(next.providerId);
- setModel(next.model);
- setReasoningEffort(undefined);
- }}
- />
-
-
- More options
-
- Jail, spend cap, environment extras, and hardware. Runtime and repo stay fixed after
- create; branch lock is optional below.
-
- Environment
- {runtime === "vm" ? (
+
+ {runtime === "host" ? (
+
+ ) : null}
+ {runtime === "docker" ? (
+ <>
+
+ {images.some((img) => img.runtime === "docker") && !backendId ? (
- Template
- {
- setTemplateId(e.target.value);
- setHwDirty(false);
- }}
- >
- {osTemplates.map((t) => (
-
- {t.name}
-
- ))}
-
+ Workspace image
+ img.runtime === "docker")
+ .map((img) => ({ value: img.id, label: img.name })),
+ ]}
+ onChange={setImageId}
+ />
) : null}
- {
- const hadRepo = Boolean(repoId);
- setRepoId(id);
- if (!id) setBranchLock(false);
- else if (!hadRepo) setBranchLock(true);
- }}
- onExtraRepoIds={setExtraRepoIds}
- onUrl={(next) => {
- setRepo(next);
+ >
+ ) : runtime === "vm" ? (
+ <>
+ {
+ setOs(next);
+ const defaults = resolveAgentDefaults(state.config, next, loadLastAgent());
+ setTemplateId(defaults.templateId);
setHwDirty(false);
+ setImageId(latestGoldenImage(images, next)?.id ?? "");
}}
- onRef={setRef}
/>
- {repoId ? (
+
+ >
+ ) : null}
+ {
+ setProviderId(next.providerId);
+ setModel(next.model);
+ setReasoningEffort(undefined);
+ }}
+ />
+
+
+ More options
+ Repo, jail, tools, and hardware. Runtime stays fixed after create.
+
+ {advancedTab === "agent" ? (
+ <>
+ {
+ const hadRepo = Boolean(repoId);
+ setRepoId(id);
+ if (!id) setBranchLock(false);
+ else if (!hadRepo) setBranchLock(true);
+ }}
+ onExtraRepoIds={setExtraRepoIds}
+ onUrl={(next) => {
+ setRepo(next);
+ setHwDirty(false);
+ }}
+ onRef={setRef}
+ />
+ {repoId ? (
+
+ Branch
+ row.id === repoId)?.checkout.ref}
+ onChange={setRef}
+ />
+
+ ) : (
+
+ Git ref (optional)
+ setRef(e.target.value)} placeholder="main" />
+
+ )}
+
+ setBranchLock(e.target.checked)}
+ />
+ Lock branch after create
+
+ {runtime === "host" ? null : (
+
+
{runtime === "docker" ? "Container network" : "Guest network"}
+
{
+ if (!isNetworkJail(value)) return;
+ setNetDirty(true);
+ setNetwork(value);
+ }}
+ />
+ {runtime === "docker" ? (
+
+ Restricted sets HTTP(S)_PROXY to an allowlist CONNECT proxy (on this machine, or
+ on the worker). None uses --network none.
+
+ ) : null}
+
+ )}
+
+
+ Autonomy
+ setAutonomy(parseAutonomyTier(value))}
+ />
+
+
+
+ {selectedWorker && workerIsMetered(selectedWorker)
+ ? "Spend cap (USD, LLM + VM)"
+ : "Spend cap (USD)"}
+
+ setMaxUsd(e.target.value)}
+ />
+
+
- Branch
- row.id === repoId)?.checkout.ref}
- onChange={setRef}
+ Instructions (optional)
+ setInstructions(e.target.value)}
+ placeholder={
+ runtime === "host"
+ ? "Prefer short answers, cite sources, …"
+ : runtime === "docker"
+ ? "Always work in /workspace, commit on a feature branch, …"
+ : "Always use /mnt/host, commit on a feature branch, …"
+ }
/>
- ) : (
- Git ref (optional)
- setRef(e.target.value)} placeholder="main" />
+ Playbook (optional)
+ setPlaybook(e.target.value)}
+ placeholder="Project SOP injected every turn — how we test, branch, and ship."
+ />
- )}
-
- setBranchLock(e.target.checked)}
- />
- Lock branch after create
-
- {runtime === "vm" && (
- <>
+ >
+ ) : null}
+ {advancedTab === "tools" ? (
+ <>
+ {runtime === "host" ? null : (
+
+ Vision
+ setVision(value as VisionMode)}
+ />
+
+ )}
+
+ Web search
+ setWebSearch(value === "on")}
+ />
+
+ {runtime === "host" ? (
+
+ Use my Chrome
+ setLiveChrome(value === "on")}
+ />
+
+ ) : null}
+
+ Allowed tools (optional)
+ setAllowedTools(e.target.value)}
+ placeholder={
+ runtime === "host"
+ ? "git_pr, git_pr_list, websearch"
+ : runtime === "docker"
+ ? "exec, screenshot, file_read, git_commit, git_push, git_pr"
+ : "exec, screenshot, git_commit, git_push, git_pr"
+ }
+ />
+
+ >
+ ) : null}
+ {advancedTab === "runtime" ? (
+ <>
+ {runtime === "vm" ? (
+
+ Template
+ ({ value: t.id, label: t.name }))}
+ onChange={(value) => {
+ setTemplateId(value);
+ setHwDirty(false);
+ }}
+ />
+
+ ) : null}
+ {runtime === "vm" ? (
Golden image
-
setImageId(e.target.value)}>
- Fresh disk from ISO / cloud image
- {images
- .filter((img) => img.os === os && img.runtime !== "docker")
- .map((img) => (
-
- {img.name}
- {img.id === latestGoldenImage(images, os)?.id ? " (default)" : ""}
-
- ))}
-
+
img.os === os && img.runtime !== "docker")
+ .map((img) => ({
+ value: img.id,
+ label:
+ img.id === latestGoldenImage(images, os)?.id
+ ? `${img.name} (default)`
+ : img.name,
+ })),
+ ]}
+ onChange={setImageId}
+ />
{images.some((img) => img.os === os && img.runtime !== "docker") ? (
Boots a copy of a saved disk so you skip the next OS install.
@@ -547,138 +677,8 @@ export function NewSessionModal() {
)}
- >
- )}
- Jail & spend
- {runtime === "host" ? null : (
-
-
{runtime === "docker" ? "Container network" : "Guest network"}
-
{
- setNetDirty(true);
- setNetwork(e.target.value as NetworkJail);
- }}
- >
- Open — full internet (needed for first-boot apt)
- Restricted — forge and providers only
- None — no guest internet
-
- {runtime === "docker" ? (
-
- Restricted sets HTTP(S)_PROXY to an allowlist CONNECT proxy (on this machine, or
- on the worker). None uses --network none.
-
- ) : null}
-
- )}
-
-
- Autonomy
- setAutonomy(parseAutonomyTier(e.target.value))}
- >
- {AUTONOMY_TIERS.map((tier) => (
-
- {formatAutonomy(tier)}
-
- ))}
-
-
-
-
- {selectedWorker && workerIsMetered(selectedWorker)
- ? "Spend cap (USD, LLM + VM)"
- : "Spend cap (USD)"}
-
- setMaxUsd(e.target.value)}
- />
-
-
-
- Instructions (optional)
- setInstructions(e.target.value)}
- placeholder={
- runtime === "host"
- ? "Prefer short answers, cite sources, …"
- : runtime === "docker"
- ? "Always work in /workspace, commit on a feature branch, …"
- : "Always use /mnt/host, commit on a feature branch, …"
- }
- />
-
-
- Playbook (optional)
- setPlaybook(e.target.value)}
- placeholder="Project SOP injected every turn — how we test, branch, and ship."
- />
-
- {runtime === "host" ? null : (
-
- Vision
- setVision(e.target.value as VisionMode)}>
-
- Latest screenshot — model sees the guest after GUI tools
-
- Off — text and accessibility trees only
-
-
- )}
-
- Web search
- setWebSearch(e.target.value === "on")}
- >
- Off — model cannot query the public web from the host
- On — model can call websearch on the host
-
-
- {runtime === "host" ? (
-
- Use my Chrome
- setLiveChrome(e.target.value === "on")}
- >
- Off — isolated host-chrome profile
- On — drive the Chrome tabs already open on this machine
-
-
- ) : null}
-
- Allowed tools (optional)
- setAllowedTools(e.target.value)}
- placeholder={
- runtime === "host"
- ? "git_pr, git_pr_list, websearch"
- : runtime === "docker"
- ? "exec, screenshot, file_read, git_commit, git_push, git_pr"
- : "exec, screenshot, git_commit, git_push, git_pr"
- }
- />
-
- {runtime === "vm" && (
- <>
- Hardware
+ ) : null}
+ {runtime === "vm" ? (
- >
- )}
- {runtime === "host" ? null : (
-
- setStartVm(e.target.checked)}
- />
- {runtime === "docker"
- ? "Start the container immediately"
- : "Start the VM immediately"}
-
- )}
-
- {error && (
-
- {error}
-
- )}
-
- dispatch({ type: "new", open: false })}>
- Cancel
-
- void submit()}
- >
- {busy ? "Creating…" : runtime === "host" ? "Start chat" : "Create agent"}
-
-
+ ) : null}
+ {runtime === "host" ? null : (
+
+ setStartVm(e.target.checked)}
+ />
+ {runtime === "docker"
+ ? "Start the container immediately"
+ : "Start the VM immediately"}
+
+ )}
+ >
+ ) : null}
+
+ {error && {error}
}
+
+ dispatch({ type: "new", open: false })}>
+ Cancel
+
+ void submit()}
+ >
+ {busy ? "Creating…" : runtime === "host" ? "Start chat" : "Create agent"}
+
-
+
);
}
diff --git a/apps/desktop/src/components/chat/PlanCard.tsx b/apps/desktop/src/components/chat/PlanCard.tsx
index 23f0eec..aa50807 100644
--- a/apps/desktop/src/components/chat/PlanCard.tsx
+++ b/apps/desktop/src/components/chat/PlanCard.tsx
@@ -12,6 +12,7 @@ import {
type SessionPlan,
} from "@locus/shared";
import { api } from "@/lib/api";
+import { showNotice } from "@/lib/notice";
import { useStore } from "@/state/store";
function stepStatus(item: PlanItem): PlanItem["status"] {
@@ -42,7 +43,7 @@ export function PlanCard({ session }: { session: AgentSession }) {
async function pinToBoard() {
const boardId = state.selectedBoardId ?? state.boards.find((row) => !row.archived)?.id;
if (!boardId) {
- window.alert("Open or create a Board first, then pin the plan onto a frame.");
+ showNotice("Open or create a Board first, then pin the plan onto a frame.");
return;
}
try {
@@ -71,12 +72,12 @@ export function PlanCard({ session }: { session: AgentSession }) {
frameId = board.objects.find((row) => row.type === "frame")?.id;
}
if (!frameId) {
- window.alert("Could not find a frame on the board");
+ showNotice("Could not find a frame on the board");
return;
}
await commitPin(boardId, frameId);
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not pin plan");
+ showNotice(err instanceof Error ? err.message : "Could not pin plan");
}
}
@@ -87,7 +88,7 @@ export function PlanCard({ session }: { session: AgentSession }) {
dispatch({ type: "selectBoard", id: boardId });
setPinFrames(null);
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not pin plan");
+ showNotice(err instanceof Error ? err.message : "Could not pin plan");
}
}
diff --git a/apps/desktop/src/components/chat/ReplayModal.tsx b/apps/desktop/src/components/chat/ReplayModal.tsx
index 844b164..1bdb52d 100644
--- a/apps/desktop/src/components/chat/ReplayModal.tsx
+++ b/apps/desktop/src/components/chat/ReplayModal.tsx
@@ -1,15 +1,13 @@
-import { useEffect, useRef, useState } from "react";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { useEffect, useState } from "react";
import { canReplay, replayFrames, type AgentSession } from "@locus/shared";
import { DesktopOverlay } from "@/components/layout/DesktopOverlay";
+import { Modal } from "@/components/layout/Modal";
export function ReplayModal({ session, onClose }: { session: AgentSession; onClose: () => void }) {
const frames = replayFrames(session.id, session.messages);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(true);
const frame = frames[index];
- const dialogRef = useRef
(null);
- useDialogFocus(true, onClose, dialogRef);
useEffect(() => {
if (!playing || frames.length < 2) return;
@@ -25,71 +23,54 @@ export function ReplayModal({ session, onClose }: { session: AgentSession; onClo
return () => clearInterval(timer);
}, [playing, frames.length]);
- useEffect(() => {
- function onKey(event: KeyboardEvent) {
- if (event.key === "Escape") onClose();
- }
- document.addEventListener("keydown", onKey);
- return () => document.removeEventListener("keydown", onKey);
- }, [onClose]);
-
if (!canReplay(session.messages) || !frame) return null;
return (
-
-
event.stopPropagation()}
- >
-
Replay
-
- {frame.who}
- {frame.content ? ` · ${frame.content.slice(0, 80)}` : ""}
-
-
- {frame.screenshotBase64 ? (
-
- ) : frame.screenshotName ? (
-
- ) : (
-
No screenshot at this step
- )}
- {frame.overlay ?
: null}
-
-
{
- setPlaying(false);
- setIndex(Number(event.target.value));
- }}
- />
-
-
- {index + 1} / {frames.length}
-
- setPlaying((value) => !value)}
- >
- {playing ? "Pause" : "Play"}
-
-
- Close
-
-
+
+ Replay
+
+ {frame.who}
+ {frame.content ? ` · ${frame.content.slice(0, 80)}` : ""}
+
+
+ {frame.screenshotBase64 ? (
+
+ ) : frame.screenshotName ? (
+
+ ) : (
+
No screenshot at this step
+ )}
+ {frame.overlay ?
: null}
+
+ {
+ setPlaying(false);
+ setIndex(Number(event.target.value));
+ }}
+ />
+
+
+ {index + 1} / {frames.length}
+
+ setPlaying((value) => !value)}
+ >
+ {playing ? "Pause" : "Play"}
+
+
+ Close
+
-
+
);
}
diff --git a/apps/desktop/src/components/chat/SessionSettings.tsx b/apps/desktop/src/components/chat/SessionSettings.tsx
index f2645c3..1aebaa5 100644
--- a/apps/desktop/src/components/chat/SessionSettings.tsx
+++ b/apps/desktop/src/components/chat/SessionSettings.tsx
@@ -15,7 +15,8 @@ import {
sessionWebSearch,
configuredToolsForRuntime,
AUTONOMY_TIERS,
- formatAutonomy,
+ formatAutonomyShort,
+ formatAutonomyHint,
parseAutonomyTier,
sessionPlacement,
workerIsMetered,
@@ -43,17 +44,18 @@ import {
type VisionMode,
type BrainMemory,
} from "@locus/shared";
-import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
+import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { useStore } from "@/state/store";
import { CodexReasoningPicker, ModelPicker } from "@/components/settings/ModelPicker";
+import { Select } from "@/components/layout/Select";
import { SubagentPicker } from "./SubagentPicker";
import { DesignPalettePicker } from "@/components/design/DesignPalettePicker";
import { DesignSystemPicker } from "@/components/design/DesignSystemPicker";
import { AssetStylePicker } from "@/components/assets/AssetStylePicker";
import { AssetBuildPicker } from "@/components/assets/AssetBuildPicker";
import { SubscriptionList } from "./SubscriptionCard";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { Modal } from "@/components/layout/Modal";
import { applyHubRoleToDraft, personaFromDraft, shouldPatchPersona } from "@/lib/personaDraft";
import { PersonaAvatarPicker } from "@/components/layout/PersonaAvatar";
@@ -89,27 +91,28 @@ export function SessionSettings() {
const [hubRole, setHubRole] = useState("");
const [personality, setPersonality] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
+ const [settingsTab, setSettingsTab] = useState<"agent" | "tools" | "runtime">("agent");
const [memories, setMemories] = useState
([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
- const openedAt = useRef(0);
- const dialogRef = useRef(null);
-
const close = useCallback(() => {
dispatch({ type: "sessionSettings", open: false });
}, [dispatch]);
- useDialogFocus(Boolean(state.sessionSettingsOpen && session), close, dialogRef);
-
- const toolHint = session
+ const toolCatalog = session
? configuredToolsForRuntime(session.os, sessionRuntime(session), state.config?.integrations)
- .map((t) => t.name)
- .join(", ")
- : "";
+ : [];
+ const allowedSet = new Set(
+ allowedTools
+ .split(",")
+ .map((name) => name.trim())
+ .filter(Boolean),
+ );
+ const allToolsAllowed = allowedSet.size === 0;
useEffect(() => {
if (!state.sessionSettingsOpen || !session) return;
- openedAt.current = Date.now();
+ setSettingsTab("agent");
setProviderId(session.providerId);
setModel(session.model);
setReasoningEffort(session.reasoningEffort);
@@ -150,13 +153,6 @@ export function SessionSettings() {
const worker = (state.config?.workers ?? []).find((row) => row.id === current.backendId);
const meterVm = sessionPlacement(current) === "worker" && worker && workerIsMetered(worker);
- function onBackdrop(event: MouseEvent) {
- if (event.target !== event.currentTarget) return;
- if (Date.now() - openedAt.current < 250) return;
- if (document.activeElement instanceof HTMLSelectElement) return;
- close();
- }
-
async function save() {
setError(null);
const names = allowedTools
@@ -220,346 +216,422 @@ export function SessionSettings() {
}
return (
-
-
e.stopPropagation()}
- >
-
- {isAssetSession(current)
- ? "Asset settings"
- : isGameSession(current)
- ? "Game settings"
- : isDesignSession(current)
- ? "Design settings"
- : hubMode
- ? "Teammate profile"
- : "Agent settings"}
-
-
- {sessionRuntime(current) === "host"
- ? "Applies on the next turn. This chat runs on the host with no VM or container."
- : sessionRuntime(current) === "docker"
- ? "Applies on the next turn. This agent uses a Docker container at /workspace."
- : "Applies on the next turn. Guest network applies the next time you start the VM."}
-
- {!isCanvasSession(current) && (hubMode || current.persona) ? (
-
-
-
- Name
- setDisplayName(e.target.value)}
- />
-
-
- Role
- {
- const next = applyHubRoleToDraft(
- {
- displayName,
- avatarId,
- avatarPose,
- avatarAccessory,
- avatarAccent,
- hubRole,
- personality,
- },
- e.target.value,
- );
- setHubRole(next.hubRole);
- setPersonality(next.personality);
- setAvatarId(next.avatarId);
- }}
- placeholder="Designer, Builder, Researcher…"
- />
-
- {HUB_ROLE_PRESETS.map((role) => (
-
- ))}
-
-
-
- Personality
- setPersonality(e.target.value)}
- placeholder="Calm, concise, cites sources"
- rows={hubMode ? 2 : undefined}
- />
-
- {hubMode ? (
-
- Brief
- setInstructions(e.target.value)}
- placeholder="Master prompt — what they own, how they should work"
- rows={3}
- data-testid="session-brief"
- />
-
- ) : null}
+
+
+ {isAssetSession(current)
+ ? "Asset settings"
+ : isGameSession(current)
+ ? "Game settings"
+ : isDesignSession(current)
+ ? "Design settings"
+ : hubMode
+ ? "Teammate profile"
+ : "Agent settings"}
+
+
+ {sessionRuntime(current) === "host"
+ ? "Applies on the next turn. This chat runs on the host with no VM or container."
+ : sessionRuntime(current) === "docker"
+ ? "Applies on the next turn. This agent uses a Docker container at /workspace."
+ : "Applies on the next turn. Guest network applies the next time you start the VM."}
+
+ {!isCanvasSession(current) && (hubMode || current.persona) ? (
+
+
+
+ Name
+ setDisplayName(e.target.value)}
+ />
-
-
Avatar
-
{
+
+ Role
+ {
+ const next = applyHubRoleToDraft(
+ {
+ displayName,
+ avatarId,
+ avatarPose,
+ avatarAccessory,
+ avatarAccent,
+ hubRole,
+ personality,
+ },
+ e.target.value,
+ );
+ setHubRole(next.hubRole);
+ setPersonality(next.personality);
setAvatarId(next.avatarId);
- setAvatarPose(next.avatarPose);
- setAvatarAccessory(next.avatarAccessory);
- setAvatarAccent(next.avatarAccent);
}}
+ placeholder="Designer, Builder, Researcher…"
/>
+
+ {HUB_ROLE_PRESETS.map((role) => (
+
+ ))}
+
+
+ Personality
+ setPersonality(e.target.value)}
+ placeholder="Calm, concise, cites sources"
+ rows={hubMode ? 2 : undefined}
+ />
+
+ {hubMode ? (
+
+ Brief
+ setInstructions(e.target.value)}
+ placeholder="Master prompt — what they own, how they should work"
+ rows={3}
+ data-testid="session-brief"
+ />
+
+ ) : null}
- ) : null}
- {memories.length ? (
-
-
What they remember
-
- {memories.map((row) => (
- {row.claim}
- ))}
-
-
- ) : null}
- {hubMode && !isCanvasSession(current) ? (
-
setShowAdvanced((open) => !open)}
- >
- {showAdvanced ? "Hide advanced" : "Advanced"}
-
- ) : null}
- {showAdvanced || isCanvasSession(current) || !hubMode ? (
- <>
-
+ Avatar
+ {
- setProviderId(next.providerId);
- setModel(next.model);
- setReasoningEffort(undefined);
+ setAvatarId(next.avatarId);
+ setAvatarPose(next.avatarPose);
+ setAvatarAccessory(next.avatarAccessory);
+ setAvatarAccent(next.avatarAccent);
}}
/>
- p.id === providerId)}
- model={model}
- value={reasoningEffort}
- onChange={setReasoningEffort}
- />
- {isDesignSession(current) ? (
- <>
- {
- setDesignSystemId(system?.id ?? "");
- if (system) setPalette({ ...system.palette });
- }}
- />
-
- >
- ) : null}
- {isAssetSession(current) ? (
- <>
- setAssetStyleId(style?.id ?? "")}
- />
+
+
+ ) : null}
+ {memories.length ? (
+
+
What they remember
+
+ {memories.map((row) => (
+ {row.claim}
+ ))}
+
+
+ ) : null}
+ {hubMode && !isCanvasSession(current) ? (
+ setShowAdvanced((open) => !open)}
+ >
+ {showAdvanced ? "Hide advanced" : "Advanced"}
+
+ ) : null}
+ {showAdvanced || isCanvasSession(current) || !hubMode ? (
+ <>
+
+ {(
+ [
+ ["agent", "Agent"],
+ ["tools", "Tools"],
+ ["runtime", "Runtime"],
+ ] as const
+ ).map(([id, label]) => (
+ setSettingsTab(id)}
+ >
+ {label}
+
+ ))}
+
+ {settingsTab === "agent" ? (
+ <>
+ {
+ setProviderId(next.providerId);
+ setModel(next.model);
+ setReasoningEffort(undefined);
+ }}
+ />
+ p.id === providerId)}
+ model={model}
+ value={reasoningEffort}
+ onChange={setReasoningEffort}
+ />
+ {isDesignSession(current) ? (
+ <>
+ {
+ setDesignSystemId(system?.id ?? "");
+ if (system) setPalette({ ...system.palette });
+ }}
+ />
+
+ >
+ ) : null}
+ {isAssetSession(current) ? (
+ <>
+ setAssetStyleId(style?.id ?? "")}
+ />
+
+ >
+ ) : null}
+ {hubMode ? null : (
-
Build
-
Instructions (optional)
+ setInstructions(e.target.value)}
+ placeholder={
+ sessionRuntime(current) === "host"
+ ? "Prefer short answers, cite sources, …"
+ : "Always use /mnt/host, commit on a feature branch, …"
+ }
/>
- >
- ) : null}
- {isCanvasSession(current) || sessionRuntime(current) === "host" ? null : (
-
- Vision
- setVision(e.target.value as VisionMode)}>
-
- {formatVision("latest")} — send the latest guest screenshot to the model
-
-
- {formatVision("off")} — text and accessibility trees only
-
-
-
- )}
-
- Web search
- setWebSearch(e.target.value === "on")}
- >
-
- {formatWebSearch(false)} — model cannot query the public web from the host
-
-
- {formatWebSearch(true)} — model can call websearch on the host
-
-
-
- {sessionRuntime(current) === "host" ? (
-
- Use my Chrome
- setLiveChrome(e.target.value === "on")}
- >
-
- {formatLiveChrome(false)} — isolated host-chrome profile
-
-
- {formatLiveChrome(true)} — drive the Chrome tabs already open on this machine
-
-
-
- ) : null}
- {isCanvasSession(current) ? null : (
+ )}
- Default subagent
-
-
- )}
- {hubMode ? null : (
-
- Instructions (optional)
+ Playbook (optional)
setInstructions(e.target.value)}
- placeholder={
- sessionRuntime(current) === "host"
- ? "Prefer short answers, cite sources, …"
- : "Always use /mnt/host, commit on a feature branch, …"
- }
+ value={playbook}
+ onChange={(e) => setPlaybook(e.target.value)}
+ placeholder="Project SOP injected every turn. How we test, branch, and ship this repo."
/>
- )}
-
- Playbook (optional)
- setPlaybook(e.target.value)}
- placeholder="Project SOP injected every turn. How we test, branch, and ship this repo."
- />
-
-
-
-
Autonomy
-
setAutonomy(parseAutonomyTier(e.target.value))}
- >
- {AUTONOMY_TIERS.map((tier) => (
-
- {formatAutonomy(tier)}
-
- ))}
-
+
+
+ Autonomy
+ ({
+ value: tier,
+ label: formatAutonomyShort(tier),
+ hint: formatAutonomyHint(tier),
+ }))}
+ onChange={(value) => setAutonomy(parseAutonomyTier(value))}
+ />
+
+
+ {meterVm ? "Spend cap (USD, LLM + VM)" : "Spend cap (USD)"}
+ setMaxUsd(e.target.value)}
+ />
+
+ >
+ ) : null}
+ {settingsTab === "tools" ? (
+ <>
- {meterVm ? "Spend cap (USD, LLM + VM)" : "Spend cap (USD)"}
- setMaxUsd(e.target.value)}
+ Web search
+ setWebSearch(value === "on")}
/>
-
- {sessionRuntime(current) === "docker" || sessionRuntime(current) === "host" ? null : (
-
- Guest network
- {
- if (isNetworkJail(e.target.value)) setNetwork(e.target.value);
- }}
- >
- {formatNetworkJail("open")}
- {formatNetworkJail("restricted")}
- {formatNetworkJail("none")}
-
-
- )}
-
- Allowed tools (optional)
- setAllowedTools(e.target.value)}
- placeholder={toolHint || "exec, screenshot, git_commit"}
- />
-
- {isCanvasSession(current) ? null : (
+ {sessionRuntime(current) === "host" ? (
+
+ Use my Chrome
+ setLiveChrome(value === "on")}
+ />
+
+ ) : null}
+ {isCanvasSession(current) ? null : (
+
+ Default subagent
+
+
+ )}
-
Subscriptions
-
{
- const next = (current.subscriptions ?? []).filter((row) => row.id !== id);
- void api
- .patchSession(current.id, { subscriptions: next.length ? next : null })
- .then((updated) => dispatch({ type: "session", session: updated }));
- }}
- onRemoveAll={
- current.subscriptions?.length
- ? () => {
- void api
- .patchSession(current.id, { subscriptions: null })
- .then((updated) => dispatch({ type: "session", session: updated }));
- }
- : undefined
- }
- />
+ Allowed tools
+
+ {allToolsAllowed
+ ? "All tools for this runtime. Uncheck to limit the next turn."
+ : `${allowedSet.size} of ${toolCatalog.length} tools.`}
+
+
+ {toolCatalog.map((tool) => {
+ const on = allToolsAllowed || allowedSet.has(tool.name);
+ return (
+
+ {
+ const catalog = toolCatalog.map((row) => row.name);
+ const currentNames = allToolsAllowed ? catalog : [...allowedSet];
+ const next = currentNames.includes(tool.name)
+ ? currentNames.filter((name) => name !== tool.name)
+ : [...currentNames, tool.name];
+ setAllowedTools(next.length === catalog.length ? "" : next.join(", "));
+ }}
+ />
+ {tool.name}
+
+ );
+ })}
+
- )}
- >
- ) : null}
- {error && (
-
- {error}
-
- )}
-
-
- Cancel
-
- void save()}>
- {busy ? "Saving…" : "Save"}
-
-
+ {isCanvasSession(current) ? null : (
+
+ Subscriptions
+ {
+ const next = (current.subscriptions ?? []).filter((row) => row.id !== id);
+ void api
+ .patchSession(current.id, { subscriptions: next.length ? next : null })
+ .then((updated) => dispatch({ type: "session", session: updated }));
+ }}
+ onRemoveAll={
+ current.subscriptions?.length
+ ? () => {
+ void api
+ .patchSession(current.id, { subscriptions: null })
+ .then((updated) => dispatch({ type: "session", session: updated }));
+ }
+ : undefined
+ }
+ />
+
+ )}
+ >
+ ) : null}
+ {settingsTab === "runtime" ? (
+ <>
+ {isCanvasSession(current) || sessionRuntime(current) === "host" ? null : (
+
+ Vision
+ setVision(value as VisionMode)}
+ />
+
+ )}
+ {sessionRuntime(current) === "docker" || sessionRuntime(current) === "host" ? null : (
+
+ Guest network
+ {
+ if (isNetworkJail(value)) setNetwork(value);
+ }}
+ />
+
+ )}
+
+ {sessionRuntime(current) === "host"
+ ? "This chat runs on the host with no VM or container."
+ : sessionRuntime(current) === "docker"
+ ? "This agent uses a Docker container at /workspace."
+ : "Guest network applies the next time you start the VM."}
+
+ >
+ ) : null}
+ >
+ ) : null}
+ {error &&
{error}
}
+
+
+ Cancel
+
+ void save()}>
+ {busy ? "Saving…" : "Save"}
+
-
+
);
}
diff --git a/apps/desktop/src/components/chat/SubagentPicker.tsx b/apps/desktop/src/components/chat/SubagentPicker.tsx
index 7691a21..ec6aac4 100644
--- a/apps/desktop/src/components/chat/SubagentPicker.tsx
+++ b/apps/desktop/src/components/chat/SubagentPicker.tsx
@@ -4,6 +4,7 @@ import {
subagentKindHint,
type SubagentKind,
} from "@locus/shared";
+import { Select } from "@/components/layout/Select";
export function SubagentPicker(props: {
value: SubagentKind;
@@ -12,21 +13,19 @@ export function SubagentPicker(props: {
disabled?: boolean;
}) {
return (
-
props.onChange(e.target.value as SubagentKind)}
- >
- {SUBAGENT_KINDS.map((kind) => (
-
- {props.compact
- ? formatSubagentKind(kind)
- : `${formatSubagentKind(kind)} — ${subagentKindHint(kind)}`}
-
- ))}
-
+ options={SUBAGENT_KINDS.map((kind) => ({
+ value: kind,
+ label: formatSubagentKind(kind),
+ hint: props.compact ? undefined : subagentKindHint(kind),
+ }))}
+ onChange={(kind) => props.onChange(kind as SubagentKind)}
+ />
);
}
diff --git a/apps/desktop/src/components/chat/SubscriptionCard.tsx b/apps/desktop/src/components/chat/SubscriptionCard.tsx
index ad42f0c..1e60467 100644
--- a/apps/desktop/src/components/chat/SubscriptionCard.tsx
+++ b/apps/desktop/src/components/chat/SubscriptionCard.tsx
@@ -67,8 +67,8 @@ export function SubscriptionList({
if (!subscriptions.length) {
return (
- Ask this agent to subscribe to a Slack channel or thread, a GitHub/GitLab/Bitbucket pull
- request, or a timer.
+ Ask this agent to subscribe to a Slack channel or thread, a GitHub/GitLab/Bitbucket/Gitea
+ pull request, a Linear/Jira/Sentry/incident.io issue, a Grafana alert, or a timer.
);
}
diff --git a/apps/desktop/src/components/chat/TraceModal.tsx b/apps/desktop/src/components/chat/TraceModal.tsx
index 9af9db5..8e766ce 100644
--- a/apps/desktop/src/components/chat/TraceModal.tsx
+++ b/apps/desktop/src/components/chat/TraceModal.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import type {
AgentSession,
ApprovalExportRow,
@@ -7,7 +7,7 @@ import type {
} from "@locus/shared";
import { api } from "@/lib/api";
import { HttpError } from "@/lib/net";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { Modal } from "@/components/layout/Modal";
import { DebugLogView, formatBytes, formatWhen } from "@/components/settings/DebugLogView";
export function TraceModal({ session, onClose }: { session: AgentSession; onClose: () => void }) {
@@ -16,8 +16,6 @@ export function TraceModal({ session, onClose }: { session: AgentSession; onClos
const [approvals, setApprovals] = useState
([]);
const [error, setError] = useState(null);
const [missing, setMissing] = useState(false);
- const dialogRef = useRef(null);
- useDialogFocus(true, onClose, dialogRef);
useEffect(() => {
let cancelled = false;
@@ -55,55 +53,42 @@ export function TraceModal({ session, onClose }: { session: AgentSession; onClos
}, [session.id]);
return (
-
-
event.stopPropagation()}
- >
-
Trace
-
- Raw JSONL events for {session.title}. Tool args already drop secret-like keys.
+
+ Trace
+
+ Raw JSONL events for {session.title}. Tool args already drop secret-like keys.
+
+ {detail ? (
+
+ {detail.source.summary} · {formatBytes(detail.source.bytes)} ·{" "}
+ {formatWhen(detail.source.mtime)}
- {detail ? (
-
- {detail.source.summary} · {formatBytes(detail.source.bytes)} ·{" "}
- {formatWhen(detail.source.mtime)}
-
- ) : null}
- {egress && (egress.hosts.length || egress.denied) ? (
-
- Egress: {egress.hosts.map((row) => `${row.host} (${row.kind})`).join(", ") || "none"} ·{" "}
- {formatBytes(egress.bytesSent)} sent · {egress.denied} denied
-
- ) : null}
- {approvals.length ? (
-
- Approvals:{" "}
- {approvals.map((row) => `${row.tool} ${row.decision} by ${row.by}`).join(" · ")}
-
- ) : null}
- {error ? (
-
- {error}
-
- ) : null}
- {detail ? (
-
- ) : missing ? (
- No trace yet. Send a chat turn to write JSONL for {session.title}.
- ) : error ? null : (
- Loading trace…
- )}
-
-
- Close
-
-
+ ) : null}
+ {egress && (egress.hosts.length || egress.denied) ? (
+
+ Egress: {egress.hosts.map((row) => `${row.host} (${row.kind})`).join(", ") || "none"} ·{" "}
+ {formatBytes(egress.bytesSent)} sent · {egress.denied} denied
+
+ ) : null}
+ {approvals.length ? (
+
+ Approvals:{" "}
+ {approvals.map((row) => `${row.tool} ${row.decision} by ${row.by}`).join(" · ")}
+
+ ) : null}
+ {error ? {error}
: null}
+ {detail ? (
+
+ ) : missing ? (
+ No trace yet. Send a chat turn to write JSONL for {session.title}.
+ ) : error ? null : (
+ Loading trace…
+ )}
+
+
+ Close
+
-
+
);
}
diff --git a/apps/desktop/src/components/codebase/ArchitecturePane.tsx b/apps/desktop/src/components/codebase/ArchitecturePane.tsx
index f8606bb..06da992 100644
--- a/apps/desktop/src/components/codebase/ArchitecturePane.tsx
+++ b/apps/desktop/src/components/codebase/ArchitecturePane.tsx
@@ -7,6 +7,7 @@ import {
} from "@locus/shared";
import { api } from "@/lib/api";
import { useStore } from "@/state/store";
+import { Select } from "@/components/layout/Select";
const SHOWN = 6;
@@ -129,24 +130,25 @@ export function ArchitecturePane({ repoId, repoName }: { repoId: string; repoNam
improving={busy === "feature"}
onImprove={() => void improve("feature")}
control={
-
{
- const next = event.target.value;
+ options={
+ featureOptions.length
+ ? featureOptions.map((row) => ({
+ value: row.path,
+ label: `${row.path} (${row.fileCount})`,
+ }))
+ : [{ value: "", label: "No features yet" }]
+ }
+ onChange={(next) => {
setFeature(next);
const rows = (report?.files ?? []).filter((row) => row.feature === next);
if (!rows.some((row) => row.path === file)) setFile(pickFile(rows));
}}
- >
- {featureOptions.length ? null : No features yet }
- {featureOptions.map((row) => (
-
- {row.path} ({row.fileCount})
-
- ))}
-
+ />
}
/>
void improve("code")}
control={
- setFile(event.target.value)}
- >
- {fileOptions.length ? null : No files yet }
- {fileOptions.map((row) => (
-
- {row.path}
- {row.flags.length ? ` · ${row.flags.length}` : ""}
-
- ))}
-
+ options={
+ fileOptions.length
+ ? fileOptions.map((row) => ({
+ value: row.path,
+ label: `${row.path}${row.flags.length ? ` · ${row.flags.length}` : ""}`,
+ }))
+ : [{ value: "", label: "No files yet" }]
+ }
+ onChange={setFile}
+ />
}
/>
diff --git a/apps/desktop/src/components/codebase/ChangeStackPanel.tsx b/apps/desktop/src/components/codebase/ChangeStackPanel.tsx
index cf915da..0e4ca35 100644
--- a/apps/desktop/src/components/codebase/ChangeStackPanel.tsx
+++ b/apps/desktop/src/components/codebase/ChangeStackPanel.tsx
@@ -15,6 +15,7 @@ import {
} from "@locus/shared";
import { PatchCard, type DiffLineRef } from "@/components/chat/PatchCard";
import { SearchField } from "@/components/layout/SearchField";
+import { Select } from "@/components/layout/Select";
import { MermaidDiagram } from "./MermaidDiagram";
export function ChangeStackPanel(props: {
@@ -253,21 +254,20 @@ export function ChangeStackPanel(props: {
{revisions.length ? (
Snapshot
- props.onViewRevision?.(event.target.value)}
- >
- {revisions.map((row) => (
-
- {row.headSha.slice(0, 8)}
- {row.current ? " · current" : ""}
- {row.source === "heuristic+model" ? " · summaries" : ""}
-
- ))}
-
+ options={revisions.map((row) => ({
+ value: row.headSha,
+ label: `${row.headSha.slice(0, 8)}${row.current ? " · current" : ""}${
+ row.source === "heuristic+model" ? " · summaries" : ""
+ }`,
+ }))}
+ onChange={(value) => props.onViewRevision?.(value)}
+ />
) : (
{stack.headSha.slice(0, 8)}
diff --git a/apps/desktop/src/components/codebase/Codebase.test.tsx b/apps/desktop/src/components/codebase/Codebase.test.tsx
index 6095cfc..480bde2 100644
--- a/apps/desktop/src/components/codebase/Codebase.test.tsx
+++ b/apps/desktop/src/components/codebase/Codebase.test.tsx
@@ -11,6 +11,7 @@ import { api } from "@/lib/api";
import { clearIssueCaches, rememberIssueList } from "@/lib/issueCache";
import { clearPullCaches, rememberPullList } from "@/lib/pullCache";
import { Store, initialState, type Action } from "@/state/store";
+import { selectOptionValues, setSelectValue } from "@/lib/selectControl";
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../index.css"), "utf8");
@@ -395,7 +396,9 @@ describe("CodebasePane", () => {
render();
expect(host.textContent).toContain("Link a repository");
expect(host.textContent).toContain("Import from Git");
- expect(host.textContent).toContain("GitHub, GitLab, and Bitbucket");
+ expect(host.textContent).toContain(
+ "Catalog a GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps repo",
+ );
expect(host.textContent).not.toContain("no repository linked");
expect(host.querySelector(".codebase-pane > .codebase-head")).toBeNull();
expect(host.querySelector(".codebase-empty")).toBeTruthy();
@@ -463,15 +466,13 @@ describe("CodebasePane", () => {
const search = host.querySelector("[aria-label='Search repositories']") as HTMLInputElement;
act(() => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
- setter?.call(search, "local");
+ setter?.call(search, "locus");
search.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(host.querySelector("[aria-label='Modsofthenation/Locus']")).toBeTruthy();
expect(host.querySelector("[aria-label='Modsofthenation/docs-site']")).toBeNull();
act(() => {
- (
- host.querySelector("[aria-label='Modsofthenation/Locus']") as HTMLInputElement
- ).click();
+ (host.querySelector("[aria-label='Modsofthenation/Locus']") as HTMLInputElement).click();
});
await act(async () => {
[...host.querySelectorAll("button")]
@@ -1443,8 +1444,7 @@ describe("CodebasePane", () => {
(tab as HTMLButtonElement).click();
await Promise.resolve();
});
- const filter = host.querySelector(".pr-inbox-filter") as HTMLSelectElement;
- expect([...filter.options].map((opt) => opt.value)).toEqual(["open", "draft", "all"]);
+ expect(selectOptionValues(host, "Pull request filter")).toEqual(["open", "draft", "all"]);
expect(host.querySelectorAll(".pr-inbox-item")).toHaveLength(2);
expect(host.textContent).toContain("1 draft");
expect(
@@ -1452,8 +1452,7 @@ describe("CodebasePane", () => {
).toBe(true);
await act(async () => {
- filter.value = "draft";
- filter.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Pull request filter", "draft");
await Promise.resolve();
});
expect(api.repoPulls).toHaveBeenCalledWith("r1", "draft");
@@ -1986,7 +1985,7 @@ describe("CodebasePane", () => {
});
expect(host.querySelector(".pr-inbox-item")).toBeTruthy();
const expand = host.querySelector(
- '.pr-detail-head button[title="Open diff full screen"]',
+ '.pr-detail-head button[aria-label="Open diff full screen"]',
) as HTMLButtonElement;
expect(expand).toBeTruthy();
act(() => {
@@ -2512,11 +2511,9 @@ describe("CodebasePane", () => {
expect(host.querySelector(".pr-stack")?.classList.contains("has-diagram")).toBe(false);
expect(host.querySelector(".pr-stack-diagram-caption")?.textContent).toMatch(/Call flow/);
expect(host.querySelector(".pr-stack-range-summary")?.textContent).toMatch(/Add currency/);
- const select = host.querySelector("[data-testid='pr-stack-revision']") as HTMLSelectElement;
- expect(select).toBeTruthy();
+ expect(host.querySelector("[data-testid='pr-stack-revision']")).toBeTruthy();
await act(async () => {
- select.value = "deadbeefdead";
- select.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Change stack snapshot", "deadbeefdead");
await Promise.resolve();
await Promise.resolve();
});
@@ -2568,11 +2565,9 @@ describe("CodebasePane", () => {
expect(host.querySelector(".pr-inbox-item.active")?.textContent).toContain("Login 500");
expect(host.textContent).toContain("stack overflow");
expect(host.textContent).toContain("repro on main");
- const filter = host.querySelector(".pr-inbox-filter") as HTMLSelectElement;
- expect([...filter.options].map((opt) => opt.value)).toEqual(["open", "closed", "all"]);
+ expect(selectOptionValues(host, "Issue filter")).toEqual(["open", "closed", "all"]);
await act(async () => {
- filter.value = "closed";
- filter.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Issue filter", "closed");
await Promise.resolve();
});
expect(api.repoIssues).toHaveBeenCalledWith("r1", "closed");
diff --git a/apps/desktop/src/components/codebase/Codebase.tsx b/apps/desktop/src/components/codebase/Codebase.tsx
index 253ddff..dc6aceb 100644
--- a/apps/desktop/src/components/codebase/Codebase.tsx
+++ b/apps/desktop/src/components/codebase/Codebase.tsx
@@ -48,6 +48,7 @@ import {
type AgentSession,
} from "@locus/shared";
import { api } from "@/lib/api";
+import { askConfirm } from "@/lib/dialog";
import { CloneProgressCard } from "@/components/chat/CloneProgressCard";
import { peekPullDetail, peekPullList } from "@/lib/pullCache";
import { peekIssueDetail, peekIssueList } from "@/lib/issueCache";
@@ -67,12 +68,15 @@ import { ArchitecturePane } from "./ArchitecturePane";
import { ChangeStackPanel } from "./ChangeStackPanel";
import { PullSummaryCell, PullTable } from "./PullTable";
import { IconCompress, IconExpand, IconMore } from "@/components/layout/Icons";
-import { Mascot } from "@/components/layout/Mascot";
-import { FeatureHintRow } from "@/components/layout/FeatureHint";
-import { SECTION_FEATURE_HINTS } from "@/lib/discoverability";
+import { EmptyHero, EmptyHint } from "@/components/layout/EmptyHero";
+import { Splitter } from "@/components/layout/Splitter";
+import { stepSplitValue } from "@/lib/splitter";
+import { BusyButton, ErrorBanner as ErrorBannerView, Skeleton } from "@/components/layout/Skeleton";
+import { Tooltip } from "@/components/layout/Tooltip";
import { ImportAccounts } from "@/components/settings/ImportAccounts";
import { SearchField } from "@/components/layout/SearchField";
import { MenuItem, MenuSep, OverflowMenu } from "@/components/layout/OverflowMenu";
+import { Select } from "@/components/layout/Select";
type RepoTab =
"overview" | "architecture" | "pulls" | "issues" | "agents" | "automations" | "settings";
@@ -222,15 +226,16 @@ function ErrorBanner({ error }: { error: string }) {
const message = formatRemoteError(error);
const auth = isForgeAuthError(message);
return (
-
-
-
{message}
- {auth ? (
-
openSettings(dispatch, "git")}>
- Open Git settings
-
- ) : null}
-
+ openSettings(dispatch, "git")}>
+ Open Git settings
+
+ ) : null
+ }
+ />
);
}
@@ -636,7 +641,7 @@ export function CodebasePane() {
setError(null);
const parsed = parseRepoUrl(linkUrl);
if (!parsed) {
- setError("Enter a GitHub, GitLab, or Bitbucket URL");
+ setError("Enter a GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps URL");
return;
}
setBusy(true);
@@ -961,7 +966,15 @@ export function CodebasePane() {
async function unlink() {
if (!repo) return;
- if (!window.confirm(`Unlink ${repo.name}? Agents keep their checkout.`)) return;
+ if (
+ !(await askConfirm({
+ title: `Unlink ${repo.name}?`,
+ body: "Agents keep their checkout.",
+ confirmLabel: "Unlink",
+ danger: true,
+ }))
+ )
+ return;
setBusy(true);
try {
await api.deleteRepo(repo.id);
@@ -1030,7 +1043,15 @@ export function CodebasePane() {
async function mergePull() {
if (!repo || !state.selectedPullNumber) return;
- if (!window.confirm("Merge this pull request on the forge?")) return;
+ if (
+ !(await askConfirm({
+ title: "Merge pull request?",
+ body: "Merge this pull request on the forge?",
+ confirmLabel: "Merge",
+ danger: true,
+ }))
+ )
+ return;
setBusy(true);
setError(null);
try {
@@ -1092,16 +1113,13 @@ export function CodebasePane() {
-
-
-
-
Codebase
-
Link a repository
-
-
+
- GitHub, GitLab, and Bitbucket can all be active at once. Locus stays the local
- catalog.{" "}
- {SECTION_FEATURE_HINTS.codebase ? (
-
- ) : null}
Sign in and pick a repo
Import from accounts already connected in Settings.
- void importFromGit()}>
- {importing ? "Importing…" : "Import from Git"}
-
+ void importFromGit()}
+ >
+ Import from Git
+
Paste a URL
@@ -1135,17 +1155,18 @@ export function CodebasePane() {
{tokened.length > 1 ? (
Account
- setLinkAccountId(e.target.value)}
- >
- Auto (one token per forge)
- {tokened.map((row) => (
-
- {row.name} · {forgeLabel(row.kind)}
-
- ))}
-
+ options={[
+ { value: "", label: "Auto (one token per forge)" },
+ ...tokened.map((row) => ({
+ value: row.id,
+ label: `${row.name} · ${forgeLabel(row.kind)}`,
+ })),
+ ]}
+ onChange={setLinkAccountId}
+ />
) : null}
void link()}>
@@ -1206,9 +1227,14 @@ export function CodebasePane() {
Start agent
)}
- void openInIde()}>
- {openingLabel ?? "Open in IDE"}
-
+ void openInIde()}
+ >
+ Open in IDE
+
}
title="Repo actions"
@@ -1307,10 +1333,11 @@ export function CodebasePane() {
Brain
- {
setBrainBusy(true);
void api
@@ -1325,8 +1352,8 @@ export function CodebasePane() {
.finally(() => setBrainBusy(false));
}}
>
- {brainBusy ? "Building…" : "Index into brain"}
-
+ Index into brain
+
{brainHint}
@@ -1529,16 +1556,17 @@ export function CodebasePane() {
Account
- setAccountId(e.target.value)}>
- Auto
- {(state.config?.forges ?? [])
- .filter((row) => row.kind === repo.checkout.forge)
- .map((row) => (
-
- {row.name}
-
- ))}
-
+ row.kind === repo.checkout.forge)
+ .map((row) => ({ value: row.id, label: row.name })),
+ ]}
+ onChange={setAccountId}
+ />
Docker image
@@ -1640,31 +1668,16 @@ type PullDetailProps = {
function PullListSkeleton() {
return (
-
- {Array.from({ length: 6 }, (_, i) => (
-
-
-
-
-
- ))}
+
+
);
}
function PullDetailSkeleton() {
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
);
}
@@ -1725,21 +1738,23 @@ function PullInbox(
{draftCount ? ` · ${draftCount} draft` : ""}
-
props.setPullState(parsePullListState(e.target.value))}
- >
- Open
- Draft
- All
-
+ options={[
+ { value: "open", label: "Open" },
+ { value: "draft", label: "Draft" },
+ { value: "all", label: "All" },
+ ]}
+ onChange={(value) => props.setPullState(parsePullListState(value))}
+ />
{props.pullsLoading && props.pulls.length === 0 ? (
) : props.pulls.length === 0 ? (
-
No pull requests.
+
) : (
props.pulls.map((row) => {
const active = props.selectedNumber === row.number;
@@ -1756,16 +1771,31 @@ function PullInbox(
})
)}
- {
+ const next = stepSplitValue({
+ key: event.key,
+ shift: event.shiftKey,
+ value: props.listWidth,
+ min: PR_LIST_MIN,
+ max: PR_LIST_MAX,
+ axis: "vertical",
+ step: 16,
+ });
+ if (next == null) return;
+ event.preventDefault();
+ props.onListWidth(next);
+ }}
+ onDoubleClick={() => props.onListWidth(PR_LIST_DEFAULT)}
/>
{props.pull ? (
@@ -1775,7 +1805,7 @@ function PullInbox(
) : props.pullLoadError ? (
) : (
-
Select a pull request.
+
)}
@@ -1838,21 +1868,23 @@ function IssueInbox(props: {
Issues
{openCount} open
-
props.setIssueState(parseIssueListState(e.target.value))}
- >
- Open
- Closed
- All
-
+ options={[
+ { value: "open", label: "Open" },
+ { value: "closed", label: "Closed" },
+ { value: "all", label: "All" },
+ ]}
+ onChange={(value) => props.setIssueState(parseIssueListState(value))}
+ />
{props.issuesLoading && props.issues.length === 0 ? (
) : props.issues.length === 0 ? (
-
No issues.
+
) : (
props.issues.map((row) => {
const active = props.selectedNumber === row.number;
@@ -1869,16 +1901,31 @@ function IssueInbox(props: {
})
)}
-
{
+ const next = stepSplitValue({
+ key: event.key,
+ shift: event.shiftKey,
+ value: props.listWidth,
+ min: PR_LIST_MIN,
+ max: PR_LIST_MAX,
+ axis: "vertical",
+ step: 16,
+ });
+ if (next == null) return;
+ event.preventDefault();
+ props.onListWidth(next);
+ }}
+ onDoubleClick={() => props.onListWidth(PR_LIST_DEFAULT)}
/>
{props.issue ? (
@@ -1896,7 +1943,7 @@ function IssueInbox(props: {
) : props.issueLoadError ? (
) : (
-
Select an issue.
+
)}
@@ -1946,14 +1993,11 @@ function IssueDetail(props: {
{issue.title}
-
- {props.focused ? : }
-
+
+
+ {props.focused ? : }
+
+
Open on forge
@@ -2382,15 +2426,16 @@ function PullDetail(props: PullDetailProps) {
-
- {props.focused ? : }
-
+
+
+ {props.focused ? : }
+
+
Fix with agent
@@ -2633,15 +2678,15 @@ function PullDetail(props: PullDetailProps) {
{props.commentPath.trim() ? (
Side
-
- props.setCommentSide(e.target.value === "LEFT" ? "LEFT" : "RIGHT")
- }
- >
- New file
- Old file
-
+ options={[
+ { value: "RIGHT", label: "New file" },
+ { value: "LEFT", label: "Old file" },
+ ]}
+ onChange={(value) => props.setCommentSide(value === "LEFT" ? "LEFT" : "RIGHT")}
+ />
) : null}
({
api: {
@@ -113,12 +114,14 @@ describe("ComposerRepoPicker", () => {
});
}
- it("keeps a single-select native picker for design context", () => {
+ it("keeps a single-select picker for design context", () => {
render([], { multiple: false, repos: repos.slice(0, 1) });
- const picker = host.querySelector("[aria-label='Repository']") as HTMLSelectElement;
- expect(picker.tagName).toBe("SELECT");
- expect(picker.options[0]?.textContent).toBe("No repo");
- expect(picker.textContent).toContain(forgeLabel("github"));
+ const picker = host.querySelector("[aria-label='Repository']");
+ expect(picker).toBeTruthy();
+ expect(picker instanceof HTMLSelectElement).toBe(false);
+ const labels = selectOptionLabels(host, "Repository");
+ expect(labels[0]).toBe("No repo");
+ expect(labels.some((label) => label.includes(forgeLabel("github")))).toBe(true);
});
it("opens a checkbox list instead of a separate Add repo select", () => {
diff --git a/apps/desktop/src/components/codebase/RepoField.tsx b/apps/desktop/src/components/codebase/RepoField.tsx
index af32c3a..8ea281a 100644
--- a/apps/desktop/src/components/codebase/RepoField.tsx
+++ b/apps/desktop/src/components/codebase/RepoField.tsx
@@ -1,6 +1,7 @@
import { forgeLabel, type LinkedRepo } from "@locus/shared";
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { IconClose } from "@/components/layout/Icons";
+import { Select } from "@/components/layout/Select";
import { api } from "@/lib/api";
function repoShortName(name: string): string {
@@ -132,19 +133,18 @@ export function ComposerRepoPicker(props: {
if (!props.multiple) {
return (
- setIds(e.target.value ? [e.target.value] : [])}
- >
- {emptyLabel}
- {props.repos.map((row) => (
-
- {row.name} · {forgeLabel(row.checkout.forge)}
-
- ))}
-
+ options={[
+ { value: "", label: emptyLabel },
+ ...props.repos.map((row) => ({
+ value: row.id,
+ label: `${row.name} · ${forgeLabel(row.checkout.forge)}`,
+ })),
+ ]}
+ onChange={(id) => setIds(id ? [id] : [])}
+ />
);
}
@@ -774,10 +774,17 @@ export function RepoField(props: {
{props.repos.length > 0 ? (
Linked repo
- {
- const id = e.target.value;
+ options={[
+ { value: "", label: "Paste a URL" },
+ ...props.repos.map((row) => ({
+ value: row.id,
+ label: `${row.name} · ${forgeLabel(row.checkout.forge)}`,
+ })),
+ ]}
+ onChange={(id) => {
props.onRepoId(id);
const linked = props.repos.find((row) => row.id === id);
if (linked) {
@@ -787,14 +794,7 @@ export function RepoField(props: {
if (id) props.onExtraRepoIds?.(extra.filter((row) => row !== id));
else props.onExtraRepoIds?.([]);
}}
- >
- Paste a URL
- {props.repos.map((row) => (
-
- {row.name} · {forgeLabel(row.checkout.forge)}
-
- ))}
-
+ />
) : null}
diff --git a/apps/desktop/src/components/design/DesignCanvas.test.tsx b/apps/desktop/src/components/design/DesignCanvas.test.tsx
index 8db7591..4c013e6 100644
--- a/apps/desktop/src/components/design/DesignCanvas.test.tsx
+++ b/apps/desktop/src/components/design/DesignCanvas.test.tsx
@@ -17,6 +17,7 @@ import { CANVAS_READY_FALLBACK_MS, DesignCanvas, isCanvasHostHidden } from "./De
import { canvasFrameDoc } from "@/lib/canvasFrame";
import { api } from "@/lib/api";
import { Store, initialState, type Action, type UiState } from "@/state/store";
+import { selectOptionLabels, selectValue, setSelectValue } from "@/lib/selectControl";
vi.mock("@/lib/api", () => ({
api: {
@@ -118,7 +119,7 @@ describe("canvas host visibility", () => {
expect(css).toMatch(/\.design-toolbar-group \{[\s\S]*?display:\s*inline-flex/);
expect(css).toMatch(/\.design-toolbar-overflow-modes \{[\s\S]*?display:\s*none/);
expect(css).toMatch(
- /@container design-pane \(max-width: 920px\) \{[\s\S]*?\.vm-status-copy \{[\s\S]*?display:\s*none/,
+ /@container design-pane \(max-width: 900px\) \{[\s\S]*?\.vm-status-copy \{[\s\S]*?display:\s*none/,
);
expect(css).toMatch(
/@container design-pane \(max-width: 760px\) \{[\s\S]*?\.design-toolbar-mode \{[\s\S]*?display:\s*none/,
@@ -129,7 +130,7 @@ describe("canvas host visibility", () => {
expect(css).toMatch(/\.design-pane \.vm-toolbar \{[\s\S]*?flex-wrap:\s*wrap/);
expect(css).toMatch(/\.design-pane \.vm-toolbar-actions \{[\s\S]*?flex-wrap:\s*wrap/);
expect(css).toMatch(
- /@container design-pane \(max-width: 520px\) \{[\s\S]*?\.design-system-picker \{[\s\S]*?display:\s*none/,
+ /@container design-pane \(max-width: 560px\) \{[\s\S]*?\.design-system-picker \{[\s\S]*?display:\s*none/,
);
});
});
@@ -172,7 +173,7 @@ describe("DesignCanvas", () => {
document.body.appendChild(host);
root = createRoot(host);
const node: ReactNode = (
-
+
);
@@ -410,10 +411,10 @@ describe("DesignCanvas", () => {
expect([...host.querySelectorAll("button")].some((btn) => btn.textContent === "Desktop")).toBe(
false,
);
- const picker = host.querySelector('[aria-label="Phone model"]') as HTMLSelectElement | null;
+ const picker = host.querySelector('[aria-label="Phone model"]');
expect(picker).toBeTruthy();
- expect(picker!.value).toBe("iphone-15");
- expect([...picker!.options].map((option) => option.textContent)).toEqual([
+ expect(selectValue(host, "Phone model")).toBe("iphone-15");
+ expect(selectOptionLabels(host, "Phone model")).toEqual([
"iPhone SE",
"iPhone 15",
"iPhone 16 Pro Max",
@@ -426,8 +427,7 @@ describe("DesignCanvas", () => {
expect((host.querySelector(".design-frame") as HTMLElement).style.height).toBe("844px");
expect(host.querySelector(".design-frame-caption")?.textContent).toBe("iPhone 15 · 390 × 844");
act(() => {
- picker!.value = "pixel-8";
- picker!.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Phone model", "pixel-8");
});
expect(host.querySelector(".design-frame")?.getAttribute("data-phone")).toBe("pixel-8");
expect((host.querySelector(".design-frame") as HTMLElement).style.width).toBe("412px");
@@ -525,6 +525,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -577,6 +578,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -638,6 +640,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -709,9 +712,9 @@ describe("DesignCanvas", () => {
act(() => {
(host.querySelector(".palette-swatch-row") as HTMLButtonElement).click();
});
- const paperBtn = [...host.querySelectorAll("button")].find((btn) =>
+ const paperBtn = [...host.querySelectorAll(".palette-pop button")].find((btn) =>
btn.textContent?.includes("Paper"),
- );
+ ) as HTMLButtonElement | undefined;
act(() => {
paperBtn!.click();
});
@@ -754,6 +757,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -987,12 +991,12 @@ describe("DesignCanvas", () => {
act(() => {
(host.querySelector(".palette-swatch-row") as HTMLButtonElement).click();
});
- const paperBtn = [...host.querySelectorAll("button")].find((btn) =>
+ const paperBtn = [...host.querySelectorAll(".palette-pop button")].find((btn) =>
btn.textContent?.includes("Paper"),
- );
- const oceanBtn = [...host.querySelectorAll("button")].find((btn) =>
+ ) as HTMLButtonElement | undefined;
+ const oceanBtn = [...host.querySelectorAll(".palette-pop button")].find((btn) =>
btn.textContent?.includes("Ocean"),
- );
+ ) as HTMLButtonElement | undefined;
act(() => {
paperBtn!.click();
oceanBtn!.click();
@@ -1045,9 +1049,9 @@ describe("DesignCanvas", () => {
act(() => {
(host.querySelector(".palette-swatch-row") as HTMLButtonElement).click();
});
- const paperBtn = [...host.querySelectorAll("button")].find((btn) =>
+ const paperBtn = [...host.querySelectorAll(".palette-pop button")].find((btn) =>
btn.textContent?.includes("Paper"),
- );
+ ) as HTMLButtonElement | undefined;
act(() => {
paperBtn!.click();
});
@@ -1064,6 +1068,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -1116,9 +1121,9 @@ describe("DesignCanvas", () => {
act(() => {
(host.querySelector(".palette-swatch-row") as HTMLButtonElement).click();
});
- const paperBtn = [...host.querySelectorAll("button")].find((btn) =>
+ const paperBtn = [...host.querySelectorAll(".palette-pop button")].find((btn) =>
btn.textContent?.includes("Paper"),
- );
+ ) as HTMLButtonElement | undefined;
act(() => {
paperBtn!.click();
});
@@ -1172,9 +1177,9 @@ describe("DesignCanvas", () => {
(host.querySelector(".palette-swatch-row") as HTMLButtonElement).click();
});
expect(host.textContent).toContain("Acme brand");
- const acmeBtn = [...host.querySelectorAll("button")].find((btn) =>
+ const acmeBtn = [...host.querySelectorAll(".palette-pop button")].find((btn) =>
btn.textContent?.includes("Acme brand"),
- );
+ ) as HTMLButtonElement | undefined;
act(() => {
acmeBtn!.click();
});
@@ -1416,10 +1421,8 @@ describe("DesignCanvas", () => {
});
await flushPaint();
expect(swapOverlay()).toBeNull();
- const picker = host.querySelector("#canvas-variant") as HTMLSelectElement;
await act(async () => {
- picker.value = "vprice";
- picker.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Design variant", "vprice");
});
expect(api.activateDesignVariant).toHaveBeenCalledWith("d1", "vprice");
expect(swapOverlay()?.textContent).toMatch(/Loading canvas/);
@@ -1428,7 +1431,7 @@ describe("DesignCanvas", () => {
expect(canvasFrameDoc(host.querySelector("iframe.is-front") as HTMLIFrameElement)).toContain(
"Hero",
);
- expect(picker.disabled).toBe(true);
+ expect(host.querySelector("#canvas-variant")?.getAttribute("aria-disabled")).toBe("true");
vi.mocked(api.canvas).mockResolvedValue({
html: nextHtml,
@@ -1524,10 +1527,8 @@ describe("DesignCanvas", () => {
await flushPaint();
expect(swapOverlay()).toBeNull();
- const picker = host.querySelector("#canvas-variant") as HTMLSelectElement;
await act(async () => {
- picker.value = "vprice";
- picker.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Design variant", "vprice");
});
expect(swapOverlay()?.textContent).toMatch(/Loading canvas/);
@@ -1601,6 +1602,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -1662,6 +1664,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -1733,6 +1736,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -1799,6 +1803,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -1863,6 +1868,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: other.id,
sessions: [session, other],
@@ -1921,10 +1927,8 @@ describe("DesignCanvas", () => {
await Promise.resolve();
await Promise.resolve();
});
- const picker = host.querySelector("#canvas-variant") as HTMLSelectElement;
await act(async () => {
- picker.value = "vprice";
- picker.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Design variant", "vprice");
await Promise.resolve();
await Promise.resolve();
});
@@ -2086,6 +2090,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: session.id,
sessions: [{ ...session, status: "running" }],
@@ -2144,6 +2149,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: session.id,
sessions: [session],
@@ -2268,6 +2274,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: session.id,
sessions: [{ ...session, status: "running" }],
@@ -2329,6 +2336,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: session.id,
sessions: [{ ...session, status: "running" }],
@@ -2351,6 +2359,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: session.id,
sessions: [{ ...session, status: "running" }],
@@ -2415,6 +2424,7 @@ describe("DesignCanvas", () => {
value={{
state: {
...initialState,
+ vmCollapsed: false,
section: "design",
selectedId: session.id,
sessions: [{ ...session, status: "running" }],
@@ -2607,11 +2617,9 @@ describe("DesignCanvas", () => {
expect(api.exportGameZip).toHaveBeenCalledWith("g1");
createObjectURL.mockRestore();
revokeObjectURL.mockRestore();
- const viewport = host.querySelector("[aria-label='Viewport']") as HTMLSelectElement;
- expect(viewport).toBeTruthy();
+ expect(host.querySelector("[aria-label='Viewport']")).toBeTruthy();
act(() => {
- viewport.value = "landscape";
- viewport.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Viewport", "landscape");
});
expect(host.querySelector(".design-frame")?.getAttribute("data-viewport")).toBe("landscape");
expect((host.querySelector(".design-frame") as HTMLElement).style.height).toBe("390px");
@@ -2707,10 +2715,10 @@ describe("DesignCanvas", () => {
await Promise.resolve();
});
expect(api.generateGameAsset).toHaveBeenCalledWith("g1", "sprite");
- const scaffold = host.querySelector("#canvas-scaffold") as HTMLSelectElement;
- expect(scaffold.value).toBe("canvas-vite");
- expect(scaffold.textContent).toContain("Three.js + Vite");
- expect(scaffold.textContent).toContain("Phaser + Vite");
+ expect(selectValue(host, "Promote scaffold")).toBe("canvas-vite");
+ expect(selectOptionLabels(host, "Promote scaffold")).toEqual(
+ expect.arrayContaining(["Three.js + Vite", "Phaser + Vite"]),
+ );
await act(async () => {
const promote = [...host.querySelectorAll("button")].find(
(btn) => btn.textContent === "Grow this into a project",
@@ -3089,11 +3097,10 @@ describe("DesignCanvas", () => {
expect(api.exportGameZip).toHaveBeenCalledWith("as1");
createObjectURL.mockRestore();
revokeObjectURL.mockRestore();
- const scaffold = host.querySelector("#canvas-scaffold") as HTMLSelectElement;
- expect(scaffold.value).toBe("three-vite");
- expect(scaffold.textContent).toContain("Three.js + Vite");
- expect(scaffold.textContent).toContain("Canvas + Vite");
- expect(scaffold.textContent).not.toContain("Phaser + Vite");
+ expect(selectValue(host, "Promote scaffold")).toBe("three-vite");
+ const scaffoldLabels = selectOptionLabels(host, "Promote scaffold");
+ expect(scaffoldLabels).toEqual(expect.arrayContaining(["Three.js + Vite", "Canvas + Vite"]));
+ expect(scaffoldLabels).not.toContain("Phaser + Vite");
await act(async () => {
const promote = [...host.querySelectorAll("button")].find(
(btn) => btn.textContent === "Promote",
diff --git a/apps/desktop/src/components/design/DesignCanvas.tsx b/apps/desktop/src/components/design/DesignCanvas.tsx
index 39ddf05..3fe1061 100644
--- a/apps/desktop/src/components/design/DesignCanvas.tsx
+++ b/apps/desktop/src/components/design/DesignCanvas.tsx
@@ -161,6 +161,7 @@ import {
import { finishCanvasSrcDoc, paintCanvasHtml } from "@/lib/canvasPaint";
import { canvasFrameSrc, isCanvasFrameReady, postCanvasFrameHtml } from "@/lib/canvasFrame";
import { useStore } from "@/state/store";
+import { matchModKey } from "@/lib/shortcuts";
import { useDeferredCollapse } from "@/lib/useDeferredCollapse";
import { DesignPalettePicker } from "./DesignPalettePicker";
import { DesignSystemPicker } from "./DesignSystemPicker";
@@ -186,6 +187,7 @@ import { filesFromDataTransfer, filesToAttachments, isImageFile } from "@/lib/at
import { IconMore, IconPanelRight } from "@/components/layout/Icons";
import { ImageBuildCard } from "@/components/chat/CloneProgressCard";
import { MenuItem, MenuSep, OverflowMenu } from "@/components/layout/OverflowMenu";
+import { Select } from "@/components/layout/Select";
import { GamePlayLinkPanel } from "@/components/game/GamePlayLink";
type Viewport = DesignViewport | "landscape";
@@ -2391,12 +2393,10 @@ export function DesignCanvas() {
);
}
function onKey(event: KeyboardEvent) {
- if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
if (typing(event.target)) return;
- const key = event.key.toLowerCase();
- if (key !== "z") return;
+ if (!matchModKey(event, "z") && !matchModKey(event, "z", true)) return;
event.preventDefault();
- if (event.shiftKey) {
+ if (matchModKey(event, "z", true)) {
if (historyAtRef.current < history.current.length - 1) {
void restoreHistory(historyAtRef.current + 1);
}
@@ -2486,26 +2486,25 @@ export function DesignCanvas() {
{game || assets ? (
-
{
- const id = event.target.value;
+ options={[
+ { value: "", label: "No kit" },
+ ...(assets ? ASSET_KITS : GAME_KITS).map((kit) => ({
+ value: kit.id,
+ label: kit.title,
+ })),
+ ]}
+ onChange={(id) => {
void api
.patchSession(session.id, { designSystemId: id || null })
.then((updated) => dispatch({ type: "session", session: updated }))
.catch(() => undefined);
}}
- >
- No kit
- {(assets ? ASSET_KITS : GAME_KITS).map((kit) => (
-
- {kit.title}
-
- ))}
-
+ />
) : (
)}
{game ? (
-
{
- void api
- .patchSession(session.id, { gameStyleId: style?.id ?? null })
- .then((updated) => dispatch({ type: "session", session: updated }))
- .catch(() => undefined);
- }}
- />
+ <>
+ {
+ void api
+ .patchSession(session.id, { gameStyleId: style?.id ?? null })
+ .then((updated) => dispatch({ type: "session", session: updated }))
+ .catch(() => undefined);
+ }}
+ />
+ ({
+ value: item.id,
+ label: item.label,
+ }))}
+ onChange={(value) => setResponsiveViewport(value as Viewport)}
+ />
+ >
) : null}
{assets ? (
)}
- {assets ? null :
}
- {assets ? null : game ? (
-
setResponsiveViewport(event.target.value as Viewport)}
- >
- {viewports.map((item) => (
-
- {item.label}
-
- ))}
-
- ) : mobile ? (
-
}
+ {assets ? null : game ? null : mobile ? (
+
setPhoneId(event.target.value)}
- >
- {DESIGN_PHONE_DEVICES.map((item) => (
-
- {item.label}
-
- ))}
-
+ options={DESIGN_PHONE_DEVICES.map((item) => ({
+ value: item.id,
+ label: item.label,
+ }))}
+ onChange={setPhoneId}
+ />
) : (
{viewports.map((item) => (
@@ -2620,19 +2616,17 @@ export function DesignCanvas() {
)}
{game && mobile ? (
-
setPhoneId(event.target.value)}
- >
- {DESIGN_PHONE_DEVICES.map((item) => (
-
- {item.label}
-
- ))}
-
+ options={DESIGN_PHONE_DEVICES.map((item) => ({
+ value: item.id,
+ label: item.label,
+ }))}
+ onChange={setPhoneId}
+ />
) : null}
{game || assets ? (
<>
@@ -3151,27 +3145,29 @@ export function DesignCanvas() {
Variant
-
{
- const id = event.target.value;
+ options={[
+ {
+ value: "",
+ label: (session.designVariants ?? []).length ? "Variants" : "No variants",
+ disabled: true,
+ },
+ ...(session.designVariants ?? []).map((item) => ({
+ value: item.id,
+ label: item.name,
+ })),
+ ]}
+ onChange={(id) => {
if (!id) return;
void activateVariant(id);
}}
- >
-
- {(session.designVariants ?? []).length ? "Variants" : "No variants"}
-
- {(session.designVariants ?? []).map((item) => (
-
- {item.name}
-
- ))}
-
+ />
{game ? "Grow as" : "Promote as"}
- setScaffold(event.target.value as CanvasScaffold)}
- >
- {game ? (
- <>
- Canvas + Vite
- Three.js + Vite
- Phaser + Vite
- Static HTML
- >
- ) : assets ? (
- <>
- Three.js + Vite
- Canvas + Vite
- Static HTML
- >
- ) : (
- <>
- Vite + React
- Next.js
- Astro
- Static HTML
- >
- )}
-
+ options={
+ game
+ ? [
+ { value: "canvas-vite", label: "Canvas + Vite" },
+ { value: "three-vite", label: "Three.js + Vite" },
+ { value: "phaser", label: "Phaser + Vite" },
+ { value: "static", label: "Static HTML" },
+ ]
+ : assets
+ ? [
+ { value: "three-vite", label: "Three.js + Vite" },
+ { value: "canvas-vite", label: "Canvas + Vite" },
+ { value: "static", label: "Static HTML" },
+ ]
+ : [
+ { value: "vite-react", label: "Vite + React" },
+ { value: "next", label: "Next.js" },
+ { value: "astro", label: "Astro" },
+ { value: "static", label: "Static HTML" },
+ ]
+ }
+ onChange={(value) => setScaffold(value as CanvasScaffold)}
+ />
void promote()}>
{promoting
@@ -3484,25 +3480,22 @@ export function DesignCanvas() {
wide
>
-
{
- const next = event.target.value as (typeof ASSET_CLIPS)[number];
- setClip(next);
+ options={visibleClips.map((id) => ({
+ value: id,
+ label: formatAssetClipLabel(id),
+ }))}
+ onChange={(next) => {
+ setClip(next as (typeof ASSET_CLIPS)[number]);
setAssetPaused(false);
postGame({ type: "locus-asset-play", clip: next });
}}
- >
- {visibleClips.map((id) => (
-
- {formatAssetClipLabel(id)}
-
- ))}
-
+ />
({
api: {
@@ -205,7 +206,7 @@ describe("DesignEmpty", () => {
render();
expect(host.querySelector(".empty.design-start")).toBeTruthy();
expect(host.querySelector(".eyebrow")).toBeNull();
- expect(host.querySelector(".design-start-hero h2")?.textContent).toBe("What should we design?");
+ expect(host.querySelector(".empty-hero h2")?.textContent).toBe("What should we design?");
expect(host.querySelector("[data-testid='locus-mascot']")?.getAttribute("data-pose")).toBe(
"idle",
);
@@ -454,12 +455,10 @@ describe("DesignEmpty", () => {
act(() => {
(host.querySelector('[aria-label="Look"]') as HTMLElement).click();
});
- const select = host.querySelector("[aria-label='Design system']") as HTMLSelectElement;
act(() => {
- select.value = second.id;
- select.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Design system", second.id);
});
- expect(select.value).toBe(second.id);
+ expect(selectValue(host, "Design system")).toBe(second.id);
await act(async () => {
root.render(
{
act(() => {
(host.querySelector('[aria-label="Look"]') as HTMLElement).click();
});
- expect((host.querySelector("[aria-label='Design system']") as HTMLSelectElement).value).toBe(
- second.id,
- );
+ expect(selectValue(host, "Design system")).toBe(second.id);
});
it("surfaces kit starting points next to Landing / Dashboard", async () => {
diff --git a/apps/desktop/src/components/design/DesignEmpty.tsx b/apps/desktop/src/components/design/DesignEmpty.tsx
index 248c1a3..09259e7 100644
--- a/apps/desktop/src/components/design/DesignEmpty.tsx
+++ b/apps/desktop/src/components/design/DesignEmpty.tsx
@@ -89,7 +89,8 @@ import {
import { ComposerMoreOptions } from "@/components/chat/ComposerMoreOptions";
import { IconReturn } from "@/components/layout/Icons";
import { OverflowMenu } from "@/components/layout/OverflowMenu";
-import { Mascot } from "@/components/layout/Mascot";
+import { Select } from "@/components/layout/Select";
+import { EmptyHero } from "@/components/layout/EmptyHero";
import {
LaunchWorkspace,
type LaunchWorkspaceSelection,
@@ -525,11 +526,7 @@ export function DesignEmpty({ surface = "design" }: { surface?: CanvasLaunchSurf
return (
-
-
- {launch.heading}
- {launch.lead}
-
+
{compactAssets ? null : (
@@ -811,23 +808,23 @@ export function DesignEmpty({ surface = "design" }: { surface?: CanvasLaunchSurf
) : (
<>
- {
+ options={[
+ { value: "", label: "No kit" },
+ ...(surface === "assets" ? ASSET_KITS : GAME_KITS).map((kit) => ({
+ value: kit.id,
+ label: kit.title,
+ })),
+ ]}
+ onChange={(id) => {
setSystemTouched(true);
- setDesignSystemId(event.target.value);
+ setDesignSystemId(id);
}}
- >
- No kit
- {(surface === "assets" ? ASSET_KITS : GAME_KITS).map((kit) => (
-
- {kit.title}
-
- ))}
-
+ />
{surface === "game" ? (
{!props.compact ? Design system
: null}
-
{
- const id = event.target.value;
- props.onChange(id ? resolveDesignSystem(id, stored) : undefined);
- }}
- >
- None
- {stored.length ? (
-
- {stored.map((row) => (
-
- {row.name}
-
- ))}
-
- ) : null}
-
- {builtins.map((row) => (
-
- {row.name}
-
- ))}
-
-
+ options={[
+ { value: "", label: "None" },
+ ...stored.map((row) => ({
+ value: row.id,
+ label: row.name,
+ hint: "My systems",
+ })),
+ ...builtins.map((row) => ({
+ value: row.id,
+ label: row.name,
+ hint: "Starters",
+ })),
+ ]}
+ onChange={(id) => props.onChange(id ? resolveDesignSystem(id, stored) : undefined)}
+ />
{props.manage !== false ? (
(null);
const [error, setError] = useState(null);
- const openedAt = useRef(0);
- const dialogRef = useRef(null);
const session = state.sessions.find((s) => s.id === state.selectedId);
const defaultId = state.config?.defaultDesignSystemId;
const builtin = isBuiltinDesignSystemId(draft.id);
@@ -136,19 +135,24 @@ export function DesignSystemsModal() {
setError(null);
}
- function confirmDiscard(): boolean {
+ async function confirmDiscard(): Promise {
if (JSON.stringify(draft) === JSON.stringify(origin)) return true;
- return window.confirm("Discard unsaved changes to this design system?");
+ return askConfirm({
+ title: "Discard unsaved changes?",
+ body: "Unsaved changes to this design system will be lost.",
+ confirmLabel: "Discard",
+ danger: true,
+ });
}
function load(next: EditorState) {
- if (!confirmDiscard()) return;
- applyEditor(next);
+ void confirmDiscard().then((ok) => {
+ if (ok) applyEditor(next);
+ });
}
useEffect(() => {
if (!state.designSystemsOpen) return;
- openedAt.current = Date.now();
setError(null);
const first = state.designSystems[0];
if (first) {
@@ -169,21 +173,21 @@ export function DesignSystemsModal() {
}, [state.designSystemsOpen]);
const close = useCallback(() => {
- if (
- JSON.stringify(draft) !== JSON.stringify(origin) &&
- !window.confirm("Discard unsaved changes to this design system?")
- ) {
- return;
- }
- dispatch({ type: "designSystems", open: false });
+ void (async () => {
+ if (
+ JSON.stringify(draft) !== JSON.stringify(origin) &&
+ !(await askConfirm({
+ title: "Discard unsaved changes?",
+ body: "Unsaved changes to this design system will be lost.",
+ confirmLabel: "Discard",
+ danger: true,
+ }))
+ ) {
+ return;
+ }
+ dispatch({ type: "designSystems", open: false });
+ })();
}, [dispatch, draft, origin]);
- useDialogFocus(state.designSystemsOpen, close, dialogRef);
-
- function onBackdrop(event: MouseEvent) {
- if (event.target !== event.currentTarget) return;
- if (Date.now() - openedAt.current < 250) return;
- close();
- }
function setRole(role: PaletteRole, raw: string) {
const hex = normalizeHex(raw);
@@ -233,9 +237,12 @@ export function DesignSystemsModal() {
async function remove() {
if (!draft.id || builtin) return;
if (
- !window.confirm(
- `Delete “${draft.name.trim() || "this design system"}”? This cannot be undone.`,
- )
+ !(await askConfirm({
+ title: `Delete “${draft.name.trim() || "this design system"}”?`,
+ body: "This cannot be undone.",
+ confirmLabel: "Delete",
+ danger: true,
+ }))
) {
return;
}
@@ -354,394 +361,382 @@ export function DesignSystemsModal() {
if (!state.designSystemsOpen) return null;
return (
-
-
e.stopPropagation()}
- >
-
-
-
Design systems
-
- Reusable brand tokens for Design. Create one, import a Locus JSON file or a Claude
- Design zip/HTML export, or export to share. New designs can start from the default.
+
+
+
+
Design systems
+
+ Reusable brand tokens for Design. Create one, import a Locus JSON file or a Claude
+ Design zip/HTML export, or export to share. New designs can start from the default.
+
+
+
+ {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (file) void importFile(file);
+ }}
+ />
+ fileInput.current?.click()}
+ disabled={blocked}
+ >
+ {importing ? "Importing…" : "Import"}
+
+
+ Export
+
+
+
+
+
+
+
+
+ {importing ? (
+
+
+
Importing {importing.name}
+
+ {importing.size >= 8 * 1024 * 1024
+ ? `${formatDesignImportBytes(importing.size)} · reading token files. Source uploads and nested zips stay in the archive.`
+ : `${formatDesignImportBytes(importing.size)} · reading design tokens…`}
-
- {
- const file = event.target.files?.[0];
- event.target.value = "";
- if (file) void importFile(file);
- }}
- />
- fileInput.current?.click()}
- disabled={blocked}
- >
- {importing ? "Importing…" : "Import"}
-
-
- Export
-
-
-
-
-
-
-
-
- {importing ? (
-
-
-
Importing {importing.name}
-
- {importing.size >= 8 * 1024 * 1024
- ? `${formatDesignImportBytes(importing.size)} · reading token files. Source uploads and nested zips stay in the archive.`
- : `${formatDesignImportBytes(importing.size)} · reading design tokens…`}
-
-
- ) : null}
-
- load(emptyDraft(session?.palette))}
- >
- New system
-
- {saved.length ? (
- <>
- Saved
- {saved.map((row) => (
- load(toEditor(row))}
- >
-
-
- {row.name}
- {defaultId === row.id ? (
- default
- ) : null}
-
-
- ))}
- >
- ) : null}
- Starters
- {PALETTE_PRESETS.map((preset) => {
- const id = `preset:${preset.id}`;
- return (
+ ) : null}
+
+ load(emptyDraft(session?.palette))}
+ >
+ New system
+
+ {saved.length ? (
+ <>
+ Saved
+ {saved.map((row) => (
- load({
- ...emptyDraft(preset.colors),
- id,
- name: preset.name,
- summary: preset.summary,
- })
- }
+ className={`ds-nav-item ${draft.id === row.id ? "active" : ""}`}
+ onClick={() => load(toEditor(row))}
>
-
- {preset.name}
-
- );
- })}
-
-
-
-
-
-
Colors
-
- {PALETTE_ROLES.map((role) => (
-
- setRole(role, e.target.value)}
- />
-
- {PALETTE_ROLE_LABELS[role]}
- setRole(role, e.target.value)}
- />
+
+
+ {row.name}
+ {defaultId === row.id ? (
+ default
+ ) : null}
-
+
))}
-
+ >
+ ) : null}
+
Starters
+ {PALETTE_PRESETS.map((preset) => {
+ const id = `preset:${preset.id}`;
+ return (
+
+ load({
+ ...emptyDraft(preset.colors),
+ id,
+ name: preset.name,
+ summary: preset.summary,
+ })
+ }
+ >
+
+ {preset.name}
+
+ );
+ })}
+
-
Type
-
- {TYPE_ROWS.map((row) => (
-
-
- {row.sample}
-
-
- {row.label}
-
- setDraft({
- ...draft,
- typography: { ...draft.typography, [row.key]: e.target.value },
- })
- }
- />
-
-
- ))}
+
+
-
-
- Size
+ Colors
+
+ {PALETTE_ROLES.map((role) => (
+
- setDraft({
- ...draft,
- typography: { ...draft.typography, basePx: Number(e.target.value) || 16 },
- })
- }
- />
-
-
- Radius
- setDraft({ ...draft, radiusPx: Number(e.target.value) || 0 })}
+ type="color"
+ aria-label={PALETTE_ROLE_LABELS[role]}
+ value={draft.palette[role]}
+ onChange={(e) => setRole(role, e.target.value)}
/>
+
+ {PALETTE_ROLE_LABELS[role]}
+ setRole(role, e.target.value)}
+ />
+
-
- Grid
- setDraft({ ...draft, spacePx: Number(e.target.value) || 8 })}
- />
+ ))}
+
+
+ Type
+
+ {TYPE_ROWS.map((row) => (
+
+
+ {row.sample}
+
+
+ {row.label}
+
+ setDraft({
+ ...draft,
+ typography: { ...draft.typography, [row.key]: e.target.value },
+ })
+ }
+ />
+
-
+ ))}
+
-
-
+
+ Guidelines (optional)
+ setDraft({ ...draft, guidelines: e.target.value })}
+ placeholder="Tone, layout habits, what to avoid…"
+ />
+
+ {builtin ? (
+
+ Starter palettes are built in. Save a copy to edit or set as default.
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ ) : null}
+ {draft.id && !builtin ? (
+
void remove()}
+ >
+ Delete
+
+ ) : null}
+
+
+
+ Close
+
+ void save()}
+ >
+ {busy ? "Saving…" : builtin || !draft.id ? "Save copy" : "Save"}
+
+
+
+
);
}
diff --git a/apps/desktop/src/components/design/MobileDesign.tsx b/apps/desktop/src/components/design/MobileDesign.tsx
index ce83c65..f56bbd6 100644
--- a/apps/desktop/src/components/design/MobileDesign.tsx
+++ b/apps/desktop/src/components/design/MobileDesign.tsx
@@ -49,6 +49,7 @@ import { useStore } from "@/state/store";
import { DesignThumb } from "./DesignThumb";
import { IconChevron, IconSend } from "@/components/layout/Icons";
import { Mascot } from "@/components/layout/Mascot";
+import { Select } from "@/components/layout/Select";
import { AssetBuildPicker } from "@/components/assets/AssetBuildPicker";
import { useAssetBuild } from "@/components/assets/useAssetBuild";
@@ -381,35 +382,35 @@ export function MobileDesignCanvas(props: {
)}
-
setScaffold(event.target.value as PromoteScaffold)}
- >
- {game ? (
- <>
- Canvas + Vite
- Three.js + Vite
- Phaser + Vite
- Static HTML
- >
- ) : assets ? (
- <>
- Three.js + Vite
- Canvas + Vite
- Static HTML
- >
- ) : (
- <>
- Vite + React
- Next.js
- Astro
- Static HTML
- >
- )}
-
+ options={
+ game
+ ? [
+ { value: "canvas-vite", label: "Canvas + Vite" },
+ { value: "three-vite", label: "Three.js + Vite" },
+ { value: "phaser", label: "Phaser + Vite" },
+ { value: "static", label: "Static HTML" },
+ ]
+ : assets
+ ? [
+ { value: "three-vite", label: "Three.js + Vite" },
+ { value: "canvas-vite", label: "Canvas + Vite" },
+ { value: "static", label: "Static HTML" },
+ ]
+ : [
+ { value: "vite-react", label: "Vite + React" },
+ { value: "next", label: "Next.js" },
+ { value: "astro", label: "Astro" },
+ { value: "static", label: "Static HTML" },
+ ]
+ }
+ onChange={(value) => setScaffold(value as PromoteScaffold)}
+ />
+
-
{
- const next = event.target.value as (typeof ASSET_CORE_CLIPS)[number];
- setClip(next);
+ options={ASSET_CORE_CLIPS.map((id) => ({
+ value: id,
+ label: formatAssetClipLabel(id),
+ }))}
+ onChange={(next) => {
+ const clipId = next as (typeof ASSET_CORE_CLIPS)[number];
+ setClip(clipId);
setAssetPaused(false);
frame.current?.contentWindow?.postMessage(
- { type: "locus-asset-play", clip: next },
+ { type: "locus-asset-play", clip: clipId },
"*",
);
}}
- >
- {ASSET_CORE_CLIPS.map((id) => (
-
- {formatAssetClipLabel(id)}
-
- ))}
-
+ />
-
-
-
-
Docker
-
No containers
-
-
-
- {state.dockerKind === "docker"
- ? "No containers on this Docker engine. Start an agent, or run something with the Docker CLI — it will show up here."
- : "Using a mock Docker engine until the Docker CLI is installed. New Docker agents still appear here so you can stop, restart, and read logs."}
-
+
{dockerStatusLabel(item)}
- {error ?
{error}
: null}
+ {error ?
void refreshList()} /> : null}
{(["logs", "inspect", "stats", "files"] as const).map((id) => (
-
-
-
-
Docker Swarm
-
Swarm inactive
-
-
-
- This Docker engine is not in Swarm mode. Initialize a swarm with the Docker CLI, or
- point DOCKER_HOST at a manager. This is the local engine — not Locus Swarm
- teams, and not the Portainer integration.
-
+
void refresh().catch(() => undefined)}>
Refresh
@@ -173,18 +170,17 @@ export function DockerSwarmPane() {
return (
-
-
-
-
Docker Swarm
-
{manager ? "Pick a service" : "Worker node"}
-
-
-
- {manager
- ? "Select a service or node in the sidebar. Scale waits for a confirm. Agents can call docker_swarm on this engine."
- : "This engine is a Swarm worker. Connect Locus to a manager to list services."}
-
+
);
@@ -239,7 +235,7 @@ export function DockerSwarmPane() {
{service.replicas}
- {error ?
{error}
: null}
+ {error ?
: null}
{(["logs", "tasks"] as const).map((id) => (
{node.managerStatus || "—"}
- {error ?
{error}
: null}
+ {error ?
: null}
{node.engineVersion ? (
Engine {node.engineVersion}
diff --git a/apps/desktop/src/components/docker/DockerWorkspace.test.tsx b/apps/desktop/src/components/docker/DockerWorkspace.test.tsx
index 510cbb3..83ac8a8 100644
--- a/apps/desktop/src/components/docker/DockerWorkspace.test.tsx
+++ b/apps/desktop/src/components/docker/DockerWorkspace.test.tsx
@@ -104,7 +104,7 @@ describe("DockerWorkspace preview", () => {
document.body.appendChild(host);
root = createRoot(host);
const node: ReactNode = (
-
+
);
@@ -145,7 +145,7 @@ describe("DockerWorkspace preview", () => {
);
const fsBtn = host.querySelector('[data-testid="workspace-fullscreen"]') as HTMLButtonElement;
expect(fsBtn).toBeTruthy();
- expect(fsBtn.getAttribute("title")).toBe("Full screen files");
+ expect(fsBtn.getAttribute("aria-label")).toBe("Full screen files");
await act(async () => {
fsBtn.click();
});
@@ -845,6 +845,7 @@ describe("DockerWorkspace preview", () => {
function Live() {
const [state, dispatch] = useReducer(reducer, {
...initialState,
+ vmCollapsed: false,
selectedId: session.id,
sessions: [session],
vms: [vm],
diff --git a/apps/desktop/src/components/docker/DockerWorkspace.tsx b/apps/desktop/src/components/docker/DockerWorkspace.tsx
index 080c8cc..265d225 100644
--- a/apps/desktop/src/components/docker/DockerWorkspace.tsx
+++ b/apps/desktop/src/components/docker/DockerWorkspace.tsx
@@ -19,6 +19,7 @@ import { useWorkspaceFullscreen } from "@/lib/useWorkspaceFullscreen";
import { dockerRuntimeLabel } from "@/lib/workspaceTree";
import { IconChevron, IconFolder, IconPanelRight } from "@/components/layout/Icons";
import { MenuItem, OverflowMenu } from "@/components/layout/OverflowMenu";
+import { Select } from "@/components/layout/Select";
import { ExpandableLog } from "@/components/chat/ExpandableLog";
import { GuestTimeline, TimelinePreviewLayer } from "@/components/chat/GuestTimeline";
import {
@@ -364,13 +365,15 @@ export function DockerWorkspace() {
disabled={!vm || savingImage}
onClick={() => {
if (!sessionId) return;
- const name = promptGoldenName(defaultGoldenName(session.title, "workspace"));
- if (!name) return;
- setSavingImage(true);
- void api
- .captureImage(sessionId, name)
- .then(() => setLog((rows) => [...rows, `saved ${name}`].slice(-80)))
- .finally(() => setSavingImage(false));
+ void (async () => {
+ const name = await promptGoldenName(defaultGoldenName(session.title, "workspace"));
+ if (!name) return;
+ setSavingImage(true);
+ void api
+ .captureImage(sessionId, name)
+ .then(() => setLog((rows) => [...rows, `saved ${name}`].slice(-80)))
+ .finally(() => setSavingImage(false));
+ })();
}}
>
{savingImage ? "Saving image…" : "Save workspace image"}
@@ -433,12 +436,17 @@ export function DockerWorkspace() {
{showingPreview ? (
<>
{preview.ports.length > 1 ? (
- {
- const port = Number(e.target.value);
+ compact
+ ariaLabel="Preview port"
+ value={String(active?.port ?? "")}
+ options={preview.ports.map((port) => ({
+ value: String(port),
+ label: `:${port}`,
+ }))}
+ onChange={(value) => {
+ const port = Number(value);
if (!sessionId || !Number.isInteger(port)) return;
void api
.patchSession(sessionId, { preview: { port, path: active?.path } })
@@ -446,13 +454,7 @@ export function DockerWorkspace() {
dispatch({ type: "session", session: updated });
});
}}
- >
- {preview.ports.map((port) => (
-
- :{port}
-
- ))}
-
+ />
) : null}
{VIEWPORTS.map((item) => (
diff --git a/apps/desktop/src/components/game/GameEmpty.test.tsx b/apps/desktop/src/components/game/GameEmpty.test.tsx
index 9f27ecc..b6bbac6 100644
--- a/apps/desktop/src/components/game/GameEmpty.test.tsx
+++ b/apps/desktop/src/components/game/GameEmpty.test.tsx
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GameEmpty } from "./GameEmpty";
import { api } from "@/lib/api";
import { Store, initialState } from "@/state/store";
+import { selectValue, setSelectValue } from "@/lib/selectControl";
vi.mock("@/lib/api", () => ({
api: {
@@ -96,10 +97,9 @@ describe("GameEmpty", () => {
expect(
host.querySelector('[aria-label="Agent runtime"] [aria-selected="true"]')?.textContent,
).toBe("Docker");
- const kit = host.querySelector("[aria-label='Game kit']") as HTMLSelectElement;
+ expect(host.querySelector("[aria-label='Game kit']")).toBeTruthy();
act(() => {
- kit.value = "pixel-pack";
- kit.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Game kit", "pixel-pack");
});
const arcade = [...host.querySelectorAll("button")].find((btn) =>
btn.textContent?.includes("Arcade"),
@@ -155,9 +155,7 @@ describe("GameEmpty", () => {
three!.click();
seat!.click();
});
- expect((host.querySelector("[aria-label='Game kit']") as HTMLSelectElement).value).toBe(
- "three-starter",
- );
+ expect(selectValue(host, "Game kit")).toBe("three-starter");
const remix = [...host.querySelectorAll("[aria-label='Remix a sentence'] button")].find(
(btn) => btn.textContent === "Vegas match-3",
) as HTMLButtonElement | undefined;
diff --git a/apps/desktop/src/components/game/GamePlayLink.tsx b/apps/desktop/src/components/game/GamePlayLink.tsx
index 38a8dee..6cdee53 100644
--- a/apps/desktop/src/components/game/GamePlayLink.tsx
+++ b/apps/desktop/src/components/game/GamePlayLink.tsx
@@ -1,4 +1,6 @@
+import { useRef } from "react";
import { PairingQr } from "@/components/settings/PairingQr";
+import { useDialogFocus } from "@/lib/useDialogFocus";
export function GamePlayLinkPanel(props: {
url: string;
@@ -7,8 +9,16 @@ export function GamePlayLinkPanel(props: {
onClose: () => void;
}) {
const qrUrl = props.lanUrls[0] || props.url;
+ const dialogRef = useRef
(null);
+ useDialogFocus(true, props.onClose, dialogRef);
return (
-
+
Unlisted
Feel set
-
- save({ ...props.studio, recipeId: event.target.value || undefined })
- }
- >
- All knobs
- {GAME_RECIPES.map((recipe) => (
-
- {recipe.title}
-
- ))}
-
+ options={[
+ { value: "", label: "All knobs" },
+ ...GAME_RECIPES.map((recipe) => ({
+ value: recipe.id,
+ label: recipe.title,
+ })),
+ ]}
+ onChange={(value) => save({ ...props.studio, recipeId: value || undefined })}
+ />
{extras.map((key) => {
const bounds = GAME_EXTRA_FEEL_DEFAULTS[key];
@@ -334,23 +334,22 @@ export function GameStudioPanel(props: {
{GAME_INPUT_CODES.map((code) => (
{code}
- {
+ options={GAME_INPUT_CODES.map((target) => ({
+ value: target,
+ label: target,
+ }))}
+ onChange={(value) => {
const inputMap = { ...props.studio.inputMap };
- if (event.target.value === code) delete inputMap[code];
- else inputMap[code] = event.target.value;
+ if (value === code) delete inputMap[code];
+ else inputMap[code] = value;
save({ ...props.studio, inputMap });
props.postGame({ type: "locus-game-remap", map: inputMap });
}}
- >
- {GAME_INPUT_CODES.map((target) => (
-
- {target}
-
- ))}
-
+ />
))}
{!props.compact ? Game style
: null}
-
{
- const id = event.target.value;
- props.onChange(id ? resolveGameStyle(id, stored) : undefined);
- }}
- >
- No style
- {stored.map((row) => (
-
- {row.name}
-
- ))}
-
+ options={[
+ { value: "", label: "No style" },
+ ...stored.map((row) => ({ value: row.id, label: row.name })),
+ ]}
+ onChange={(id) => props.onChange(id ? resolveGameStyle(id, stored) : undefined)}
+ />
{props.manage !== false ? (
(emptyDraft());
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
- const openedAt = useRef(0);
- const dialogRef = useRef(null);
const session = state.sessions.find((s) => s.id === state.selectedId);
const defaultId = state.config?.defaultGameStyleId;
const dirty = JSON.stringify(draft) !== JSON.stringify(origin);
@@ -62,11 +61,8 @@ export function GameStylesModal() {
dispatch({ type: "gameStyles", open: false });
}, [dispatch]);
- useDialogFocus(state.gameStylesOpen, close, dialogRef);
-
useEffect(() => {
if (!state.gameStylesOpen) return;
- openedAt.current = Date.now();
setError(null);
const boundId = isGameSession(session ?? {}) ? session?.gameStyleId : undefined;
const bound = boundId ? state.gameStyles.find((row) => row.id === boundId) : undefined;
@@ -79,12 +75,6 @@ export function GameStylesModal() {
else load(emptyDraft());
}, [state.gameStylesOpen]);
- function onBackdrop(event: MouseEvent) {
- if (event.target !== event.currentTarget) return;
- if (Date.now() - openedAt.current < 250) return;
- close();
- }
-
async function refresh() {
const catalog = await api.gameStyles();
dispatch({ type: "gameStyleList", styles: catalog.styles });
@@ -183,133 +173,134 @@ export function GameStylesModal() {
if (!state.gameStylesOpen) return null;
return (
-
-
-
-
Game styles
-
-
+
+
+
Game styles
+
+
+
+
+
+
+ load(emptyDraft())}>
+ New
-
-
-
- load(emptyDraft())}>
- New
+ {saved.map((row) => (
+ load(toEditor(row))}
+ >
+ {row.name}
+ {row.id === defaultId ? " · default" : ""}
- {saved.map((row) => (
- load(toEditor(row))}
- >
- {row.name}
- {row.id === defaultId ? " · default" : ""}
-
- ))}
-
-
+ ))}
+
+
+
+ Name
+ setDraft({ ...draft, name: event.target.value })}
+ />
+
+
+ Summary
+ setDraft({ ...draft, summary: event.target.value })}
+ />
+
+
+
+ Guidelines
+ setDraft({ ...draft, guidelines: event.target.value })}
+ />
+
+ {error ?
{error}
: null}
-
- void remove()}
- >
- Delete
-
+
+
+ void remove()}
+ >
+ Delete
+
+ void makeDefault()}
+ >
+ Default
+
+ {session && isGameSession(session) ? (
void makeDefault()}
+ disabled={busy}
+ onClick={() => void saveFromGame()}
>
- Default
+ Save from this game
- {session && isGameSession(session) ? (
- void saveFromGame()}
- >
- Save from this game
-
- ) : null}
- void save()}
- >
- {busy ? "Saving…" : draft.id ? "Save" : "Create"}
-
-
+ ) : null}
+
void save()}
+ >
+ {busy ? "Saving…" : draft.id ? "Save" : "Create"}
+
-
+
);
}
diff --git a/apps/desktop/src/components/grafana/Grafana.test.tsx b/apps/desktop/src/components/grafana/Grafana.test.tsx
index de9b586..de5cd67 100644
--- a/apps/desktop/src/components/grafana/Grafana.test.tsx
+++ b/apps/desktop/src/components/grafana/Grafana.test.tsx
@@ -8,6 +8,7 @@ import { GrafanaPane } from "./Grafana";
import { api } from "@/lib/api";
import { Store, initialState, type Action, type UiState } from "@/state/store";
import type { AgentSession, GrafanaDashboardQueryResult } from "@locus/shared";
+import { selectValue } from "@/lib/selectControl";
vi.mock("@/lib/api", () => ({
api: {
@@ -621,9 +622,9 @@ describe("GrafanaPane", () => {
await Promise.resolve();
await Promise.resolve();
});
- const env = host.querySelector("[aria-label='Environment']") as HTMLSelectElement;
+ const env = host.querySelector("[aria-label='Environment']") as HTMLElement;
const groups = host.querySelector(".grafana-var-menu summary") as HTMLElement;
- expect(env.value).toBe("prod");
+ expect(selectValue(host, "Environment")).toBe("prod");
expect(getComputedStyle(env).minWidth).toBe("148px");
expect(getComputedStyle(env).paddingLeft).not.toBe("0px");
expect(groups.textContent).toBe("2 selected");
diff --git a/apps/desktop/src/components/grafana/Grafana.tsx b/apps/desktop/src/components/grafana/Grafana.tsx
index 8c0d240..5d8560b 100644
--- a/apps/desktop/src/components/grafana/Grafana.tsx
+++ b/apps/desktop/src/components/grafana/Grafana.tsx
@@ -29,7 +29,9 @@ import { api } from "@/lib/api";
import { loadLastAgent, pickProvider } from "@/lib/lastAgent";
import { SECTION_FEATURE_HINTS } from "@/lib/discoverability";
import { FeatureHintRow } from "@/components/layout/FeatureHint";
-import { Mascot } from "@/components/layout/Mascot";
+import { EmptyHero, EmptyHint } from "@/components/layout/EmptyHero";
+import { InlineError, Skeleton } from "@/components/layout/Skeleton";
+import { Select } from "@/components/layout/Select";
import { openSettings, sectionQuery, useStore } from "@/state/store";
const DEFAULT_QUERY = "logger.level:error";
@@ -355,7 +357,7 @@ export function GrafanaPane() {
return (
);
@@ -366,19 +368,20 @@ export function GrafanaPane() {
-
-
Grafana explorer
-
- Open Grafana dashboards, query Elasticsearch logs, trace stack frames into linked
- repos, and start an Ops loop agent.
-
-
openSettings(dispatch, "integrations")}
- >
- Add Grafana
-
+
+
+ openSettings(dispatch, "integrations")}
+ >
+ Add Grafana
+
+
{SECTION_FEATURE_HINTS.grafana ? (
) : null}
@@ -395,7 +398,7 @@ export function GrafanaPane() {
Firing
{alerts.length === 0 ? (
- No firing alerts.
+
) : (
{alerts.map((row) => (
@@ -530,16 +533,18 @@ export function GrafanaPane() {
Refresh
- setRefreshMs(Number(e.target.value))}
- >
- Off
- 15s
- 30s
- 1m
-
+ options={[
+ { value: "0", label: "Off" },
+ { value: "15000", label: "15s" },
+ { value: "30000", label: "30s" },
+ { value: "60000", label: "1m" },
+ ]}
+ onChange={(value) => setRefreshMs(Number(value))}
+ />
{dashboard?.url ? (
1 ? (
Datasource
- setDatasource(e.target.value)}>
- Auto
- {status.datasources.map((row) => (
-
- {row.name}
-
- ))}
-
+ ({
+ value: row.name,
+ label: row.name,
+ })),
+ ]}
+ onChange={setDatasource}
+ />
) : null}
@@ -588,8 +598,8 @@ export function GrafanaPane() {
) : null}
)}
- {status.error ? {status.error}
: null}
- {error ? {error}
: null}
+ {status.error ? : null}
+ {error ? : null}
{dashboardUid ? (
<>
{traceHop.datasource ? `Trace · ${traceHop.datasource}` : "Trace"}
- {traceHop.error ? {traceHop.error}
: null}
+ {traceHop.error ? : null}
{traceHop.spans.length ? (
{traceHop.spans.slice(0, 12).map((span) => (
@@ -854,18 +864,16 @@ function GrafanaVariables({
) : row.multi ? (
) : (
- onChange(row.name, e.target.value)}
- >
- {row.includeAll ? All : null}
- {row.options.map((opt) => (
-
- {opt.text}
-
- ))}
-
+ options={[
+ ...(row.includeAll ? [{ value: "$__all", label: "All" }] : []),
+ ...row.options.map((opt) => ({ value: opt.value, label: opt.text })),
+ ]}
+ onChange={(next) => onChange(row.name, next)}
+ />
)}
);
@@ -953,16 +961,13 @@ function GrafanaDashboardSkeleton() {
aria-label="Loading dashboard"
>
-
-
+
-
-
+
-
-
+
);
@@ -1074,7 +1079,7 @@ function GrafanaDashboardPanelBody({
annotations: GrafanaAnnotation[];
onOpenLink: (link: GrafanaRepoLink) => void;
}) {
- if (panel.error) return
{panel.error}
;
+ if (panel.error) return
;
if (panel.stat) {
return (
diff --git a/apps/desktop/src/components/guest/VmWorkspace.test.tsx b/apps/desktop/src/components/guest/VmWorkspace.test.tsx
index 12bfcbe..d2b2fc5 100644
--- a/apps/desktop/src/components/guest/VmWorkspace.test.tsx
+++ b/apps/desktop/src/components/guest/VmWorkspace.test.tsx
@@ -125,7 +125,7 @@ describe("VmWorkspace golden image toast", () => {
document.body.appendChild(host);
root = createRoot(host);
const node: ReactNode = (
-
+
);
diff --git a/apps/desktop/src/components/guest/VmWorkspace.tsx b/apps/desktop/src/components/guest/VmWorkspace.tsx
index 70390c8..cc94b04 100644
--- a/apps/desktop/src/components/guest/VmWorkspace.tsx
+++ b/apps/desktop/src/components/guest/VmWorkspace.tsx
@@ -234,7 +234,7 @@ export function VmWorkspace(props: { embedded?: boolean } = {}) {
async function saveImage() {
if (!sessionId) return;
- const name = promptGoldenName(defaultGoldenName(session?.title));
+ const name = await promptGoldenName(defaultGoldenName(session?.title));
if (!name) return;
setSavingImage(true);
setSaveError(null);
diff --git a/apps/desktop/src/components/layout/BootScreen.test.tsx b/apps/desktop/src/components/layout/BootScreen.test.tsx
index 7d4f7b4..73b5959 100644
--- a/apps/desktop/src/components/layout/BootScreen.test.tsx
+++ b/apps/desktop/src/components/layout/BootScreen.test.tsx
@@ -53,7 +53,7 @@ describe("BootScreen", () => {
expect(host.querySelector("[data-testid='locus-mascot']")?.getAttribute("aria-label")).toBe(
"Locus is loading",
);
- expect(host.textContent).toContain("Loading…");
+ expect(host.querySelector('[aria-label="Loading…"]')).toBeTruthy();
expect(host.querySelector(".app")).toBeNull();
expect(css).toMatch(/\.boot-screen\s*\{[\s\S]*?position:\s*fixed/);
expect(css).toMatch(/\.boot-screen\s*\{[\s\S]*?inset:\s*0/);
diff --git a/apps/desktop/src/components/layout/BootScreen.tsx b/apps/desktop/src/components/layout/BootScreen.tsx
index 8fb39bc..7a4e074 100644
--- a/apps/desktop/src/components/layout/BootScreen.tsx
+++ b/apps/desktop/src/components/layout/BootScreen.tsx
@@ -1,5 +1,6 @@
import { useEffect, type ReactNode } from "react";
import { Mascot } from "./Mascot";
+import { Skeleton } from "./Skeleton";
import { useStore } from "@/state/store";
export function BootScreen(props: { onRetry?: () => void }) {
@@ -19,7 +20,11 @@ export function BootScreen(props: { onRetry?: () => void }) {
size={76}
title={error ? "Locus could not load" : "Locus is loading"}
/>
- {error ?? "Loading…"}
+ {error ? (
+ {error}
+ ) : (
+
+ )}
{error && props.onRetry ? (
void;
}) {
const copy = closeConfirmCopy(agents);
- const dialogRef = useRef(null);
- useDialogFocus(true, onCancel, dialogRef);
return (
-
-
event.stopPropagation()}
- >
-
{copy.title}
-
{copy.body}
-
-
- Cancel
-
-
- Close
-
-
+
+ {copy.title}
+ {copy.body}
+
+
+ Cancel
+
+
+ Close
+
-
+
);
}
diff --git a/apps/desktop/src/components/layout/ConfirmModal.tsx b/apps/desktop/src/components/layout/ConfirmModal.tsx
index 664bd63..5634ff3 100644
--- a/apps/desktop/src/components/layout/ConfirmModal.tsx
+++ b/apps/desktop/src/components/layout/ConfirmModal.tsx
@@ -1,5 +1,4 @@
-import { useRef } from "react";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { Modal } from "./Modal";
export function ConfirmModal(props: {
title: string;
@@ -10,38 +9,27 @@ export function ConfirmModal(props: {
onCancel: () => void;
onConfirm: () => void;
}) {
- const dialogRef = useRef
(null);
- useDialogFocus(true, props.onCancel, dialogRef);
-
return (
-
-
event.stopPropagation()}
- >
-
{props.title}
-
{props.body}
-
-
- Cancel
-
-
- {props.confirmLabel}
-
-
+
{props.title}
+
{props.body}
+
+
+ Cancel
+
+
+ {props.confirmLabel}
+
-
+
);
}
diff --git a/apps/desktop/src/components/layout/DialogHost.tsx b/apps/desktop/src/components/layout/DialogHost.tsx
new file mode 100644
index 0000000..e88aef7
--- /dev/null
+++ b/apps/desktop/src/components/layout/DialogHost.tsx
@@ -0,0 +1,92 @@
+import { useEffect, useState } from "react";
+import {
+ bindDialogHost,
+ type AlertOptions,
+ type ConfirmOptions,
+ type PromptOptions,
+} from "@/lib/dialog";
+import { ConfirmModal } from "./ConfirmModal";
+import { PromptModal } from "./PromptModal";
+
+type Pending =
+ | { kind: "confirm"; opts: ConfirmOptions; resolve: (ok: boolean) => void }
+ | { kind: "prompt"; opts: PromptOptions; resolve: (value: string | null) => void }
+ | { kind: "alert"; opts: AlertOptions; resolve: () => void };
+
+export function DialogHost() {
+ const [pending, setPending] = useState
(null);
+
+ useEffect(() => {
+ bindDialogHost({
+ confirm: (opts) =>
+ new Promise((resolve) => {
+ setPending({ kind: "confirm", opts, resolve });
+ }),
+ prompt: (opts) =>
+ new Promise((resolve) => {
+ setPending({ kind: "prompt", opts, resolve });
+ }),
+ alert: (opts) =>
+ new Promise((resolve) => {
+ setPending({ kind: "alert", opts, resolve });
+ }),
+ });
+ return () => bindDialogHost(null);
+ }, []);
+
+ if (!pending) return null;
+
+ if (pending.kind === "confirm") {
+ return (
+ {
+ pending.resolve(false);
+ setPending(null);
+ }}
+ onConfirm={() => {
+ pending.resolve(true);
+ setPending(null);
+ }}
+ />
+ );
+ }
+
+ if (pending.kind === "prompt") {
+ return (
+ {
+ pending.resolve(null);
+ setPending(null);
+ }}
+ onConfirm={(value) => {
+ pending.resolve(value);
+ setPending(null);
+ }}
+ />
+ );
+ }
+
+ return (
+ {
+ pending.resolve();
+ setPending(null);
+ }}
+ onConfirm={() => {
+ pending.resolve();
+ setPending(null);
+ }}
+ />
+ );
+}
diff --git a/apps/desktop/src/components/layout/EmptyHero.test.tsx b/apps/desktop/src/components/layout/EmptyHero.test.tsx
new file mode 100644
index 0000000..7d4b436
--- /dev/null
+++ b/apps/desktop/src/components/layout/EmptyHero.test.tsx
@@ -0,0 +1,34 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import { EmptyHero, EmptyHint, EmptyListNote } from "./EmptyHero";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("empty states", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("renders the compact hint and list note", () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render(
+ <>
+
+
+ No matching agents
+ >,
+ );
+ });
+ expect(host.querySelector(".empty-hero h2")?.textContent).toBe("Board");
+ expect(host.querySelector(".empty-hint-title")?.textContent).toBe("No plugins yet.");
+ expect(host.querySelector(".empty-list-body")?.textContent).toBe("No matching agents");
+ });
+});
diff --git a/apps/desktop/src/components/layout/EmptyHero.tsx b/apps/desktop/src/components/layout/EmptyHero.tsx
new file mode 100644
index 0000000..ee8a431
--- /dev/null
+++ b/apps/desktop/src/components/layout/EmptyHero.tsx
@@ -0,0 +1,59 @@
+import type { ReactNode } from "react";
+import { Mascot, type MascotPose } from "./Mascot";
+
+/** Shared empty-state heading: mascot, one title, one sentence. */
+export function EmptyHero(props: {
+ title: string;
+ lead: string;
+ eyebrow?: string;
+ pose?: MascotPose;
+ followCursor?: boolean;
+ hideMascot?: boolean;
+ compact?: boolean;
+ size?: number;
+ children?: ReactNode;
+}) {
+ return (
+
+ {props.hideMascot ? null : (
+
+ )}
+
+ {props.eyebrow ?
{props.eyebrow}
: null}
+
{props.title}
+
{props.lead}
+ {props.children}
+
+
+ );
+}
+
+/** Compact in-pane empty copy (lists, rails, filters). */
+export function EmptyHint(props: {
+ title: string;
+ lead?: string;
+ action?: ReactNode;
+ testId?: string;
+}) {
+ return (
+
+
{props.title}
+ {props.lead ?
{props.lead}
: null}
+ {props.action}
+
+ );
+}
+
+/** Sidebar / catalog empty row with the rest mascot. */
+export function EmptyListNote({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/EmptyState.test.tsx b/apps/desktop/src/components/layout/EmptyState.test.tsx
index 6f814d5..b398b9f 100644
--- a/apps/desktop/src/components/layout/EmptyState.test.tsx
+++ b/apps/desktop/src/components/layout/EmptyState.test.tsx
@@ -19,6 +19,7 @@ import { Sidebar } from "./Sidebar";
import { FINISH_SETUP_DISMISSED_KEY } from "@/components/settings/FinishSetupChecklist";
import { api } from "@/lib/api";
import { Store, initialState, reducer, type Action, type UiState } from "@/state/store";
+import { selectValue, setSelectValue } from "@/lib/selectControl";
vi.mock("@/lib/api", () => ({
api: {
@@ -560,8 +561,10 @@ describe("EmptyState recipes", () => {
render();
const attach = host.querySelector("button.composer-add") as HTMLButtonElement;
expect(attach).not.toBeNull();
- expect(attach.title).toMatch(/Copied into \/workspace\/\.locus-attachments/);
- expect(attach.title).not.toMatch(/Vision is off/);
+ expect(attach.getAttribute("aria-label")).toMatch(
+ /Copied into \/workspace\/\.locus-attachments/,
+ );
+ expect(attach.getAttribute("aria-label")).not.toMatch(/Vision is off/);
expect(host.querySelector(".composer-attach")).toBeNull();
const dockerTab = [...host.querySelectorAll("button")].find(
(btn) => btn.textContent === "Docker",
@@ -569,16 +572,16 @@ describe("EmptyState recipes", () => {
act(() => {
dockerTab!.click();
});
- expect((host.querySelector("button.composer-add") as HTMLButtonElement).title).toMatch(
- /Vision is off/,
- );
+ expect(
+ (host.querySelector("button.composer-add") as HTMLButtonElement).getAttribute("aria-label"),
+ ).toMatch(/Vision is off/);
const vmTab = [...host.querySelectorAll("button")].find((btn) => btn.textContent === "VM");
act(() => {
vmTab!.click();
});
- expect((host.querySelector("button.composer-add") as HTMLButtonElement).title).not.toMatch(
- /Vision is off/,
- );
+ expect(
+ (host.querySelector("button.composer-add") as HTMLButtonElement).getAttribute("aria-label"),
+ ).not.toMatch(/Vision is off/);
act(() => {
dockerTab!.click();
});
@@ -639,7 +642,9 @@ describe("EmptyState recipes", () => {
});
const attach = host.querySelector("button.composer-add") as HTMLButtonElement;
expect(attach).not.toBeNull();
- expect(attach.title).toMatch(/Copied into \/workspace\/\.locus-attachments/);
+ expect(attach.getAttribute("aria-label")).toMatch(
+ /Copied into \/workspace\/\.locus-attachments/,
+ );
const input = host.querySelector("input[type='file']") as HTMLInputElement;
const file = new File(["hello"], "brief.txt", { type: "text/plain" });
await act(async () => {
@@ -1816,16 +1821,12 @@ describe("EmptyState named environment", () => {
,
);
});
- const picker = host.querySelector('[aria-label="Named environment"]') as HTMLSelectElement;
- expect(picker).toBeTruthy();
+ expect(host.querySelector('[aria-label="Named environment"]')).toBeTruthy();
expect(host.textContent).toContain("Save as environment");
act(() => {
- picker.value = "env-1";
- picker.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Named environment", "env-1");
});
- expect((host.querySelector('[aria-label="Jail"]') as HTMLSelectElement).value).toBe(
- "restricted",
- );
+ expect(selectValue(host, "Jail")).toBe("restricted");
expect((host.querySelector('[aria-label="Spend cap (USD)"]') as HTMLInputElement).value).toBe(
"20",
);
@@ -1881,10 +1882,8 @@ describe("EmptyState named environment", () => {
,
);
});
- const picker = host.querySelector('[aria-label="Named environment"]') as HTMLSelectElement;
act(() => {
- picker.value = "env-1";
- picker.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Named environment", "env-1");
});
act(() => {
[...host.querySelectorAll("button")]
diff --git a/apps/desktop/src/components/layout/EmptyState.tsx b/apps/desktop/src/components/layout/EmptyState.tsx
index 6e2980b..75efa6f 100644
--- a/apps/desktop/src/components/layout/EmptyState.tsx
+++ b/apps/desktop/src/components/layout/EmptyState.tsx
@@ -67,7 +67,8 @@ import {
saveFinishSetupDismissed,
} from "@/components/settings/FinishSetupChecklist";
import { LaunchWorkspace, type LaunchWorkspaceSelection } from "./LaunchWorkspace";
-import { Mascot } from "./Mascot";
+import { EmptyHero } from "./EmptyHero";
+import { Select } from "./Select";
import { PersonaAvatarPicker } from "./PersonaAvatar";
export function EmptyState() {
@@ -152,7 +153,7 @@ export function EmptyState() {
: undefined;
const launchHostDir = hostChat && workspace.kind === "host" ? workspace.path : undefined;
const mockDocker = effectiveRuntime === "docker" && state.dockerKind === "mock" && !backendId;
- const mockHypervisor = effectiveRuntime === "vm" && state.hypervisorKind === "mock";
+ const mockHypervisor = effectiveRuntime === "vm" && !backendId && state.hypervisorKind === "mock";
const engineBlocked = mockDocker || mockHypervisor;
const canCreate =
Boolean(state.config) &&
@@ -161,7 +162,7 @@ export function EmptyState() {
(effectiveRuntime === "docker" ? Boolean(launchDockerImage) : available.length > 0));
const showRuntimeChrome = !hubMode || showAdvanced;
const workerHint = backendId
- ? `${effectiveRuntime === "host" ? "The host workspace" : "The container"} runs on ${
+ ? `${effectiveRuntime === "host" ? "The host workspace" : effectiveRuntime === "vm" ? "The VM" : "The container"} runs on ${
selectedWorker ? workerWhereLabel(selectedWorker) : "the worker"
}. Chat and keys stay on this machine.${
selectedWorker && workerIsMetered(selectedWorker)
@@ -226,8 +227,7 @@ export function EmptyState() {
setRuntime(next);
setNamedEnvId("");
setWorkspace({ kind: "private" });
- if (next === "vm") setBackendId("");
- if (next === "docker") {
+ if (next === "docker" || next === "vm") {
const last = loadLastAgent();
setBackendId((id) => id || rememberedWorkerId(last, workers));
}
@@ -407,7 +407,12 @@ export function EmptyState() {
);
const showEngineCallout = engineBlocked && !checklistOpen;
const showImageCallout = Boolean(
- setup && runtime === "vm" && !engineBlocked && !setup.linuxImageReady && !checklistOpen,
+ setup &&
+ runtime === "vm" &&
+ !backendId &&
+ !engineBlocked &&
+ !setup.linuxImageReady &&
+ !checklistOpen,
);
const showGitCallout = Boolean(
state.config && !hasForge && !checklistOpen && !showEngineCallout && !setupHintDismissed,
@@ -422,15 +427,16 @@ export function EmptyState() {
return (
-
- {hubMode ? null : }
- {hubMode ? "Create a teammate" : "What should we talk about?"}
-
- {hubMode
+
-
+ : "Choose how it runs, then the folder it can touch."
+ }
+ />
{hubMode ? (
@@ -447,10 +453,15 @@ export function EmptyState() {
Role (optional)
- {
+ options={[
+ { value: "", label: "Ask when we start" },
+ ...HUB_ROLE_PRESETS.map((role) => ({ value: role, label: role })),
+ ]}
+ onChange={(value) => {
const next = applyHubRoleToDraft(
{
displayName,
@@ -461,21 +472,13 @@ export function EmptyState() {
hubRole,
personality,
},
- e.target.value,
+ value,
);
setHubRole(next.hubRole);
setPersonality(next.personality);
setAvatarId(next.avatarId);
}}
- data-testid="hub-role"
- >
- Ask when we start
- {HUB_ROLE_PRESETS.map((role) => (
-
- {role}
-
- ))}
-
+ />
Personality (optional)
@@ -609,12 +612,6 @@ export function EmptyState() {
value={backendId}
onChange={(id) => {
setBackendId(id);
- if (id) {
- if (runtime === "vm") changeRuntime("docker");
- else if (recipeId && recipes.find((r) => r.id === recipeId)?.runtime === "vm") {
- clearRecipeExtras();
- }
- }
}}
/>
{workerHint || "\u00a0"}
@@ -639,7 +636,6 @@ export function EmptyState() {
disabledRuntimes={hostAgentsLocked ? ["host"] : []}
onRuntimeChange={(next) => {
if (hostAgentsLocked && next === "host") return;
- if (backendId && next === "vm") setBackendId("");
changeRuntime(next);
}}
/>
@@ -779,11 +775,7 @@ export function EmptyState() {
- {error && (
-
- {error}
-
- )}
+ {error &&
{error}
}
{showOptions ? (
+
-
Try next
+
Tip
+ {SHORTCUTS.map((row) => (
+
+ {row.action}
+ {shortcutDisplay(row.keys)}
+
+ ))}
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/MobileShell.test.tsx b/apps/desktop/src/components/layout/MobileShell.test.tsx
index 1f0c33e..8163d12 100644
--- a/apps/desktop/src/components/layout/MobileShell.test.tsx
+++ b/apps/desktop/src/components/layout/MobileShell.test.tsx
@@ -6,6 +6,7 @@ import { MobileShell } from "./MobileShell";
import { api } from "@/lib/api";
import { setUiShell } from "@/lib/mobileShell";
import { Store, initialState, reducer, type UiState } from "@/state/store";
+import { selectOptionValues, selectValue, setSelectValue } from "@/lib/selectControl";
vi.mock("@/lib/api", () => ({
api: {
@@ -1380,13 +1381,8 @@ describe("MobileShell", () => {
expect(row?.textContent).toMatch(/Needs you/);
expect(host.querySelector("[data-testid='mobile-notify-needs-you']")).toBeTruthy();
expect(host.querySelector("[data-testid='mobile-pairing']")?.textContent).toMatch(/desktop/i);
- const density = host.querySelector('select[aria-label="Chat density"]') as HTMLSelectElement;
- expect(density?.value).toBe("compact");
- expect([...density.querySelectorAll("option")].map((opt) => opt.value)).toEqual([
- "comfortable",
- "compact",
- "dense",
- ]);
+ expect(selectValue(host, "Chat density")).toBe("compact");
+ expect(selectOptionValues(host, "Chat density")).toEqual(["comfortable", "compact", "dense"]);
});
it("points Slack sign-in at the desktop", async () => {
@@ -1829,13 +1825,9 @@ describe("MobileShell", () => {
act(() => {
row!.click();
});
- const scaffold = host.querySelector(
- 'select[aria-label="Promote scaffold"]',
- ) as HTMLSelectElement;
- expect(scaffold.value).toBe("vite-react");
+ expect(selectValue(host, "Promote scaffold")).toBe("vite-react");
act(() => {
- scaffold.value = "next";
- scaffold.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Promote scaffold", "next");
});
const promote = [...host.querySelectorAll("button")].find(
(btn) => btn.textContent === "Promote",
diff --git a/apps/desktop/src/components/layout/MobileShell.tsx b/apps/desktop/src/components/layout/MobileShell.tsx
index 2538099..733447e 100644
--- a/apps/desktop/src/components/layout/MobileShell.tsx
+++ b/apps/desktop/src/components/layout/MobileShell.tsx
@@ -95,7 +95,6 @@ import { screenshotSrc } from "@/components/chat/GuestTimeline";
import { BrowserPane } from "@/components/guest/BrowserPane";
import {
IconAgents,
- IconChevron,
IconGear,
IconGlobe,
IconHash,
@@ -109,9 +108,12 @@ import {
IconStop,
} from "./Icons";
import { MenuItem, OverflowMenu } from "./OverflowMenu";
+import { Select } from "./Select";
import { MobileFitPane } from "./MobileFitPane";
import { observePinThreadToLatest } from "@/lib/threadScroll";
import { visitMarkerId } from "@/lib/chatNewMarker";
+import { askConfirm } from "@/lib/dialog";
+import { showNotice } from "@/lib/notice";
import { PatchCard, ToolPatch } from "@/components/chat/PatchCard";
import { CloneProgressCard, ImageBuildCard } from "@/components/chat/CloneProgressCard";
import { SubagentCard } from "@/components/chat/SubagentCard";
@@ -958,7 +960,7 @@ function MobileMore(props: {
props.onOpenAgent(result.sessionId);
},
(err: unknown) => {
- window.alert(err instanceof Error ? err.message : "Could not open project");
+ showNotice(err instanceof Error ? err.message : "Could not open project");
},
);
}}
@@ -1030,29 +1032,29 @@ function MobileMore(props: {
Theme
- void pickTheme(event.target.value)}>
- {THEME_OPTIONS.map((opt) => (
-
- {opt.name}
-
- ))}
-
-
+ ({ value: opt.id, label: opt.name }))}
+ onChange={(id) => void pickTheme(id)}
+ />
Chat density
- void pickDensity(event.target.value)}
- >
- Comfortable
- Compact
- Dense
-
-
+ options={[
+ { value: "comfortable", label: "Comfortable" },
+ { value: "compact", label: "Compact" },
+ { value: "dense", label: "Dense" },
+ ]}
+ onChange={(id) => void pickDensity(id)}
+ />
@@ -1251,6 +1253,14 @@ function MobileMore(props: {
+
+ Brain
+
+ Open on desktop
+ Index · remember · orient
+
+
+
Grafana
setGrafanaOpen(true)}>
@@ -1259,6 +1269,14 @@ function MobileMore(props: {
+
+ Docker
+
+ Open on desktop
+ Containers · compose
+
+
+
Automations
{state.automations.length ? (
@@ -1377,8 +1395,7 @@ function MobileCompose(props: {
setError(null);
try {
const isCanvas = props.kind === "design" || props.kind === "game" || props.kind === "assets";
- const useDocker =
- isCanvas || runtime === "docker" || (runtime === "vm" && Boolean(backendId));
+ const useDocker = isCanvas || runtime === "docker";
const dockerImage = useDocker
? parseDockerImage(
isCanvas ? DESIGN_DOCKER_IMAGE : (prefs.dockerImage ?? DEFAULT_DOCKER_IMAGE),
@@ -1395,7 +1412,7 @@ function MobileCompose(props: {
: props.kind === "assets"
? "assets"
: undefined,
- runtime: isCanvas ? "docker" : runtime === "vm" && backendId ? "docker" : runtime,
+ runtime: isCanvas ? "docker" : runtime,
dockerImage,
providerId: defaults.providerId,
model: defaults.model,
@@ -1408,7 +1425,7 @@ function MobileCompose(props: {
saveLastAgent({
...prefs,
os: isCanvas ? "ubuntu" : prefs.os,
- runtime: isCanvas ? "docker" : runtime === "vm" && backendId ? "docker" : runtime,
+ runtime: isCanvas ? "docker" : runtime,
dockerImage: isCanvas ? DESIGN_DOCKER_IMAGE : prefs.dockerImage,
providerId: defaults.providerId,
model: defaults.model,
@@ -1662,13 +1679,23 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {
{canClearChat(session) ? (
{
- if (!window.confirm(CLEAR_CHAT_CONFIRM)) return;
- void api
- .clearChat(session.id)
- .then((updated) => dispatch({ type: "session", session: updated }))
- .catch((err) => {
+ void (async () => {
+ if (
+ !(await askConfirm({
+ title: "Clear chat?",
+ body: CLEAR_CHAT_CONFIRM,
+ confirmLabel: "Clear",
+ danger: true,
+ }))
+ )
+ return;
+ try {
+ const updated = await api.clearChat(session.id);
+ dispatch({ type: "session", session: updated });
+ } catch (err) {
setError(err instanceof Error ? err.message : "Couldn't clear");
- });
+ }
+ })();
}}
>
Clear chat
diff --git a/apps/desktop/src/components/layout/Modal.test.tsx b/apps/desktop/src/components/layout/Modal.test.tsx
new file mode 100644
index 0000000..256a519
--- /dev/null
+++ b/apps/desktop/src/components/layout/Modal.test.tsx
@@ -0,0 +1,44 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Modal } from "./Modal";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("Modal", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("traps focus, labels the dialog, and closes on backdrop and Escape", async () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ const onClose = vi.fn();
+ await act(async () => {
+ root.render(
+
+ Sample
+ First
+ Last
+ ,
+ );
+ });
+ const dialog = host.querySelector("[role='dialog']") as HTMLElement;
+ expect(dialog.getAttribute("aria-labelledby")).toBe("sample-title");
+ expect(dialog.getAttribute("aria-modal")).toBe("true");
+ expect(host.querySelector("[data-testid='sample-modal']")).toBeTruthy();
+ await act(async () => {
+ (host.querySelector("[data-testid='sample-modal']") as HTMLElement).click();
+ });
+ expect(onClose).toHaveBeenCalledOnce();
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/desktop/src/components/layout/Modal.tsx b/apps/desktop/src/components/layout/Modal.tsx
new file mode 100644
index 0000000..ad82f9e
--- /dev/null
+++ b/apps/desktop/src/components/layout/Modal.tsx
@@ -0,0 +1,60 @@
+import { useRef, type CSSProperties, type FormEventHandler, type ReactNode } from "react";
+import { useDialogFocus } from "@/lib/useDialogFocus";
+
+/** Shared dialog chrome: backdrop, focus trap, Escape, and optional click-outside close. */
+export function Modal(props: {
+ onClose: () => void;
+ children: ReactNode;
+ titleId?: string;
+ label?: string;
+ className?: string;
+ backdropClassName?: string;
+ testId?: string;
+ dialogTestId?: string;
+ closeOnBackdrop?: boolean;
+ trap?: boolean;
+ busy?: boolean;
+ describedBy?: string;
+ style?: CSSProperties;
+ onInput?: FormEventHandler;
+ onChange?: FormEventHandler;
+ /** Ignore backdrop clicks for this many ms after mount (opening click). */
+ closeGuardMs?: number;
+}) {
+ const dialogRef = useRef(null);
+ const openedAt = useRef(Date.now());
+ useDialogFocus(true, props.onClose, dialogRef, { trap: props.trap });
+ const closeOnBackdrop = props.closeOnBackdrop !== false;
+ const labelledBy = props.titleId;
+ const label = labelledBy ? undefined : props.label;
+ function onBackdrop() {
+ if (!closeOnBackdrop) return;
+ if (props.closeGuardMs && Date.now() - openedAt.current < props.closeGuardMs) return;
+ props.onClose();
+ }
+ return (
+
+
event.stopPropagation()}
+ onInput={props.onInput}
+ onChange={props.onChange}
+ >
+ {props.children}
+
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/NoticeBanner.tsx b/apps/desktop/src/components/layout/NoticeBanner.tsx
index 2f98514..de70fd6 100644
--- a/apps/desktop/src/components/layout/NoticeBanner.tsx
+++ b/apps/desktop/src/components/layout/NoticeBanner.tsx
@@ -1,23 +1,49 @@
import { useEffect, useState } from "react";
-import { dismissNotice, subscribeNotice } from "@/lib/notice";
+import { dismissNotice, subscribeNotices, type Notice } from "@/lib/notice";
export function NoticeBanner() {
- const [message, setMessage] = useState(null);
- useEffect(() => subscribeNotice(setMessage), []);
- if (!message) return null;
+ const [items, setItems] = useState([]);
+ useEffect(() => subscribeNotices(setItems), []);
+ useEffect(() => {
+ if (!items.length) return;
+ function onKey(event: KeyboardEvent) {
+ if (event.key !== "Escape") return;
+ if (document.querySelector('[role="dialog"]')) return;
+ event.preventDefault();
+ const latest = items[items.length - 1];
+ if (latest) dismissNotice(latest.id);
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [items]);
+ if (!items.length) return null;
return (
-
+ {items.map((item) => (
+
+
{item.message}
+ {item.actionLabel && item.onAction ? (
+
{
+ item.onAction?.();
+ dismissNotice(item.id);
+ }}
+ >
+ {item.actionLabel}
+
+ ) : null}
+
dismissNotice(item.id)}
+ >
+ ×
+
+
+ ))}
);
}
diff --git a/apps/desktop/src/components/layout/OverflowMenu.test.tsx b/apps/desktop/src/components/layout/OverflowMenu.test.tsx
index 9b2fc25..f4e2296 100644
--- a/apps/desktop/src/components/layout/OverflowMenu.test.tsx
+++ b/apps/desktop/src/components/layout/OverflowMenu.test.tsx
@@ -10,6 +10,8 @@ import {
fixedContainingOrigin,
placeMenuPop,
placePointerMenu,
+ typeaheadMenuIndex,
+ wrapMenuIndex,
} from "./OverflowMenu";
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../index.css"), "utf8");
@@ -214,6 +216,17 @@ describe("OverflowMenu CSS", () => {
});
});
+describe("menu keyboard helpers", () => {
+ it("wraps arrow movement and matches typeahead from the next item", () => {
+ expect(wrapMenuIndex(0, 3, 1)).toBe(1);
+ expect(wrapMenuIndex(2, 3, 1)).toBe(0);
+ expect(wrapMenuIndex(0, 3, -1)).toBe(2);
+ expect(typeaheadMenuIndex(["Guided", "Supervised", "Autonomous"], "s", 0)).toBe(1);
+ expect(typeaheadMenuIndex(["Guided", "Supervised", "Autonomous"], "a", 1)).toBe(2);
+ expect(typeaheadMenuIndex(["Guided", "Supervised", "Autonomous"], "g", 0)).toBe(0);
+ });
+});
+
describe("OverflowMenu", () => {
let host: HTMLDivElement;
let root: Root;
@@ -304,6 +317,49 @@ describe("OverflowMenu", () => {
expect(details().open).toBe(false);
});
+ it("moves through items with arrows, Home/End, and typeahead, then restores focus", () => {
+ render(
+
+ Alpha
+ Bravo
+ Charlie
+ ,
+ );
+ const trigger = host.querySelector("summary") as HTMLElement;
+ act(() => {
+ trigger.focus();
+ trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
+ });
+ expect((host.querySelector("details.menu") as HTMLDetailsElement).open).toBe(true);
+ expect(document.activeElement?.textContent).toContain("Alpha");
+
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
+ });
+ expect(document.activeElement?.textContent).toContain("Bravo");
+
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
+ });
+ expect(document.activeElement?.textContent).toContain("Charlie");
+
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
+ });
+ expect(document.activeElement?.textContent).toContain("Alpha");
+
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true }));
+ });
+ expect(document.activeElement?.textContent).toContain("Charlie");
+
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+ expect((host.querySelector("details.menu") as HTMLDetailsElement).open).toBe(false);
+ expect(document.activeElement).toBe(trigger);
+ });
+
it("does not widen the placed pop when the chat pane scrolls", () => {
render(
diff --git a/apps/desktop/src/components/layout/OverflowMenu.tsx b/apps/desktop/src/components/layout/OverflowMenu.tsx
index 30ff24d..ee5800f 100644
--- a/apps/desktop/src/components/layout/OverflowMenu.tsx
+++ b/apps/desktop/src/components/layout/OverflowMenu.tsx
@@ -10,6 +10,7 @@ import {
type ReactNode,
} from "react";
import { IconMore } from "./Icons";
+import { Tooltip } from "./Tooltip";
const MENU_GAP = 6;
const MENU_PAD = 8;
@@ -111,6 +112,39 @@ export function placePointerMenu(opts: {
return { top: placed.top, left: placed.left };
}
+const TYPEAHEAD_MS = 600;
+
+export function menuItemButtons(root: ParentNode | null): HTMLButtonElement[] {
+ if (!root) return [];
+ return [
+ ...root.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
+ ].filter((el) => !el.disabled);
+}
+
+export function wrapMenuIndex(index: number, length: number, delta: number): number {
+ if (length <= 0) return 0;
+ return (index + delta + length) % length;
+}
+
+export function typeaheadMenuIndex(labels: string[], query: string, fromIndex: number): number {
+ const q = query.trim().toLowerCase();
+ if (!q || !labels.length) return -1;
+ const start = labels.length ? (fromIndex + 1 + labels.length) % labels.length : 0;
+ for (let i = 0; i < labels.length; i++) {
+ const idx = (start + i) % labels.length;
+ if (labels[idx]!.trim().toLowerCase().startsWith(q)) return idx;
+ }
+ return -1;
+}
+
+function isTypingTarget(node: EventTarget | null): boolean {
+ return (
+ node instanceof HTMLInputElement ||
+ node instanceof HTMLTextAreaElement ||
+ (node instanceof HTMLElement && node.isContentEditable)
+ );
+}
+
export function OverflowMenu(props: {
label?: ReactNode;
title?: string;
@@ -119,16 +153,55 @@ export function OverflowMenu(props: {
toggleClassName?: string;
wide?: boolean;
prefer?: "up" | "down";
+ disabled?: boolean;
onOpen?: () => void;
+ /** Current value, mirrored on the trigger for tests and a11y. */
+ dataValue?: string;
+ id?: string;
+ testId?: string;
children: ReactNode;
}) {
const root = useRef(null);
const pop = useRef(null);
const popSize = useRef<{ width: number; height: number } | null>(null);
+ const typeahead = useRef({ query: "", at: 0 });
+ const pendingFocus = useRef<"first" | "last" | "active">("active");
const [open, setOpen] = useState(false);
const [place, setPlace] = useState<{ up: boolean; top: number; left: number } | null>(null);
const align = props.align === "left" ? "left" : props.align === "after" ? "after" : "right";
- const close = useCallback(() => setOpen(false), []);
+
+ const focusTrigger = useCallback(() => {
+ const summary = root.current?.querySelector(":scope > summary");
+ if (summary instanceof HTMLElement) summary.focus();
+ }, []);
+
+ const close = useCallback(() => {
+ const active = document.activeElement;
+ const restore = Boolean(
+ root.current && active instanceof Node && root.current.contains(active),
+ );
+ setOpen(false);
+ if (restore) {
+ queueMicrotask(focusTrigger);
+ }
+ }, [focusTrigger]);
+
+ function items(): HTMLButtonElement[] {
+ return menuItemButtons(pop.current);
+ }
+
+ const focusItem = useCallback((index: number) => {
+ menuItemButtons(pop.current)[index]?.focus();
+ }, []);
+
+ function openMenu(focus: "first" | "last" | "active" = "active") {
+ if (props.disabled) return;
+ pendingFocus.current = focus;
+ if (!open) {
+ setOpen(true);
+ props.onOpen?.();
+ }
+ }
useEffect(() => {
const summary = root.current?.querySelector(":scope > summary");
@@ -140,6 +213,20 @@ export function OverflowMenu(props: {
return () => summary.removeEventListener("click", suppressNativeToggle, true);
}, []);
+ useLayoutEffect(() => {
+ if (!open) return;
+ const list = items();
+ if (!list.length) return;
+ const active = list.findIndex(
+ (el) => el.getAttribute("aria-current") === "true" || el.classList.contains("active"),
+ );
+ const want = pendingFocus.current;
+ const index =
+ want === "last" ? list.length - 1 : want === "first" ? 0 : active >= 0 ? active : 0;
+ pendingFocus.current = "active";
+ list[index]?.focus();
+ }, [open]);
+
useEffect(() => {
if (!open) return;
function onPointer(event: PointerEvent) {
@@ -148,9 +235,56 @@ export function OverflowMenu(props: {
setOpen(false);
}
function onKey(event: KeyboardEvent) {
- if (event.key !== "Escape") return;
- event.preventDefault();
- setOpen(false);
+ if (isTypingTarget(event.target)) return;
+ const list = items();
+ const current = list.findIndex((el) => el === document.activeElement);
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setOpen(false);
+ focusTrigger();
+ return;
+ }
+ if (event.key === "Tab") {
+ setOpen(false);
+ focusTrigger();
+ return;
+ }
+ if (!list.length) return;
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ focusItem(wrapMenuIndex(current < 0 ? -1 : current, list.length, 1));
+ return;
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ focusItem(wrapMenuIndex(current < 0 ? 0 : current, list.length, -1));
+ return;
+ }
+ if (event.key === "Home") {
+ event.preventDefault();
+ focusItem(0);
+ return;
+ }
+ if (event.key === "End") {
+ event.preventDefault();
+ focusItem(list.length - 1);
+ return;
+ }
+ if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
+ if (event.key === " ") return;
+ const now = Date.now();
+ if (now - typeahead.current.at > TYPEAHEAD_MS) typeahead.current.query = "";
+ typeahead.current.query += event.key;
+ typeahead.current.at = now;
+ const next = typeaheadMenuIndex(
+ list.map((el) => el.textContent ?? ""),
+ typeahead.current.query,
+ current,
+ );
+ if (next >= 0) {
+ event.preventDefault();
+ focusItem(next);
+ }
}
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
@@ -158,7 +292,7 @@ export function OverflowMenu(props: {
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("keydown", onKey);
};
- }, [open]);
+ }, [open, focusTrigger, focusItem]);
useLayoutEffect(() => {
if (!open) {
@@ -204,27 +338,55 @@ export function OverflowMenu(props: {
? { top: place.top, left: place.left }
: undefined;
- return (
+ const menu = (
{
// Native in a flex toolbar (and React's open prop) can toggle
// twice on one click, so the menu appears to open and immediately close.
event.preventDefault();
+ if (props.disabled) return;
const next = !open;
+ pendingFocus.current = "active";
setOpen(next);
if (next) props.onOpen?.();
}}
+ onKeyDown={(event) => {
+ if (props.disabled) return;
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ openMenu("first");
+ return;
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ openMenu("last");
+ return;
+ }
+ if (event.key === "Home") {
+ event.preventDefault();
+ openMenu("first");
+ return;
+ }
+ if (event.key === "End") {
+ event.preventDefault();
+ openMenu("last");
+ }
+ }}
>
{props.label ?? }
@@ -239,6 +401,7 @@ export function OverflowMenu(props: {
);
+ return {menu} ;
}
export function MenuItem(props: {
@@ -251,12 +414,13 @@ export function MenuItem(props: {
pressed?: boolean;
testId?: string;
title?: string;
+ value?: string;
children: ReactNode;
}) {
const close = useContext(MenuCloser);
const toggle = props.checked !== undefined;
const on = Boolean(props.checked ?? props.active);
- return (
+ const item = (
{
close?.();
@@ -280,6 +446,12 @@ export function MenuItem(props: {
{props.children}
);
+ if (!props.title) return item;
+ return (
+
+ {item}
+
+ );
}
export function MenuSep() {
diff --git a/apps/desktop/src/components/layout/PromptModal.test.tsx b/apps/desktop/src/components/layout/PromptModal.test.tsx
new file mode 100644
index 0000000..bb5f5b9
--- /dev/null
+++ b/apps/desktop/src/components/layout/PromptModal.test.tsx
@@ -0,0 +1,47 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { PromptModal } from "./PromptModal";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("PromptModal", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("submits the typed value and cancels on Escape", async () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ const onCancel = vi.fn();
+ const onConfirm = vi.fn();
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ const dialog = host.querySelector("[data-testid='prompt-modal']");
+ expect(dialog).toBeTruthy();
+ expect(host.querySelector("#prompt-modal-title")?.textContent).toBe("Name this golden image");
+ const input = host.querySelector(".prompt-modal-input") as HTMLInputElement;
+ expect(input.value).toBe("Web app image");
+ await act(async () => {
+ [...host.querySelectorAll("button")].find((btn) => btn.textContent === "Save")!.click();
+ });
+ expect(onConfirm).toHaveBeenCalledWith("Web app image");
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/desktop/src/components/layout/PromptModal.tsx b/apps/desktop/src/components/layout/PromptModal.tsx
new file mode 100644
index 0000000..53c2509
--- /dev/null
+++ b/apps/desktop/src/components/layout/PromptModal.tsx
@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { Modal } from "./Modal";
+
+export function PromptModal(props: {
+ title: string;
+ body?: string;
+ value?: string;
+ confirmLabel?: string;
+ testId?: string;
+ onCancel: () => void;
+ onConfirm: (value: string) => void;
+}) {
+ const [value, setValue] = useState(props.value ?? "");
+
+ return (
+
+ {props.title}
+ {props.body ? {props.body}
: null}
+ {
+ event.preventDefault();
+ props.onConfirm(value);
+ }}
+ >
+ setValue(event.target.value)}
+ autoFocus
+ aria-label={props.title}
+ />
+
+
+ Cancel
+
+
+ {props.confirmLabel ?? "Save"}
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/Select.test.tsx b/apps/desktop/src/components/layout/Select.test.tsx
new file mode 100644
index 0000000..ffb8383
--- /dev/null
+++ b/apps/desktop/src/components/layout/Select.test.tsx
@@ -0,0 +1,84 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import { Select } from "./Select";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("Select", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("shows the selected label and emits onChange from the menu", () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ let value = "guided";
+ function App() {
+ return (
+ {
+ value = next;
+ }}
+ />
+ );
+ }
+ act(() => {
+ root.render( );
+ });
+ expect(host.querySelector(".ui-select-value")?.textContent).toBe("Guided");
+ act(() => {
+ host.querySelector("summary")?.click();
+ });
+ const supervised = [...host.querySelectorAll("[role='menuitem']")].find((row) =>
+ row.textContent?.includes("Supervised"),
+ );
+ expect(supervised?.textContent).toContain("Show every mutating command");
+ act(() => {
+ supervised?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(value).toBe("supervised");
+ });
+
+ it("exposes the value on the trigger and moves with arrow keys", () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render(
+ undefined}
+ />,
+ );
+ });
+ const trigger = host.querySelector('[aria-label="Autonomy"]') as HTMLElement;
+ expect(trigger.closest("[data-value]")?.getAttribute("data-value")).toBe("guided");
+ act(() => {
+ trigger.focus();
+ trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
+ });
+ expect(document.activeElement?.getAttribute("data-value")).toBe("guided");
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
+ });
+ expect(document.activeElement?.getAttribute("data-value")).toBe("supervised");
+ });
+});
diff --git a/apps/desktop/src/components/layout/Select.tsx b/apps/desktop/src/components/layout/Select.tsx
new file mode 100644
index 0000000..abc01bc
--- /dev/null
+++ b/apps/desktop/src/components/layout/Select.tsx
@@ -0,0 +1,100 @@
+import type { ReactNode } from "react";
+import { MenuItem, OverflowMenu } from "./OverflowMenu";
+
+export type SelectOption = {
+ value: T;
+ label: string;
+ hint?: string;
+ disabled?: boolean;
+};
+
+export function Select(props: {
+ value: T;
+ options: SelectOption[];
+ onChange: (value: T) => void;
+ ariaLabel: string;
+ disabled?: boolean;
+ className?: string;
+ compact?: boolean;
+ prefer?: "up" | "down";
+ align?: "left" | "right" | "after";
+ id?: string;
+ testId?: string;
+}) {
+ const selected = props.options.find((row) => row.value === props.value);
+ const label = selected?.label ?? props.value;
+ return (
+ {label}}
+ toggleClassName="ui-select-toggle"
+ >
+ {props.options.map((row) => (
+ props.onChange(row.value)}
+ >
+
+ {row.label}
+ {row.hint ? {row.hint} : null}
+
+
+ ))}
+
+ );
+}
+
+export function selectOptionsFromList(
+ values: readonly string[],
+ label: (value: string) => string = (value) => value,
+ hint?: (value: string) => string | undefined,
+): SelectOption[] {
+ return values.map((value) => ({
+ value,
+ label: label(value),
+ hint: hint?.(value),
+ }));
+}
+
+export function SelectField(props: { label: ReactNode; children: ReactNode }) {
+ return (
+
+ {typeof props.label === "string" ? {props.label} : props.label}
+ {props.children}
+
+ );
+}
+
+export function FormTabs(props: {
+ ariaLabel: string;
+ value: T;
+ tabs: ReadonlyArray<{ id: T; label: string }>;
+ onChange: (id: T) => void;
+}) {
+ return (
+
+ {props.tabs.map((tab) => (
+ props.onChange(tab.id)}
+ >
+ {tab.label}
+
+ ))}
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/SessionSwitcher.test.tsx b/apps/desktop/src/components/layout/SessionSwitcher.test.tsx
index 2fb77b7..5ccc9e4 100644
--- a/apps/desktop/src/components/layout/SessionSwitcher.test.tsx
+++ b/apps/desktop/src/components/layout/SessionSwitcher.test.tsx
@@ -90,6 +90,7 @@ describe("SessionSwitcher palette", () => {
host.remove();
vi.restoreAllMocks();
setSettingsDirty(false);
+ localStorage.removeItem("locus.palette.recents");
});
it("opens with Ctrl+K and runs Start guest / Replay / Theme", async () => {
@@ -356,20 +357,20 @@ describe("SessionSwitcher palette", () => {
expect(host.querySelector("[aria-label='Command palette']")).toBeNull();
expect(host.querySelector("[aria-label='Keyboard shortcuts']")).toBeTruthy();
expect(host.querySelector("[data-testid='palette-shortcuts']")?.textContent).toContain(
- "Ctrl/Cmd+K",
+ "Ctrl+K",
);
expect(host.querySelector("[data-testid='palette-shortcuts']")?.textContent).toContain(
- "Ctrl/Cmd+F",
+ "Ctrl+F",
);
expect(host.querySelector("[data-testid='palette-shortcuts']")?.textContent).toContain(
- "Ctrl/Cmd+,",
+ "Ctrl+,",
);
expect(host.querySelector("[data-testid='palette-shortcuts']")?.textContent).toContain(
- "Ctrl/Cmd+1–9",
+ "Ctrl+1–9",
);
expect(host.querySelector("[data-testid='palette-shortcuts']")?.textContent).toContain("F12");
expect(host.querySelector("[data-testid='palette-shortcuts']")?.textContent).toContain(
- "Ctrl/Cmd+W",
+ "Ctrl+W",
);
act(() => {
@@ -386,9 +387,7 @@ describe("SessionSwitcher palette", () => {
setter?.call(search, "?");
search.dispatchEvent(new Event("input", { bubbles: true }));
});
- expect(host.querySelector("[data-testid='palette-shortcuts']")?.textContent).toMatch(
- /Ctrl\/Cmd\+N/,
- );
+ expect(host.querySelector("[data-testid='palette-shortcuts']")?.textContent).toMatch(/Ctrl\+N/);
expect(host.textContent).not.toContain("Settings · MCP");
});
diff --git a/apps/desktop/src/components/layout/SessionSwitcher.tsx b/apps/desktop/src/components/layout/SessionSwitcher.tsx
index 42f70f7..3c4ef23 100644
--- a/apps/desktop/src/components/layout/SessionSwitcher.tsx
+++ b/apps/desktop/src/components/layout/SessionSwitcher.tsx
@@ -8,20 +8,28 @@ import {
} from "@locus/shared";
import { applyTheme } from "@/lib/theme";
import { api } from "@/lib/api";
-import { showNotice } from "@/lib/notice";
+import { showErrorNotice, showNotice } from "@/lib/notice";
+import { archiveOne } from "@/lib/sessionUndo";
+import { askConfirm } from "@/lib/dialog";
import { exportSessionChat } from "@/lib/chatExport";
import { liveDockerContainers, runStopContainers } from "@/lib/docker";
import { defaultGoldenName, promptGoldenName } from "@/lib/goldenName";
import { rememberBrainInbox } from "@/lib/brainViewPrefs";
import { needsYouItems, needsYouSessions } from "@/lib/needsYou";
import {
- PALETTE_SHORTCUTS,
+ applyPaletteRecents,
editorOwnsCommandK,
filterPaletteItems,
isPaletteShortcutsQuery,
paletteItems,
paletteItemsForMode,
} from "@/lib/palette";
+import { matchModDigit, matchModKey } from "@/lib/shortcuts";
+import { KeyboardShortcutsSheet } from "./KeyboardShortcutsSheet";
+import { loadPaletteRecents, rememberPaletteRecent } from "@/lib/paletteRecents";
+import { setPendingSettingsQuery } from "@/lib/settingsQuery";
+import { HighlightQuery } from "@/lib/queryMark";
+import { Modal } from "./Modal";
import { orderPaletteForBrowse, tryNextContext } from "@/lib/discoverability";
import { confirmLeaveSettings, paletteActionLeavesSettings } from "@/lib/settingsLeave";
import type { SettingsTab } from "@/lib/settingsNav";
@@ -43,19 +51,6 @@ import {
requestWorkspaceTerminalNew,
} from "@/components/workspace/WorkspaceTerminal";
-function KeyboardShortcutsSheet() {
- return (
-
- {PALETTE_SHORTCUTS.map((row) => (
-
- {row.action}
- {row.keys}
-
- ))}
-
- );
-}
-
export function SessionSwitcher() {
const { state, dispatch } = useStore();
const [open, setOpen] = useState(false);
@@ -82,24 +77,31 @@ export function SessionSwitcher() {
brain: state.brain,
projects: state.projects,
setup: state.setup,
+ config: state.config,
});
const scoped = paletteItemsForMode(raw, parseProductMode(state.config?.productMode));
const filtered = filterPaletteItems(scoped, query);
if (query.trim()) return filtered;
- return orderPaletteForBrowse(
- filtered,
- tryNextContext({
- automations: state.automations,
- projects: state.projects,
- boards: state.boards,
- repos: state.repos,
- brain: state.brain,
- slackStatus: state.slackStatus,
- mcpServerCount: state.config?.mcpServers?.length ?? 0,
- session,
- sessions: state.sessions,
- }),
- );
+ const recents = applyPaletteRecents(filtered, loadPaletteRecents());
+ const recentRows = recents.filter((item) => item.group === "Recents");
+ const rest = recents.filter((item) => item.group !== "Recents");
+ return [
+ ...recentRows,
+ ...orderPaletteForBrowse(
+ rest,
+ tryNextContext({
+ automations: state.automations,
+ projects: state.projects,
+ boards: state.boards,
+ repos: state.repos,
+ brain: state.brain,
+ slackStatus: state.slackStatus,
+ mcpServerCount: state.config?.mcpServers?.length ?? 0,
+ session,
+ sessions: state.sessions,
+ }),
+ ),
+ ];
}, [
state.sessions,
state.repos,
@@ -113,6 +115,7 @@ export function SessionSwitcher() {
state.slackStatus,
state.config?.mcpServers,
state.config?.productMode,
+ state.config,
session,
vm,
query,
@@ -121,9 +124,7 @@ export function SessionSwitcher() {
useEffect(() => {
function onKey(e: KeyboardEvent) {
- const meta = e.metaKey || e.ctrlKey;
- if (meta && e.key.toLowerCase() === "k") {
- if (e.shiftKey) return;
+ if (matchModKey(e, "k")) {
if (editorOwnsCommandK(document.activeElement)) return;
e.preventDefault();
setShortcutsOpen(false);
@@ -132,7 +133,7 @@ export function SessionSwitcher() {
setActive(0);
return;
}
- if (meta && sessionForDigit(state.sessions, e.key)) {
+ if (matchModDigit(e) && sessionForDigit(state.sessions, e.key)) {
e.preventDefault();
const next = sessionForDigit(state.sessions, e.key);
if (next) {
@@ -192,6 +193,10 @@ export function SessionSwitcher() {
}
setOpen(false);
setQuery("");
+ rememberPaletteRecent(id);
+ if (id.startsWith("settings:") && query.trim()) {
+ setPendingSettingsQuery(query);
+ }
if (id === "keyboard-shortcuts") {
setShortcutsOpen(true);
return;
@@ -446,10 +451,10 @@ export function SessionSwitcher() {
if (!result) return;
dispatch({ type: "containers", containers: result.containers });
if (result.errors.length) {
- window.alert(result.errors.map((row) => `${row.id}: ${row.error}`).join("\n"));
+ showNotice(result.errors.map((row) => `${row.id}: ${row.error}`).join("\n"));
}
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not stop containers");
+ showErrorNotice(err, "Could not stop containers");
}
return;
}
@@ -458,7 +463,7 @@ export function SessionSwitcher() {
return;
}
const guestErr = (err: unknown, fallback: string) => {
- showNotice(err instanceof Error ? err.message : fallback);
+ showErrorNotice(err, fallback);
};
if (id === "guest-start" && session)
await api.startVm(session.id).catch((err) => guestErr(err, "Could not start the guest"));
@@ -473,7 +478,7 @@ export function SessionSwitcher() {
session.title,
sessionRuntime(session) === "docker" ? "workspace" : "golden",
);
- const name = promptGoldenName(fallback);
+ const name = await promptGoldenName(fallback);
if (!name) return;
await api
.captureImage(session.id, name)
@@ -488,16 +493,34 @@ export function SessionSwitcher() {
return;
}
if (id === "chat-stop" && session) {
- await api.abortChat(session.id).catch(() => undefined);
+ await api.abortChat(session.id).catch((err: unknown) => {
+ showErrorNotice(err, "Could not stop");
+ });
return;
}
if (id === "chat-archive" && session) {
- await api.patchSession(session.id, { archived: !session.archived }).catch(() => undefined);
+ if (session.archived) {
+ await api.patchSession(session.id, { archived: false }).catch(() => undefined);
+ } else {
+ archiveOne(session, dispatch);
+ }
return;
}
if (id === "chat-clear" && session) {
- if (!canClearChat(session) || !window.confirm(CLEAR_CHAT_CONFIRM)) return;
- const updated = await api.clearChat(session.id).catch(() => undefined);
+ if (
+ !canClearChat(session) ||
+ !(await askConfirm({
+ title: "Clear chat?",
+ body: CLEAR_CHAT_CONFIRM,
+ confirmLabel: "Clear chat",
+ danger: true,
+ }))
+ )
+ return;
+ const updated = await api.clearChat(session.id).catch((err: unknown) => {
+ showErrorNotice(err, "Could not clear chat");
+ return undefined;
+ });
if (updated) dispatch({ type: "session", session: updated });
return;
}
@@ -528,17 +551,14 @@ export function SessionSwitcher() {
if (shortcutsOpen) {
return (
- setShortcutsOpen(false)}>
-
e.stopPropagation()}
- >
-
Keyboard shortcuts
-
-
-
+ setShortcutsOpen(false)}
+ >
+ Keyboard shortcuts
+
+
);
}
@@ -546,65 +566,60 @@ export function SessionSwitcher() {
let lastGroup = "";
return (
- setOpen(false)}>
-
e.stopPropagation()}
- >
-
setQuery(e.target.value)}
- />
-
- {showShortcuts ? (
-
- ) : items.length === 0 ? (
-
No matching agents or commands
- ) : (
- items.map((item, i) => {
- const heading = item.group !== lastGroup ? item.group : null;
- lastGroup = item.group;
- const sessionIndex = item.id.startsWith("session:")
- ? items
- .filter((row) => row.id.startsWith("session:"))
- .findIndex((row) => row.id === item.id)
- : -1;
- return (
-
- {heading ?
{heading}
: null}
-
setActive(i)}
- onClick={() => void run(item.id)}
- >
-
-
- {item.title}
- {item.hint}
-
- {!query && sessionIndex >= 0 && sessionIndex < 9 ? (
- {sessionIndex + 1}
- ) : null}
-
-
- );
- })
- )}
-
+ setOpen(false)}>
+ setQuery(e.target.value)}
+ />
+
+ {showShortcuts ? (
+
+ ) : items.length === 0 ? (
+
No matching agents or commands
+ ) : (
+ items.map((item, i) => {
+ const heading = item.group !== lastGroup ? item.group : null;
+ lastGroup = item.group;
+ const sessionIndex = item.id.startsWith("session:")
+ ? items
+ .filter((row) => row.id.startsWith("session:"))
+ .findIndex((row) => row.id === item.id)
+ : -1;
+ return (
+
+ {heading ?
{heading}
: null}
+
setActive(i)}
+ onClick={() => void run(item.id)}
+ >
+
+
+
+
+
+ {item.hint}
+
+ {!query && sessionIndex >= 0 && sessionIndex < 9 ? (
+ {sessionIndex + 1}
+ ) : null}
+
+
+ );
+ })
+ )}
-
+
);
}
diff --git a/apps/desktop/src/components/layout/Sidebar.test.tsx b/apps/desktop/src/components/layout/Sidebar.test.tsx
index 27b5520..9025e76 100644
--- a/apps/desktop/src/components/layout/Sidebar.test.tsx
+++ b/apps/desktop/src/components/layout/Sidebar.test.tsx
@@ -6,6 +6,8 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { assessSetup, type AgentSession, type LinkedRepo } from "@locus/shared";
import { Sidebar } from "./Sidebar";
+import { NoticeBanner } from "./NoticeBanner";
+import { dismissNotice } from "@/lib/notice";
import { LAST_SLACK_SIDEBAR_GROUPS_KEY } from "@/components/slack/Slack";
import { api } from "@/lib/api";
import { setSettingsDirty } from "@/lib/settingsLeave";
@@ -130,6 +132,7 @@ describe("Sidebar", () => {
window.localStorage.removeItem(SWARM_ROSTER_PREFS_KEY);
vi.restoreAllMocks();
setSettingsDirty(false);
+ dismissNotice();
});
function render(state: UiState) {
@@ -155,38 +158,13 @@ describe("Sidebar", () => {
const labels = [...host.querySelectorAll(".side-rail-nav > button")].map((el) =>
el.getAttribute("aria-label"),
);
- expect(labels).toEqual([
- "Agents",
- "Design",
- "Games",
- "Assets",
- "IDE",
- "Automations",
- "Board",
- "Projects",
- "Codebase",
- "Brain",
- "Slack",
- "Grafana",
- "Docker",
- ]);
- expect(host.querySelector(".rail-more")).toBeNull();
+ expect(labels).toEqual(["Agents", "Design", "IDE", "Codebase", "Automations"]);
+ expect(host.querySelector(".rail-more")).toBeTruthy();
+ expect(host.querySelector(".rail-more summary")?.getAttribute("aria-label")).toBe("More");
expect(host.querySelector(".side-rail-nav .rail-sep")?.getAttribute("role")).toBe("separator");
expect(host.querySelector(".brand p")?.textContent).toBe("Agents");
expect(host.querySelector(".brand-mark [data-testid='locus-mascot']")).toBeNull();
- expect(host.querySelector("[data-testid='product-mode-toggle']")).toBeTruthy();
- expect(
- host
- .querySelector(".brand-actions [data-testid='product-mode-developer']")
- ?.getAttribute("aria-checked"),
- ).toBe("true");
- expect(
- host.querySelector(".brand-actions [data-testid='product-mode-swarm']")?.textContent,
- ).toBe("Swarm");
- expect(css).toMatch(
- /\.brand-actions \.product-mode-toggle > button \{[\s\S]*?overflow:\s*visible/,
- );
- expect(css).toMatch(/\.brand-actions \.product-mode-toggle \{[\s\S]*?max-width:\s*none/);
+ expect(host.querySelector(".brand-actions [data-testid='product-mode-toggle']")).toBeNull();
expect(
host.querySelector(".side-rail-nav button[aria-current='page']")?.getAttribute("aria-label"),
).toBe("Agents");
@@ -199,16 +177,22 @@ describe("Sidebar", () => {
expect(host.querySelector(".search-wrap kbd")).toBeNull();
expect(
[...host.querySelectorAll(".side-rail-nav .rail-label")].map((el) => el.textContent),
- ).toEqual([
- "Agents",
- "Design",
+ ).toEqual(["Agents", "Design", "IDE", "Codebase", "Automations", "More"]);
+ });
+
+ it("lists Games, Assets, Board, and Docker in the More menu", () => {
+ render({ ...initialState, sessions: [session] });
+ act(() => {
+ (host.querySelector(".rail-more summary") as HTMLElement | null)?.click();
+ });
+ const moreItems = [...host.querySelectorAll(".rail-more [role='menuitem']")].map(
+ (el) => el.textContent,
+ );
+ expect(moreItems).toEqual([
"Games",
"Assets",
- "IDE",
- "Automations",
"Board",
"Projects",
- "Codebase",
"Brain",
"Slack",
"Grafana",
@@ -219,7 +203,7 @@ describe("Sidebar", () => {
it("fits full rail labels and wraps the mode toggle below the brand when narrow", () => {
expect(css).toMatch(/--rail-w: 72px/);
expect(css).toMatch(
- /\.side-rail-nav > button \{[\s\S]*?width: calc\(var\(--rail-w, 64px\) - 8px\)/,
+ /\.side-rail-nav > button,[\s\S]*?width: calc\(var\(--rail-w, 64px\) - 8px\)/,
);
expect(css).toMatch(/\.side-main \{[\s\S]*?container: side-main \/ inline-size/);
expect(css).toMatch(
@@ -228,7 +212,7 @@ describe("Sidebar", () => {
});
it("separates Automations from the rest of the icon rail", () => {
- expect(css).toMatch(/\.side-rail-nav > button \{/);
+ expect(css).toMatch(/\.side-rail-nav > button,/);
expect(css).toMatch(/\.side-rail-nav \.rail-sep \{[\s\S]*?height:\s*1px/);
expect(css).toMatch(/\.side-rail-nav \.rail-sep \{[\s\S]*?background:\s*var\(--line-strong\)/);
expect(css).toMatch(/\.side-rail-nav \{[\s\S]*?overflow-y:\s*auto/);
@@ -411,12 +395,12 @@ describe("Sidebar", () => {
expect(host.querySelector(".brand p")?.textContent).toBe("Teammates");
});
- it("sends Swarm back to teammates from developer sections", () => {
+ it("shows Teammates, Teams, and More on the Swarm icon rail", () => {
function Harness() {
const [state, dispatch] = useReducer(reducer, {
...initialState,
config: { productMode: "swarm" } as NonNullable
,
- section: "design",
+ section: "agents",
});
return (
@@ -431,11 +415,10 @@ describe("Sidebar", () => {
root.render( );
});
expect(host.querySelector(".brand p")?.textContent).toBe("Teammates");
- expect(host.querySelector("[data-testid='swarm-routines']")).toBeNull();
- expect(host.querySelector("[data-testid='swarm-brain']")).toBeNull();
- expect(
- [...host.querySelectorAll(".swarm-shell-nav button")].map((el) => el.textContent),
- ).toEqual(["Teammates", "Teams"]);
+ expect(host.querySelector("[data-testid='swarm-teammates']")).toBeTruthy();
+ expect(host.querySelector("[data-testid='swarm-teams']")).toBeTruthy();
+ expect(host.querySelector(".rail-more")).toBeTruthy();
+ expect(host.querySelector(".swarm-shell-nav")).toBeNull();
const teams = host.querySelector("[data-testid='swarm-teams']") as HTMLButtonElement;
act(() => {
teams.click();
@@ -800,10 +783,15 @@ describe("Sidebar", () => {
expect(host.querySelector(".icon-badge.danger")?.textContent).toBe("1");
expect(host.querySelector(".session-group-label.warn")?.textContent).toBe("Needs you");
expect(host.textContent).toContain("Name the owner");
- expect(host.querySelector(".side-rail-nav button[aria-label='Board, unread']")).toBeTruthy();
+ expect(host.querySelector(".rail-more .unread-dot")).toBeTruthy();
+ act(() => {
+ (host.querySelector(".rail-more summary") as HTMLElement | null)?.click();
+ });
expect(
- host.querySelector(".side-rail-nav button[aria-label='Board, unread'] .unread-dot"),
- ).toBeTruthy();
+ [...host.querySelectorAll(".rail-more [role='menuitem']")].some((el) =>
+ el.textContent?.includes("Board · unread"),
+ ),
+ ).toBe(true);
});
it("stretches New board in the same side-actions row as New design", () => {
@@ -878,11 +866,18 @@ describe("Sidebar", () => {
});
expect(search().value).toBe("no-such-agent");
expect(host.querySelector(".empty-list")?.textContent).toMatch(/No matching agents/);
- const slackItem = () =>
- host.querySelector(".side-rail-nav button[aria-label='Slack']") as HTMLButtonElement;
- act(() => {
- slackItem().click();
- });
+ const openSlack = () => {
+ act(() => {
+ (host.querySelector(".rail-more summary") as HTMLElement | null)?.click();
+ });
+ const item = [...host.querySelectorAll(".rail-more [role='menuitem']")].find((el) =>
+ (el.textContent ?? "").startsWith("Slack"),
+ );
+ act(() => {
+ item?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ };
+ openSlack();
expect(search().value).toBe("");
expect(host.querySelector(".session-group .session-item .title-text")?.textContent).toBe("eng");
expect(host.textContent).toContain("bob");
@@ -896,9 +891,7 @@ describe("Sidebar", () => {
});
expect(search().value).toBe("no-such-agent");
expect(host.querySelector(".empty-list")?.textContent).toMatch(/No matching agents/);
- act(() => {
- slackItem().click();
- });
+ openSlack();
expect(search().value).toBe("zzz");
expect(host.querySelector(".empty-list")?.textContent).toMatch(/No matching channels/);
});
@@ -1288,9 +1281,8 @@ describe("Sidebar", () => {
prompt.mockRestore();
});
- it("deletes an agent from the session row menu after confirm", async () => {
- vi.mocked(api.deleteSession).mockResolvedValue({ ok: true });
- const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
+ it("archives a live agent from Delete and offers undo", async () => {
+ vi.mocked(api.patchSession).mockResolvedValue({ ...session, archived: true });
const dispatch = vi.fn();
host = document.createElement("div");
document.body.appendChild(host);
@@ -1304,6 +1296,7 @@ describe("Sidebar", () => {
}}
>
+
,
);
});
@@ -1321,17 +1314,73 @@ describe("Sidebar", () => {
await act(async () => {
del!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
+ await Promise.resolve();
});
- expect(confirm).toHaveBeenCalledWith("Delete Build the agent? This cannot be undone.");
+ expect(api.patchSession).toHaveBeenCalledWith("s1", { archived: true });
+ expect(api.deleteSession).not.toHaveBeenCalled();
+ expect(host.querySelector("[data-testid='app-notice-item']")?.textContent).toContain(
+ "Deleted Build the agent",
+ );
+ });
+
+ it("hard-deletes an archived agent after confirm", async () => {
+ vi.mocked(api.deleteSession).mockResolvedValue({ ok: true });
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
+ const dispatch = vi.fn();
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ act(() => {
+ const box = host.querySelector(".archive-toggle input") as HTMLInputElement;
+ if (box && !box.checked) box.click();
+ });
+ const toggle = host.querySelector(
+ '.session-item.has-menu [aria-haspopup="menu"]',
+ ) as HTMLElement;
+ act(() => {
+ toggle.click();
+ });
+ const del = [...host.querySelectorAll("[role='menuitem']")].find(
+ (el) => el.textContent === "Delete",
+ );
+ await act(async () => {
+ del!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ });
+ expect(confirm).toHaveBeenCalledWith("This cannot be undone.");
expect(api.deleteSession).toHaveBeenCalledWith("s1");
expect(dispatch).toHaveBeenCalledWith({ type: "deleted", id: "s1" });
confirm.mockRestore();
});
- it("does not delete when the session row confirm is cancelled", async () => {
+ it("does not delete an archived agent when confirm is cancelled", async () => {
vi.mocked(api.deleteSession).mockClear();
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
- render({ ...initialState, selectedId: "s1", sessions: [session] });
+ render({
+ ...initialState,
+ selectedId: "s1",
+ sessions: [{ ...session, archived: true }],
+ });
+ act(() => {
+ const box = host.querySelector(".archive-toggle input") as HTMLInputElement;
+ if (box && !box.checked) box.click();
+ });
const toggle = host.querySelector(
'.session-item.has-menu [aria-haspopup="menu"]',
) as HTMLElement;
@@ -1393,7 +1442,7 @@ describe("Sidebar", () => {
del!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
- expect(confirm).toHaveBeenCalledWith("Delete Retro? This cannot be undone.");
+ expect(confirm).toHaveBeenCalledWith("This cannot be undone.");
expect(api.deleteBoard).toHaveBeenCalledWith("b1");
expect(dispatch).toHaveBeenCalledWith({ type: "boardDeleted", id: "b1" });
confirm.mockRestore();
@@ -1468,7 +1517,6 @@ describe("Sidebar", () => {
});
it("archives idle chats and empty drafts from the session list menu", async () => {
- vi.spyOn(window, "confirm").mockReturnValue(true);
const dispatch = vi.fn();
const now = Date.now();
const old = new Date(now - 8 * 24 * 60 * 60 * 1000).toISOString();
@@ -1526,6 +1574,7 @@ describe("Sidebar", () => {
}}
>
+
,
);
});
@@ -1544,8 +1593,12 @@ describe("Sidebar", () => {
await act(async () => {
idleItem.click();
await Promise.resolve();
+ await Promise.resolve();
});
- expect(window.confirm).toHaveBeenCalledWith("Archive 1 idle chat older than 7 days?");
+ expect(host.querySelector("[data-testid='app-notice-item']")?.textContent).toContain(
+ "Archived 1 idle chat",
+ );
+ expect(host.querySelector(".app-notice-action")?.textContent).toBe("Undo");
expect(api.patchSession).toHaveBeenCalledWith("idle-old", { archived: true });
expect(api.patchSession).not.toHaveBeenCalledWith("idle-pin", { archived: true });
expect(api.patchSession).not.toHaveBeenCalledWith("idle-new", { archived: true });
@@ -1563,8 +1616,12 @@ describe("Sidebar", () => {
await act(async () => {
draftsAgain.click();
await Promise.resolve();
+ await Promise.resolve();
});
- expect(window.confirm).toHaveBeenCalledWith("Archive 1 empty draft that never sent a prompt?");
+ const noticeText = [...host.querySelectorAll("[data-testid='app-notice-item']")].map(
+ (el) => el.textContent,
+ );
+ expect(noticeText.some((text) => text?.includes("Archived 1 empty draft"))).toBe(true);
expect(api.patchSession).toHaveBeenCalledWith("draft-empty", { archived: true });
expect(api.patchSession).not.toHaveBeenCalledWith("draft-used", { archived: true });
});
@@ -1591,16 +1648,16 @@ describe("Sidebar", () => {
expect(
host.querySelector(".side-rail button[aria-label='Design'] .rail-icon svg"),
).toBeTruthy();
- expect(host.querySelector(".side-rail button[aria-label='Games'] .rail-icon svg")).toBeTruthy();
+ expect(host.querySelector(".side-rail button[aria-label='Games']")).toBeNull();
expect(host.querySelector(".side-rail button[aria-label='IDE'] .rail-icon svg")).toBeTruthy();
expect(host.querySelector(".side-rail button[aria-label='Automations, unread']")).toBeTruthy();
expect(
host.querySelector(".side-rail button[aria-label='Automations, unread'] .unread-dot"),
).toBeTruthy();
- expect(host.querySelector(".rail-more")).toBeNull();
+ expect(host.querySelector(".rail-more")).toBeTruthy();
expect(host.querySelector(".side-rail-nav .rail-sep")).toBeTruthy();
- expect(host.querySelector(".side-rail button[aria-label='Board']")).toBeTruthy();
- expect(host.querySelector(".side-rail button[aria-label='Docker']")).toBeTruthy();
+ expect(host.querySelector(".side-rail button[aria-label='Board']")).toBeNull();
+ expect(host.querySelector(".side-rail button[aria-label='Docker']")).toBeNull();
expect(host.querySelector(".side-rail .unread-dot")).toBeTruthy();
expect(host.querySelector(".setup-btn")?.getAttribute("aria-label")).toBe("Setup");
expect(host.querySelector(".setup-btn .rail-icon svg")).toBeTruthy();
@@ -1853,10 +1910,7 @@ describe("Sidebar", () => {
expect(activity?.textContent).toContain("Activity");
expect(activity?.textContent).toContain("1");
expect(activity?.querySelector(".unread-dot")).toBeTruthy();
- expect(host.querySelector(".side-rail-nav button[aria-label='Slack, unread']")).toBeTruthy();
- expect(
- host.querySelector(".side-rail-nav button[aria-label='Slack, unread'] .unread-dot"),
- ).toBeTruthy();
+ expect(host.querySelector(".rail-more .unread-dot")).toBeTruthy();
expect(css).toMatch(/\.slack-activity-item \{/);
});
diff --git a/apps/desktop/src/components/layout/Sidebar.tsx b/apps/desktop/src/components/layout/Sidebar.tsx
index bb652ae..bf6588f 100644
--- a/apps/desktop/src/components/layout/Sidebar.tsx
+++ b/apps/desktop/src/components/layout/Sidebar.tsx
@@ -87,10 +87,10 @@ import {
IconStop,
IconWrench,
IconInbox,
+ IconMore,
} from "./Icons";
import { DesignThumb } from "@/components/design/DesignThumb";
import { MenuItem, MenuLabel, MenuSep, OverflowMenu } from "./OverflowMenu";
-import { Mascot } from "./Mascot";
import { SearchField } from "./SearchField";
import { rememberBrainInbox } from "@/lib/brainViewPrefs";
import { needsYouSessions, sessionHasNeedsYou } from "@/lib/needsYou";
@@ -147,11 +147,14 @@ import {
settingsNavTabs,
} from "@/lib/settingsNav";
import { confirmLeaveSettings } from "@/lib/settingsLeave";
-import { showNotice } from "@/lib/notice";
+import { showErrorNotice, showNotice, showUndoNotice } from "@/lib/notice";
+import { askConfirm, askPrompt } from "@/lib/dialog";
+import { archiveOne, archiveSessions, deleteSessionWithUndo } from "@/lib/sessionUndo";
import { moreRailSections, primaryRailSections } from "@/lib/railNav";
-import { ProductModeToggle } from "@/components/layout/ProductModeToggle";
import { swarmShellKeepsSection } from "@/lib/productMode";
+import { sessionStatusLabel } from "@/lib/sessionStatus";
import { PersonaAvatar } from "@/components/layout/PersonaAvatar";
+import { EmptyListNote } from "./EmptyHero";
const SECTION_META: Record, { label: string; icon: ReactNode }> = {
agents: { label: "Agents", icon: },
@@ -212,16 +215,14 @@ function sectionSearchPlaceholder(
return mode === "swarm" ? "Search teammates" : "Search agents";
}
-function EmptyListNote({ children }: { children: ReactNode }) {
- return (
-
- );
-}
-
-const FORGE_ORDER: ForgeKind[] = ["github", "gitlab", "bitbucket"];
+const FORGE_ORDER: ForgeKind[] = [
+ "github",
+ "gitlab",
+ "bitbucket",
+ "gitea",
+ "forgejo",
+ "azuredevops",
+];
function groupRepos(repos: LinkedRepo[]): Array<{ forge: ForgeKind; repos: LinkedRepo[] }> {
const buckets = new Map();
@@ -372,24 +373,20 @@ function SessionRow(props: {
function patch(next: { pinned?: boolean; archived?: boolean; title?: string }) {
void api.patchSession(s.id, next).catch((err: unknown) => {
- showNotice(err instanceof Error ? err.message : "Could not update this agent");
+ showErrorNotice(err, "Could not update this agent");
});
}
function rename() {
- const next = window.prompt("Rename", s.title)?.trim();
- if (!next || next === s.title) return;
- patch({ title: next });
+ void (async () => {
+ const next = (await askPrompt({ title: "Rename", value: s.title }))?.trim();
+ if (!next || next === s.title) return;
+ patch({ title: next });
+ })();
}
function remove() {
- if (!window.confirm(`Delete ${s.title}? This cannot be undone.`)) return;
- void api
- .deleteSession(s.id)
- .then(() => dispatch({ type: "deleted", id: s.id }))
- .catch((err: unknown) => {
- showNotice(err instanceof Error ? err.message : "Could not delete this agent");
- });
+ void deleteSessionWithUndo(s, dispatch, askConfirm);
}
function duplicateSession() {
@@ -400,7 +397,7 @@ function SessionRow(props: {
dispatch({ type: "select", id: forked.id });
})
.catch((err: unknown) => {
- showNotice(err instanceof Error ? err.message : "Could not duplicate");
+ showErrorNotice(err, "Could not duplicate");
});
}
@@ -410,7 +407,9 @@ function SessionRow(props: {
function hideTeammate() {
if (!rosterPrefs) return;
+ const previous = rosterPrefs;
updateRoster(hideSwarmSession(rosterPrefs, s.id));
+ showUndoNotice(`Hidden ${s.title}`, () => updateRoster(previous));
}
function restoreTeammate() {
@@ -420,9 +419,11 @@ function SessionRow(props: {
function newSection() {
if (!rosterPrefs) return;
- const name = window.prompt("Section name")?.trim();
- if (!name) return;
- updateRoster(addSwarmRosterSection(rosterPrefs, name, s.id));
+ void (async () => {
+ const name = (await askPrompt({ title: "Section name" }))?.trim();
+ if (!name) return;
+ updateRoster(addSwarmRosterSection(rosterPrefs, name, s.id));
+ })();
}
function moveToSection(sectionId: string | null) {
@@ -463,7 +464,12 @@ function SessionRow(props: {
>
) : null}
Rename
- patch({ archived: !s.archived })}>
+ {
+ if (s.archived) patch({ archived: false });
+ else archiveOne(s, dispatch);
+ }}
+ >
{s.archived ? "Unarchive" : "Archive"}
@@ -497,7 +503,7 @@ function SessionRow(props: {
>
props.onSelect(s.id)}>
{props.preview}
-
+
props.onSelect(s.id)}
>
-
+
{copy}
{trust}
@@ -558,7 +564,7 @@ function SessionRow(props: {
title={sessionDisplayName(s)}
/>
) : null}
-
+
{copy}
{menu}
@@ -933,29 +939,12 @@ export function Sidebar() {
showNotice(result.errors.map((row) => `${row.id}: ${row.error}`).join("\n"));
}
} catch (err) {
- showNotice(err instanceof Error ? err.message : "Could not stop containers");
+ showErrorNotice(err, "Could not stop containers");
} finally {
setStopping(false);
}
}
- async function archiveSessions(targets: AgentSession[], prompt: string) {
- if (!targets.length) return;
- if (!window.confirm(prompt)) return;
- const errors: string[] = [];
- for (const session of targets) {
- try {
- await api.patchSession(session.id, { archived: true });
- dispatch({ type: "session", session: { ...session, archived: true } });
- } catch (err) {
- errors.push(
- `${session.title}: ${err instanceof Error ? err.message : "Could not archive"}`,
- );
- }
- }
- if (errors.length) showNotice(errors.join("\n"));
- }
-
const sessionListMenu =
state.section === "agents" ||
state.section === "design" ||
@@ -969,9 +958,8 @@ export function Sidebar() {
const n = idleTargets.length;
void archiveSessions(
idleTargets,
- n === 1
- ? "Archive 1 idle chat older than 7 days?"
- : `Archive ${n} idle chats older than 7 days?`,
+ dispatch,
+ n === 1 ? "Archived 1 idle chat" : `Archived ${n} idle chats`,
);
}}
>
@@ -983,9 +971,8 @@ export function Sidebar() {
const n = emptyDrafts.length;
void archiveSessions(
emptyDrafts,
- n === 1
- ? "Archive 1 empty draft that never sent a prompt?"
- : `Archive ${n} empty drafts that never sent a prompt?`,
+ dispatch,
+ n === 1 ? "Archived 1 empty draft" : `Archived ${n} empty drafts`,
);
}}
>
@@ -1042,7 +1029,7 @@ export function Sidebar() {
return (
- {swarmMode ? null : (
+ {
@@ -1066,8 +1053,24 @@ export function Sidebar() {
className={state.section === row.id ? "active" : ""}
aria-label={label}
aria-current={state.section === row.id ? "page" : undefined}
+ data-testid={
+ swarmMode && row.id === "agents"
+ ? "swarm-teammates"
+ : swarmMode && row.id === "project"
+ ? "swarm-teams"
+ : undefined
+ }
onClick={() => {
if (state.section === "settings" && !confirmLeaveSettings()) return;
+ if (swarmMode && row.id === "project") {
+ const selected = state.projects.find(
+ (item) => item.id === state.selectedProjectId,
+ );
+ if (!selected || selected.surface !== "swarm") {
+ dispatch({ type: "selectProject", id: null });
+ return;
+ }
+ }
dispatch({ type: "section", section: row.id });
}}
>
@@ -1080,30 +1083,48 @@ export function Sidebar() {
);
})}
{secondarySections.length ?
: null}
- {secondarySections.map((row) => {
- const unread =
- (row.id === "slack" && slackUnread) ||
- (row.id === "board" && boardWaiting.length > 0);
- const label = unread ? `${row.label}, unread` : row.label;
- return (
- {
- if (state.section === "settings" && !confirmLeaveSettings()) return;
- dispatch({ type: "section", section: row.id });
- }}
- >
-
- {row.icon}
-
- {unread ? : null}
- {row.label}
-
- );
- })}
+ {secondarySections.length ? (
+ row.id === state.section) ? " active" : ""}`}
+ title="More"
+ align="after"
+ label={
+ <>
+
+
+
+ {secondarySections.some(
+ (row) =>
+ (row.id === "slack" && slackUnread) ||
+ (row.id === "board" && boardWaiting.length > 0),
+ ) ? (
+
+ ) : null}
+ More
+ >
+ }
+ toggleClassName="rail-more-toggle"
+ >
+ {secondarySections.map((row) => {
+ const unread =
+ (row.id === "slack" && slackUnread) ||
+ (row.id === "board" && boardWaiting.length > 0);
+ return (
+ {
+ if (state.section === "settings" && !confirmLeaveSettings()) return;
+ dispatch({ type: "section", section: row.id });
+ }}
+ >
+ {row.label}
+ {unread ? " · unread" : ""}
+
+ );
+ })}
+
+ ) : null}
@@ -1180,11 +1201,11 @@ export function Sidebar() {
- )}
+ }
@@ -1202,43 +1223,8 @@ export function Sidebar() {
: sectionLabel(state.section, productMode)}
-
+
- {swarmMode && state.section !== "settings" ? (
-
- {
- if (!confirmLeaveSettings()) return;
- dispatch({ type: "section", section: "agents" });
- }}
- >
- Teammates
-
- {
- if (!confirmLeaveSettings()) return;
- const selected = state.projects.find((row) => row.id === state.selectedProjectId);
- if (!selected || selected.surface !== "swarm") {
- dispatch({ type: "selectProject", id: null });
- return;
- }
- dispatch({ type: "section", section: "project" });
- }}
- >
- Teams
-
-
- ) : null}
{state.section !== "settings" ? (
{state.section === "automations" ? (
@@ -1338,7 +1324,7 @@ export function Sidebar() {
void api.slackConversations(true).then(
(conversations) => dispatch({ type: "slackConversations", conversations }),
(err: unknown) => {
- showNotice(err instanceof Error ? err.message : "Could not refresh Slack");
+ showErrorNotice(err, "Could not refresh Slack");
},
);
}}
@@ -1371,7 +1357,7 @@ export function Sidebar() {
}
},
(err: unknown) => {
- showNotice(err instanceof Error ? err.message : "Could not refresh Docker");
+ showErrorNotice(err, "Could not refresh Docker");
},
);
}}
@@ -1849,11 +1835,20 @@ export function Sidebar() {
{
- if (!window.confirm(`Delete ${row.title}? This cannot be undone.`))
- return;
- void api
- .deleteBoard(row.id)
- .then(() => dispatch({ type: "boardDeleted", id: row.id }));
+ void (async () => {
+ if (
+ !(await askConfirm({
+ title: `Delete ${row.title}?`,
+ body: "This cannot be undone.",
+ confirmLabel: "Delete",
+ danger: true,
+ }))
+ )
+ return;
+ void api
+ .deleteBoard(row.id)
+ .then(() => dispatch({ type: "boardDeleted", id: row.id }));
+ })();
}}
>
Delete
@@ -1939,17 +1934,22 @@ export function Sidebar() {
{
- if (
- !window.confirm(
- swarmMode
- ? `Delete ${displayName}? Teammates stay in Swarm.`
- : `Delete ${displayName}? Members stay on Agents.`,
+ void (async () => {
+ if (
+ !(await askConfirm({
+ title: `Delete ${displayName}?`,
+ body: swarmMode
+ ? "Teammates stay in Swarm."
+ : "Members stay on Agents.",
+ confirmLabel: "Delete",
+ danger: true,
+ }))
)
- )
- return;
- void api
- .deleteProject(row.id)
- .then(() => dispatch({ type: "projectDeleted", id: row.id }));
+ return;
+ void api
+ .deleteProject(row.id)
+ .then(() => dispatch({ type: "projectDeleted", id: row.id }));
+ })();
}}
>
Delete
@@ -2102,22 +2102,27 @@ export function Sidebar() {
{
- if (
- !window.confirm(
- `Unlink ${row.name}? Agents keep their checkout.`,
+ void (async () => {
+ if (
+ !(await askConfirm({
+ title: `Unlink ${row.name}?`,
+ body: "Agents keep their checkout.",
+ confirmLabel: "Unlink",
+ danger: true,
+ }))
)
- )
- return;
- void api.deleteRepo(row.id).then(
- () => dispatch({ type: "repoDeleted", id: row.id }),
- (err: unknown) => {
- showNotice(
- err instanceof Error
- ? err.message
- : `Could not unlink ${row.name}`,
- );
- },
- );
+ return;
+ void api.deleteRepo(row.id).then(
+ () => dispatch({ type: "repoDeleted", id: row.id }),
+ (err: unknown) => {
+ showNotice(
+ err instanceof Error
+ ? err.message
+ : `Could not unlink ${row.name}`,
+ );
+ },
+ );
+ })();
}}
>
Unlink
diff --git a/apps/desktop/src/components/layout/Skeleton.test.tsx b/apps/desktop/src/components/layout/Skeleton.test.tsx
new file mode 100644
index 0000000..8ef8c28
--- /dev/null
+++ b/apps/desktop/src/components/layout/Skeleton.test.tsx
@@ -0,0 +1,72 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { BusyButton, ErrorBanner, InlineError, Skeleton } from "./Skeleton";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("Skeleton and InlineError", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("renders placeholder lines and a retryable alert", async () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ const onRetry = vi.fn();
+ await act(async () => {
+ root.render(
+ <>
+
+
+ >,
+ );
+ });
+ expect(
+ host.querySelector("[data-testid='ui-skeleton']")?.querySelectorAll(".skeleton"),
+ ).toHaveLength(2);
+ expect(host.querySelector("[data-testid='inline-error']")?.textContent).toContain(
+ "Could not load Grafana",
+ );
+ await act(async () => {
+ host
+ .querySelector("[data-testid='inline-error'] button")
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+
+ it("renders a pane error banner and a busy button", async () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ const onRetry = vi.fn();
+ await act(async () => {
+ root.render(
+ <>
+
+
+ Save
+
+ >,
+ );
+ });
+ expect(host.querySelector("[data-testid='error-banner']")?.textContent).toContain(
+ "Could not load board",
+ );
+ await act(async () => {
+ host
+ .querySelector("[data-testid='error-banner'] button")
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(onRetry).toHaveBeenCalledOnce();
+ const busy = host.querySelector("button[aria-busy='true']");
+ expect(busy?.textContent).toContain("Saving…");
+ expect(busy).toHaveProperty("disabled", true);
+ });
+});
diff --git a/apps/desktop/src/components/layout/Skeleton.tsx b/apps/desktop/src/components/layout/Skeleton.tsx
new file mode 100644
index 0000000..158cd97
--- /dev/null
+++ b/apps/desktop/src/components/layout/Skeleton.tsx
@@ -0,0 +1,103 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import { Tooltip } from "./Tooltip";
+
+export function Skeleton(props: {
+ lines?: number;
+ testId?: string;
+ className?: string;
+ label?: string;
+}) {
+ const lines = Math.max(1, props.lines ?? 3);
+ return (
+
+ {Array.from({ length: lines }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+export function InlineError(props: {
+ message: string;
+ onRetry?: () => void;
+ retryLabel?: string;
+ testId?: string;
+}) {
+ return (
+
+
{props.message}
+ {props.onRetry ? (
+
+ {props.retryLabel ?? "Retry"}
+
+ ) : null}
+
+ );
+}
+
+/** Pane-level failure. Use InlineError for a field or section. */
+export function ErrorBanner(props: {
+ message: string;
+ onRetry?: () => void;
+ retryLabel?: string;
+ action?: ReactNode;
+ testId?: string;
+}) {
+ return (
+
+
+
{props.message}
+ {props.onRetry ? (
+
+ {props.retryLabel ?? "Retry"}
+
+ ) : null}
+ {props.action}
+
+ );
+}
+
+export function BusyButton({
+ busy,
+ busyLabel,
+ disabledReason,
+ children,
+ className,
+ disabled,
+ type = "button",
+ ...rest
+}: ButtonHTMLAttributes & {
+ busy?: boolean;
+ busyLabel?: string;
+ disabledReason?: string;
+}) {
+ const off = Boolean(disabled || busy);
+ const button = (
+
+ {busy ? : null}
+ {busy ? (busyLabel ?? children) : children}
+
+ );
+ if (!disabledReason) return button;
+ return (
+
+ {button}
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/Splitter.tsx b/apps/desktop/src/components/layout/Splitter.tsx
new file mode 100644
index 0000000..e24ae40
--- /dev/null
+++ b/apps/desktop/src/components/layout/Splitter.tsx
@@ -0,0 +1,38 @@
+import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
+import type { SplitAxis } from "@/lib/splitter";
+
+/** Keyboard-accessible resize gutter. Arrow keys move, Home/End jump, double-click resets. */
+export function Splitter(props: {
+ orientation: SplitAxis;
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ valueText?: string;
+ className?: string;
+ hidden?: boolean;
+ disabled?: boolean;
+ onPointerDown?: (event: ReactPointerEvent) => void;
+ onKeyDown?: (event: ReactKeyboardEvent) => void;
+ onDoubleClick?: () => void;
+}) {
+ const off = Boolean(props.disabled || props.hidden);
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/SwarmComputerPanel.tsx b/apps/desktop/src/components/layout/SwarmComputerPanel.tsx
index b95ace5..0ed4b5e 100644
--- a/apps/desktop/src/components/layout/SwarmComputerPanel.tsx
+++ b/apps/desktop/src/components/layout/SwarmComputerPanel.tsx
@@ -13,6 +13,7 @@ import { swarmComputerStatus } from "@/lib/swarmPresence";
import { useDeferredCollapse } from "@/lib/useDeferredCollapse";
import { useStore } from "@/state/store";
import { IconClock, IconClose, IconGear, IconPanelRight } from "@/components/layout/Icons";
+import { InlineError } from "@/components/layout/Skeleton";
type ComputerMode = "status" | "preview" | "takeover";
@@ -200,7 +201,7 @@ export function SwarmComputerPanel(props: { screen: ReactNode }) {
)}
- {actionError ? {actionError}
: null}
+ {actionError ? : null}
{showScreen ? {props.screen}
: null}
diff --git a/apps/desktop/src/components/layout/Tooltip.test.tsx b/apps/desktop/src/components/layout/Tooltip.test.tsx
new file mode 100644
index 0000000..a7e4a50
--- /dev/null
+++ b/apps/desktop/src/components/layout/Tooltip.test.tsx
@@ -0,0 +1,50 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import { Tooltip } from "./Tooltip";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("Tooltip", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("sets aria-label on an unlabeled control", () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render(
+
+
+ FS
+
+ ,
+ );
+ });
+ expect(host.querySelector("button")?.getAttribute("aria-label")).toBe("Full screen files");
+ expect(host.querySelector("[role='tooltip']")?.textContent).toBe("Full screen files");
+ });
+
+ it("explains why a disabled control is off", () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render(
+
+
+ Send
+
+ ,
+ );
+ });
+ expect(host.querySelector("[role='tooltip']")?.textContent).toBe("Write a message first");
+ expect(host.querySelector(".ui-tooltip")?.className).toContain("is-disabled");
+ });
+});
diff --git a/apps/desktop/src/components/layout/Tooltip.tsx b/apps/desktop/src/components/layout/Tooltip.tsx
new file mode 100644
index 0000000..82656b5
--- /dev/null
+++ b/apps/desktop/src/components/layout/Tooltip.tsx
@@ -0,0 +1,25 @@
+import { cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
+
+/** Themed hover/focus label. Sets aria-label on the child when it has none. */
+export function Tooltip(props: { label: string; disabledReason?: string; children: ReactNode }) {
+ const child = props.children;
+ const childProps = isValidElement(child)
+ ? (child.props as { "aria-label"?: string; disabled?: boolean })
+ : null;
+ const disabled = Boolean(childProps?.disabled);
+ const text = disabled && props.disabledReason ? props.disabledReason : props.label;
+ const labeled =
+ isValidElement(child) && !childProps?.["aria-label"]
+ ? cloneElement(child as ReactElement<{ "aria-label"?: string }>, {
+ "aria-label": text,
+ })
+ : child;
+ return (
+
+ {labeled}
+
+ {text}
+
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/Topbar.tsx b/apps/desktop/src/components/layout/Topbar.tsx
index 3d787d2..537b351 100644
--- a/apps/desktop/src/components/layout/Topbar.tsx
+++ b/apps/desktop/src/components/layout/Topbar.tsx
@@ -26,7 +26,10 @@ import {
projectTeamName,
} from "@locus/shared";
import { api } from "@/lib/api";
-import { showNotice } from "@/lib/notice";
+import { showErrorNotice } from "@/lib/notice";
+import { askConfirm } from "@/lib/dialog";
+import { archiveOne, deleteSessionWithUndo } from "@/lib/sessionUndo";
+import { Tooltip } from "./Tooltip";
import {
liveDockerContainers,
confirmDockerContainerAction,
@@ -70,14 +73,14 @@ export function Topbar() {
dispatch({ type: "select", id: forked.id });
})
.catch((err: unknown) => {
- showNotice(err instanceof Error ? err.message : "Could not duplicate");
+ showErrorNotice(err, "Could not duplicate");
});
}
async function runDocker(action: "start" | "stop" | "restart") {
const item = state.containers.find((row) => row.id === state.selectedContainerId);
if (!item) return;
- if (action !== "start" && !confirmDockerContainerAction(action, item)) return;
+ if (action !== "start" && !(await confirmDockerContainerAction(action, item))) return;
setDockerBusy(action);
try {
const next = await api.dockerContainerAction(item.id, action);
@@ -85,7 +88,7 @@ export function Topbar() {
const snapshot = await api.dockerContainers();
dispatch({ type: "containers", containers: snapshot.containers });
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Command failed");
+ showErrorNotice(err, "Command failed");
} finally {
setDockerBusy(null);
}
@@ -476,7 +479,7 @@ export function Topbar() {
dispatch({ type: "selectBoard", id: board.id });
})
.catch((err: unknown) => {
- showNotice(err instanceof Error ? err.message : "Could not duplicate");
+ showErrorNotice(err, "Could not duplicate");
});
}}
>
@@ -487,10 +490,20 @@ export function Topbar() {
{
- if (!confirm(`Delete ${item.title}? This cannot be undone.`)) return;
- void api
- .deleteBoard(item.id)
- .then(() => dispatch({ type: "boardDeleted", id: item.id }));
+ void (async () => {
+ if (
+ !(await askConfirm({
+ title: `Delete ${item.title}?`,
+ body: "This cannot be undone.",
+ confirmLabel: "Delete",
+ danger: true,
+ }))
+ )
+ return;
+ void api
+ .deleteBoard(item.id)
+ .then(() => dispatch({ type: "boardDeleted", id: item.id }));
+ })();
}}
>
Delete
@@ -575,7 +588,7 @@ export function Topbar() {
dispatch({ type: "selectProject", id: project.id });
})
.catch((err: unknown) => {
- showNotice(err instanceof Error ? err.message : "Could not duplicate");
+ showErrorNotice(err, "Could not duplicate");
});
}}
>
@@ -586,17 +599,22 @@ export function Topbar() {
{
- if (
- !confirm(
- `Delete ${hub ? (teamTitle ?? item.title) : item.title}? ${
- hub ? "Teammates stay in Swarm." : "Members stay on Agents."
- }`,
- )
- )
- return;
- void api
- .deleteProject(item.id)
- .then(() => dispatch({ type: "projectDeleted", id: item.id }));
+ void (async () => {
+ const name = hub ? (teamTitle ?? item.title) : item.title;
+ const ok = await askConfirm({
+ title: `Delete ${name}?`,
+ body: hub ? "Teammates stay in Swarm." : "Members stay on Agents.",
+ confirmLabel: "Delete",
+ danger: true,
+ });
+ if (!ok) return;
+ try {
+ await api.deleteProject(item.id);
+ dispatch({ type: "projectDeleted", id: item.id });
+ } catch (err) {
+ showErrorNotice(err, "Could not delete");
+ }
+ })();
}}
>
Delete
@@ -663,9 +681,7 @@ export function Topbar() {
onClick={() =>
void api
.resumeVm(session.id)
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not resume"),
- )
+ .catch((err: unknown) => showErrorNotice(err, "Could not resume"))
}
>
Resume
@@ -678,9 +694,7 @@ export function Topbar() {
onClick={() =>
void api
.pauseVm(session.id)
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not pause"),
- )
+ .catch((err: unknown) => showErrorNotice(err, "Could not pause"))
}
>
Pause
@@ -693,9 +707,7 @@ export function Topbar() {
onClick={() =>
void api
.stopVm(session.id)
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not stop"),
- )
+ .catch((err: unknown) => showErrorNotice(err, "Could not stop"))
}
>
Stop
@@ -709,9 +721,7 @@ export function Topbar() {
onClick={() =>
void api
.stopVm(session.id)
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not stop"),
- )
+ .catch((err: unknown) => showErrorNotice(err, "Could not stop"))
}
>
Stop
@@ -721,7 +731,11 @@ export function Topbar() {
type="button"
className="btn small primary"
title="Start"
- onClick={() => void api.startVm(session.id)}
+ onClick={() =>
+ void api
+ .startVm(session.id)
+ .catch((err: unknown) => showErrorNotice(err, "Could not start"))
+ }
>
Start
@@ -739,10 +753,22 @@ export function Topbar() {
{
- if (!confirm(CLEAR_CHAT_CONFIRM)) return;
- void api
- .clearChat(session.id)
- .then((updated) => dispatch({ type: "session", session: updated }));
+ void (async () => {
+ if (
+ !(await askConfirm({
+ title: "Clear chat?",
+ body: CLEAR_CHAT_CONFIRM,
+ confirmLabel: "Clear chat",
+ danger: true,
+ }))
+ )
+ return;
+ const updated = await api.clearChat(session.id).catch((err: unknown) => {
+ showErrorNotice(err, "Could not clear chat");
+ return undefined;
+ });
+ if (updated) dispatch({ type: "session", session: updated });
+ })();
}}
>
Clear chat
@@ -753,7 +779,10 @@ export function Topbar() {
{session.pinned ? "Unpin" : "Pin"}
void api.patchSession(session.id, { archived: !session.archived })}
+ onClick={() => {
+ if (session.archived) void api.patchSession(session.id, { archived: false });
+ else archiveOne(session, dispatch);
+ }}
>
{session.archived ? "Unarchive" : "Archive"}
@@ -791,13 +820,7 @@ export function Topbar() {
{
- if (!confirm(`Delete ${session.title}? This cannot be undone.`)) return;
- void api
- .deleteSession(session.id)
- .then(() => dispatch({ type: "deleted", id: session.id }))
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not delete"),
- );
+ void deleteSessionWithUndo(session, dispatch, askConfirm);
}}
>
Delete
@@ -899,15 +922,16 @@ export function Topbar() {
{session.error &&
}
{hubMode ? (
-
dispatch({ type: "toggleVmPane" })}
- >
-
-
+
+ dispatch({ type: "toggleVmPane" })}
+ >
+
+
+
) : null}
{hubMode || hostChat ? null : vm &&
(vm.status === "running" || vm.status === "ready" || vm.status === "paused") ? (
@@ -940,9 +964,7 @@ export function Topbar() {
onClick={() =>
void api
.resumeVm(session.id)
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not resume"),
- )
+ .catch((err: unknown) => showErrorNotice(err, "Could not resume"))
}
>
Resume
@@ -955,9 +977,7 @@ export function Topbar() {
onClick={() =>
void api
.pauseVm(session.id)
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not pause"),
- )
+ .catch((err: unknown) => showErrorNotice(err, "Could not pause"))
}
>
Pause
@@ -970,9 +990,7 @@ export function Topbar() {
onClick={() =>
void api
.stopVm(session.id)
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not stop"),
- )
+ .catch((err: unknown) => showErrorNotice(err, "Could not stop"))
}
>
Stop
@@ -987,9 +1005,7 @@ export function Topbar() {
onClick={() =>
void api
.stopVm(session.id)
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not stop"),
- )
+ .catch((err: unknown) => showErrorNotice(err, "Could not stop"))
}
>
Stop
@@ -999,7 +1015,11 @@ export function Topbar() {
type="button"
className="btn small primary"
title="Start"
- onClick={() => void api.startVm(session.id)}
+ onClick={() =>
+ void api
+ .startVm(session.id)
+ .catch((err: unknown) => showErrorNotice(err, "Could not start"))
+ }
>
Start
@@ -1049,10 +1069,22 @@ export function Topbar() {
{
- if (!confirm(CLEAR_CHAT_CONFIRM)) return;
- void api
- .clearChat(session.id)
- .then((updated) => dispatch({ type: "session", session: updated }));
+ void (async () => {
+ if (
+ !(await askConfirm({
+ title: "Clear chat?",
+ body: CLEAR_CHAT_CONFIRM,
+ confirmLabel: "Clear chat",
+ danger: true,
+ }))
+ )
+ return;
+ const updated = await api.clearChat(session.id).catch((err: unknown) => {
+ showErrorNotice(err, "Could not clear chat");
+ return undefined;
+ });
+ if (updated) dispatch({ type: "session", session: updated });
+ })();
}}
>
Clear chat
@@ -1068,7 +1100,10 @@ export function Topbar() {
{session.pinned ? "Unpin" : "Pin"}
void api.patchSession(session.id, { archived: !session.archived })}
+ onClick={() => {
+ if (session.archived) void api.patchSession(session.id, { archived: false });
+ else archiveOne(session, dispatch);
+ }}
>
{session.archived ? "Unarchive" : "Archive"}
@@ -1095,10 +1130,15 @@ export function Topbar() {
!vm || (vm.status !== "running" && vm.status !== "ready" && vm.status !== "paused")
}
onClick={() => {
- const fallback = defaultGoldenName(session.title, docker ? "workspace" : "golden");
- const name = promptGoldenName(fallback);
- if (!name) return;
- void api.captureImage(session.id, name);
+ void (async () => {
+ const fallback = defaultGoldenName(
+ session.title,
+ docker ? "workspace" : "golden",
+ );
+ const name = await promptGoldenName(fallback);
+ if (!name) return;
+ void api.captureImage(session.id, name);
+ })();
}}
>
Save golden image…
@@ -1110,13 +1150,15 @@ export function Topbar() {
!vm || (vm.status !== "running" && vm.status !== "ready" && vm.status !== "paused")
}
onClick={() => {
- const fallback = defaultEnvironmentName(session.title);
- const name = promptEnvironmentName(fallback);
- if (!name) return;
- void api.saveEnvironment(session.id, name).then((result) => {
- dispatch({ type: "config", config: result.config });
- if (result.repo) dispatch({ type: "repo", repo: result.repo });
- });
+ void (async () => {
+ const fallback = defaultEnvironmentName(session.title);
+ const name = await promptEnvironmentName(fallback);
+ if (!name) return;
+ void api.saveEnvironment(session.id, name).then((result) => {
+ dispatch({ type: "config", config: result.config });
+ if (result.repo) dispatch({ type: "repo", repo: result.repo });
+ });
+ })();
}}
>
Save as environment…
@@ -1140,13 +1182,7 @@ export function Topbar() {
{
- if (!confirm(`Delete ${session.title}? This cannot be undone.`)) return;
- void api
- .deleteSession(session.id)
- .then(() => dispatch({ type: "deleted", id: session.id }))
- .catch((err: unknown) =>
- showNotice(err instanceof Error ? err.message : "Could not delete"),
- );
+ void deleteSessionWithUndo(session, dispatch, askConfirm);
}}
>
Delete
diff --git a/apps/desktop/src/components/projects/ProjectEmpty.tsx b/apps/desktop/src/components/projects/ProjectEmpty.tsx
index 85f421e..fe1ace4 100644
--- a/apps/desktop/src/components/projects/ProjectEmpty.tsx
+++ b/apps/desktop/src/components/projects/ProjectEmpty.tsx
@@ -14,7 +14,7 @@ import {
import { useState } from "react";
import { api } from "@/lib/api";
import { useStore } from "@/state/store";
-import { Mascot } from "@/components/layout/Mascot";
+import { EmptyHero } from "@/components/layout/EmptyHero";
import { IconPlus } from "@/components/layout/Icons";
import { PersonaAvatar } from "@/components/layout/PersonaAvatar";
import { FeatureHintRow } from "@/components/layout/FeatureHint";
@@ -93,15 +93,14 @@ export function ProjectEmpty() {
return (
-
-
-
{hubMode ? "Teams" : "Projects"}
-
- {hubMode
+
-
+ : "A body of work with a coordinator, shared context, and members. The coordinator plans and delegates — it does not write application code."
+ }
+ />
{SECTION_FEATURE_HINTS.project ? (
) : null}
diff --git a/apps/desktop/src/components/projects/ProjectPane.test.tsx b/apps/desktop/src/components/projects/ProjectPane.test.tsx
index 8a97c93..aff9ec1 100644
--- a/apps/desktop/src/components/projects/ProjectPane.test.tsx
+++ b/apps/desktop/src/components/projects/ProjectPane.test.tsx
@@ -5,6 +5,7 @@ import type { AgentSession, LocusConfig, ProjectDocument } from "@locus/shared";
import { ProjectFleet } from "./ProjectPane";
import { api } from "@/lib/api";
import { Store, initialState, type Action, type UiState } from "@/state/store";
+import { setSelectValue } from "@/lib/selectControl";
vi.mock("@/lib/api", () => ({
api: {
@@ -237,12 +238,9 @@ describe("ProjectFleet hub roster", () => {
act(() => {
more.open = true;
});
- const select = host.querySelector('select[aria-label="Board"]') as HTMLSelectElement;
- expect(select).toBeTruthy();
+ expect(host.querySelector('[aria-label="Board"]')).toBeTruthy();
act(() => {
- const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!;
- setter.call(select, "board-1");
- select.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Board", "board-1");
});
await act(async () => {
const pin = [...host.querySelectorAll("button")].find((btn) => btn.textContent === "Pin");
diff --git a/apps/desktop/src/components/projects/ProjectPane.tsx b/apps/desktop/src/components/projects/ProjectPane.tsx
index cd1a424..0141b2e 100644
--- a/apps/desktop/src/components/projects/ProjectPane.tsx
+++ b/apps/desktop/src/components/projects/ProjectPane.tsx
@@ -21,10 +21,14 @@ import {
type ProjectDocument,
} from "@locus/shared";
import { api } from "@/lib/api";
+import { askPrompt } from "@/lib/dialog";
+import { showNotice } from "@/lib/notice";
import { useStore } from "@/state/store";
import { ChatPane } from "@/components/chat/ChatPane";
import { IconBrain, IconPause, IconPlay } from "@/components/layout/Icons";
import { PersonaAvatar } from "@/components/layout/PersonaAvatar";
+import { Select } from "@/components/layout/Select";
+import { ErrorBanner, Skeleton } from "@/components/layout/Skeleton";
import { WorkspaceBody } from "@/components/workspace/WorkspaceBody";
import { formatWhen } from "@/components/automations/Automations";
import { ProjectEmpty } from "./ProjectEmpty";
@@ -60,14 +64,14 @@ export function ProjectPane() {
if (error) {
return (
);
}
if (!cached) {
return (
);
}
@@ -162,7 +166,7 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
const project = await api.patchProject(props.project.id, { paused: !props.project.paused });
dispatch({ type: "project", project });
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not pause project");
+ showNotice(err instanceof Error ? err.message : "Could not pause project");
}
}
@@ -173,7 +177,7 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
});
dispatch({ type: "project", project });
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not update keep-alive");
+ showNotice(err instanceof Error ? err.message : "Could not update keep-alive");
}
}
@@ -182,15 +186,17 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
action: "approve" | "answer" | "retry" | "cancel" | "takeover",
) {
const response =
- action === "answer" ? (window.prompt("Answer for the teammate") ?? "") : undefined;
- if (action === "answer" && !(response ?? "").trim()) return;
+ action === "answer"
+ ? ((await askPrompt({ title: "Answer for the teammate" })) ?? "").trim()
+ : undefined;
+ if (action === "answer" && !response) return;
setNeedBusy(needId);
try {
const project = await api.projectNeedAction(props.project.id, needId, action, response);
dispatch({ type: "project", project });
setMission(await api.projectMissionControl(props.project.id));
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not update Needs you");
+ showNotice(err instanceof Error ? err.message : "Could not update Needs you");
} finally {
setNeedBusy(null);
}
@@ -208,7 +214,7 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
setHandoffTask("");
setMission(await api.projectMissionControl(props.project.id));
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not send team handoff");
+ showNotice(err instanceof Error ? err.message : "Could not send team handoff");
} finally {
setHandoffBusy(null);
}
@@ -221,7 +227,7 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
await api.projectHandoffAction(props.project.id, handoffId, action);
setMission(await api.projectMissionControl(props.project.id));
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not update team handoff");
+ showNotice(err instanceof Error ? err.message : "Could not update team handoff");
} finally {
setHandoffBusy(null);
}
@@ -357,18 +363,18 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
Team handoffs
{swarmTeams.length > 0 ? (
-
setHandoffTarget(event.target.value)}
- aria-label="Receiving team"
- >
- Choose a receiving team
- {swarmTeams.map((row) => (
-
- {row.teamName ?? row.title}
-
- ))}
-
+ options={[
+ { value: "", label: "Choose a receiving team" },
+ ...swarmTeams.map((row) => ({
+ value: row.id,
+ label: row.teamName ?? row.title,
+ })),
+ ]}
+ onChange={setHandoffTarget}
+ />
setHandoffTask(event.target.value)}
@@ -454,20 +460,17 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
Pin a planning canvas to this project.
)}
- setBoardPick(event.target.value)}
- >
- No board
- {state.boards
- .filter((row) => !row.archived)
- .map((row) => (
-
- {row.title}
-
- ))}
-
+ options={[
+ { value: "", label: "No board" },
+ ...state.boards
+ .filter((row) => !row.archived)
+ .map((row) => ({ value: row.id, label: row.title })),
+ ]}
+ onChange={setBoardPick}
+ />
Active team
- dispatch({ type: "selectProject", id: event.target.value })}
- >
- {state.projects
+ options={state.projects
.filter((row) => row.surface === "swarm")
- .map((row) => (
-
- {row.teamName ?? row.title}
-
- ))}
-
+ .map((row) => ({
+ value: row.id,
+ label: row.teamName ?? row.title,
+ }))}
+ onChange={(id) => dispatch({ type: "selectProject", id })}
+ />
- {briefBusy ? Loading…
: null}
+ {briefBusy ? : null}
{briefError ? {briefError}
: null}
{briefContent != null && !briefBusy ? (
<>
@@ -839,7 +841,7 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
{group.label} {group.members.length}
{group.members.length === 0 ? (
- None
+ No one in this column yet
) : (
group.members.map((row) => {
const teamMember = props.project.team?.members.find(
@@ -933,7 +935,7 @@ export function ProjectFleet(props: { project: ProjectDocument }) {
Close
- {briefBusy ?
Loading…
: null}
+ {briefBusy ?
: null}
{briefError ?
{briefError}
: null}
{briefContent != null && !briefBusy ? (
<>
diff --git a/apps/desktop/src/components/projects/TeamEditor.test.tsx b/apps/desktop/src/components/projects/TeamEditor.test.tsx
index 154f5cc..bb2b227 100644
--- a/apps/desktop/src/components/projects/TeamEditor.test.tsx
+++ b/apps/desktop/src/components/projects/TeamEditor.test.tsx
@@ -5,6 +5,7 @@ import type { AgentSession, LocusConfig, ProjectDocument } from "@locus/shared";
import { TeamEditor } from "./TeamEditor";
import { api } from "@/lib/api";
import { Store, initialState, type Action } from "@/state/store";
+import { setSelectValue } from "@/lib/selectControl";
vi.mock("@/lib/api", () => ({
api: {
@@ -17,17 +18,11 @@ vi.mock("@/lib/api", () => ({
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
-function setControl(el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, value: string) {
+function setControl(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
const prototype =
- el instanceof HTMLTextAreaElement
- ? HTMLTextAreaElement.prototype
- : el instanceof HTMLSelectElement
- ? HTMLSelectElement.prototype
- : HTMLInputElement.prototype;
+ el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(prototype, "value")?.set?.call(el, value);
- el.dispatchEvent(
- new Event(el instanceof HTMLSelectElement ? "change" : "input", { bubbles: true }),
- );
+ el.dispatchEvent(new Event("input", { bubbles: true }));
}
const config: LocusConfig = {
@@ -140,11 +135,10 @@ describe("TeamEditor", () => {
vi.mocked(api.project).mockResolvedValue(enrolled);
render();
const name = host.querySelector('[aria-label="Teammate name"]') as HTMLInputElement;
- const role = host.querySelector('[aria-label="Teammate role"]') as HTMLSelectElement;
const brief = host.querySelector('[aria-label="Teammate brief"]') as HTMLTextAreaElement;
act(() => {
setControl(name, "Nova");
- setControl(role, "Ops");
+ setSelectValue(host, "Teammate role", "Ops");
setControl(brief, "Watch deploys");
});
const add = [...host.querySelectorAll("button")].find(
@@ -206,9 +200,8 @@ describe("TeamEditor", () => {
session: enrolledSession,
});
render(project, [nova]);
- const pick = host.querySelector('[aria-label="Existing teammate"]') as HTMLSelectElement;
act(() => {
- setControl(pick, "s-nova");
+ setSelectValue(host, "Existing teammate", "s-nova");
});
const add = [...host.querySelectorAll("button")].find(
(btn) => btn.textContent === "Add to team",
diff --git a/apps/desktop/src/components/projects/TeamEditor.tsx b/apps/desktop/src/components/projects/TeamEditor.tsx
index da78c12..fde761d 100644
--- a/apps/desktop/src/components/projects/TeamEditor.tsx
+++ b/apps/desktop/src/components/projects/TeamEditor.tsx
@@ -13,6 +13,7 @@ import { api } from "@/lib/api";
import { resolveAgentDefaults, loadLastAgent } from "@/lib/lastAgent";
import { emptyPersonaDraft, personaFromDraft } from "@/lib/personaDraft";
import { useStore } from "@/state/store";
+import { Select } from "@/components/layout/Select";
function existingTeammateLabel(
session: AgentSession,
@@ -194,20 +195,21 @@ export function TeamEditor(props: { project: ProjectDocument }) {
Existing teammate
Teammate
- setExistingId(event.target.value)}
- >
-
- {existing.length ? "Choose a teammate" : "No teammates to add"}
-
- {existing.map((session) => (
-
- {existingTeammateLabel(session, props.project.id, state.projects)}
-
- ))}
-
+ options={[
+ {
+ value: "",
+ label: existing.length ? "Choose a teammate" : "No teammates to add",
+ },
+ ...existing.map((session) => ({
+ value: session.id,
+ label: existingTeammateLabel(session, props.project.id, state.projects),
+ })),
+ ]}
+ onChange={setExistingId}
+ />
{memberBusy ? "Adding…" : "Add to team"}
@@ -227,18 +229,15 @@ export function TeamEditor(props: { project: ProjectDocument }) {
Role
- setRole(event.target.value)}
- >
- Ask when we start
- {HUB_ROLE_PRESETS.map((preset) => (
-
- {preset}
-
- ))}
-
+ options={[
+ { value: "", label: "Ask when we start" },
+ ...HUB_ROLE_PRESETS.map((preset) => ({ value: preset, label: preset })),
+ ]}
+ onChange={setRole}
+ />
Brief
diff --git a/apps/desktop/src/components/settings/AppearanceSettings.test.tsx b/apps/desktop/src/components/settings/AppearanceSettings.test.tsx
new file mode 100644
index 0000000..5a7d084
--- /dev/null
+++ b/apps/desktop/src/components/settings/AppearanceSettings.test.tsx
@@ -0,0 +1,53 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import { AppearanceSettings } from "./AppearanceSettings";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("AppearanceSettings", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("groups See all themes by family", async () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ await act(async () => {
+ root.render(
+ undefined}
+ onEditor={() => undefined}
+ />,
+ );
+ });
+ expect(host.querySelector("[data-testid='theme-family-dark']")).toBeNull();
+ const seeAll = [...host.querySelectorAll("button")].find((btn) =>
+ btn.textContent?.startsWith("See all"),
+ );
+ expect(seeAll).toBeTruthy();
+ await act(async () => {
+ seeAll!.click();
+ });
+ expect(host.querySelector("[data-testid='theme-family-dark']")?.textContent).toContain("Dark");
+ expect(host.querySelector("[data-testid='theme-family-light']")?.textContent).toContain(
+ "Light",
+ );
+ expect(host.querySelector("[data-testid='theme-family-contrast']")?.textContent).toContain(
+ "Contrast",
+ );
+ expect(host.querySelector("[data-testid='theme-family-gradient']")?.textContent).toContain(
+ "Gradient",
+ );
+ });
+});
diff --git a/apps/desktop/src/components/settings/AppearanceSettings.tsx b/apps/desktop/src/components/settings/AppearanceSettings.tsx
index e3d4dbf..95802d5 100644
--- a/apps/desktop/src/components/settings/AppearanceSettings.tsx
+++ b/apps/desktop/src/components/settings/AppearanceSettings.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import {
THEME_OPTIONS,
+ FEATURED_THEME_IDS,
editorFontSize,
filterThemeOptions,
parseChatDensity,
@@ -11,6 +12,7 @@ import {
themeOption,
type ChatDensity,
type ProductMode,
+ type ThemeFamily,
type ThemeFilter,
type ThemeId,
type ThemeOption,
@@ -100,10 +102,14 @@ export function AppearanceSettings(props: {
const recents = loadThemeRecents()
.filter((id) => id !== selected)
.slice(0, 3);
- const pinnedIds = [selected, ...recents];
+ const featuredIds = [
+ selected,
+ ...FEATURED_THEME_IDS.filter((id) => id !== selected),
+ ...recents.filter((id) => id !== selected && !FEATURED_THEME_IDS.includes(id)),
+ ];
const shown =
filter === "all" && !seeAll
- ? pinnedIds
+ ? featuredIds
.map((id) => themes.find((opt) => opt.id === id) ?? themeOption(id))
.filter((opt, index, rows) => rows.findIndex((row) => row.id === opt.id) === index)
: themes;
@@ -228,23 +234,50 @@ export function AppearanceSettings(props: {
) : null}
{filter === "all" && !seeAll ? (
+
+ Featured: Ember, Midnight, Paper, and Contrast. Hover to preview.
+
setSeeAll(true)}>
See all {themes.length} themes
) : null}
-
- {shown.map((opt) => (
- selectTheme(opt.id)}
- onPreview={() => setHoverId(opt.id)}
- onPreviewEnd={() => setHoverId((id) => (id === opt.id ? null : id))}
- />
- ))}
-
+ {filter === "all" && seeAll ? (
+ groupedThemeFamilies(themes).map((row) => (
+
+ {themeFamilyLabel(row.family)}
+
+ {row.items.map((opt) => (
+ selectTheme(opt.id)}
+ onPreview={() => setHoverId(opt.id)}
+ onPreviewEnd={() => setHoverId((id) => (id === opt.id ? null : id))}
+ />
+ ))}
+
+
+ ))
+ ) : (
+
+ {shown.map((opt) => (
+ selectTheme(opt.id)}
+ onPreview={() => setHoverId(opt.id)}
+ onPreviewEnd={() => setHoverId((id) => (id === opt.id ? null : id))}
+ />
+ ))}
+
+ )}
({
+ family,
+ items: themes.filter((opt) => themeFamily(opt) === family),
+ })).filter((row) => row.items.length);
+}
+
function ThemeCard(props: {
opt: ThemeOption;
selected: boolean;
diff --git a/apps/desktop/src/components/settings/EnvironmentPicker.tsx b/apps/desktop/src/components/settings/EnvironmentPicker.tsx
index ec62ed5..2decf38 100644
--- a/apps/desktop/src/components/settings/EnvironmentPicker.tsx
+++ b/apps/desktop/src/components/settings/EnvironmentPicker.tsx
@@ -13,6 +13,8 @@ import {
import { useState } from "react";
import { api } from "@/lib/api";
import { useStore } from "@/state/store";
+import { Select } from "@/components/layout/Select";
+import { InlineError } from "@/components/layout/Skeleton";
export function EnvironmentPicker(props: {
value: string;
@@ -78,32 +80,32 @@ export function EnvironmentPicker(props: {
return (
Environment
-
{
- const id = event.target.value;
+ options={[
+ {
+ value: "",
+ label: props.noneLabel ?? "None — pick runtime, recipe, and Where as usual",
+ },
+ ...items.map((env) => {
+ const worker = workers.find((row) => row.id === env.backendId);
+ const bits = [
+ env.name,
+ env.network ? formatNetworkJail(env.network) : "",
+ typeof env.maxUsd === "number" ? `$${env.maxUsd}` : "",
+ worker ? workerWhereLabel(worker) : "",
+ ].filter(Boolean);
+ return { value: env.id, label: bits.join(" · ") };
+ }),
+ ]}
+ onChange={(id) => {
props.onChange(
id,
items.find((row) => row.id === id),
);
}}
- >
-
- {props.noneLabel ?? "None — pick runtime, recipe, and Where as usual"}
-
- {items.map((env) => {
- const worker = workers.find((row) => row.id === env.backendId);
- return (
-
- {env.name}
- {env.network ? ` · ${formatNetworkJail(env.network)}` : ""}
- {typeof env.maxUsd === "number" ? ` · $${env.maxUsd}` : ""}
- {worker ? ` · ${workerWhereLabel(worker)}` : ""}
-
- );
- })}
-
+ />
{props.value ? (
{(() => {
@@ -155,7 +157,7 @@ export function EnvironmentPicker(props: {
Save as environment
)}
- {error ?
{error}
: null}
+ {error ?
: null}
);
}
diff --git a/apps/desktop/src/components/settings/ImportAccounts.test.tsx b/apps/desktop/src/components/settings/ImportAccounts.test.tsx
index f2a366f..8f656c0 100644
--- a/apps/desktop/src/components/settings/ImportAccounts.test.tsx
+++ b/apps/desktop/src/components/settings/ImportAccounts.test.tsx
@@ -142,8 +142,7 @@ describe("ImportAccounts", () => {
});
expect(host.querySelector(".btn.primary")?.textContent).toBe("Link selected (2)");
expect(
- (host.querySelector("[aria-label='Modsofthenation/Locus']") as HTMLInputElement)
- .checked,
+ (host.querySelector("[aria-label='Modsofthenation/Locus']") as HTMLInputElement).checked,
).toBe(true);
expect(linkedBox.checked).toBe(true);
diff --git a/apps/desktop/src/components/settings/ModelPicker.test.tsx b/apps/desktop/src/components/settings/ModelPicker.test.tsx
index f2db030..4250c2d 100644
--- a/apps/desktop/src/components/settings/ModelPicker.test.tsx
+++ b/apps/desktop/src/components/settings/ModelPicker.test.tsx
@@ -3,6 +3,12 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { BUILTIN_PROVIDERS, builtinProviderKey, type AiProvider } from "@locus/shared";
import { CodexReasoningPicker, ModelPicker } from "./ModelPicker";
+import {
+ selectOptionLabels,
+ selectOptionValues,
+ selectValue,
+ setSelectValue,
+} from "@/lib/selectControl";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -40,9 +46,7 @@ describe("ModelPicker", () => {
/>,
);
expect(host.textContent).toMatch(/\$2 in · \$8 out \/1M/);
- const fullOptions = [...host.querySelectorAll('select[aria-label="Model"] option')].map(
- (o) => o.textContent,
- );
+ const fullOptions = selectOptionLabels(host, "Model");
expect(fullOptions).toEqual(
expect.arrayContaining([expect.stringContaining("gpt-4.1 · $2 in · $8 out /1M")]),
);
@@ -63,10 +67,9 @@ describe("ModelPicker", () => {
onChange={() => undefined}
/>,
);
- const provider = host.querySelector('select[aria-label="Provider"]') as HTMLSelectElement;
- const model = host.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
- expect(provider.selectedOptions[0]?.textContent).toBe("No provider");
- expect(model.selectedOptions[0]?.textContent).toBe("No model");
+ expect(
+ host.querySelector("[aria-label='Model'] .model-picker-combined-label")?.textContent,
+ ).toBe("No provider");
});
it("does not add a placeholder when the selected provider is listed", () => {
@@ -79,9 +82,18 @@ describe("ModelPicker", () => {
onChange={() => undefined}
/>,
);
- const provider = host.querySelector('select[aria-label="Provider"]') as HTMLSelectElement;
- expect([...provider.options].some((o) => o.disabled)).toBe(false);
- expect(provider.selectedOptions[0]?.value).toBe("openai");
+ expect(
+ host.querySelector("[aria-label='Model'] .model-picker-combined-label")?.textContent,
+ ).toBe("OpenAI · gpt-4.1");
+ act(() => {
+ host
+ .querySelector("[aria-label='Model']")
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ const disabled = [...host.querySelectorAll("[role='menuitem']")].some(
+ (el) => (el as HTMLButtonElement).disabled,
+ );
+ expect(disabled).toBe(false);
});
it("does not show Fast or thinking for OpenAI", () => {
@@ -100,8 +112,7 @@ describe("ModelPicker", () => {
it("shows only the reasoning levels supported by the selected Codex model", () => {
const codex = providers.find((p) => p.id === "codex-cli")!;
render( undefined} />);
- const effort = host.querySelector('select[aria-label="Reasoning effort"]') as HTMLSelectElement;
- expect([...effort.options].map((option) => option.value)).toEqual([
+ expect(selectOptionValues(host, "Reasoning effort")).toEqual([
"",
"low",
"medium",
@@ -121,19 +132,16 @@ describe("ModelPicker", () => {
onChange={(next) => changes.push(next)}
/>,
);
- const modelSelect = host.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
- expect([...modelSelect.options].map((opt) => opt.value)).toEqual([
+ expect(selectOptionValues(host, "Model")).toEqual([
"composer-2",
"auto",
"claude-sonnet-4-5",
"gpt-5",
]);
expect(host.querySelector('[aria-label="Thinking"]')).toBeNull();
- const fast = host.querySelector('select[aria-label="Fast"]') as HTMLSelectElement;
- expect(fast.value).toBe("off");
+ expect(selectValue(host, "Fast")).toBe("off");
act(() => {
- fast.value = "fast";
- fast.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Fast", "fast");
});
expect(changes).toEqual([{ providerId: "cursor-cli", model: "composer-2-fast" }]);
});
@@ -149,15 +157,9 @@ describe("ModelPicker", () => {
/>,
);
expect(host.querySelector('[aria-label="Fast"]')).toBeNull();
- const thinking = host.querySelector('select[aria-label="Thinking"]') as HTMLSelectElement;
- expect([...thinking.options].map((opt) => opt.textContent)).toEqual([
- "Off",
- "Thinking",
- "High",
- ]);
+ expect(selectOptionLabels(host, "Thinking")).toEqual(["Off", "Thinking", "High"]);
act(() => {
- thinking.value = "thinking";
- thinking.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Thinking", "thinking");
});
expect(changes.at(-1)).toEqual({
providerId: "cursor-cli",
@@ -174,8 +176,7 @@ describe("ModelPicker", () => {
/>,
);
});
- const gptThinking = host.querySelector('select[aria-label="Thinking"]') as HTMLSelectElement;
- expect([...gptThinking.options].map((opt) => opt.textContent)).toEqual([
+ expect(selectOptionLabels(host, "Thinking")).toEqual([
"None",
"Low",
"Medium",
@@ -183,8 +184,7 @@ describe("ModelPicker", () => {
"Extra-high",
]);
act(() => {
- gptThinking.value = "xhigh";
- gptThinking.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Thinking", "xhigh");
});
expect(changes.at(-1)).toEqual({ providerId: "cursor-cli", model: "gpt-5-xhigh" });
@@ -198,10 +198,8 @@ describe("ModelPicker", () => {
/>,
);
});
- const fast = host.querySelector('select[aria-label="Fast"]') as HTMLSelectElement;
act(() => {
- fast.value = "fast";
- fast.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Fast", "fast");
});
expect(changes.at(-1)).toEqual({ providerId: "cursor-cli", model: "gpt-5-xhigh-fast" });
});
@@ -225,21 +223,12 @@ describe("ModelPicker", () => {
onChange={(next) => changes.push(next)}
/>,
);
- const modelSelect = host.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
- expect(modelSelect.value).toBe("cursor-grok-4.6");
- const thinking = host.querySelector('select[aria-label="Thinking"]') as HTMLSelectElement;
- expect([...thinking.options].map((opt) => opt.textContent)).toEqual([
- "Low",
- "Medium",
- "High",
- "Extra-high",
- ]);
- expect(thinking.value).toBe("high");
- const fast = host.querySelector('select[aria-label="Fast"]') as HTMLSelectElement;
- expect(fast.value).toBe("off");
+ expect(selectValue(host, "Model")).toBe("cursor-grok-4.6");
+ expect(selectOptionLabels(host, "Thinking")).toEqual(["Low", "Medium", "High", "Extra-high"]);
+ expect(selectValue(host, "Thinking")).toBe("high");
+ expect(selectValue(host, "Fast")).toBe("off");
act(() => {
- thinking.value = "low";
- thinking.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Thinking", "low");
});
expect(changes.at(-1)).toEqual({ providerId: "cursor-cli", model: "cursor-grok-4.6-low" });
act(() => {
@@ -252,10 +241,8 @@ describe("ModelPicker", () => {
/>,
);
});
- const fastOn = host.querySelector('select[aria-label="Fast"]') as HTMLSelectElement;
act(() => {
- fastOn.value = "fast";
- fastOn.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Fast", "fast");
});
expect(changes.at(-1)).toEqual({
providerId: "cursor-cli",
@@ -279,9 +266,17 @@ describe("ModelPicker", () => {
onChange={(next) => changes.push(next)}
/>,
);
- const modelSelect = host.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
- expect(modelSelect.value).toBe("composer-2");
- expect([...modelSelect.options].map((opt) => opt.value)).toEqual(["composer-2", "auto"]);
+ expect(host.querySelector(".model-picker-combined-label")?.textContent).toMatch(/composer-2/);
+ act(() => {
+ host
+ .querySelector("[aria-label='Model']")
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ const modelItems = [...host.querySelectorAll(".model-picker-combined [role='menuitem']")].map(
+ (el) => el.textContent,
+ );
+ expect(modelItems).toEqual(expect.arrayContaining(["composer-2", "auto"]));
+ expect(modelItems).not.toContain("composer-2-fast");
act(() => {
(host.querySelector('[aria-label="Model options"]') as HTMLElement).click();
});
@@ -374,7 +369,7 @@ describe("ModelPicker", () => {
);
expect(host.querySelector('[aria-label="Model options"]')).toBeNull();
expect(host.querySelector(".model-picker-more")).toBeNull();
- expect(host.querySelector(".menu-pop")).toBeNull();
+ expect(host.querySelector(".model-picker-combined")).toBeTruthy();
});
it("opens compact model options above the composer footer", () => {
@@ -409,16 +404,21 @@ describe("ModelPicker", () => {
onChange={() => undefined}
/>,
);
- const modelSelect = host.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
- expect(modelSelect?.value).toBe("claude-sonnet-4-5");
- expect([...modelSelect.options].map((opt) => opt.textContent)).toEqual([
- "claude-sonnet-4-5",
- "claude-opus-4-5",
- "claude-haiku-4-5",
- ]);
+ expect(host.querySelector(".model-picker-combined-label")?.textContent).toBe(
+ "Anthropic · claude-sonnet-4-5",
+ );
+ act(() => {
+ host
+ .querySelector("[aria-label='Model']")
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(
+ [...host.querySelectorAll(".model-picker-combined [role='menuitem']")].map(
+ (el) => el.textContent,
+ ),
+ ).toEqual(expect.arrayContaining(["claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5"]));
expect(host.querySelector(".model-cost-chip")).toBeNull();
expect(host.querySelector('[aria-label="Model options"]')).toBeNull();
- expect(host.querySelector("summary")).toBeNull();
expect(host.querySelector(".model-cost-table")).toBeNull();
expect(host.textContent).not.toMatch(/Compare providers/);
});
diff --git a/apps/desktop/src/components/settings/ModelPicker.tsx b/apps/desktop/src/components/settings/ModelPicker.tsx
index d02d068..d58234c 100644
--- a/apps/desktop/src/components/settings/ModelPicker.tsx
+++ b/apps/desktop/src/components/settings/ModelPicker.tsx
@@ -17,8 +17,10 @@ import {
type CodexReasoningEffort,
type AiProvider,
} from "@locus/shared";
+import { Fragment } from "react";
import { modelsForProvider } from "@/lib/lastAgent";
import { MenuItem, MenuLabel, MenuSep, OverflowMenu } from "@/components/layout/OverflowMenu";
+import { Select } from "@/components/layout/Select";
export function ModelPicker(props: {
providers: AiProvider[];
@@ -54,55 +56,48 @@ export function ModelPicker(props: {
? props.providers.filter((p) => p.enabled || p.id === props.providerId)
: props.providers;
const providerSelect = (
- {
- const next = props.providers.find((p) => p.id === e.target.value);
+ options={[
+ listedProviders.some((p) => p.id === props.providerId)
+ ? null
+ : {
+ value: props.providerId,
+ label: props.providerId || "No provider",
+ disabled: true,
+ },
+ ...listedProviders.map((p) => ({
+ value: p.id,
+ label: p.enabled ? p.name : `${p.name} (off)`,
+ })),
+ ].filter((row): row is NonNullable => Boolean(row))}
+ onChange={(id) => {
+ const next = props.providers.find((p) => p.id === id);
props.onChange({
- providerId: e.target.value,
+ providerId: id,
model: next?.defaultModel || next?.models[0] || "",
});
}}
- >
- {listedProviders.some((p) => p.id === props.providerId) ? null : (
-
- {props.providerId || "No provider"}
-
- )}
- {listedProviders.map((p) => (
-
- {p.name}
- {p.enabled ? "" : " (off)"}
-
- ))}
-
+ />
);
const modelSelect = (
-
- setModel(cursorIds ? retargetCursorModel(props.model, e.target.value) : e.target.value)
+ options={
+ models.length
+ ? models.map((m) => {
+ const rates = formatRatePair(resolveUsageRates(provider, m));
+ return { value: m, label: rates ? `${m} · ${rates}` : m };
+ })
+ : [{ value: "", label: "No model", disabled: true }]
}
- >
- {models.length ? null : (
-
- No model
-
- )}
- {models.map((m) => {
- const rates = props.compact ? undefined : formatRatePair(resolveUsageRates(provider, m));
- return (
-
- {rates ? `${m} · ${rates}` : m}
-
- );
- })}
-
+ onChange={(value) => setModel(cursorIds ? retargetCursorModel(props.model, value) : value)}
+ />
);
const cursorControls =
@@ -127,13 +122,70 @@ export function ModelPicker(props: {
const modelControls = cursorControls || codexControls;
if (props.compact) {
+ const combinedLabel = !provider
+ ? "No provider"
+ : !modelValue
+ ? `${provider.name} · No model`
+ : `${provider.name} · ${modelValue}`;
return (
-
- {providerSelect}
-
- {modelSelect}
+
{combinedLabel}}
+ >
+ {listedProviders.length ? (
+ listedProviders.map((row) => {
+ const listed = modelsForProvider(
+ row,
+ row.id === props.providerId ? selectedModel : row.defaultModel,
+ );
+ const ids = usesCursorModelIds(row) ? cursorModelPickerIds(listed) : listed;
+ return (
+
+
+ {row.name}
+ {row.enabled ? "" : " (off)"}
+
+ {ids.length ? (
+ ids.map((m) => (
+
+ props.onChange({
+ providerId: row.id,
+ model: usesCursorModelIds(row)
+ ? retargetCursorModel(
+ row.id === props.providerId
+ ? props.model
+ : row.defaultModel || m,
+ m,
+ )
+ : m,
+ })
+ }
+ >
+ {m}
+
+ ))
+ ) : (
+ No model
+ )}
+
+ );
+ })
+ ) : (
+ No provider
+ )}
+
{modelControls ? (
Reasoning effort
-
- props.onChange((e.target.value || undefined) as CodexReasoningEffort | undefined)
+ options={[
+ { value: "", label: "Auto (Codex default)" },
+ ...levels.map((level) => ({
+ value: level,
+ label: level === "xhigh" ? "XHigh" : level[0]!.toUpperCase() + level.slice(1),
+ })),
+ ]}
+ onChange={(value) =>
+ props.onChange((value || undefined) as CodexReasoningEffort | undefined)
}
- >
- Auto (Codex default)
- {levels.map((level) => (
-
- {level === "xhigh" ? "XHigh" : level[0]!.toUpperCase() + level.slice(1)}
-
- ))}
-
+ />
Controls how much reasoning Codex uses for this model.
);
@@ -314,33 +366,29 @@ export function CursorModelControls(props: {
const fastControl = caps.supportsFast ? (
Fast
- emit({ fast: e.target.value === "fast" })}
- >
- Off
- On
-
+ options={[
+ { value: "off", label: "Off" },
+ { value: "fast", label: "On" },
+ ]}
+ onChange={(value) => emit({ fast: value === "fast" })}
+ />
) : null;
const thinkingControl = caps.thinkingLevels.length ? (
Thinking
- emit({ thinking: e.target.value as (typeof parsed)["thinking"] })}
- >
- {caps.thinkingLevels.map((row) => (
-
- {row.label}
-
- ))}
-
+ options={caps.thinkingLevels.map((row) => ({ value: row.value, label: row.label }))}
+ onChange={(value) => emit({ thinking: value as (typeof parsed)["thinking"] })}
+ />
) : null;
diff --git a/apps/desktop/src/components/settings/Settings.test.tsx b/apps/desktop/src/components/settings/Settings.test.tsx
index 570d82e..7870ec1 100644
--- a/apps/desktop/src/components/settings/Settings.test.tsx
+++ b/apps/desktop/src/components/settings/Settings.test.tsx
@@ -75,7 +75,7 @@ vi.mock("@/lib/api", () => ({
rotateChromeExtension: vi.fn(),
checkUpdates: vi.fn(async () => ({
status: "unavailable",
- current: "0.16.1",
+ current: "0.17.0",
message: "No GitHub release found yet. Check for updates works after a v* tag is published.",
})),
refreshCursorCli: vi.fn(async () => ({ changed: false, models: [] })),
@@ -564,7 +564,7 @@ describe("Settings", () => {
});
const contrastCards = [...host!.querySelectorAll(".theme-card")];
expect(contrastCards.map((el) => el.querySelector("h3")?.textContent)).toEqual([
- "Signal",
+ "Contrast",
"Beacon",
"Ion",
"Siren",
@@ -854,7 +854,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("Sentry");
expect(host!.textContent).toContain("GitHub");
const addMiro = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add Miro",
+ (el) => el.querySelector("strong")?.textContent === "Add Miro",
);
expect(addMiro).toBeTruthy();
await act(async () => {
@@ -864,7 +864,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("miro_list");
expect(host!.textContent).toContain("miro_export");
const addConfluence = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add Confluence",
+ (el) => el.querySelector("strong")?.textContent === "Add Confluence",
);
expect(addConfluence).toBeTruthy();
await act(async () => {
@@ -873,7 +873,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("confluence_search");
expect(host!.textContent).toContain("confluence_comment");
const addGrafana = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add Grafana",
+ (el) => el.querySelector("strong")?.textContent === "Add Grafana",
);
expect(addGrafana).toBeTruthy();
await act(async () => {
@@ -882,7 +882,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("API key / service account token");
expect(host!.textContent).toContain("grafana_query");
const addIncident = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add incident.io",
+ (el) => el.querySelector("strong")?.textContent === "Add incident.io",
);
expect(addIncident).toBeTruthy();
await act(async () => {
@@ -892,7 +892,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("incidentio_search");
expect(host!.textContent).toContain("incidentio_create");
const addLinear = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add Linear",
+ (el) => el.querySelector("strong")?.textContent === "Add Linear",
);
expect(addLinear).toBeTruthy();
await act(async () => {
@@ -902,7 +902,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("linear_search");
expect(host!.textContent).toContain("linear_create");
const addAws = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add AWS IAM",
+ (el) => el.querySelector("strong")?.textContent === "Add AWS IAM",
);
expect(addAws).toBeTruthy();
await act(async () => {
@@ -916,7 +916,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("aws_sts_identity");
expect(host!.textContent).toContain("aws_iam_simulate");
const addPortainer = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add Portainer",
+ (el) => el.querySelector("strong")?.textContent === "Add Portainer",
);
expect(addPortainer).toBeTruthy();
await act(async () => {
@@ -926,7 +926,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("portainer_endpoints");
expect(host!.textContent).toContain("portainer_container_action");
const addFigma = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add Figma",
+ (el) => el.querySelector("strong")?.textContent === "Add Figma",
);
expect(addFigma).toBeTruthy();
await act(async () => {
@@ -936,7 +936,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("figma_list");
expect(host!.textContent).toContain("figma_comment");
const addK8s = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add Kubernetes",
+ (el) => el.querySelector("strong")?.textContent === "Add Kubernetes",
);
expect(addK8s).toBeTruthy();
await act(async () => {
@@ -947,7 +947,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("k8s_list");
expect(host!.textContent).toContain("k8s_apply");
const addSentry = [...host!.querySelectorAll("button")].find(
- (el) => el.textContent === "Add Sentry",
+ (el) => el.querySelector("strong")?.textContent === "Add Sentry",
);
expect(addSentry).toBeTruthy();
await act(async () => {
@@ -1563,7 +1563,7 @@ describe("Settings", () => {
expect(host!.textContent).toContain("Danger zone");
expect(host!.textContent).toContain("/tmp");
expect(host!.textContent).toContain("Autonomy");
- expect(host!.textContent).toContain("Guided — ask for push, PR, MCP, plugins, and installs");
+ expect(host!.textContent).toContain("ask for push, PR, MCP, plugins, and installs");
vi.spyOn(window, "confirm").mockReturnValueOnce(false);
const button = [...host!.querySelectorAll("button")].find(
(el) => el.textContent === "Reset all data",
@@ -1846,10 +1846,13 @@ describe("Settings", () => {
expect(host!.textContent).toContain("worker host");
expect(host!.textContent).toContain("Steer interrupts");
expect(host!.textContent).not.toContain("Add a key to enable this provider");
- expect(host!.querySelector('select[aria-label="Fast"]')).toBeTruthy();
- expect((host!.querySelector('select[aria-label="Fast"]') as HTMLSelectElement).value).toBe(
- "off",
- );
+ expect(host!.querySelector('[aria-label="Fast"]')).toBeTruthy();
+ expect(
+ host!
+ .querySelector('[aria-label="Fast"]')
+ ?.closest("[data-value]")
+ ?.getAttribute("data-value"),
+ ).toBe("off");
});
it("shows a Claude Code hint and optional key on the provider card", async () => {
diff --git a/apps/desktop/src/components/settings/Settings.tsx b/apps/desktop/src/components/settings/Settings.tsx
index 1ab88be..37d0fa3 100644
--- a/apps/desktop/src/components/settings/Settings.tsx
+++ b/apps/desktop/src/components/settings/Settings.tsx
@@ -4,10 +4,8 @@ import {
FORGE_KINDS,
GUEST_OS_OPTIONS,
INTEGRATION_KINDS,
- AUTONOMY_TIERS,
defaultForgeApiUrl,
defaultIntegrationUrl,
- formatAutonomy,
forgeLabel,
forgeOauthCallbackUrl,
formatForgeUsedAt,
@@ -17,6 +15,7 @@ import {
preferredPairingLink,
generateGuestPassword,
integrationLabel,
+ integrationBlurb,
isCursorCliProvider,
parseAutonomyTier,
providerAllowedByPolicy,
@@ -60,6 +59,8 @@ import {
} from "@/lib/settingsNav";
import { setSettingsDirty, setSettingsPersist } from "@/lib/settingsLeave";
import { applyProductModeSideEffects, beginProductModeTransition } from "@/lib/productMode";
+import { Select } from "@/components/layout/Select";
+import { AUTONOMY_SELECT_OPTIONS } from "@/lib/formOptions";
import { blankSkill, configForSave } from "@/lib/configPersist";
import { useStore } from "@/state/store";
import { PairingQr } from "./PairingQr";
@@ -84,8 +85,15 @@ import { PolicyBanner, policyControlLocked } from "./PolicyBanner";
import { AppearanceSettings } from "./AppearanceSettings";
import { NotificationSettings } from "./NotificationSettings";
import { SearchField } from "@/components/layout/SearchField";
+import { BusyButton } from "@/components/layout/Skeleton";
+import { EmptyHint } from "@/components/layout/EmptyHero";
+import { askConfirm } from "@/lib/dialog";
+import { showUndoNotice } from "@/lib/notice";
+import { HighlightQuery } from "@/lib/queryMark";
+import { takePendingSettingsQuery } from "@/lib/settingsQuery";
import {
appendConfigList,
+ insertConfigList,
patchConfigList,
removeConfigList,
type ConfigListKey,
@@ -184,6 +192,10 @@ export function Settings() {
} | null>(null);
const [updateBusy, setUpdateBusy] = useState(false);
const [find, setFind] = useState("");
+ useEffect(() => {
+ const pending = takePendingSettingsQuery();
+ if (pending) setFind(pending);
+ }, []);
const pairingLink =
phone?.enabled && phone.urls.length ? preferredPairingLink(phone.urls) : undefined;
const draftRef = useRef(draft);
@@ -708,8 +720,17 @@ export function Settings() {
setDraft((current) => (current ? patchConfigList(current, key, index, patch) : current));
}
- function removeList
(key: K, index: number) {
- setDraft((current) => (current ? removeConfigList(current, key, index) : current));
+ function removeListUndoable(key: K, index: number, label: string) {
+ setDraft((current) => {
+ if (!current) return current;
+ const rows = [...((current[key] as ConfigListRow[] | undefined) ?? [])];
+ const removed = rows[index];
+ if (!removed) return current;
+ showUndoNotice(`Removed ${label}`, () => {
+ setDraft((draft) => (draft ? insertConfigList(draft, key, index, removed) : draft));
+ });
+ return removeConfigList(current, key, index);
+ });
}
function appendList(key: K, row: ConfigListRow) {
@@ -719,13 +740,24 @@ export function Settings() {
async function resetAll() {
const dir = state.config?.advanced.dataDir ?? current.advanced.dataDir;
if (
- !window.confirm(
- `Delete all Locus data in ${dir}? Sessions, secrets, disks, golden images, media, and automations will be gone. QEMU and Docker on this host stay.`,
- )
+ !(await askConfirm({
+ title: "Delete all Locus data?",
+ body: `Delete all Locus data in ${dir}? Sessions, secrets, disks, golden images, media, and automations will be gone. QEMU and Docker on this host stay.`,
+ confirmLabel: "Continue",
+ danger: true,
+ }))
) {
return;
}
- if (!window.confirm("Reset all data? This cannot be undone.")) return;
+ if (
+ !(await askConfirm({
+ title: "Reset all data?",
+ body: "This cannot be undone.",
+ confirmLabel: "Reset",
+ danger: true,
+ }))
+ )
+ return;
setError(null);
setResetting(true);
try {
@@ -1086,8 +1118,10 @@ export function Settings() {
{findHits.slice(0, 8).map((hit) => (
focusSettingsHit(hit)}>
- {settingsNavLabel(hit.tab)}
- {hit.id ? ` · ${hit.label}` : ""}
+
))}
@@ -1134,34 +1168,34 @@ export function Settings() {
Add provider
- {draft.providers.map((p, i) => (
- setExpandedProvider((id) => (id === p.id ? null : p.id))}
- onPatch={(patch) => patchProvider(i, patch)}
- onRefresh={() => void refreshModels(i)}
- onTest={() => void testConn(i)}
- onRemove={
- isBuiltinProvider(p)
- ? undefined
- : () => {
- setDraft({
- ...draft,
- providers: draft.providers.filter((row) => row.id !== p.id),
- });
- setExpandedProvider((id) => (id === p.id ? null : id));
- }
- }
- />
- ))}
+ {draft.providers
+ .map((p, i) => ({ p, i }))
+ .sort((a, b) => Number(b.p.enabled) - Number(a.p.enabled))
+ .map(({ p, i }) => (
+ setExpandedProvider((id) => (id === p.id ? null : p.id))}
+ onPatch={(patch) => patchProvider(i, patch)}
+ onRefresh={() => void refreshModels(i)}
+ onTest={() => void testConn(i)}
+ onRemove={
+ isBuiltinProvider(p)
+ ? undefined
+ : () => {
+ removeListUndoable("providers", i, p.name);
+ setExpandedProvider((id) => (id === p.id ? null : id));
+ }
+ }
+ />
+ ))}
>
)}
@@ -1213,10 +1247,7 @@ export function Settings() {
isBuiltinTemplate(t.id)
? undefined
: () => {
- setDraft({
- ...draft,
- templates: draft.templates.filter((row) => row.id !== t.id),
- });
+ removeListUndoable("templates", i, t.name);
setExpandedMachine((id) => (id === t.id ? null : id));
}
}
@@ -1241,7 +1272,7 @@ export function Settings() {
container pane. New agents default to the latest matching image.
{images.length === 0 ? (
- No golden images yet.
+
) : (
images.map((img) => (
- Match a GitHub, GitLab, or Bitbucket URL (or a substring like org/repo) to give that
- project a different CPU, RAM, or disk than the guest default. Agent and automation
- forms still let you override per run.
+ Match a GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps URL (or a substring
+ like org/repo) to give that project a different CPU, RAM, or disk than the guest
+ default. Agent and automation forms still let you override per run.
{(draft.hardwareProfiles ?? []).map((profile, i) => (
@@ -1350,38 +1381,26 @@ export function Settings() {
Guest OS (optional)
- {
+ options={[
+ { value: "", label: "Any" },
+ ...GUEST_OS_OPTIONS.map((opt) => ({ value: opt.os, label: opt.name })),
+ ]}
+ onChange={(value) => {
patchList("hardwareProfiles", i, (profile) => ({
...profile,
- os:
- e.target.value === ""
- ? undefined
- : (e.target.value as typeof profile.os),
+ os: value === "" ? undefined : (value as typeof profile.os),
}));
}}
- >
- Any
- {GUEST_OS_OPTIONS.map((opt) => (
-
- {opt.name}
-
- ))}
-
+ />
{
- setDraft({
- ...draft,
- hardwareProfiles: (draft.hardwareProfiles ?? []).filter(
- (p) => p.id !== profile.id,
- ),
- });
- }}
+ onClick={() => removeListUndoable("hardwareProfiles", i, profile.name)}
>
Remove
@@ -1417,10 +1436,10 @@ export function Settings() {
Paste the add line from npm run dev:worker (or{" "}
locus config workers add). Locus does not scan the LAN. Chat, keys, and
- approvals stay on this computer. Docker or Host. Cloud VMs destroy guests on stop
- and after idle TTL. Set hourly USD so the spend cap meters VM time. Cloud VMs need
- an image allowlist. Fargate needs a size allowlist and a task definition that
- already runs locus-worker.
+ approvals stay on this computer. Docker, Host, or QEMU VM. Cloud VMs destroy guests
+ on stop and after idle TTL. Set hourly USD so the spend cap meters VM time. Cloud
+ VMs need an image allowlist. Fargate needs a size allowlist and a task definition
+ that already runs locus-worker.
@@ -1468,7 +1487,7 @@ export function Settings() {
onPatch={(patch) => patchList("workers", i, patch)}
onTest={() => void testWorker(row)}
onRemove={() => {
- removeList("workers", i);
+ removeListUndoable("workers", i, row.name);
setExpandedWorker((id) => (id === row.id ? null : id));
}}
/>
@@ -1529,12 +1548,7 @@ export function Settings() {
onToggleExpand={() => setExpandedEnv((id) => (id === env.id ? null : env.id))}
onRename={(name) => patchList("namedEnvironments", i, { name })}
onRemove={() => {
- setDraft({
- ...draft,
- namedEnvironments: (draft.namedEnvironments ?? []).filter(
- (row) => row.id !== env.id,
- ),
- });
+ removeListUndoable("namedEnvironments", i, env.name);
setExpandedEnv((id) => (id === env.id ? null : id));
}}
/>
@@ -1545,11 +1559,12 @@ export function Settings() {
{tab === "git" && (
<>
- Add one or more GitHub, GitLab, and Bitbucket accounts. Sign in with GitHub, GitLab,
- or Bitbucket so SSO-protected orgs work, or paste a token. Either is enough to clone,
- fetch a branch or pull request, and push over HTTPS — no SSH key required. Multiple
- accounts of the same forge can be active at once; each linked repo remembers which
- account to use.
+ Add one or more GitHub, GitLab, Bitbucket, Gitea, Forgejo, and Azure DevOps accounts.
+ Sign in with GitHub, GitLab, or Bitbucket so SSO-protected orgs work, or paste a token
+ (Gitea, Forgejo, and Azure DevOps are PAT-only). Either is enough to clone, fetch a
+ branch or pull request, and push over HTTPS — no SSH key required. Multiple accounts
+ of the same forge can be active at once; each linked repo remembers which account to
+ use.
{draft.forges.map((f, i) => {
@@ -1576,7 +1591,7 @@ export function Settings() {
onDisconnect={() => void disconnectOauth(f.id)}
onRemove={() => {
const next = draft.forges[i - 1]?.id ?? draft.forges[i + 1]?.id ?? null;
- removeList("forges", i);
+ removeListUndoable("forges", i, f.name);
setExpandedForge((id) => (id === f.id ? next : id));
}}
/>
@@ -1609,18 +1624,18 @@ export function Settings() {
{tab === "integrations" && (
<>
- First-party GitHub, GitLab, Bitbucket, Jira, Confluence, Linear, Slack, Grafana,
- Sentry, Miro, Figma, incident.io, AWS IAM, Portainer, Gmail, Google Calendar, Google
- Drive, and Kubernetes, plus plugins the Locus agent can author. Tokens stay in the
- vault, never in the guest or the prompt. Sign in with Slack so Locus can act as you by
- default. Sign in with Google for Gmail, Calendar, and Drive. Composer and slack_post
- can also send as the Locus bot when a bot token is present. Agents only get those
- tools after that account has the required values. Mutating Jira comments, creates,
- transitions, Confluence pages, Linear comments/creates/updates, Slack posts, Sentry
- updates, Miro exports, Figma comments, incident.io creates/updates, Portainer
- start/stop, Gmail sends, Calendar creates, Drive uploads, Kubernetes
- apply/delete/scale, and plugin writes wait for Approve / Deny. Ask an agent to add a
- plugin for a bespoke HTTP API.
+ First-party GitHub, GitLab, Bitbucket, Gitea, Forgejo, Azure DevOps, Jira, Confluence,
+ Linear, Slack, Grafana, Sentry, Miro, Figma, incident.io, AWS IAM, Portainer, Gmail,
+ Google Calendar, Google Drive, and Kubernetes, plus plugins the Locus agent can
+ author. Tokens stay in the vault, never in the guest or the prompt. Sign in with Slack
+ so Locus can act as you by default. Sign in with Google for Gmail, Calendar, and
+ Drive. Composer and slack_post can also send as the Locus bot when a bot token is
+ present. Agents only get those tools after that account has the required values.
+ Mutating Jira comments, creates, transitions, Confluence pages, Linear
+ comments/creates/updates, Slack posts, Sentry updates, Miro exports, Figma comments,
+ incident.io creates/updates, Portainer start/stop, Gmail sends, Calendar creates,
+ Drive uploads, Kubernetes apply/delete/scale, and plugin writes wait for Approve /
+ Deny. Ask an agent to add a plugin for a bespoke HTTP API.
Git forges
@@ -1649,7 +1664,10 @@ export function Settings() {
.join(" · ")}
) : (
-
No Git accounts yet. Add GitHub, GitLab, or Bitbucket in Git.
+
+ No Git accounts yet. Add GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps
+ in Git.
+
)}
Jira, Confluence, Linear, Slack, Grafana, Sentry, Miro, Figma, incident.io, AWS IAM,
@@ -1666,11 +1684,20 @@ export function Settings() {
className="btn small"
data-testid="revoke-all-tokens"
onClick={() => {
- if (!window.confirm("Revoke every integration token stored in the vault?")) return;
- void api.revokeAllIntegrations().then((result) => {
+ void (async () => {
+ if (
+ !(await askConfirm({
+ title: "Revoke all tokens?",
+ body: "Revoke every integration token stored in the vault?",
+ confirmLabel: "Revoke",
+ danger: true,
+ }))
+ )
+ return;
+ const result = await api.revokeAllIntegrations();
setDraft(result.config);
dispatch({ type: "config", config: result.config });
- });
+ })();
}}
>
Revoke all tokens
@@ -1701,16 +1728,17 @@ export function Settings() {
onSignInGoogle={() => void startGoogleOauth(row)}
onDisconnectGoogle={() => void disconnectGoogle(row.id)}
onRemove={() => {
- removeList("integrations", i);
+ removeListUndoable("integrations", i, row.name);
setExpandedIntegration((id) => (id === row.id ? null : id));
}}
/>
))}
-
+
{INTEGRATION_KINDS.map((kind) => (
{
const next: IntegrationAccount = {
id: crypto.randomUUID(),
@@ -1723,7 +1751,8 @@ export function Settings() {
setExpandedIntegration(next.id);
}}
>
- Add {integrationLabel(kind)}
+ Add {integrationLabel(kind)}
+ {integrationBlurb(kind)}
))}
@@ -1733,7 +1762,7 @@ export function Settings() {
Secrets stay in the vault.
{(draft.integrationPlugins ?? []).length === 0 ? (
-
No plugins yet.
+
) : (
(draft.integrationPlugins ?? []).map((plugin, i) => (
patchPlugin(draft, setDraft, i, patch)}
onPatchConfig={(key, value) => patchPluginConfig(draft, setDraft, i, key, value)}
onRemove={() => {
- setDraft({
- ...draft,
- integrationPlugins: (draft.integrationPlugins ?? []).filter(
- (_, idx) => idx !== i,
- ),
- });
+ removeListUndoable("integrationPlugins", i, plugin.name || plugin.id);
setExpandedPlugin((id) => (id === plugin.id ? null : id));
}}
/>
@@ -1789,7 +1813,7 @@ export function Settings() {
}
onPatch={(patch) => patchList("mcpServers", i, patch)}
onRemove={() => {
- removeList("mcpServers", i);
+ removeListUndoable("mcpServers", i, server.name);
setExpandedMcp((id) => (id === server.id ? null : id));
}}
/>
@@ -1811,7 +1835,7 @@ export function Settings() {
onToggleExpand={() => setExpandedSkill((id) => (id === skill.id ? null : skill.id))}
onPatch={(patch) => patchList("skills", i, patch)}
onRemove={() => {
- removeList("skills", i);
+ removeListUndoable("skills", i, skill.title || skill.name);
setExpandedSkill((id) => (id === skill.id ? null : id));
}}
/>
@@ -2405,11 +2429,14 @@ export function Settings() {
Tailscale Funnel
-
-
+ {
setFunnelBusy(true);
void api
@@ -2432,12 +2459,8 @@ export function Settings() {
.finally(() => setFunnelBusy(false));
}}
>
- {funnelBusy
- ? "Working…"
- : tailscale?.funnelEnabled
- ? "Disable Funnel"
- : "Expose /api/hooks"}
-
+ {tailscale?.funnelEnabled ? "Disable Funnel" : "Expose /api/hooks"}
+
{tailscale?.funnelUrl ??
tailscale?.error ??
@@ -2496,25 +2519,21 @@ export function Settings() {
Autonomy
-
+ options={AUTONOMY_SELECT_OPTIONS}
+ onChange={(value) =>
setDraft({
...draft,
advanced: {
...draft.advanced,
- autonomy: parseAutonomyTier(e.target.value),
+ autonomy: parseAutonomyTier(value),
},
})
}
- >
- {AUTONOMY_TIERS.map((tier) => (
-
- {formatAutonomy(tier)}
-
- ))}
-
+ />
One ladder from showing every mutating command to overnight CI. Autonomous
auto-allows push, PR, MCP, plugins, and package installs (protected default
@@ -2661,14 +2680,16 @@ export function Settings() {
and automations. Host QEMU/Docker installs are not touched. First-run setup runs
again.
-
void resetAll()}
>
- {resetting ? "Resetting…" : "Reset all data"}
-
+ Reset all data
+
>
@@ -2699,14 +2720,15 @@ export function Settings() {
-
void checkForUpdates()}
>
- {updateBusy ? "Checking…" : "Check for updates"}
-
+ Check for updates
+
GitHub releases
diff --git a/apps/desktop/src/components/settings/SettingsCards.tsx b/apps/desktop/src/components/settings/SettingsCards.tsx
index aa0cb2c..c632bd6 100644
--- a/apps/desktop/src/components/settings/SettingsCards.tsx
+++ b/apps/desktop/src/components/settings/SettingsCards.tsx
@@ -65,6 +65,9 @@ import {
import { api, type HypervisorStatus } from "@/lib/api";
import { CursorModelControls } from "./ModelPicker";
import { IconChevron, IconLock } from "@/components/layout/Icons";
+import { Select } from "@/components/layout/Select";
+import { BusyButton, InlineError } from "@/components/layout/Skeleton";
+import { useDialogFocus } from "@/lib/useDialogFocus";
export function isBuiltinProvider(provider: AiProvider): boolean {
return BUILTIN_PROVIDERS.some((row) => row.kind === provider.kind && row.name === provider.name);
@@ -297,29 +300,25 @@ export function ProviderCard({
Default model
{p.models.length > 0 ? (
-
+ options={(usesCursorModelIds(p)
+ ? cursorModelPickerIds([p.defaultModel, ...p.models])
+ : p.models
+ ).map((model) => ({ value: model, label: model }))}
+ onChange={(value) =>
onPatch({
defaultModel: usesCursorModelIds(p)
- ? retargetCursorModel(p.defaultModel, e.target.value)
- : e.target.value,
+ ? retargetCursorModel(p.defaultModel, value)
+ : value,
})
}
- >
- {(usesCursorModelIds(p)
- ? cursorModelPickerIds([p.defaultModel, ...p.models])
- : p.models
- ).map((model) => (
-
- {model}
-
- ))}
-
+ />
) : (
onPatch({ defaultModel: model })}
/>
) : null}
-
-
- Input $/1M
- {
- const raw = e.target.value;
- const n = raw === "" ? undefined : Number(raw);
- onPatch({
- inputUsdPerMillion: n != null && Number.isFinite(n) && n >= 0 ? n : undefined,
- });
- }}
- />
-
-
-
Output $/1M
-
{
- const raw = e.target.value;
- const n = raw === "" ? undefined : Number(raw);
- onPatch({
- outputUsdPerMillion: n != null && Number.isFinite(n) && n >= 0 ? n : undefined,
- });
- }}
- />
+
+ Prices and overrides
+
-
-
- Leave blank to use approximate published per-model rates in the picker and spend
- estimates. A value here overrides every model on this provider.
-
+
+ Leave blank to use approximate published per-model rates in the picker and spend
+ estimates. A value here overrides every model on this provider.
+
+
Refresh models
@@ -805,6 +808,8 @@ function DiskImageField({
} | null>(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
+ const dialogRef = useRef(null);
+ useDialogFocus(open, () => setOpen(false), dialogRef);
async function load(path?: string) {
setBusy(true);
@@ -839,16 +844,23 @@ function DiskImageField({
Browse
{onFetch ? (
-
- {fetching ? "Downloading…" : fetchLabel}
-
+
+ {fetchLabel}
+
) : null}
{open ? (
{listing?.path ?? (busy ? "Listing…" : "")}
@@ -874,7 +886,7 @@ function DiskImageField({
))}
- {error ?
{error}
: null}
+ {error ?
: null}
setOpen(false)}>
Cancel
@@ -1365,19 +1377,21 @@ export function McpServerCard({
Runtime
-
+ options={[
+ { value: "host", label: "Host (security exception)" },
+ { value: "guest", label: "Guest (Docker stdio)" },
+ ]}
+ onChange={(value) =>
onPatch((row) => ({
...row,
- runtime: e.target.value === "guest" ? "guest" : "host",
+ runtime: value === "guest" ? "guest" : "host",
}))
}
- >
- Host (security exception)
- Guest (Docker stdio)
-
+ />
Service
- {
- const kind = e.target.value as IntegrationKind;
+ options={INTEGRATION_KINDS.map((kind) => ({
+ value: kind,
+ label: integrationLabel(kind),
+ }))}
+ onChange={(value) => {
+ const kind = value as IntegrationKind;
if (!isIntegrationKind(kind)) return;
onPatch((row) => ({
...row,
@@ -1956,13 +1975,7 @@ export function IntegrationCard({
name: row.name || integrationLabel(kind),
}));
}}
- >
- {INTEGRATION_KINDS.map((kind) => (
-
- {integrationLabel(kind)}
-
- ))}
-
+ />
@@ -2559,23 +2572,27 @@ export function MachineInventory({
Open
{live ? (
- session && onStop(session.id)}
>
- {busyId === session?.id ? "Stopping…" : "Stop"}
-
+ Stop
+
) : (
- session && onStart(session.id)}
>
- {busyId === session?.id ? "Starting…" : "Start"}
-
+ Start
+
)}
diff --git a/apps/desktop/src/components/settings/SetupWizard.tsx b/apps/desktop/src/components/settings/SetupWizard.tsx
index fad5d37..f8f19e2 100644
--- a/apps/desktop/src/components/settings/SetupWizard.tsx
+++ b/apps/desktop/src/components/settings/SetupWizard.tsx
@@ -1,5 +1,5 @@
-import { useRef, useState } from "react";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { useState } from "react";
+import { Modal } from "@/components/layout/Modal";
import type { AiProvider, LocusConfig } from "@locus/shared";
import { DEFAULT_DOCKER_IMAGE, hasUsableKey, isLocalOpenAiBase } from "@locus/shared";
import { api } from "@/lib/api";
@@ -7,6 +7,7 @@ import { loadLastAgent, pickProvider, saveLastAgent } from "@/lib/lastAgent";
import { useStore } from "@/state/store";
import { Mascot } from "@/components/layout/Mascot";
import { PRODUCT_MODE_LABELS } from "@/lib/productMode";
+import { Select } from "@/components/layout/Select";
import { QemuInstallStatus } from "./SettingsCards";
export function SetupWizard() {
@@ -20,15 +21,6 @@ export function SetupWizard() {
const [restartNote, setRestartNote] = useState
(null);
const [productMode, setProductMode] = useState<"developer" | "swarm">("developer");
- const dialogRef = useRef(null);
- useDialogFocus(
- Boolean(state.setupOpen && setup && config),
- () => {
- dispatch({ type: "setupOpen", open: false });
- },
- dialogRef,
- );
-
if (!state.setupOpen || !setup || !config) return null;
const current = config;
const dockerFound = setup.dockerFound;
@@ -146,77 +138,76 @@ export function SetupWizard() {
const keyOptional = hasUsableKey("", provider?.baseUrl, provider?.kind);
return (
-
-
e.stopPropagation()}
- style={{ width: 640 }}
- >
-
-
-
-
Set up Locus
-
- Detected {hostLabel}. Add a provider key, then start chatting on this machine. Docker
- and QEMU are optional if you want a container or a full guest desktop later.
-
-
+
dispatch({ type: "setupOpen", open: false })}
+ >
+
+
+
+
Set up Locus
+
+ Detected {hostLabel}. Add a provider key, then start chatting on this machine. Docker
+ and QEMU are optional if you want a container or a full guest desktop later.
+
+
+
+
+ Provider
+
+ {setup.hasProvider
+ ? "An enabled provider can answer chat."
+ : "Paste an OpenCode Zen key for the free tier, or another provider key. Local servers such as Ollama, and CLI providers (Cursor CLI, Claude Code, Codex CLI, Kiro CLI), do not need a key."}
+
+ {!setup.hasProvider && (
+
+ ({
+ value: p.id,
+ label: p.name,
+ }))}
+ onChange={(id) => {
+ setProviderId(id);
+ setApiKey("");
+ }}
+ />
+ setApiKey(e.target.value)}
+ />
+ void saveProvider()}
+ >
+ Save key
+
+
+ )}
+
+
+
+ Set up Docker or a VM later
-
- Provider
-
- {setup.hasProvider
- ? "An enabled provider can answer chat."
- : "Paste an OpenCode Zen key for the free tier, or another provider key. Local servers such as Ollama, and CLI providers (Cursor CLI, Claude Code, Codex CLI, Kiro CLI), do not need a key."}
-
- {!setup.hasProvider && (
-
- {
- setProviderId(e.target.value);
- setApiKey("");
- }}
- >
- {current.providers.map((p: AiProvider) => (
-
- {p.name}
-
- ))}
-
- setApiKey(e.target.value)}
- />
- void saveProvider()}
- >
- Save key
-
-
- )}
-
Docker (recommended)
@@ -279,65 +270,67 @@ export function SetupWizard() {
) : null}
-
- How do you want to start?
-
- {PRODUCT_MODE_LABELS.swarm} is teammate-first (avatars, Teams, Routines).{" "}
- {PRODUCT_MODE_LABELS.developer} is the full control plane. You can switch anytime.
-
-
- setProductMode("swarm")}
- >
- {PRODUCT_MODE_LABELS.swarm}
-
- setProductMode("developer")}
- >
- {PRODUCT_MODE_LABELS.developer}
-
-
-
- {restartNote &&
{restartNote}
}
- {error && (
-
- {error}
-
- )}
-
-
void finish(false)}>
- Skip for now
-
- {setup.dockerFound ? (
+
+
+
+ How do you want to start?
+
+ {PRODUCT_MODE_LABELS.swarm} is teammate-first (avatars, Teams, Routines).{" "}
+ {PRODUCT_MODE_LABELS.developer} is the full control plane. You can switch anytime.
+
+
void finish(true)}
+ type="button"
+ className={`btn small${productMode === "swarm" ? " primary" : ""}`}
+ role="radio"
+ aria-checked={productMode === "swarm"}
+ data-testid="setup-mode-swarm"
+ onClick={() => setProductMode("swarm")}
>
- Continue
+ {PRODUCT_MODE_LABELS.swarm}
- ) : (
void finish(true, "host")}
+ type="button"
+ className={`btn small${productMode === "developer" ? " primary" : ""}`}
+ role="radio"
+ aria-checked={productMode === "developer"}
+ data-testid="setup-mode-developer"
+ onClick={() => setProductMode("developer")}
>
- Use Host instead
+ {PRODUCT_MODE_LABELS.developer}
- )}
-
+
+
+
+ {restartNote &&
{restartNote}
}
+ {error && (
+
+ {error}
+
+ )}
+
+ void finish(false)}>
+ Skip for now
+
+ {setup.dockerFound ? (
+ void finish(true)}
+ >
+ Continue
+
+ ) : (
+ void finish(true, "host")}
+ >
+ Use Host instead
+
+ )}
-
+
);
}
diff --git a/apps/desktop/src/components/settings/ThemePreview.tsx b/apps/desktop/src/components/settings/ThemePreview.tsx
index 4d5bb51..a25754a 100644
--- a/apps/desktop/src/components/settings/ThemePreview.tsx
+++ b/apps/desktop/src/components/settings/ThemePreview.tsx
@@ -1,5 +1,5 @@
import { resolveTheme, themeOption } from "@locus/shared";
-import { Mascot } from "@/components/layout/Mascot";
+import { EmptyHero } from "@/components/layout/EmptyHero";
export function ThemePreview({
theme,
@@ -34,12 +34,12 @@ export function ThemePreview({
-
-
What should we talk about?
-
- A coding agent on this machine. Pick a folder, or leave it empty for a private
- workspace.
-
+
Docker
VM
diff --git a/apps/desktop/src/components/settings/WorkersModal.tsx b/apps/desktop/src/components/settings/WorkersModal.tsx
index ef20cce..04d4c36 100644
--- a/apps/desktop/src/components/settings/WorkersModal.tsx
+++ b/apps/desktop/src/components/settings/WorkersModal.tsx
@@ -5,11 +5,12 @@ import {
lastUserPrompt,
type AgentSession,
} from "@locus/shared";
-import { useRef, useState } from "react";
+import { useState } from "react";
import { useStore } from "@/state/store";
import { api } from "@/lib/api";
import { startWorkers } from "@/lib/compare";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { Modal } from "@/components/layout/Modal";
+import { Select } from "@/components/layout/Select";
export function WorkersModal({ session, onClose }: { session: AgentSession; onClose: () => void }) {
const { dispatch } = useStore();
@@ -17,8 +18,6 @@ export function WorkersModal({ session, onClose }: { session: AgentSession; onCl
const [busy, setBusy] = useState(false);
const [error, setError] = useState
(null);
const prompt = lastUserPrompt(session.messages);
- const dialogRef = useRef(null);
- useDialogFocus(true, onClose, dialogRef);
async function start() {
if (!prompt) return;
@@ -41,52 +40,38 @@ export function WorkersModal({ session, onClose }: { session: AgentSession; onCl
}
return (
-
-
e.stopPropagation()}
- >
-
Spawn workers
-
- Snapshot this workspace and run the last prompt on {count} isolated Docker containers.
- Keep one, archive the rest.
-
-
- Workers
- setCount(Number(e.target.value))}>
- {Array.from(
- { length: WORKER_COUNT_MAX - WORKER_COUNT_MIN + 1 },
- (_, i) => WORKER_COUNT_MIN + i,
- ).map((n) => (
-
- {n}
-
- ))}
-
-
- {error && (
-
- {error}
-
- )}
-
-
- Cancel
-
- void start()}
- >
- {busy ? "Starting…" : "Spawn workers"}
-
-
+
+ Spawn workers
+
+ Snapshot this workspace and run the last prompt on {count} isolated Docker containers. Keep
+ one, archive the rest.
+
+
+ Workers
+ WORKER_COUNT_MIN + i,
+ ).map((n) => ({ value: String(n), label: String(n) }))}
+ onChange={(value) => setCount(Number(value))}
+ />
-
+ {error &&
{error}
}
+
+
+ Cancel
+
+ void start()}
+ >
+ {busy ? "Starting…" : "Spawn workers"}
+
+
+
);
}
diff --git a/apps/desktop/src/components/slack/MobileSlack.tsx b/apps/desktop/src/components/slack/MobileSlack.tsx
index 7c7fa0d..a222bce 100644
--- a/apps/desktop/src/components/slack/MobileSlack.tsx
+++ b/apps/desktop/src/components/slack/MobileSlack.tsx
@@ -28,6 +28,7 @@ import { LAST_SLACK_POST_AS_KEY, SlackBody, slackWorkspaceBooting } from "./Slac
import { IconLock, IconMore, IconSend } from "@/components/layout/Icons";
import { Mascot } from "@/components/layout/Mascot";
import { MobileFitPane } from "@/components/layout/MobileFitPane";
+import { useDialogFocus } from "@/lib/useDialogFocus";
import { observePinThreadToLatest } from "@/lib/threadScroll";
function loadLastSlackPostAs(): SlackPostAs {
@@ -626,10 +627,18 @@ function MobileSlackReactPicker(props: {
onPick: (name: string) => void;
onClose: () => void;
}) {
+ const dialogRef = useRef
(null);
const [query, setQuery] = useState("");
const names = useMemo(() => listSlackReactionNames(props.emoji, query), [props.emoji, query]);
+ useDialogFocus(true, props.onClose, dialogRef);
return (
-
+
{
await act(async () => {
ask!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
- const provider = host.querySelector(
- '.slack-ask select[aria-label="Provider"]',
- ) as HTMLSelectElement;
- const model = host.querySelector('.slack-ask select[aria-label="Model"]') as HTMLSelectElement;
- expect(provider?.value).toBe("openai");
- expect(model?.value).toBe("gpt-4.1");
+ const picker = host.querySelector(".slack-ask .model-picker-combined") as HTMLElement;
+ expect(picker.querySelector(".model-picker-combined-label")?.textContent).toBe(
+ "OpenAI · gpt-4.1",
+ );
const bar = host.querySelector(".slack-ask-bar");
- expect(bar?.contains(provider)).toBe(true);
- expect(bar?.contains(model)).toBe(true);
+ expect(bar?.contains(picker)).toBe(true);
const actions = bar!.querySelector(".slack-ask-actions");
expect([...actions!.querySelectorAll("button")].map((el) => el.textContent)).toEqual([
"Cancel",
@@ -737,17 +734,17 @@ describe("SlackPane", () => {
expect(css).toMatch(/\.slack-ask-bar \{[\s\S]*?flex-wrap:\s*wrap/);
expect(css).toMatch(/\.slack-ask-actions \{[\s\S]*?flex:\s*0 0 auto/);
await act(async () => {
- provider.value = "anthropic";
- provider.dispatchEvent(new Event("change", { bubbles: true }));
+ picker.querySelector("summary")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
- const nextModel = host.querySelector(
- '.slack-ask select[aria-label="Model"]',
- ) as HTMLSelectElement;
- expect(nextModel.value).toBe("claude-sonnet-4-5");
+ const opus = [...host.querySelectorAll(".slack-ask [role='menuitem']")].find(
+ (el) => el.textContent === "claude-opus-4-5",
+ );
await act(async () => {
- nextModel.value = "claude-opus-4-5";
- nextModel.dispatchEvent(new Event("change", { bubbles: true }));
+ opus?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
+ expect(picker.querySelector(".model-picker-combined-label")?.textContent).toBe(
+ "Anthropic · claude-opus-4-5",
+ );
const input = host.querySelector(".slack-ask input") as HTMLInputElement;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -800,12 +797,9 @@ describe("SlackPane", () => {
await act(async () => {
ask!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
- const provider = host.querySelector(
- '.slack-ask select[aria-label="Provider"]',
- ) as HTMLSelectElement;
- const model = host.querySelector('.slack-ask select[aria-label="Model"]') as HTMLSelectElement;
- expect(provider?.value).toBe("anthropic");
- expect(model?.value).toBe("claude-opus-4-5");
+ expect(host.querySelector(".slack-ask .model-picker-combined-label")?.textContent).toBe(
+ "Anthropic · claude-opus-4-5",
+ );
});
it("searches Slack messages and jumps to a hit", async () => {
diff --git a/apps/desktop/src/components/slack/Slack.tsx b/apps/desktop/src/components/slack/Slack.tsx
index 1f73d44..0443895 100644
--- a/apps/desktop/src/components/slack/Slack.tsx
+++ b/apps/desktop/src/components/slack/Slack.tsx
@@ -80,11 +80,13 @@ import {
} from "@locus/shared";
import { loadLastAgent, pickProvider, saveLastAgent } from "@/lib/lastAgent";
import { api } from "@/lib/api";
+import { showNotice } from "@/lib/notice";
import { createPortal } from "react-dom";
import { formatMarkdown } from "@/lib/markdown";
import { isStuckToBottom } from "@/lib/threadScroll";
import {
listWindowFromSizes,
+ listWindowRange,
shouldUpdateListWindow,
sameListWindow,
type ListWindow,
@@ -98,7 +100,9 @@ import {
IconPin,
IconStar,
} from "@/components/layout/Icons";
-import { Mascot } from "@/components/layout/Mascot";
+import { EmptyHero, EmptyHint } from "@/components/layout/EmptyHero";
+import { ErrorBanner, InlineError, Skeleton } from "@/components/layout/Skeleton";
+import { useDialogFocus } from "@/lib/useDialogFocus";
import { ModelPicker } from "@/components/settings/ModelPicker";
import { SearchField } from "@/components/layout/SearchField";
@@ -348,7 +352,7 @@ function SlackDraftSend({
);
dispatch({ type: "session", session: updated });
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not resolve draft");
+ showNotice(err instanceof Error ? err.message : "Could not resolve draft");
} finally {
setWorking(false);
}
@@ -473,7 +477,7 @@ export function SlackListenButton(props: {
dispatch({ type: "session", session: updated });
setPicker(false);
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not subscribe");
+ showNotice(err instanceof Error ? err.message : "Could not subscribe");
} finally {
setWorking(false);
}
@@ -491,7 +495,7 @@ export function SlackListenButton(props: {
const updated = await api.patchSession(session.id, { subscriptions: result.subscriptions });
dispatch({ type: "session", session: updated });
} catch (err) {
- window.alert(err instanceof Error ? err.message : "Could not unsubscribe");
+ showNotice(err instanceof Error ? err.message : "Could not unsubscribe");
} finally {
setWorking(false);
}
@@ -504,7 +508,7 @@ export function SlackListenButton(props: {
return;
}
if (!candidates.length) {
- window.alert("Start an agent first, then pin this conversation onto it.");
+ showNotice("Start an agent first, then pin this conversation onto it.");
return;
}
if (preferred) {
@@ -1190,6 +1194,10 @@ export function SlackPane() {
() => state.slackMessages.filter((row) => isSlackChannelMessage(row)),
[state.slackMessages],
);
+ const threadRows = useMemo(
+ () => (replies.length ? replies : topLevel.filter((row) => row.ts === state.slackThreadTs)),
+ [replies, topLevel, state.slackThreadTs],
+ );
const channelScroll = usePinnedScroller(
state.selectedSlackChannelId,
`${topLevel.length}:${historyLoading}`,
@@ -1243,6 +1251,31 @@ export function SlackPane() {
});
}
const threadScroll = usePinnedScroller(state.slackThreadTs, `${replies.length}:${repliesReady}`);
+ const [threadWin, setThreadWin] = useState
(() =>
+ listWindowRange({
+ scrollTop: 0,
+ clientHeight: 0,
+ rowCount: 0,
+ rowPx: SLACK_ROW_PX,
+ stickToEnd: true,
+ }),
+ );
+
+ function syncThreadWindow(el: HTMLDivElement | null, stickToEnd?: boolean) {
+ if (!el) return;
+ const next = listWindowRange({
+ scrollTop: el.scrollTop,
+ clientHeight: el.clientHeight,
+ rowCount: threadRows.length,
+ rowPx: SLACK_ROW_PX,
+ stickToEnd: stickToEnd ?? (threadScroll.stick.current || el.clientHeight <= 0),
+ });
+ setThreadWin((current) => {
+ if (sameListWindow(current, next)) return current;
+ if (current.start === next.start && current.end === next.end) return next;
+ return shouldUpdateListWindow(current, next) ? next : current;
+ });
+ }
useLayoutEffect(() => {
slackHeightsRef.current.clear();
@@ -1266,6 +1299,11 @@ export function SlackPane() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topLevel, slackSizes, state.selectedSlackChannelId]);
+ useLayoutEffect(() => {
+ syncThreadWindow(threadScroll.ref.current, true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state.slackThreadTs, threadRows.length, repliesReady]);
+
useEffect(() => {
saveSlackThreadWidth(threadWidth);
}, [threadWidth]);
@@ -1846,7 +1884,7 @@ export function SlackPane() {
return (
{error ? (
-
{error}
+
) : (
Loading workspace…
)}
@@ -1872,17 +1910,15 @@ export function SlackPane() {
if (!connected) {
return (
- {error ?
{error}
: null}
+ {error ?
: null}
-
-
-
Sign in as yourself
-
-
- Tokens live in Settings → Integrations. Locus is the local client. Sign in as yourself
- to read Slack; send as you by default, or as the Locus bot. Slack.com is still Slack.
-
+
- {error ? {error}
: null}
+ {error ? : null}
{state.slackActivity.length === 0 ? (
-
-
-
All caught up
-
-
- Mentions, thread replies, and unread DMs land here. Desktop notifications fire when
- new ones arrive.
-
+
) : (
@@ -1945,18 +1979,16 @@ export function SlackPane() {
if (!channel) {
return (
- {error ?
{error}
: null}
+ {error ?
: null}
{state.slackConversations.length === 0 ? (
-
-
-
Pick a channel
-
-
- Unreads, starred, channels, and DMs land in the sidebar. Locus reads as you. Sends
- default to you; switch the composer to post as the Locus bot.
-
+
) : (
@@ -2068,7 +2100,7 @@ export function SlackPane() {
) : null}
- {error ?
{error}
: null}
+ {error ?
: null}
{askOpen && !askThread ? (
) : topLevel.length === 0 ? (
-
No messages yet. Say something as you.
+
) : (
<>
@@ -2330,42 +2362,50 @@ export function SlackPane() {
{
+ const node = event.currentTarget;
+ syncThreadWindow(node, isStuckToBottom(node) || node.clientHeight <= 0);
+ threadScroll.onScroll();
+ }}
>
{!repliesReady ? (
) : (
- (replies.length
- ? replies
- : topLevel.filter((row) => row.ts === state.slackThreadTs)
- ).map((row, index, rows) => (
-
void reactOn(row, name)}
- onAsk={() => openAsk(state.slackThreadTs ?? row.ts)}
- onStartEdit={() => {
- setEditingTs(row.ts);
- setEditDraft(row.text);
- }}
- onSaveEdit={() => void saveEdit()}
- onCancelEdit={() => setEditingTs(null)}
- onDelete={() => void deleteOn(row)}
- onCopyLink={() => copyLink(row)}
- />
- ))
+ <>
+
+ {threadRows.slice(threadWin.start, threadWin.end).map((row, paintedIndex) => {
+ const index = threadWin.start + paintedIndex;
+ return (
+ void reactOn(row, name)}
+ onAsk={() => openAsk(state.slackThreadTs ?? row.ts)}
+ onStartEdit={() => {
+ setEditingTs(row.ts);
+ setEditDraft(row.text);
+ }}
+ onSaveEdit={() => void saveEdit()}
+ onCancelEdit={() => setEditingTs(null)}
+ onDelete={() => void deleteOn(row)}
+ onCopyLink={() => copyLink(row)}
+ />
+ );
+ })}
+
+ >
)}
@@ -2618,11 +2658,13 @@ function AskLocusForm(props: {
}
function SlackGifPicker(props: { onPick: (gif: SlackGifHit) => void; onClose: () => void }) {
+ const dialogRef = useRef
(null);
const [query, setQuery] = useState("");
const [hits, setHits] = useState([]);
const [notice, setNotice] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
+ useDialogFocus(true, props.onClose, dialogRef);
useEffect(() => {
let cancelled = false;
@@ -2653,7 +2695,7 @@ function SlackGifPicker(props: { onPick: (gif: SlackGifHit) => void; onClose: ()
}, [query]);
return (
-
+
void; onClose: ()
Close
- {error ?
{error}
: null}
+ {error ?
: null}
{!error && notice ?
{notice}
: null}
{hits.map((gif) => (
@@ -2680,7 +2722,7 @@ function SlackGifPicker(props: { onPick: (gif: SlackGifHit) => void; onClose: ()
))}
{busy && hits.length === 0 ?
Loading GIFs…
: null}
- {!busy && hits.length === 0 ?
No GIFs for that search.
: null}
+ {!busy && hits.length === 0 ?
: null}
);
@@ -2759,32 +2801,16 @@ export function SlackAlertToasts({
function SlackHistorySkeleton() {
return (
-
- {Array.from({ length: 6 }, (_, i) => (
-
- ))}
+
+
);
}
export function SlackChannelListSkeleton() {
return (
-
- {Array.from({ length: 8 }, (_, i) => (
-
- ))}
+
+
);
}
@@ -3045,10 +3071,17 @@ function SlackReactionPicker(props: {
onPick: (name: string) => void;
onClose: () => void;
}) {
+ const dialogRef = useRef
(null);
const [query, setQuery] = useState("");
const names = useMemo(() => listSlackReactionNames(props.emoji, query), [props.emoji, query]);
+ useDialogFocus(true, props.onClose, dialogRef);
return (
-
+
))}
- {names.length === 0 ?
No emoji for that search.
: null}
+ {names.length === 0 ?
: null}
);
diff --git a/apps/desktop/src/components/workspace/HostFolderField.tsx b/apps/desktop/src/components/workspace/HostFolderField.tsx
index ce150c8..e9caa07 100644
--- a/apps/desktop/src/components/workspace/HostFolderField.tsx
+++ b/apps/desktop/src/components/workspace/HostFolderField.tsx
@@ -1,7 +1,7 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "@/lib/api";
-import { useDialogFocus } from "@/lib/useDialogFocus";
+import { Modal } from "@/components/layout/Modal";
import { folderName, loadIdeRecents, rememberIdeRecent } from "@/lib/ideRecents";
import { IconFolder } from "@/components/layout/Icons";
import { SearchField } from "@/components/layout/SearchField";
@@ -30,13 +30,11 @@ export function HostFolderPicker({
onClose: () => void;
onPick: (dir: string) => void;
}) {
- const dialogRef = useRef
(null);
const [listing, setListing] = useState(null);
const [pathDraft, setPathDraft] = useState("");
const [filter, setFilter] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
- useDialogFocus(open, onClose, dialogRef);
async function load(path?: string) {
setBusy(true);
@@ -84,125 +82,119 @@ export function HostFolderPicker({
if (!open) return null;
const dialog = (
-
-
event.stopPropagation()}
+
+ {title}
+ {lead}
+ {
+ event.preventDefault();
+ go(pathDraft.trim() || undefined);
+ }}
>
- {title}
- {lead}
- {
- event.preventDefault();
- go(pathDraft.trim() || undefined);
- }}
- >
-
- setPathDraft(e.target.value)}
- spellCheck={false}
- aria-label="Folder path"
- placeholder="/path/to/folder"
- />
-
- Go
-
-
-
- setFilter(e.target.value)}
- placeholder="Filter folders"
- aria-label="Filter folders"
- />
- {recentRows.length ? (
-
-
Recent
-
- {recentRows.map((row) => (
- go(row.path)}
- >
-
-
- {row.title}
- {row.path}
-
-
- ))}
-
-
- ) : null}
- Folders
-
- {listing?.parent ? (
-
go(listing.parent ?? undefined)}
- >
-
- ..
-
-
- Parent folder
- {listing.parent}
-
-
- ) : null}
- {entries.map((entry) => (
-
go(entry.path)}
- >
-
+
+ setPathDraft(e.target.value)}
+ spellCheck={false}
+ aria-label="Folder path"
+ placeholder="/path/to/folder"
+ />
+
+ Go
+
+
+
+ setFilter(e.target.value)}
+ placeholder="Filter folders"
+ aria-label="Filter folders"
+ />
+ {recentRows.length ? (
+
+
Recent
+
+ {recentRows.map((row) => (
+
go(row.path)}
+ >
-
- {entry.name}
-
- ))}
- {busy && !listing ?
Listing…
: null}
- {listing && !entries.length ? (
-
- {needle ? "No matching folders" : "No folders in this directory"}
-
- ) : null}
+
+ {row.title}
+ {row.path}
+
+
+ ))}
+
- {error ? {error}
: null}
- {listing?.blocked ? {listing.blocked}
: null}
-
-
- Cancel
+ ) : null}
+ Folders
+
+ {listing?.parent ? (
+ go(listing.parent ?? undefined)}
+ >
+
+ ..
+
+
+ Parent folder
+ {listing.parent}
+
+ ) : null}
+ {entries.map((entry) => (
{
- if (!listing || listing.blocked) return;
- choose(listing.path);
- }}
+ className="host-folder-modal-item"
+ onClick={() => go(entry.path)}
>
- {confirmLabel}
+
+
+
+ {entry.name}
-
+ ))}
+ {busy && !listing ? Listing…
: null}
+ {listing && !entries.length ? (
+ {needle ? "No matching folders" : "No folders in this directory"}
+ ) : null}
-
+ {error ? {error}
: null}
+ {listing?.blocked ? {listing.blocked}
: null}
+
+
+ Cancel
+
+ {
+ if (!listing || listing.blocked) return;
+ choose(listing.path);
+ }}
+ >
+ {confirmLabel}
+
+
+
);
return createPortal(dialog, document.body);
diff --git a/apps/desktop/src/components/workspace/HostWorkspace.test.tsx b/apps/desktop/src/components/workspace/HostWorkspace.test.tsx
index 7ddd4c8..d7b9732 100644
--- a/apps/desktop/src/components/workspace/HostWorkspace.test.tsx
+++ b/apps/desktop/src/components/workspace/HostWorkspace.test.tsx
@@ -140,6 +140,7 @@ describe("HostWorkspace", () => {
it("opens the host Files editor", async () => {
render({
...initialState,
+ vmCollapsed: false,
selectedId: session.id,
sessions: [session],
});
@@ -156,19 +157,19 @@ describe("HostWorkspace", () => {
expect(host.querySelector('[title="Collapse files"]')).toBeTruthy();
const fsBtn = host.querySelector('[data-testid="workspace-fullscreen"]') as HTMLButtonElement;
expect(fsBtn).toBeTruthy();
- expect(fsBtn.getAttribute("title")).toBe("Full screen files");
+ expect(fsBtn.getAttribute("aria-label")).toBe("Full screen files");
await act(async () => {
fsBtn.click();
});
expect(host.querySelector(".vm-pane.workspace-fs")).toBeTruthy();
- expect(fsBtn.getAttribute("title")).toBe("Exit full screen");
+ expect(fsBtn.getAttribute("aria-label")).toBe("Exit full screen");
expect(fsBtn.getAttribute("aria-pressed")).toBe("true");
expect(host.querySelector('[title="Collapse files"]')).toBeNull();
await act(async () => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
expect(host.querySelector(".vm-pane.workspace-fs")).toBeNull();
- expect(fsBtn.getAttribute("title")).toBe("Full screen files");
+ expect(fsBtn.getAttribute("aria-label")).toBe("Full screen files");
});
it("keeps the editor open in the IDE section and hides the files collapse control", async () => {
@@ -260,20 +261,32 @@ describe("HostWorkspace", () => {
await Promise.resolve();
await Promise.resolve();
});
- const preview = [...host.querySelectorAll("button")].find((btn) =>
+ const more = host.querySelector(".host-workspace-more summary") as HTMLElement;
+ expect(more).toBeTruthy();
+ await act(async () => {
+ more.click();
+ await Promise.resolve();
+ });
+ const preview = [...host.querySelectorAll(".menu-item")].find((btn) =>
btn.textContent?.startsWith("Preview"),
- );
+ ) as HTMLElement | undefined;
expect(preview).toBeTruthy();
await act(async () => {
preview!.click();
await Promise.resolve();
});
- const select = host.querySelector('[aria-label="Preview port"]') as HTMLSelectElement;
+ const select = host.querySelector('[aria-label="Preview port"]') as HTMLElement;
expect(select).toBeTruthy();
- expect(select.value).toBe("3000");
+ expect(select.closest("[data-value]")?.getAttribute("data-value")).toBe("3000");
+ await act(async () => {
+ select.click();
+ await Promise.resolve();
+ });
+ const next = [...host.querySelectorAll(".menu-item")].find(
+ (btn) => btn.getAttribute("data-value") === "5173",
+ ) as HTMLElement | undefined;
await act(async () => {
- select.value = "5173";
- select.dispatchEvent(new Event("change", { bubbles: true }));
+ next?.click();
await Promise.resolve();
});
expect(api.patchSession).toHaveBeenCalledWith("s1", { preview: { port: 5173, path: "/" } });
diff --git a/apps/desktop/src/components/workspace/HostWorkspace.tsx b/apps/desktop/src/components/workspace/HostWorkspace.tsx
index 37bbad3..bf36c55 100644
--- a/apps/desktop/src/components/workspace/HostWorkspace.tsx
+++ b/apps/desktop/src/components/workspace/HostWorkspace.tsx
@@ -4,6 +4,8 @@ import { useDeferredCollapse } from "@/lib/useDeferredCollapse";
import { api } from "@/lib/api";
import { useWorkspaceFullscreen } from "@/lib/useWorkspaceFullscreen";
import { IconChevron, IconFolder, IconPanelRight, IconPlus } from "@/components/layout/Icons";
+import { MenuItem, OverflowMenu } from "@/components/layout/OverflowMenu";
+import { Select } from "@/components/layout/Select";
import { AgentCanvasPane, useAgentCanvasFocus, useAgentCanvasOpen } from "./AgentCanvasPane";
import { WorkspaceEditor } from "./WorkspaceEditor";
import { WorkspaceFullscreenButton } from "./WorkspaceFullscreenButton";
@@ -189,15 +191,12 @@ export function HostWorkspace(props: { embedded?: boolean } = {}) {
{
- setView((current) => (current === "browser" ? "files" : "browser"));
- setPreviewOn(false);
- }}
+ className={`btn small ghost ${termCollapsed ? "" : "on"}`}
+ aria-pressed={!termCollapsed}
+ title="Terminal"
+ onClick={() => setTermCollapsed((current) => !current)}
>
- Browser
+ Terminal
{session?.agentCanvases?.length ? (
) : null}
- setTermCollapsed((current) => !current)}
- >
- Terminal
-
- {
- setView("files");
- setPreviewOn(false);
- setTermCollapsed(true);
- }}
- >
- Focus editor
-
- {
- setView("files");
- setPreviewOn((on) => !on);
- }}
+
- Preview{active ? ` :${active.port}` : ""}
-
+ {
+ setView((current) => (current === "browser" ? "files" : "browser"));
+ setPreviewOn(false);
+ }}
+ >
+ Browser
+
+ {
+ setView("files");
+ setPreviewOn(false);
+ setTermCollapsed(true);
+ }}
+ >
+ Focus editor
+
+ {
+ setView("files");
+ setPreviewOn((on) => !on);
+ }}
+ >
+ Preview{active ? ` :${active.port}` : ""}
+
+
{showingPreview && preview.ports.length > 1 ? (
-
{
- const port = Number(e.target.value);
+ compact
+ ariaLabel="Preview port"
+ value={String(active?.port ?? "")}
+ options={preview.ports.map((port) => ({
+ value: String(port),
+ label: `:${port}`,
+ }))}
+ onChange={(value) => {
+ const port = Number(value);
if (!sessionId || !Number.isInteger(port)) return;
void api
.patchSession(sessionId, { preview: { port, path: active?.path } })
@@ -263,13 +268,7 @@ export function HostWorkspace(props: { embedded?: boolean } = {}) {
dispatch({ type: "session", session: updated });
});
}}
- >
- {preview.ports.map((port) => (
-
- :{port}
-
- ))}
-
+ />
) : null}
{
}
it("renders chat on the right with a collapse control", () => {
- render({ ...initialState, section: "ide" });
+ render({ ...initialState, section: "ide", vmCollapsed: false });
expect(host.querySelector(".ide-chat")).toBeTruthy();
expect(host.querySelector(".chat")?.textContent).toBe("thread");
expect(host.querySelector('[title="Collapse chat"]')).toBeTruthy();
diff --git a/apps/desktop/src/components/workspace/IdeEmpty.test.tsx b/apps/desktop/src/components/workspace/IdeEmpty.test.tsx
index ce976a6..83f8e6d 100644
--- a/apps/desktop/src/components/workspace/IdeEmpty.test.tsx
+++ b/apps/desktop/src/components/workspace/IdeEmpty.test.tsx
@@ -132,7 +132,7 @@ describe("IdeEmpty", () => {
render();
expect(host.querySelector("[data-testid='ide-start']")).toBeTruthy();
expect(host.querySelector("[data-testid='locus-mascot']")).toBeTruthy();
- expect(host.querySelector(".ide-start-hero h2")?.textContent).toBe("IDE");
+ expect(host.querySelector(".empty-hero h2")?.textContent).toBe("IDE");
expect(host.textContent).toContain("Open folder");
expect(host.textContent).toContain("Clone repo");
expect(host.querySelector("[data-testid='ide-clone-panel']")).toBeNull();
diff --git a/apps/desktop/src/components/workspace/IdeEmpty.tsx b/apps/desktop/src/components/workspace/IdeEmpty.tsx
index 1ce47c2..18408d4 100644
--- a/apps/desktop/src/components/workspace/IdeEmpty.tsx
+++ b/apps/desktop/src/components/workspace/IdeEmpty.tsx
@@ -19,11 +19,18 @@ import { openSettings, useStore } from "@/state/store";
import { HostFolderPicker } from "./HostFolderField";
import { IconClone, IconFolder } from "@/components/layout/Icons";
import { ImportAccounts } from "@/components/settings/ImportAccounts";
-import { Mascot } from "@/components/layout/Mascot";
+import { EmptyHero } from "@/components/layout/EmptyHero";
import { FeatureHintRow } from "@/components/layout/FeatureHint";
import { SECTION_FEATURE_HINTS } from "@/lib/discoverability";
-const FORGE_ORDER: ForgeKind[] = ["github", "gitlab", "bitbucket"];
+const FORGE_ORDER: ForgeKind[] = [
+ "github",
+ "gitlab",
+ "bitbucket",
+ "gitea",
+ "forgejo",
+ "azuredevops",
+];
function groupRepos(repos: LinkedRepo[]): Array<{ forge: ForgeKind; repos: LinkedRepo[] }> {
const buckets = new Map();
@@ -128,7 +135,7 @@ export function IdeEmpty() {
async function cloneUrlSubmit() {
const parsed = parseRepoUrl(cloneUrl);
if (!parsed) {
- setError("Enter a GitHub, GitLab, or Bitbucket URL");
+ setError("Enter a GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps URL");
return;
}
await createWorkspace({
@@ -175,14 +182,11 @@ export function IdeEmpty() {
return (
-
-
- IDE
-
- Open a folder on this machine, or clone a repository from the GitHub, GitLab, and
- Bitbucket accounts already connected in Settings.
-
-
+
{SECTION_FEATURE_HINTS.ide ?
: null}
({
api: {
@@ -813,7 +814,7 @@ describe("WorkspaceEditor", () => {
await flush();
const tabMs = performance.now() - clickAt;
expect(host.querySelector(".workspace-tab.on")?.getAttribute("title")).toBe("src/app.ts");
- expect(tabMs).toBeLessThan(DELAY_MS / 2);
+ expect(tabMs).toBeLessThan(DELAY_MS);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, DELAY_MS + 20));
});
@@ -1556,13 +1557,13 @@ describe("WorkspaceEditor", () => {
expect(css).toMatch(/\.workspace-status \{[\s\S]*?height:\s*24px/);
expect(css).toMatch(/\.workspace-status-start,\n\.workspace-status-end \{/);
expect(css).toMatch(
- /@container workspace-status \(max-width: 860px\) \{[\s\S]*?\.workspace-status-indent/,
+ /@container workspace-status \(max-width: 900px\) \{[\s\S]*?\.workspace-status-indent/,
);
expect(css).toMatch(
- /@container workspace-status \(max-width: 720px\) \{[\s\S]*?\.workspace-status-run/,
+ /@container workspace-status \(max-width: 760px\) \{[\s\S]*?\.workspace-status-run/,
);
expect(css).toMatch(
- /@container workspace-status \(max-width: 580px\) \{[\s\S]*?\.workspace-status-cursor/,
+ /@container workspace-status \(max-width: 560px\) \{[\s\S]*?\.workspace-status-cursor/,
);
expect(css).toMatch(/\.workspace-status-more \{[\s\S]*?flex:\s*0 0 auto/);
expect(css).toMatch(/\.workspace-editor-more \{[\s\S]*?flex:\s*0 0 auto/);
@@ -2339,7 +2340,7 @@ describe("WorkspaceEditor", () => {
const more = status.querySelector(".workspace-status-more") as HTMLDetailsElement;
expect(more).toBeTruthy();
const trigger = more.querySelector("summary") as HTMLElement;
- expect(trigger.getAttribute("title")).toBe("Editor commands");
+ expect(trigger.getAttribute("aria-label")).toBe("Editor commands");
await act(async () => {
trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
trigger.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
@@ -3061,22 +3062,17 @@ describe("WorkspaceEditor", () => {
(host.querySelector('[data-mode="git"]') as HTMLButtonElement).click();
});
await flush();
- const select = host.querySelector('[aria-label="Git repository"]') as HTMLSelectElement;
- expect(select).toBeTruthy();
- expect(select.value).toBe("app");
+ expect(host.querySelector('[aria-label="Git repository"]')).toBeTruthy();
+ expect(selectValue(host, "Git repository")).toBe("app");
expect(host.querySelector('[data-path="src/app.ts"]')).toBeTruthy();
await act(async () => {
- const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
- setter?.call(select, "lib");
- select.dispatchEvent(new Event("change", { bubbles: true }));
+ setSelectValue(host, "Git repository", "lib");
await Promise.resolve();
});
await flush();
expect(api.workspaceGit).toHaveBeenCalledWith("s1", "lib");
expect(host.querySelector('[aria-label="Git repository"]')).toBeTruthy();
- expect((host.querySelector('[aria-label="Git repository"]') as HTMLSelectElement).value).toBe(
- "lib",
- );
+ expect(selectValue(host, "Git repository")).toBe("lib");
expect(host.querySelector('[data-path="src/a.ts"]')).toBeTruthy();
await act(async () => {
(host.querySelector('[data-path="src/a.ts"]') as HTMLButtonElement).click();
diff --git a/apps/desktop/src/components/workspace/WorkspaceEditor.tsx b/apps/desktop/src/components/workspace/WorkspaceEditor.tsx
index 70fadcd..4d4a3f7 100644
--- a/apps/desktop/src/components/workspace/WorkspaceEditor.tsx
+++ b/apps/desktop/src/components/workspace/WorkspaceEditor.tsx
@@ -37,6 +37,13 @@ import {
type WorkspaceSignatureHelp,
} from "@/lib/api";
import { formatMarkdown } from "@/lib/markdown";
+import { askConfirm, askPrompt } from "@/lib/dialog";
+import { showUndoNotice } from "@/lib/notice";
+import { BusyButton } from "@/components/layout/Skeleton";
+import { Tooltip } from "@/components/layout/Tooltip";
+import { WorkspaceEditorTabs } from "./WorkspaceEditorTabs";
+import { matchModKey } from "@/lib/shortcuts";
+import { useDialogFocus } from "@/lib/useDialogFocus";
import { isUnreachableError, UNREACHABLE_ERROR } from "@/lib/net";
import { loadEditorDrafts, saveEditorDrafts } from "@/lib/editorDrafts";
import { loadEditorOpenFiles, saveEditorOpenFiles } from "@/lib/editorOpenFiles";
@@ -755,6 +762,8 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
const [pylspMissing, setPylspMissing] = useState(false);
const [prComments, setPrComments] = useState([]);
const [openPrComment, setOpenPrComment] = useState(null);
+ const prCommentRef = useRef(null);
+ useDialogFocus(Boolean(openPrComment), () => setOpenPrComment(null), prCommentRef);
const [wrapWidth, setWrapWidth] = useState(640);
const [signature, setSignature] = useState<{
help: WorkspaceSignatureHelp;
@@ -1076,28 +1085,25 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
useEffect(() => {
function onKey(event: globalThis.KeyboardEvent) {
- const meta = event.metaKey || event.ctrlKey;
- if (!meta || event.altKey) return;
if (document.querySelector(".modal")) return;
- const key = event.key.toLowerCase();
- if (key === "p" && !event.shiftKey) {
+ if (matchModKey(event, "p")) {
event.preventDefault();
setSymbolOpen(false);
setQuickOpen(true);
return;
}
- if (key === "t" && !event.shiftKey) {
+ if (matchModKey(event, "t")) {
event.preventDefault();
setQuickOpen(false);
setSymbolOpen(true);
return;
}
- if (key === "f" && event.shiftKey) {
+ if (matchModKey(event, "f", true)) {
event.preventDefault();
setFileSearch(true);
return;
}
- if ((key === "\\" || event.code === "Backslash") && !event.shiftKey) {
+ if (matchModKey(event, "\\")) {
event.preventDefault();
toggleSplitRef.current();
}
@@ -2221,11 +2227,14 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
if (typeof api.promoteIdeToDocker !== "function") return;
const runtime = store?.state.dockerKind === "docker" ? "docker" : "host";
if (
- !window.confirm(
- runtime === "docker"
- ? "Create a Docker agent from this folder?"
- : "Docker is not available. Create a Host agent from this folder?",
- )
+ !(await askConfirm({
+ title: runtime === "docker" ? "Create a Docker agent?" : "Create a Host agent?",
+ body:
+ runtime === "docker"
+ ? "Create a Docker agent from this folder?"
+ : "Docker is not available. Create a Host agent from this folder?",
+ confirmLabel: "Create",
+ }))
) {
return;
}
@@ -2420,24 +2429,30 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
}
function closeTab(path: string, group?: 0 | 1) {
+ const snapshot = { docs, activePath, split: splitRef.current };
const currentSplit = splitRef.current;
if (currentSplit) {
const result = closeInGroup(currentSplit, group ?? currentSplit.focused, path);
if (result.dropBuffer) {
- const doc = docs.find((item) => item.path === path);
- if (doc && doc.content !== doc.original && !window.confirm("Discard unsaved edits?"))
- return;
setDocs((current) => current.filter((item) => item.path !== path));
}
setSplit(result.split);
setActivePath(result.active);
+ showUndoNotice(`Closed ${path}`, () => {
+ setDocs(snapshot.docs);
+ setActivePath(snapshot.activePath);
+ setSplit(snapshot.split);
+ });
return;
}
- const doc = docs.find((item) => item.path === path);
- if (doc && doc.content !== doc.original && !window.confirm("Discard unsaved edits?")) return;
const next = docs.filter((item) => item.path !== path);
setDocs(next);
if (activePath === path) setActivePath(next.at(-1)?.path ?? "");
+ showUndoNotice(`Closed ${path}`, () => {
+ setDocs(snapshot.docs);
+ setActivePath(snapshot.activePath);
+ setSplit(snapshot.split);
+ });
}
async function saveAll() {
@@ -2459,21 +2474,25 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
function closeOthers() {
if (!activePath) return;
+ const snapshot = { docs, activePath, split: splitRef.current };
const currentSplit = splitRef.current;
if (currentSplit) {
setSplit(closeOthersInFocused(currentSplit));
+ showUndoNotice("Closed other tabs", () => {
+ setDocs(snapshot.docs);
+ setActivePath(snapshot.activePath);
+ setSplit(snapshot.split);
+ });
return;
}
const keep = docs.find((doc) => doc.path === activePath);
if (!keep) return;
- const others = docs.filter((doc) => doc.path !== activePath);
- if (
- others.some((doc) => doc.content !== doc.original) &&
- !window.confirm("Close other tabs and discard unsaved edits?")
- ) {
- return;
- }
setDocs([keep]);
+ showUndoNotice("Closed other tabs", () => {
+ setDocs(snapshot.docs);
+ setActivePath(snapshot.activePath);
+ setSplit(snapshot.split);
+ });
}
function closeSaved() {
@@ -2775,10 +2794,10 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
}
async function createFile(fromDir?: string) {
- const name = window.prompt(
- "New file",
- suggestedChildPath(fromDir ?? parentDir(), "untitled.ts"),
- );
+ const name = await askPrompt({
+ title: "New file",
+ value: suggestedChildPath(fromDir ?? parentDir(), "untitled.ts"),
+ });
if (!name) return;
const path = name.replace(/^\/+/, "").trim();
if (!path) return;
@@ -2794,7 +2813,10 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
}
async function createFolder(fromDir?: string) {
- const name = window.prompt("New folder", suggestedChildPath(fromDir ?? parentDir(), "src"));
+ const name = await askPrompt({
+ title: "New folder",
+ value: suggestedChildPath(fromDir ?? parentDir(), "src"),
+ });
if (!name) return;
const path = name.replace(/^\/+/, "").trim();
if (!path) return;
@@ -2808,7 +2830,15 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
}
async function removePath(path: string) {
- if (!window.confirm(`Delete ${path}?`)) return;
+ if (
+ !(await askConfirm({
+ title: `Delete ${path}?`,
+ body: "This cannot be undone.",
+ confirmLabel: "Delete",
+ danger: true,
+ }))
+ )
+ return;
setError(null);
try {
await api.deleteWorkspaceFile(sessionId, path);
@@ -2969,7 +2999,7 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
}
}
- function onEditorKey(e: KeyboardEvent) {
+ async function onEditorKey(e: KeyboardEvent) {
const ta = e.currentTarget;
const meta = e.metaKey || e.ctrlKey;
if (hover && e.key !== "Shift" && e.key !== "Control" && e.key !== "Alt" && e.key !== "Meta") {
@@ -2985,7 +3015,7 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
closeInlineAi();
return;
}
- if (meta && e.shiftKey && e.key.toLowerCase() === "k") {
+ if (matchModKey(e, "k", true)) {
e.preventDefault();
e.stopPropagation();
openInlineAi();
@@ -3028,7 +3058,7 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
scheduleComplete(ta.value, ta.selectionStart, true);
return;
}
- if (meta && e.key.toLowerCase() === "s") {
+ if (matchModKey(e, "s")) {
e.preventDefault();
e.stopPropagation();
void save();
@@ -3052,15 +3082,14 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
void renameSymbol();
return;
}
- if (meta && e.key.toLowerCase() === "z") {
+ if (matchModKey(e, "z", true) || matchModKey(e, "y")) {
e.preventDefault();
- if (e.shiftKey) redoEdit();
- else undoEdit();
+ redoEdit();
return;
}
- if (meta && e.key.toLowerCase() === "y") {
+ if (matchModKey(e, "z")) {
e.preventDefault();
- redoEdit();
+ undoEdit();
return;
}
if (e.altKey && e.shiftKey && e.key.toLowerCase() === "f") {
@@ -3073,39 +3102,38 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
void organizeActive();
return;
}
- if ((e.ctrlKey || e.metaKey) && e.key === ".") {
+ if (matchModKey(e, ".")) {
e.preventDefault();
void requestFixes();
return;
}
- if (meta && e.shiftKey && e.key.toLowerCase() === "s") {
+ if (matchModKey(e, "s", true)) {
e.preventDefault();
void saveAll();
return;
}
- if (meta && e.key.toLowerCase() === "w") {
+ if (matchModKey(e, "w")) {
e.preventDefault();
e.stopPropagation();
- closeTab(activePath);
+ void closeTab(activePath);
return;
}
- if (meta && e.key.toLowerCase() === "f") {
+ if (matchModKey(e, "f", true)) {
e.preventDefault();
e.stopPropagation();
- if (e.shiftKey) setFileSearch(true);
- else openFind();
+ setFileSearch(true);
return;
}
- if (meta && e.key.toLowerCase() === "h") {
+ if (matchModKey(e, "f") || matchModKey(e, "h")) {
e.preventDefault();
e.stopPropagation();
openFind();
return;
}
- if (meta && e.key.toLowerCase() === "g") {
+ if (matchModKey(e, "g")) {
e.preventDefault();
e.stopPropagation();
- const raw = window.prompt("Go to line", String(cursor.line));
+ const raw = await askPrompt({ title: "Go to line", value: String(cursor.line) });
const line = Number.parseInt(raw ?? "", 10);
if (!Number.isFinite(line) || line < 1) return;
const start = offsetAtLine(content, line);
@@ -3171,81 +3199,24 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
group?: 0 | 1,
listRef?: RefObject,
) {
- const items = paths
- .map((path) => docs.find((doc) => doc.path === path) ?? stubOpenDoc(path))
- .filter((doc) => paths.includes(doc.path));
return (
-
- {items.map((doc) => {
- const name = doc.path.split("/").pop() ?? doc.path;
- const tabDirty =
- doc.kind !== "image" && doc.kind !== "pdf" && doc.content !== doc.original;
- const glyph =
- languageFromPath(doc.path) === "json"
- ? "{}"
- : languageFromPath(doc.path) === "tsx" || languageFromPath(doc.path) === "jsx"
- ? "<>"
- : "";
- const on = doc.path === selected;
- return (
-
{
- noteOpenPath(doc.path, group);
- setEditing(true);
- if (doc.cold) void openFile(doc.path);
- else expandToFile(doc.path);
- }}
- onAuxClick={(event) => {
- if (event.button !== 1) return;
- event.preventDefault();
- closeTab(doc.path, group);
- }}
- onKeyDown={(event) => {
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "w") {
- event.preventDefault();
- closeTab(doc.path, group);
- return;
- }
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- noteOpenPath(doc.path, group);
- setEditing(true);
- if (doc.cold) void openFile(doc.path);
- else expandToFile(doc.path);
- }
- }}
- >
- {tabDirty ? : null}
- {glyph ? {glyph} : null}
- {name}
- {
- event.stopPropagation();
- closeTab(doc.path, group);
- }}
- >
-
-
-
- );
- })}
-
+ {
+ noteOpenPath(path, group);
+ setEditing(true);
+ if (cold) void openFile(path);
+ else expandToFile(path);
+ }}
+ onClose={(path) => {
+ void closeTab(path, group);
+ }}
+ />
);
}
@@ -3255,33 +3226,36 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
EXPLORER
- void refresh()}
- >
-
-
- void createFile()}
- >
-
-
- void createFolder()}
- >
-
-
+
+ void refresh()}
+ >
+
+
+
+
+ void createFile()}
+ >
+
+
+
+
+ void createFolder()}
+ >
+
+
+
) : null}
- void save()}
>
- {saving ? "Saving…" : dirty ? "Save" : "Saved"}
-
+ {dirty ? "Save" : "Saved"}
+
>
)
) : (
@@ -3498,7 +3474,7 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
Minimap
- Close others
+ void closeOthers()}>Close others
Close saved
{onAskAgent ? (
@@ -4386,7 +4362,9 @@ export const WorkspaceEditor = memo(function WorkspaceEditor({
{openPrComment.author ?
{openPrComment.author} : null}
{openPrComment.body}
diff --git a/apps/desktop/src/components/workspace/WorkspaceEditorTabs.test.tsx b/apps/desktop/src/components/workspace/WorkspaceEditorTabs.test.tsx
new file mode 100644
index 0000000..bd1a344
--- /dev/null
+++ b/apps/desktop/src/components/workspace/WorkspaceEditorTabs.test.tsx
@@ -0,0 +1,53 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { WorkspaceEditorTabs } from "./WorkspaceEditorTabs";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("WorkspaceEditorTabs", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("opens, closes, and labels dirty tabs", async () => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ const onOpen = vi.fn();
+ const onClose = vi.fn();
+ await act(async () => {
+ root.render(
+
({ path, content: "", original: "", kind: "text", cold: true })}
+ onOpen={onOpen}
+ onClose={onClose}
+ />,
+ );
+ });
+ expect(host.querySelector("[role='tablist']")?.getAttribute("aria-label")).toBe("Open files");
+ expect(host.querySelector(".workspace-tab-dot")).toBeTruthy();
+ await act(async () => {
+ (host.querySelector(".workspace-tab") as HTMLElement).click();
+ });
+ expect(onOpen).toHaveBeenCalledWith("/src/app.ts", false);
+ await act(async () => {
+ (host.querySelector(".workspace-tab-close") as HTMLButtonElement).click();
+ });
+ expect(onClose).toHaveBeenCalledWith("/src/app.ts");
+ });
+});
diff --git a/apps/desktop/src/components/workspace/WorkspaceEditorTabs.tsx b/apps/desktop/src/components/workspace/WorkspaceEditorTabs.tsx
new file mode 100644
index 0000000..4a11dc7
--- /dev/null
+++ b/apps/desktop/src/components/workspace/WorkspaceEditorTabs.tsx
@@ -0,0 +1,93 @@
+import type { RefObject } from "react";
+import { IconClose } from "@/components/layout/Icons";
+import { Tooltip } from "@/components/layout/Tooltip";
+import { languageFromPath } from "@/lib/highlight";
+import { matchModKey } from "@/lib/shortcuts";
+
+export type EditorTabDoc = {
+ path: string;
+ content: string;
+ original: string;
+ kind?: "text" | "image" | "pdf";
+ cold?: boolean;
+};
+
+export function WorkspaceEditorTabs(props: {
+ paths: string[];
+ selected: string;
+ docs: EditorTabDoc[];
+ group?: 0 | 1;
+ listRef?: RefObject;
+ stubDoc: (path: string) => EditorTabDoc;
+ onOpen: (path: string, cold: boolean) => void;
+ onClose: (path: string) => void;
+}) {
+ const items = props.paths
+ .map((path) => props.docs.find((doc) => doc.path === path) ?? props.stubDoc(path))
+ .filter((doc) => props.paths.includes(doc.path));
+ return (
+
+ {items.map((doc) => {
+ const name = doc.path.split("/").pop() ?? doc.path;
+ const tabDirty = doc.kind !== "image" && doc.kind !== "pdf" && doc.content !== doc.original;
+ const glyph =
+ languageFromPath(doc.path) === "json"
+ ? "{}"
+ : languageFromPath(doc.path) === "tsx" || languageFromPath(doc.path) === "jsx"
+ ? "<>"
+ : "";
+ const on = doc.path === props.selected;
+ return (
+
props.onOpen(doc.path, Boolean(doc.cold))}
+ onAuxClick={(event) => {
+ if (event.button !== 1) return;
+ event.preventDefault();
+ props.onClose(doc.path);
+ }}
+ onKeyDown={(event) => {
+ if (matchModKey(event, "w")) {
+ event.preventDefault();
+ props.onClose(doc.path);
+ return;
+ }
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ props.onOpen(doc.path, Boolean(doc.cold));
+ }
+ }}
+ >
+ {tabDirty ? : null}
+ {glyph ? {glyph} : null}
+ {name}
+
+ {
+ event.stopPropagation();
+ props.onClose(doc.path);
+ }}
+ >
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/desktop/src/components/workspace/WorkspaceFullscreenButton.tsx b/apps/desktop/src/components/workspace/WorkspaceFullscreenButton.tsx
index d10c51f..5f79907 100644
--- a/apps/desktop/src/components/workspace/WorkspaceFullscreenButton.tsx
+++ b/apps/desktop/src/components/workspace/WorkspaceFullscreenButton.tsx
@@ -1,16 +1,19 @@
import { IconCompress, IconExpand } from "@/components/layout/Icons";
+import { Tooltip } from "@/components/layout/Tooltip";
export function WorkspaceFullscreenButton(props: { isFullscreen: boolean; onToggle: () => void }) {
+ const label = props.isFullscreen ? "Exit full screen" : "Full screen files";
return (
-
- {props.isFullscreen ? : }
-
+
+
+ {props.isFullscreen ? : }
+
+
);
}
diff --git a/apps/desktop/src/components/workspace/WorkspaceGitPane.tsx b/apps/desktop/src/components/workspace/WorkspaceGitPane.tsx
index 5473247..f1cc892 100644
--- a/apps/desktop/src/components/workspace/WorkspaceGitPane.tsx
+++ b/apps/desktop/src/components/workspace/WorkspaceGitPane.tsx
@@ -6,6 +6,7 @@ import {
type WorkspaceGitStash,
type WorkspaceGitStatus,
} from "@/lib/api";
+import { Select } from "@/components/layout/Select";
export function WorkspaceGitPane({
sessionId,
@@ -89,51 +90,45 @@ export function WorkspaceGitPane({
{dests.length > 1 ? (
-
{
- const next = event.target.value;
+ options={dests.map((row) => ({
+ value: row.dest,
+ label: row.dest || row.name,
+ }))}
+ onChange={(next) => {
if (!next || next === dest) return;
onDestChange?.(next);
}}
- >
- {dests.map((row) => (
-
- {row.dest || row.name}
-
- ))}
-
+ />
) : null}
{branches.length ? (
-
{
- const branch = event.target.value;
+ options={[
+ ...(git.branch && !branches.includes(git.branch)
+ ? [{ value: git.branch, label: git.branch }]
+ : []),
+ ...branches.map((branch) => ({ value: branch, label: branch })),
+ ...remotes.map((branch) => ({
+ value: branch,
+ label: `origin/${branch}`,
+ })),
+ ]}
+ onChange={(branch) => {
if (!branch || branch === git.branch) return;
void api
.workspaceGitCheckout(sessionId, branch, false, ...destArg)
.then(onGit)
.catch((err) => catchGit(err, "Checkout failed"));
}}
- >
- {git.branch && !branches.includes(git.branch) ? (
- {git.branch}
- ) : null}
- {branches.map((branch) => (
-
- {branch}
-
- ))}
- {remotes.map((branch) => (
-
- origin/{branch}
-
- ))}
-
+ />
) : git.branch ? (
{git.branch}
) : null}
@@ -554,22 +549,27 @@ export function WorkspaceGitPane({
onto {onto || "upstream"}
{todos.map((todo, index) => (
- {
- const action = event.target.value as WorkspaceGitRebaseTodo["action"];
+ options={[
+ { value: "pick", label: "pick" },
+ { value: "reword", label: "reword" },
+ { value: "squash", label: "squash" },
+ { value: "fixup", label: "fixup" },
+ { value: "drop", label: "drop" },
+ ]}
+ onChange={(action) => {
setTodos((current) =>
- current.map((row, i) => (i === index ? { ...row, action } : row)),
+ current.map((row, i) =>
+ i === index
+ ? { ...row, action: action as WorkspaceGitRebaseTodo["action"] }
+ : row,
+ ),
);
}}
- >
- pick
- reword
- squash
- fixup
- drop
-
+ />
{todo.message}
))}
diff --git a/apps/desktop/src/components/workspace/WorkspaceQuickOpen.tsx b/apps/desktop/src/components/workspace/WorkspaceQuickOpen.tsx
index 63fabe2..0fac0f9 100644
--- a/apps/desktop/src/components/workspace/WorkspaceQuickOpen.tsx
+++ b/apps/desktop/src/components/workspace/WorkspaceQuickOpen.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { api, type WorkspaceGrepHit, type WorkspaceIndexHit } from "@/lib/api";
+import { useDialogFocus } from "@/lib/useDialogFocus";
export const WORKSPACE_GOTO_EVENT = "locus:workspace-goto";
export const WORKSPACE_GOTO_SYMBOL_EVENT = "locus:workspace-goto-symbol";
@@ -70,6 +71,8 @@ export function WorkspaceQuickOpen({
const [index, setIndex] = useState(0);
const [fileCount, setFileCount] = useState
(null);
const input = useRef(null);
+ const dialogRef = useRef(null);
+ useDialogFocus(open, onClose, dialogRef);
const seq = useRef(0);
const filesRef = useRef([]);
const symbolsRef = useRef([]);
@@ -157,7 +160,9 @@ export function WorkspaceQuickOpen({
{
if (event.key === "Escape") {
event.preventDefault();
@@ -254,7 +259,9 @@ export function WorkspaceFileSearch({
const [replaced, setReplaced] = useState
(null);
const [grepError, setGrepError] = useState(null);
const input = useRef(null);
+ const dialogRef = useRef(null);
const seq = useRef(0);
+ useDialogFocus(open, onClose, dialogRef);
useEffect(() => {
if (!open) {
@@ -355,7 +362,9 @@ export function WorkspaceFileSearch({
{
if (event.key === "Escape") {
event.preventDefault();
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css
index ee7e612..fc27ee4 100644
--- a/apps/desktop/src/index.css
+++ b/apps/desktop/src/index.css
@@ -8,8 +8,8 @@
--line: rgba(255, 255, 255, 0.07);
--line-strong: rgba(255, 255, 255, 0.12);
--text: #f3f1ec;
- --muted: #8b909c;
- --faint: #5c616c;
+ --muted: #b4b8c2;
+ --faint: #8b909c;
--accent: #e7b07a;
--accent-2: #7dcec4;
--accent-ink: #1a120c;
@@ -50,10 +50,10 @@
--radius-control: var(--radius-sm);
--radius-pane: var(--radius);
--radius-pill: 999px;
- --type-2xs: 10px;
- --type-xs: 11px;
- --type-sm: 12px;
- --type-md: 13px;
+ --type-2xs: 12px;
+ --type-xs: 12px;
+ --type-sm: 13px;
+ --type-md: 14px;
--type-body: 14px;
--type-lg: 16px;
--type-xl: 18px;
@@ -77,9 +77,34 @@
--ease-emphasized: cubic-bezier(0.4, 0, 0.2, 1);
--focus-ring: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);
--focus-offset: 2px;
- --chat-font: 13px;
+ --z-base: 0;
+ --z-raised: 1;
+ --z-sticky: 6;
+ --z-chrome: 8;
+ --z-float: 12;
+ --z-hint: 20;
+ --z-flyout: 30;
+ --z-popover: 40;
+ --z-toast: 45;
+ --z-popover-raised: 50;
+ --z-overlay: 80;
+ --z-menu: 90;
+ --z-menu-raised: 91;
+ --z-modal: 100;
+ --z-modal-raised: 110;
+ --z-lightbox: 120;
+ --z-tooltip: 130;
+ --z-boot: 200;
+ --z-transition: 250;
+ --z-crash: 300;
+ --bp-phone: 560px;
+ --bp-tablet: 760px;
+ --bp-wide: 900px;
+ --bp-xl: 980px;
+ --bp-desktop: 1100px;
+ --chat-font: 14px;
--chat-line: 1.5;
- --chat-who: 11px;
+ --chat-who: 12px;
--chat-who-gap: 3px;
--chat-msg-gap: 12px;
--chat-msg-gap-notice: 8px;
@@ -87,15 +112,15 @@
--chat-thread-pad-md: 14px 14px 8px;
--chat-thread-pad-sm: 12px 10px 6px;
--chat-user-pad: 8px 12px;
- --chat-system: 12px;
- --chat-working: 13px;
+ --chat-system: 13px;
+ --chat-working: 14px;
--chat-working-gap: 8px;
--chat-trace-margin: 1px 0 6px;
- --chat-trace-font: 12.5px;
+ --chat-trace-font: 13px;
--chat-trace-line: 1.4;
--chat-trace-body-margin: 6px 0 0;
--chat-trace-body-pad: 6px 0 0 10px;
- --chat-trace-body-size: 12px;
+ --chat-trace-body-size: 13px;
--chat-trace-body-line: 1.4;
--chat-code-pad: 8px 10px;
--chat-code-top: 28px;
@@ -106,7 +131,7 @@
--chat-composer-wrap-md: 0 12px 10px;
--chat-composer-box-pad: 2px 0 4px;
--chat-composer-head-pad: 4px 8px 0;
- --chat-composer-font: 13px;
+ --chat-composer-font: 14px;
--chat-composer-line: 1.45;
--chat-composer-min: 48px;
--chat-composer-min-sm: 48px;
@@ -354,7 +379,7 @@ button:disabled {
}
.skip-links {
position: absolute;
- z-index: 100;
+ z-index: var(--z-modal);
}
.skip-links a {
position: absolute;
@@ -469,7 +494,8 @@ button:disabled {
color: var(--muted);
flex: none;
}
-.side-rail-nav > button {
+.side-rail-nav > button,
+.side-rail-nav .rail-more > summary {
width: calc(var(--rail-w, 64px) - 8px);
height: auto;
min-width: calc(var(--rail-w, 64px) - 8px);
@@ -481,6 +507,28 @@ button:disabled {
justify-content: center;
gap: 3px;
}
+.side-rail-nav .rail-more {
+ position: relative;
+ width: calc(var(--rail-w, 64px) - 8px);
+ flex: none;
+}
+.side-rail-nav .rail-more > summary {
+ list-style: none;
+ cursor: pointer;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+ color: var(--muted);
+}
+.side-rail-nav .rail-more > summary::-webkit-details-marker {
+ display: none;
+}
+.side-rail-nav .rail-more.active > summary,
+.side-rail-nav .rail-more[open] > summary {
+ background: var(--bg-2);
+ color: var(--text);
+ border-color: var(--line);
+}
.side-rail button:hover,
.side-rail button.btn:hover,
.side-rail button.btn.ghost:hover {
@@ -514,7 +562,7 @@ button:disabled {
opacity: 0;
pointer-events: none;
box-shadow: var(--shadow);
- z-index: 20;
+ z-index: var(--z-hint);
}
.side-rail button:hover .rail-tip,
.side-rail button:focus-visible .rail-tip {
@@ -546,6 +594,32 @@ button:disabled {
background: var(--accent);
transform: translateY(-50%);
}
+/* Flyout rows live inside `.side-rail`; undo the 36px icon-button sizing. */
+.side-rail .rail-more .menu-item {
+ width: 100%;
+ height: auto;
+ min-width: 0;
+ display: flex;
+ place-items: initial;
+ align-items: center;
+ padding: 7px var(--space-2-5) 7px var(--space-3);
+ border: 0;
+ border-radius: var(--radius-md);
+ color: var(--muted);
+}
+.side-rail .rail-more .menu-item:hover {
+ background: var(--hover);
+ color: var(--text);
+}
+.side-rail .rail-more .menu-item.active {
+ background: var(--bg-2);
+ color: var(--text);
+ font-weight: var(--weight-semibold);
+}
+.side-rail .rail-more .menu-item.active::before {
+ left: 3px;
+ height: 14px;
+}
.side-rail .rail-icon {
display: grid;
place-items: center;
@@ -1322,7 +1396,7 @@ label.search-wrap:has(kbd) > input {
border-radius: var(--radius-sm);
display: grid;
place-items: center;
- font-size: 8px;
+ font-size: var(--type-2xs);
font-weight: var(--weight-bold);
letter-spacing: 0.04em;
background: var(--bg-3);
@@ -1337,9 +1411,18 @@ label.search-wrap:has(kbd) > input {
background: color-mix(in srgb, var(--ok) 18%, var(--bg-2));
color: var(--ok);
}
+.forge-glyph.gitea,
+.forge-glyph.forgejo {
+ background: color-mix(in srgb, var(--warn) 18%, var(--bg-2));
+ color: var(--warn);
+}
+.forge-glyph.azuredevops {
+ background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
+ color: var(--accent);
+}
.repo-flag {
margin-left: var(--space-1-5);
- font-size: 9px;
+ font-size: var(--type-2xs);
font-weight: var(--weight-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -1768,7 +1851,7 @@ label.search-wrap:has(kbd) > input {
.topbar {
position: relative;
- z-index: 8;
+ z-index: var(--z-chrome);
min-height: var(--header-h);
height: var(--header-h);
flex-shrink: 0;
@@ -2020,7 +2103,7 @@ label.search-wrap:has(kbd) > input {
position: absolute;
right: 0;
top: calc(100% + 6px);
- z-index: 30;
+ z-index: var(--z-flyout);
width: min(520px, 70vw);
box-shadow: var(--shadow);
}
@@ -3819,7 +3902,7 @@ button.patch-path-open:hover {
left: 8px;
right: 8px;
bottom: calc(100% + 6px);
- z-index: 8;
+ z-index: var(--z-chrome);
display: flex;
flex-direction: column;
gap: var(--space-0-5);
@@ -3980,7 +4063,7 @@ button.patch-path-open:hover {
}
.context-meter-pop {
position: fixed;
- z-index: 40;
+ z-index: var(--z-popover);
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -4127,7 +4210,7 @@ button.patch-path-open:hover {
}
.composer-paste-preview {
position: fixed;
- z-index: 50;
+ z-index: var(--z-popover-raised);
max-width: min(420px, calc(100vw - 16px));
max-height: min(320px, calc(100vh - 16px));
background: var(--bg-1);
@@ -4561,7 +4644,7 @@ button.patch-path-open:hover {
top: calc(100% + 6px);
left: 0;
right: 0;
- z-index: 40;
+ z-index: var(--z-popover);
min-width: 220px;
max-height: min(280px, 50vh);
padding: var(--space-1-5);
@@ -4835,7 +4918,7 @@ button.patch-path-open:hover {
padding-left: var(--space-2-5);
border-left: 1px solid var(--line);
}
-@container composer (max-width: 520px) {
+@container composer (max-width: 560px) {
.composer-actions,
.composer-toolbar-end {
flex: 1 1 100%;
@@ -4886,7 +4969,7 @@ button.patch-path-open:hover {
.vm-pane.workspace-fs {
position: fixed;
inset: 0;
- z-index: 80;
+ z-index: var(--z-overlay);
width: 100%;
height: 100%;
max-width: none;
@@ -6274,7 +6357,7 @@ button.patch-path-open:hover {
}
.workspace-inline-ai {
position: absolute;
- z-index: 12;
+ z-index: var(--z-float);
left: 12px;
right: 28px;
max-width: 560px;
@@ -6534,13 +6617,13 @@ button.patch-path-open:hover {
color: var(--faint);
font-size: var(--type-xs);
}
-@container workspace-status (max-width: 860px) {
+@container workspace-status (max-width: 900px) {
.workspace-status-project,
.workspace-status-indent {
display: none;
}
}
-@container workspace-status (max-width: 720px) {
+@container workspace-status (max-width: 760px) {
.workspace-status-run,
.workspace-status-blame {
display: none;
@@ -6549,7 +6632,7 @@ button.patch-path-open:hover {
max-width: 7rem;
}
}
-@container workspace-status (max-width: 580px) {
+@container workspace-status (max-width: 560px) {
.workspace-status-cursor,
.workspace-status-pylsp {
display: none;
@@ -6718,7 +6801,7 @@ button.patch-path-open:hover {
border-radius: 50%;
background: transparent;
color: var(--accent);
- font-size: 9px;
+ font-size: var(--type-2xs);
line-height: 10px;
cursor: pointer;
}
@@ -6981,13 +7064,13 @@ button.patch-path-open:hover {
.workspace-ctx-backdrop {
position: fixed;
inset: 0;
- z-index: 90;
+ z-index: var(--z-menu);
border: 0;
background: transparent;
}
.workspace-ctx {
position: fixed;
- z-index: 91;
+ z-index: var(--z-menu-raised);
min-width: 200px;
padding: var(--space-1);
background: var(--bg-1);
@@ -7040,7 +7123,7 @@ button.patch-path-open:hover {
.workspace-quick-open-backdrop {
position: absolute;
inset: 0;
- z-index: 8;
+ z-index: var(--z-chrome);
display: flex;
align-items: flex-start;
justify-content: center;
@@ -7299,7 +7382,7 @@ button.patch-path-open:hover {
position: absolute;
top: calc(100% + 6px);
right: 0;
- z-index: 40;
+ z-index: var(--z-popover);
width: 320px;
max-width: min(320px, calc(100vw - 24px));
max-height: min(480px, calc(100vh - 16px));
@@ -7946,7 +8029,7 @@ button.patch-path-open:hover {
.design-pane .vm-toolbar-actions > .btn.icon {
flex-shrink: 0;
}
-@container design-pane (max-width: 920px) {
+@container design-pane (max-width: 900px) {
.vm-status-copy {
display: none;
}
@@ -7959,15 +8042,13 @@ button.patch-path-open:hover {
display: contents;
}
}
-@container design-pane (max-width: 640px) {
+@container design-pane (max-width: 560px) {
.vm-toolbar-actions {
gap: var(--space-1);
}
.design-system-picker.compact .design-system-picker-row select {
max-width: 108px;
}
-}
-@container design-pane (max-width: 520px) {
.design-system-picker {
display: none;
}
@@ -8647,7 +8728,7 @@ button.patch-path-open:hover {
width: 100%;
}
.asset-cluster-label {
- font-size: 9px;
+ font-size: var(--type-2xs);
font-weight: var(--weight-bold);
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -8973,7 +9054,7 @@ button.patch-path-open:hover {
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--accent-ink);
- font-size: 9px;
+ font-size: var(--type-2xs);
font-weight: var(--weight-bold);
line-height: 14px;
text-align: center;
@@ -9905,7 +9986,7 @@ button.patch-path-open:hover {
.boot-screen {
position: fixed;
inset: 0;
- z-index: 200;
+ z-index: var(--z-boot);
display: grid;
place-items: center;
background: var(--bg);
@@ -9930,7 +10011,7 @@ button.patch-path-open:hover {
.product-mode-transition {
position: fixed;
inset: 0;
- z-index: 250;
+ z-index: var(--z-transition);
display: grid;
place-items: center;
background: var(--bg);
@@ -9968,7 +10049,7 @@ button.patch-path-open:hover {
.crash-screen {
position: fixed;
inset: 0;
- z-index: 300;
+ z-index: var(--z-crash);
display: grid;
place-items: center;
padding: var(--space-6);
@@ -10015,7 +10096,7 @@ button.patch-path-open:hover {
.media-lightbox {
position: fixed;
inset: 0;
- z-index: 120;
+ z-index: var(--z-lightbox);
display: grid;
place-items: center;
padding: var(--space-3);
@@ -10791,7 +10872,7 @@ button.patch-path-open:hover {
min-width: 0;
}
.host-folder-backdrop {
- z-index: 110;
+ z-index: var(--z-modal-raised);
}
.modal.host-folder-modal {
width: min(520px, calc(100% - 32px));
@@ -11012,7 +11093,7 @@ details.advanced > .hint {
place-items: center;
padding: var(--space-6);
overflow: auto;
- z-index: 100;
+ z-index: var(--z-modal);
}
.modal {
width: min(720px, calc(100% - 32px));
@@ -11181,10 +11262,11 @@ details.advanced > .hint {
margin: 0;
}
.field label {
- font-size: var(--type-micro);
- font-family: var(--mono);
- letter-spacing: 0.08em;
- text-transform: uppercase;
+ font-size: var(--type-sm);
+ font-family: var(--font);
+ font-weight: var(--weight-medium);
+ letter-spacing: 0;
+ text-transform: none;
color: var(--muted);
}
.field input,
@@ -11738,7 +11820,7 @@ details.advanced > .hint {
.launch-footer {
margin-top: var(--space-1);
}
-@media (max-width: 640px) {
+@media (max-width: 560px) {
.launch-ws-row {
grid-template-columns: 1fr;
}
@@ -11804,7 +11886,7 @@ details.advanced > .hint {
position: fixed;
left: calc(var(--rail-w, 64px) + 16px);
bottom: 16px;
- z-index: 40;
+ z-index: var(--z-popover);
width: min(280px, calc(100vw - var(--rail-w, 64px) - 32px));
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-3-5);
border-radius: var(--radius-xl);
@@ -11865,6 +11947,9 @@ details.advanced > .hint {
flex: 1 1 12rem;
min-width: 0;
}
+.empty-actions + .feature-hint {
+ margin-top: var(--space-5);
+}
.feature-nudge {
flex: none;
@@ -12410,7 +12495,7 @@ label.search-wrap.settings-find-wrap > input.search {
color: var(--faint);
text-align: right;
}
-@media (max-width: 720px) {
+@media (max-width: 760px) {
.git-account-summary {
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.9fr) 28px;
}
@@ -14743,7 +14828,7 @@ button.pill {
.menu-pop {
position: absolute;
top: calc(100% + 6px);
- z-index: 40;
+ z-index: var(--z-popover);
min-width: 196px;
max-height: min(360px, calc(100vh - 16px));
padding: var(--space-1-5);
@@ -14880,6 +14965,15 @@ button.pill {
color: var(--faint);
margin-top: var(--space-2);
}
+.scrim-note.is-danger {
+ color: var(--danger);
+}
+.setup-wizard-modal {
+ width: min(640px, calc(100vw - 32px));
+}
+.setup-hero-lead {
+ margin-top: 0;
+}
::-webkit-scrollbar {
width: 10px;
@@ -18419,7 +18513,7 @@ html[data-shell="mobile"][data-theme-gradient] .mobile-screen {
border-left: 2px solid var(--accent);
padding-left: 8px;
}
-@media (max-width: 960px) {
+@media (max-width: 980px) {
.architecture-sections {
grid-template-columns: 1fr;
}
@@ -18499,6 +18593,13 @@ html[data-shell="mobile"][data-theme-gradient] .mobile-screen {
.forge-badge.bitbucket {
color: var(--ok);
}
+.forge-badge.gitea,
+.forge-badge.forgejo {
+ color: var(--warn);
+}
+.forge-badge.azuredevops {
+ color: var(--accent);
+}
.import-accounts {
display: flex;
flex-direction: column;
@@ -19050,7 +19151,7 @@ button.repo-stat:hover {
display: none;
}
}
-@media (max-width: 860px) {
+@media (max-width: 900px) {
.pr-table-head,
.pr-table-row {
grid-template-columns: minmax(0, 1fr);
@@ -20666,7 +20767,7 @@ button.repo-stat:hover {
}
.slack-listen-picker {
position: absolute;
- z-index: 20;
+ z-index: var(--z-hint);
top: calc(100% + 4px);
right: 0;
margin: 0;
@@ -20928,6 +21029,7 @@ button.repo-stat:hover {
white-space: nowrap;
}
.grafana-vars select,
+.grafana-vars .ui-select-toggle,
.grafana-vars input,
.grafana-var-menu summary {
box-sizing: border-box;
@@ -20943,6 +21045,7 @@ button.repo-stat:hover {
line-height: 28px;
}
.grafana-vars select,
+.grafana-vars .ui-select-toggle,
.grafana-var-menu summary {
appearance: none;
-webkit-appearance: none;
@@ -21487,7 +21590,8 @@ button.repo-stat:hover {
border-top: 1px solid var(--line);
}
}
-.slack-pane > .error {
+.slack-pane > .error,
+.slack-pane > .error-row {
margin: var(--space-2) 18px;
flex-shrink: 0;
}
@@ -21568,7 +21672,7 @@ button.repo-stat:hover {
}
.slack-search-hits {
position: absolute;
- z-index: 20;
+ z-index: var(--z-hint);
top: calc(100% + 4px);
right: 0;
left: 0;
@@ -21653,7 +21757,7 @@ button.repo-stat:hover {
position: fixed;
right: 18px;
bottom: 18px;
- z-index: 41;
+ z-index: var(--z-toast);
display: flex;
flex-direction: column-reverse;
gap: var(--space-2);
@@ -21689,7 +21793,7 @@ button.repo-stat:hover {
position: fixed;
right: 18px;
bottom: 18px;
- z-index: 40;
+ z-index: var(--z-popover);
display: flex;
flex-direction: column-reverse;
gap: var(--space-2);
@@ -22482,7 +22586,7 @@ img.slack-emoji {
.slack-image-viewer {
position: fixed;
inset: 0;
- z-index: 120;
+ z-index: var(--z-lightbox);
display: grid;
place-items: center;
padding: var(--space-3);
@@ -23655,7 +23759,7 @@ html[data-shell="mobile"] #root {
background: transparent;
color: var(--faint);
font: inherit;
- font-size: 9px;
+ font-size: var(--type-2xs);
font-weight: var(--weight-semibold);
letter-spacing: 0;
}
@@ -24672,7 +24776,7 @@ html[data-shell="mobile"] #root {
position: absolute;
left: 12px;
top: 8px;
- z-index: 8;
+ z-index: var(--z-chrome);
display: flex;
flex-direction: column;
gap: var(--space-1);
@@ -24966,7 +25070,7 @@ html[data-shell="mobile"] #root {
}
.board-shape-pop {
position: absolute;
- z-index: 12;
+ z-index: var(--z-float);
top: calc(100% + 6px);
left: 12px;
width: 320px;
@@ -24980,7 +25084,7 @@ html[data-shell="mobile"] #root {
}
.board-shape-overlay {
position: absolute;
- z-index: 12;
+ z-index: var(--z-float);
pointer-events: auto;
}
.board-shape-overlay .board-shape-pop {
@@ -25529,13 +25633,13 @@ html[data-shell="mobile"] #root {
.board-ctx-backdrop {
position: fixed;
inset: 0;
- z-index: 90;
+ z-index: var(--z-menu);
border: 0;
background: transparent;
}
.board-ctx {
position: fixed;
- z-index: 91;
+ z-index: var(--z-menu-raised);
min-width: 220px;
}
.board-wires text {
@@ -26720,7 +26824,7 @@ html[data-shell="mobile"] #root {
width: 100%;
min-width: 0;
}
-@media (max-width: 640px) {
+@media (max-width: 560px) {
.persona-avatar-editor.panel {
grid-template-columns: 1fr;
}
diff --git a/apps/desktop/src/lib/configDraft.test.ts b/apps/desktop/src/lib/configDraft.test.ts
index 0755254..58b51cd 100644
--- a/apps/desktop/src/lib/configDraft.test.ts
+++ b/apps/desktop/src/lib/configDraft.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import type { LocusConfig, WorkerBackend } from "@locus/shared";
import { UBUNTU_DEFAULT_HARDWARE, defaultNetworking } from "@locus/shared";
-import { appendConfigList, patchConfigList, removeConfigList } from "./configDraft";
+import {
+ appendConfigList,
+ insertConfigList,
+ patchConfigList,
+ removeConfigList,
+} from "./configDraft";
const worker = (patch: Partial