diff --git a/ui-desktop/AGENTS.md b/ui-desktop/AGENTS.md new file mode 100644 index 000000000..8f2cd2819 --- /dev/null +++ b/ui-desktop/AGENTS.md @@ -0,0 +1,210 @@ +# Desktop Engineering Guide + +How to build ClawCodex Desktop well. This is a judgment guide, not an inventory — +it teaches the invariants and the reasoning behind them so a change fits the app +even as files move. Read it with the repository `AGENTS.md` (root rules still +apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract. + +When a rule here and the code disagree, trust the code and fix whichever is +wrong — but never break an invariant to make a change easier. + +## What this app is + +Desktop is its own native chat surface. It is not the browser dashboard and it +does not embed the TUI. Three parties, each authoritative for one thing: + +- **Electron** owns the machine: process lifecycle, native filesystem/git/ + windows, install/update, and a narrow, typed capability bridge. +- **The renderer** owns the experience: navigation, presentation, and ephemeral + interaction state. +- **The agent backend** owns the work: sessions, tools, model calls, streaming. + +Keep the seams clean. The renderer never reaches for Node or Electron directly; +native power arrives through a deliberate capability, not a general escape hatch. +Agent behavior lives behind the gateway, never reimplemented in React. When a +change blurs a seam, that is the smell — fix the seam, don't widen it. + +## Decide state by authority + +The first question for any piece of state is *who is allowed to be right about +it*, not where it is convenient to store it. Put state with its authority: + +- The **backend** is authoritative for anything another ClawCodex surface can also + change. Treat the renderer's copy as a cache of that truth. +- **Electron** is authoritative for machine and runtime facts. +- The **renderer** owns only what is purely about this window's presentation. + +From that, everything else follows: shared renderer state lives in small stores +owned by the feature that owns the concern; request-shaped server data that wants +invalidation lives in the query layer; short-lived interaction detail stays in +the component; hot coordination that must not paint stays in a ref. Reach for the +narrowest home that still lets the state be correct. A new global store is a +claim that many distant surfaces need it — earn that claim. + +Persisted state must declare its scope in its own key: is this global, or does it +belong to a connection, a profile, a stored session, a project, or a window? +Getting the scope wrong is how one profile's setting bleeds into another. + +## Identity is not incidental + +Sessions have more than one identity, and conflating them is a recurring source +of "session not found" and vanishing history. Reason about which identity a +surface needs: durable navigation and anything the user pins or persists key off +the stable/durable identity; live streaming keys off the runtime identity; state +that must outlive compression keys off the lineage root. Keep the mapping between +them explicit and translate at the boundary rather than passing the wrong id +inward. + +## Server truth is cached, not owned + +The renderer paints from a cache of backend truth, so it must reconcile, not +assume: + +- **Merge, don't clobber.** A refresh is new information layered over what you + already know, not a replacement that can drop live or pinned rows. +- **Be optimistic, then honest.** Direct manipulation should paint immediately + from a snapshot; a failed write rolls back visibly and an authoritative + refresh gets the last word. +- **Guard against the past.** Async results can arrive out of order; a stale + response must never overwrite newer intent. Generation counters and request + tokens exist for this. +- **Isolate the foreground.** Only the surface the user is looking at may publish + into the shared view; background work updates its own cache quietly. +- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but + let terminal transitions (a turn finishing, needing input, failing) reach the + user immediately. +- **Preserve reference identity on no-ops.** Handing React a fresh array that + contains the same data re-renders expensive trees for nothing. + +## Switching context is a re-home, not a reboot + +Changing profile, connection, or mode is a workspace switch, not a cold start. +The shell and whatever the user was doing stay put; only the gateway-bound view +is cleared and repopulated, and the previous context must not leak into the next +one. Reserve the full-screen boot/connecting experience for a genuinely unusable +backend. + +There are three distinct switch shapes, and conflating them is the classic bug: + +- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home: + shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query + invalidation alone cannot evict live session stores — wipe them. +- A **runtime home change** (switching the underlying `CLAWCODEX_CONFIG_DIR` profile) is + a hard re-home: the window legitimately reloads and state resets by remount. +- A **live profile swap** in the same window activates another profile's socket + while background profiles keep streaming; lists merge rather than wipe, and + only an explicit user selection starts a fresh foreground draft. + +Treating a soft switch as hard flickers the app; treating a hard one as soft +strands stale rows. After any swap, the active socket, active profile, and +connection atoms must agree, or REST and filesystem calls route to the wrong +backend. + +## Cross everything as an observable ladder + +Desktop lives at the seams: versions, profiles, local vs remote vs cloud, +partially installed runtimes, stale caches, older backends. The durable technique +for all of it is the same — an ordered ladder of candidates: + +1. Precedence is written down, in one place, as data or a pure function. +2. A candidate is trusted only after it is validated at the right boundary. + Existence is not proof; probe what you're about to rely on. +3. A failed *read* falls to the next rung; a failed *authoritative write* + surfaces or rolls back rather than silently retargeting. +4. A missing capability and a transient failure are different: the first may + enable a compatibility path or a disabled state; the second should retry. +5. Retries are bounded and end in a real recovery affordance — never an infinite + spinner or a hot loop. +6. One resolver owns each policy so every caller gets the same answer. Scatter is + how two call sites drift apart. + +This is the shape of backend discovery, command/version fallbacks, connection and +auth resolution, workspace-cwd selection, capability detection, and preview +normalization alike. Learn the shape, not a snapshot of the current rungs. + +Two auth-flavored corollaries worth naming because they are easy to get wrong: + +- **One-time credentials are never reused.** An OAuth gateway connection mints a + fresh WebSocket ticket on every dial and never falls back to the cached URL. + Only a confirmed 401/403 (or an explicitly tagged auth rejection) means + reauthentication; timeout, network, malformed-response, and server failures + remain connectivity errors. Only long-lived token/local auth may reuse a + cached URL as a lower rung. +- **A connection test must exercise the leg you'll actually use.** An HTTP + status probe passing while the WebSocket/auth leg fails is a false positive + that ships as "it said connected but nothing works." + +## Compatibility without carrying the past forever + +Desktop and its runtime update on separate clocks, so a change can meet an older +backend. Keep those users working: preserve the current feature, keep the +fallback narrow and tied to an identified older runtime, and cover it with a +test. A fallback that quietly degrades the feature it's meant to protect is worse +than the crash it replaced. + +## Keep the waist narrow, grow at the edges + +The root contribution rubric governs here too. New capability should arrive at +the smallest surface that solves it: extend what exists, add a feature locally, +lean on an existing seam — before you invent a framework. The shell's internal +registries are composition seams, not a public plugin ABI; do not build a +universal extension system, a manifest, or a plugin adapter for a single +consumer. Design a shared contract only once more than one real consumer proves +its shape. "Plugin" means several unrelated things across ClawCodex — do not assume +one surface's extension model runs in another. + +When the new capability is an **agent-callable** one — a tool that acts on this +renderer (open a pane, read the in-app browser, react to a message) — it is a +property of the SESSION's client, not of the backend host. Wire its +availability off the session source the app already sends on `session.create` +(`source: 'desktop'`), never off an env var on the backend process: that +process might be a remote or cloud gateway this app merely connected to. See +the root AGENTS.md, "Surface capability is a property of the SESSION." + +## Respect the person using it + +Design and engineering meet at intent. The user's attention and context are +sacred: + +- Never navigate, move focus, or open a surface because something *happened* in + the background. Offer; don't hijack. +- The states around loading are distinct experiences — empty, loading, + reconnecting, degraded/stale, and exhausted-recovery each deserve their own + honest copy and their own way out. +- Keyboard ownership follows focus. The focused surface wins its keys; one + cancel gesture does exactly one thing. +- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden. + Visibility is not lifecycle. + +## Make it feel instant + +Performance is a feature the user feels, especially in drag, resize, scroll, +typing, streaming, and terminals. The principles are timeless even as the code +changes: keep hot-path state local or narrowly derived; don't subscribe heavy +trees to per-frame updates; coalesce pointer work; avoid reading layout right +after writing style; and don't mount expensive content mid-gesture. Prove speed +against realistic content — a fast empty demo proves nothing about a long +transcript. If motion is masking latency, remove the motion, don't tune it. + +## Testing as a habit of proof + +Test the behavior that would actually break a user, not a snapshot of today's +data. Favor invariants over frozen values. Exercise the real path for anything +at a seam — resolver precedence and its failure rungs, identity and scope +boundaries, optimistic rollback and stale-response ordering, and both sides of a +local/remote adapter with its profile routing intact. Match how the suite is +actually run rather than inventing a command; when in doubt, read the scripts. + +## The taste test before you hand off + +- Does every piece of state live with its authority, at the narrowest scope? +- Would a background event ever steal the foreground or the user's focus? +- Does each resolver have one home, a validated ladder, and a bounded, recoverable + end? +- Do local, remote, and profile routing still agree? +- Does async failure leave a usable UI and a way forward? +- Do hot interactions stay cheap under realistic load? +- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all + locales? + +If any answer is "not sure," that's the part to go verify. diff --git a/ui-desktop/DESIGN.md b/ui-desktop/DESIGN.md new file mode 100644 index 000000000..2256ab683 --- /dev/null +++ b/ui-desktop/DESIGN.md @@ -0,0 +1,337 @@ +# Desktop Design System + +Conventions for the Electron desktop app (`apps/desktop`). Read this before +adding a component, overlay, or style. The rule of thumb: **one source per +concern, tokens over literals, flat over boxed.** If you reach for a raw color, +a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop, +there's already a primitive for it. + +This file owns the visual and interaction contract. Read +[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and +testing rules. + +This doc contains two kinds of content, maintained differently: + +- **Principles** (flatness, intent, feedback, motion, cancellation) are durable. + They hold as components come and go. +- **Named contracts** (tokens, `Button` variants, primitive names) are the + design system's current API. They are maintained *with* the code: if you + change a primitive, token, or variant, update its entry here **in the same + change** — a stale name in this file is a bug, exactly like a stale type. + +When a rule and the code disagree, fix whichever is wrong rather than forking a +one-off at the call site. + +## Principles + +1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel. + Group with whitespace and a single hairline, never nested rounded boxes. +2. **Borderless elevation for floating panels.** Overlays float on + `shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel + structure may use token hairlines sparingly. +3. **One primitive per concern.** One `Button`, one set of control variants, + one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't + fork. +4. **Tokens, not literals.** Reference CSS vars (`--ui-*`, `--shadow-nous`, + `--theme-*`), never raw hex / ad-hoc rgba in components. +5. **Style lives in the primitive.** Variants and sizes own padding, radius, + color, chrome. Call sites pass a `variant`/`size`, not `className` overrides + that re-specify those. +6. **Intent before automation.** Surface useful actions and previews, but do not + open panes, move focus, or navigate because a tool happened to produce + something. +7. **Immediate feedback.** Direct manipulation updates the view first. Network + or disk persistence reconciles afterward and rolls back visibly on failure. + +## Information architecture + +- **Chat is the home surface.** The transcript and composer stay primary; tools, + previews, files, review, and terminal complement the conversation. +- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts + remain in shell chrome. Do not hide a distinct product noun inside an + unrelated page. +- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles, + Agents, and Starmap render as `OverlayView` cards and return to the previous + route on close. Model/session pickers and dialogs layer above the current + surface; they are not navigation stacks. +- **Panes are working context.** Preview, files, review, and terminal remain + attached to the current task. Their state survives temporary hiding and chat + switches where the underlying tool is meant to persist. +- **One action, one home.** A command may have keyboard, palette, and visible + affordances, but they invoke the same action and state. Do not fork behavior + per entry point. +- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and + worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow. + +Navigation must preserve context. A background session finishing, a tool result +arriving, or a project refresh may update badges and cached data; it must not +replace the foreground transcript or steal focus. + +## Surfaces & elevation + +Floating panels (base `Dialog`, route overlays, boot/install/update surfaces, +model-picker, onboarding, prompt overlays, notifications) use: + +``` +shadow-nous /* downward-weighted, layered contact→ambient falloff */ +border-(--stroke-nous) /* currentColor hairline, theme-adaptive */ +``` + +Both are CSS vars in `src/styles.css` — tune in one place, everything inherits. +Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)` +one-offs; if elevation needs to change, change the token. + +Menus and popovers use their own shared `shadow-md` + +`--ui-stroke-secondary` primitive treatment. Drag affordances may use tokenized +dashed targets and local blur. These are semantic surface classes, not licenses +for call-site shadow or border inventions. + +## Stroke & color tokens + +| Token | Use | +| --- | --- | +| `--ui-stroke-primary…quaternary` | hairlines, in descending strength | +| `--ui-stroke-tertiary` | the default in-panel divider / list hairline — and every bordered surface in the transcript | +| `--stroke-nous` | the overlay hairline (pairs with `shadow-nous`) | +| `--ui-text-primary / -secondary / -tertiary` | text hierarchy | +| `--ui-bg-quaternary` | soft control fill (secondary button) | +| `--ui-widget-surface-background` | fill for inline chat widgets (`WIDGET_SHELL_CLASS`) | +| `--chrome-action-hover` | hover fill for quiet controls | +| `--theme-primary`, `--ui-accent` | brand/accent | + +Never hardcode `border-gray-*`, `bg-white`, `text-black`, etc. The white tile in +`BrandMark` is the one sanctioned literal (the mark needs a fixed backdrop). + +## Buttons — one component + +`src/components/ui/button.tsx` is the single source. Pick a `variant` + `size`; +do **not** pass `h-*`, `px-*`, `py-*`, or icon-size overrides. + +**Variants:** `default` (primary), `destructive`, `secondary` (soft fill — +the default non-primary look), `outline` (transparent + 1px inset ring, no +fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel", +"Clear"), `textStrong` (bold underlined inline affordance — "Change", +"Open logs"). + +**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons +that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro` +(status-stack/table-footers), and the icon family `icon` / `icon-xs` / +`icon-sm` / `icon-lg` / `icon-titlebar`. + +**Tooltips only when hover teaches something new.** `` is for discovery, +not a tax on every icon. Ask: does hover reveal something the user cannot +already see or infer? If not, skip the tip; keep an `aria-label` for a11y. + +Tip unlabeled chrome when the job (or a keybind / truncated path / host / +other detail) is not already on screen — toolbar / titlebar / statusbar icons, +`TipKeybindLabel` shortcuts, ownership chips, unlabeled icon grids. + +Do **not** tip: + +- Menu triggers (kebabs / ⋯ / `ActionsMenu` / `DropdownMenuTrigger`) — the + affordance is "open menu"; verbs live in the menu. Never tip + `"Actions for ${row title}"` / `"Project actions"` / `"Actions"`. +- Close / dismiss X buttons — the glyph is the label (`aria-label` only). +- Controls whose visible label already says what the tip would ("click to…", + paraphrases of the same words, timer labels restating "Running"). + +Never use native HTML `title=` on buttons — unstyled, ~500ms OS delay, clashes +with the themed `Tip`. `src/components/ui/__tests__/no-native-title.test.ts` +fails on any ` + {openFailed && } + + ) +} + +function MediaAttachment({ path }: { path: string }) { + const [src, setSrc] = useState('') + const [failed, setFailed] = useState(false) + const { open, openFailed } = useOpenMediaFile(path) + const kind = mediaKind(path) + const name = mediaName(path) + + useEffect(() => { + let cancelled = false + let objectUrl = '' + + setFailed(false) + setSrc('') + + if (kind === 'file') { + setFailed(true) + + return () => { + cancelled = true + } + } + + void resolveMediaPlaybackSrc(path) + .then(value => { + if (value.startsWith('blob:')) { + objectUrl = value + } + + if (!cancelled) { + setSrc(value) + } else if (objectUrl) { + URL.revokeObjectURL(objectUrl) + } + }) + .catch(() => { + if (!cancelled) { + setFailed(true) + } + }) + + return () => { + cancelled = true + + if (objectUrl) { + URL.revokeObjectURL(objectUrl) + } + } + }, [kind, path]) + + if (kind === 'image' && src) { + return ( + + + + ) + } + + if (kind === 'audio' && src) { + return ( + + {name} + + ) + } + + if (kind === 'video' && src) { + return ( + + {name} + + ) + } + + return ( + + { + event.preventDefault() + open() + }} + > + {failed ? `Open ${name}` : `Loading ${name}...`} + + {openFailed && } + + ) +} + +function childrenToText(children: unknown): string { + if (typeof children === 'string' || typeof children === 'number') { + return String(children).trim() + } + + if (Array.isArray(children) && children.every(c => typeof c === 'string' || typeof c === 'number')) { + return children.join('').trim() + } + + return '' +} + +function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a'>) { + const mediaPath = mediaPathFromMarkdownHref(href) + + if (mediaPath) { + return + } + + const previewTarget = previewTargetFromMarkdownHref(href) + + if (previewTarget) { + return + } + + const sessionRef = sessionRefFromMarkdownHref(href) + + if (sessionRef) { + return + } + + const target = href ? normalizeExternalUrl(href) : href + + if (!target || !/^https?:\/\//i.test(target)) { + return ( + + {children} + + ) + } + + const text = childrenToText(children) + + // Bare autolink → inline rich embed when a provider matches. Labeled links + // (`[watch](url)`) stay plain. Desktop only (webview / iframe renderers). + if (window.clawcodexDesktop && text && normalizeExternalUrl(text) === target) { + const embed = detectEmbed(target) + + if (embed) { + return + } + } + + const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined + + return ( + + ) +} + +// Generated/inline media often arrives as image markdown — `![clip](clip.mp4)`. +// A raw with a video/audio source renders a broken-image icon (the file is +// valid, the browser just can't paint it as an image), so route those sources to +// MediaAttachment, which picks the right