From 94b184bc836ca500b2dce651093ccef599086661 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 4 May 2026 20:49:05 +0100 Subject: [PATCH 01/57] docs(agents-server-ui): add tile-based layout refactor plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans a VS Code/Cursor-style splittable workspace where the workspace is a recursive tree of Splits → Groups → Tiles, each tile rendered through a pluggable view registry (Chat, State Explorer, future Logs/Inspector/etc.). Splits and views stay orthogonal — splitTile() and setTileView() are the two primitives every menu item composes from. URL strategy is hybrid: clean default URL (active tile only) plus localStorage layout persistence per server, with an opt-in ?layout= import param for shareable layouts. Migration ships in five sequential PRs starting with the view registry, then workspace skeleton, SplitMenu, drag-and-drop, and finally persistence + URL polish. Co-authored-by: Cursor --- packages/agents-server-ui/TILE_LAYOUT_PLAN.md | 620 ++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 packages/agents-server-ui/TILE_LAYOUT_PLAN.md diff --git a/packages/agents-server-ui/TILE_LAYOUT_PLAN.md b/packages/agents-server-ui/TILE_LAYOUT_PLAN.md new file mode 100644 index 0000000000..ccc58f4e5d --- /dev/null +++ b/packages/agents-server-ui/TILE_LAYOUT_PLAN.md @@ -0,0 +1,620 @@ +# Tile-Based Layout Refactor — Agents Server UI + +Follow-up to the Radix → Base UI migration. With Base UI + CSS Modules in +place, the next foundation is a **VS Code / Cursor-style splittable +workspace** so multiple agents — and multiple **views** of an agent — can +be open side-by-side. + +--- + +## 1. What we're building + +### Concepts + +1. **Workspace** — the root pane container that fills the area to the + right of the sidebar. Holds a single root `Split` node (or nothing). +2. **Split** — a horizontal _or_ vertical container of children. + Children are either other `Split` nodes or `Group`s. +3. **Group** (a.k.a. _editor group_ in VS Code) — a leaf area that + holds one or more `Tile`s, only one of which is "active" at a time, + with a tab strip across the top. +4. **Tile** — what's rendered. A tile is `{ entityUrl, viewId }`. The + same entity can be open in multiple tiles (e.g. chat + state explorer + side-by-side). +5. **View** — a pluggable renderer registered against an `id` (e.g. + `chat`, `state-explorer`, future `logs`, `inspector`, `metrics`, + etc.). Splitting and view-switching are **orthogonal** primitives. + +### User-facing behaviour + +| Action | Result | +| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Click an entity in the sidebar | Opens it as a new tile in the **active group**, replacing the current tile. (v1: always replace; preview-tab semantics deferred.) | +| Cmd/Ctrl-click sidebar entity | Open in a new group split to the right. | +| Drag entity from sidebar onto workspace | Shows split-zone overlay; drop into one of 5 zones (centre/N/E/S/W of an existing group, or onto a tab strip). | +| Drag a tab between groups | Move tile to that group. Same 5-zone overlay. | +| `…` menu → **Split Right / Split Down / Split Left / Split Up** | Duplicates the active tile into a new group split in that direction (matches Cursor's chat-pane menu). | +| `…` menu → **View ▸ {viewId} ▸ Open here / Split right / Split down / Split left / Split up** | Opens an additional view of the _same_ entity. | +| Close last tile in a group | Group is removed; sibling expands. | +| Close last group | Workspace returns to the empty `NewSessionPage`. | +| Drag the divider between groups | Resizes the split. | +| Hotkeys | `⌘D` Split Right · `⇧⌘D` Split Down · `⌘W` close active tile · `⌘\` switch active group · `⌘1..9` focus group N. | + +### Out of scope for this work + +- Floating windows / detach to OS window. +- Pinned tabs ordering / drag-to-reorder _within_ a tab strip (v1: drop + on tab strip = append). +- "Preview" (italic) tab semantics — start with "always replace active + tile" then iterate. +- Per-server-keyed workspace persistence (start global; revisit later). + +--- + +## 2. Why a new layout model (and not just CSS flex) + +The current `EntityPage` (`src/router.tsx`) hardcodes: + +- one entity at a time (URL-driven), +- a single optional right drawer for the State Explorer with a + hand-rolled splitter, +- the State Explorer toggle living on `EntityHeader`. + +To support arbitrary nested splits we need a **recursive tree data +structure** as the source of truth, not URL + boolean flags. Once the +tree exists, the URL becomes a _projection_ of it (one of the tiles is +"focused" and its url shows up in the address bar) instead of the +source. + +--- + +## 3. Architecture + +### 3.1 Data model + +`src/lib/workspace/types.ts`: + +```ts +export type ViewId = string // 'chat' | 'state-explorer' | 'logs' | ... + +export type Tile = { + id: string // nanoid; stable across renders + entityUrl: string // '/horton/foo-123' + viewId: ViewId +} + +export type Group = { + kind: 'group' + id: string + tiles: Tile[] + activeTileId: string +} + +export type Split = { + kind: 'split' + id: string + direction: 'horizontal' | 'vertical' // horizontal = side-by-side + // Each child carries its own size as a fraction (sums to ~1). + children: { node: WorkspaceNode; size: number }[] +} + +export type WorkspaceNode = Split | Group + +export type Workspace = { + root: WorkspaceNode | null // null = empty / new-session + activeGroupId: string | null +} +``` + +`ViewId` is a plain string instead of a string-literal union, because +the registry (§3.3) is the source of truth and grows over time. We +type-check at the registration site. + +### 3.2 Reducer + provider + +`src/lib/workspace/workspaceReducer.ts` — pure operations: + +- `openTile(state, { entityUrl, viewId, target: { groupId, position } })` + where `position` is `'replace' | 'append' | 'split-right' | 'split-down' | 'split-left' | 'split-up'`. +- `closeTile(state, tileId)` — collapses empty groups; unwraps + single-child splits. +- `moveTile(state, tileId, target)` — drag-and-drop primitive. +- `setActive(state, { groupId, tileId? })`. +- `setTileView(state, tileId, viewId)` — view switching in place. +- `resizeSplit(state, splitId, sizes[])`. +- `splitTileWithView(state, tileId, viewId, direction)` — composition + helper used by the menu (split + setTileView in one). + +`src/hooks/useWorkspace.tsx` — `WorkspaceProvider` (wraps `useReducer`) +and `useWorkspace()` returning `{ workspace, dispatch, helpers }`. +Helpers wrap dispatch for ergonomics, e.g. +`helpers.openEntity(url, { in: 'active' | 'split-right' | … })`. + +> **Implementation note:** the reducer must be **synchronous and +> side-effect-free** so it's cheap to unit-test. Vitest suite in +> `src/lib/workspace/workspaceReducer.test.ts` covers the tricky bits +> (closing the last tile, splitting an only-tile group, normalising +> sizes after a delete, view-switching during a drag). + +### 3.3 View registry + +`src/lib/workspace/viewRegistry.tsx`: + +```ts +import type { ComponentType } from 'react' +import type { LucideIcon } from 'lucide-react' +import type { ElectricEntity } from '../ElectricAgentsProvider' + +export type ViewProps = { + baseUrl: string + entityUrl: string + entity: ElectricEntity + entityStopped: boolean + isSpawning: boolean + // The tile id is passed so views can scope local state (e.g. scroll + // position, selected row) per-tile rather than per-entity, matching + // VS Code editor behaviour where two splits of the same file scroll + // independently. + tileId: string +} + +export type ViewDefinition = { + id: ViewId + label: string // 'Chat', 'State Explorer' + icon: LucideIcon // shown in tab + menu + shortLabel?: string // shown in narrow tabs + description?: string // shown as menu hint + // Whether this view applies to a given entity. Lets us hide views + // that don't make sense (e.g. a Coding-session-only timeline view). + isAvailable?: (entity: ElectricEntity) => boolean + // Default split direction when the menu's "View ▸ X" leaf is clicked + // directly (not its sub-items). Preserves muscle-memory like + // "State Explorer pops out to the right". + defaultSplit?: 'right' | 'down' + Component: ComponentType +} + +const registry = new Map() + +export function registerView(def: ViewDefinition): void { + registry.set(def.id, def) +} +export function getView(id: ViewId): ViewDefinition | undefined { + return registry.get(id) +} +export function listViews(entity?: ElectricEntity): ViewDefinition[] { + const all = Array.from(registry.values()) + return entity ? all.filter((v) => v.isAvailable?.(entity) ?? true) : all +} +``` + +Registration happens once at app boot in +`src/lib/workspace/registerViews.ts`: + +```ts +import { MessageSquare, Database } from 'lucide-react' +import { registerView } from './viewRegistry' +import { ChatView } from '../../components/views/ChatView' +import { StateExplorerView } from '../../components/views/StateExplorerView' + +registerView({ + id: 'chat', + label: 'Chat', + icon: MessageSquare, + Component: ChatView, +}) + +registerView({ + id: 'state-explorer', + label: 'State Explorer', + icon: Database, + description: 'Inspect shared state and event log', + defaultSplit: 'right', + Component: StateExplorerView, +}) +``` + +`registerViews` is imported once from `main.tsx` so the side-effect +runs before the app mounts. + +#### Why splits and views stay orthogonal + +| Operation | Affects layout? | Affects view? | +| ------------------------------------- | ---------------------- | ---------------------------- | +| `Split Right` (`⌘D`) | yes — adds a new group | no — duplicates current view | +| `View ▸ State Explorer ▸ Open here` | no | yes — swaps in-place | +| `View ▸ State Explorer ▸ Split right` | yes | yes — both, in one step | +| Drag tab to another group | yes | no | +| Click a tab in the strip | no | yes (changes active tile) | + +Two clean primitives (`split`, `setTileView`) compose to express every +menu item. + +### 3.4 URL ↔ workspace sync (hybrid strategy) + +The app uses `createHashHistory` (`src/router.tsx`), so URLs look like +`https://app/#/entity/foo`. We adopt a **hybrid model**: + +1. **Default URL = active tile only.** Clean and human-readable; + matches a user's mental model of "I'm looking at X right now". +2. **Layout state lives in localStorage**, keyed by server (so two + different Electric servers each remember their own layout). +3. **`?layout=…` is an opt-in import param** for sharing — + pasting/visiting one hydrates the workspace; we then strip the + param so the URL settles back to "active tile only". + +Examples: + +``` +#/ → empty workspace +#/entity/horton/foo → single chat tile +#/entity/horton/foo?view=state-explorer → single State Explorer tile +#/entity/horton/foo → multi-tile workspace where + 'foo' is the active tile; + full layout from localStorage +#/entity/horton/foo?layout=H(…) → explicit layout import; we + hydrate then strip the param +``` + +#### Layout encoding mini-DSL + +Compact, human-debuggable, URL-safe (no encoding needed for +parens/commas/colons/dot/at): + +``` +node := group | hsplit | vsplit +hsplit := 'H' '(' sized (',' sized)+ ')' // horizontal = side-by-side +vsplit := 'V' '(' sized (',' sized)+ ')' // vertical = stacked +sized := node (':' int)? // size as percentage; default = even +group := tile (',' tile)* ('@' int)? // @int = active tile index, default 0 +tile := '.' viewId +``` + +| Layout | Encoded | +| ------------------------------------ | ------------------------------------------------------------- | +| Single tile | `horton%2Ffoo.chat` | +| Two tabs in one group, second active | `horton%2Ffoo.chat,horton%2Ffoo.state-explorer@1` | +| Chat 60% + State 40% side-by-side | `H(horton%2Ffoo.chat:60,horton%2Ffoo.state-explorer:40)` | +| Chat left, two stacked right | `H(horton%2Ffoo.chat,V(horton%2Fbar.chat,horton%2Fbaz.logs))` | + +Encoder/decoder lives in `src/lib/workspace/layoutCodec.ts` with a +Vitest suite covering round-trips. We deliberately avoid base64+JSON +because the DSL is ~2× shorter and visually parseable in the URL bar +when something goes wrong. + +#### URL → workspace (hydration order on load / external nav) + +1. If `?layout=…` is present → parse it, replace the workspace, + `navigate({ replace: true })` to strip the param. +2. Else if a serialized workspace exists in + `localStorage[electric-agents-ui.workspace..v1]` **and** + it contains a tile matching the URL's `entity`+`view` → restore it + and mark that tile active. +3. Else → fresh workspace with a single tile from the URL (current + behaviour). + +If the URL points at an entity that's missing from the restored +layout we **insert a new tile in the active group** rather than +discarding the layout — preserves "open this link" semantics while +respecting existing splits. + +#### Workspace → URL (sync after every state change) + +Critical rule: the **active tile drives the URL**, and we use +`push` vs `replace` carefully so back/forward maps to user intent +rather than splitter drags. + +| Change | History | +| ------------------------------------------------------------------------ | --------------------------------------------------------- | +| Active tile changes (click tab, click sidebar row, switch view in place) | `push` — back/forward navigates between tiles | +| Open new tile in active group | `push` — it became the new active tile | +| Split (active → new group) | `push` — focus follows split, becomes the new active tile | +| Resize splitter | none (no URL change) | +| Move/drag tile, close non-active tile | `replace` — no URL change, but localStorage update fires | +| Close active tile | `push` — the new active tile becomes the URL | +| Drag sidebar entity into a non-active group | `replace` — active tile didn't change | + +#### Persistence + +- Debounced 250 ms write of the whole `Workspace` to + `localStorage[electric-agents-ui.workspace..v1]`. +- Prune-on-load entities that no longer exist in `entitiesCollection`; + if pruning empties a group, collapse the group; if it empties the + workspace, reset to single-tile from the URL. +- A schema-version-stamped envelope (`{ v: 1, workspace: {…} }`) so + a future format change can either migrate or fall back to a fresh + workspace. + +#### "Copy layout link" affordance + +A menu item in the workspace `…` menu (or a workspace-level menu in +the sidebar header): + +```ts +function copyLayoutLink() { + const encoded = encodeLayout(workspace) // 'H(...)' + const url = new URL(window.location.href) + const [path, query = ''] = url.hash.replace(/^#/, '').split('?') + const params = new URLSearchParams(query) + params.set('layout', encoded) + url.hash = '#' + path + '?' + params.toString() + navigator.clipboard.writeText(url.toString()) +} +``` + +The URL is long but only when explicitly requested; the address bar +during normal use stays clean. + +### 3.5 Component tree + +``` + + ├── (existing; gets DnD source bindings) + └── (new — replaces EntityPage's body) + └── + └── if Split: with s + s + └── if Group: + ├── + ├── (header + … menu, wraps EntityHeader) + └── (resolves view from registry) + └── (drag-target zones, rendered into a portal) +``` + +`TileBody`: + +```tsx +const def = getView(tile.viewId) +if (!def) return +const View = def.Component +return ( + +) +``` + +#### Files added + +- `src/components/workspace/Workspace.tsx` +- `src/components/workspace/NodeRenderer.tsx` +- `src/components/workspace/SplitContainer.tsx` + `.module.css` +- `src/components/workspace/Splitter.tsx` + `.module.css` + (extract & generalise the existing `EntityPage` splitter) +- `src/components/workspace/GroupContainer.tsx` + `.module.css` +- `src/components/workspace/TabStrip.tsx` + `.module.css` +- `src/components/workspace/TileChrome.tsx` + `.module.css` +- `src/components/workspace/DropOverlay.tsx` + `.module.css` +- `src/components/workspace/SplitMenu.tsx` (the `…` menu) +- `src/components/views/ChatView.tsx` +- `src/components/views/StateExplorerView.tsx` +- `src/lib/workspace/types.ts` +- `src/lib/workspace/workspaceReducer.ts` (+ test) +- `src/lib/workspace/viewRegistry.tsx` +- `src/lib/workspace/registerViews.ts` +- `src/hooks/useWorkspace.tsx` + +#### Files changed + +- `router.tsx` — replace `EntityPage`'s ad-hoc body with ``; + add the URL-sync effect; remove `statePanelWidth` / bespoke splitter. +- `EntityHeader.tsx` — strip the State-Explorer toggle (it moves into + `SplitMenu` as `View ▸ State Explorer ▸ …`); the per-tile `…` menu + becomes the new actions cluster. +- `Sidebar.tsx` / `SidebarRow.tsx` / `SidebarTree.tsx` — make rows + draggable (HTML5 `draggable=true` + `dragstart` payload); allow + `Cmd/Ctrl+click` and `Middle-click` shortcuts to open in a new + split. + +### 3.6 Drag-and-drop strategy + +Use **native HTML5 DnD** (no `react-dnd`). The surface is small +(sidebar rows, tabs, group-body drop zones), and Cursor-style overlays +are pure CSS once we have the geometry. A thin abstraction: + +- `useDraggable({ payload: WorkspaceDragPayload })` — wires + `draggable`, `dragstart`/`dragend`, sets `dataTransfer` (JSON via + `application/x-electric-tile`). +- `useDropTarget(groupId)` — on `dragover`, computes which of the 5 + zones the cursor is in (centre / N / E / S / W using a 25% inset), + shows the overlay, and on `drop` dispatches `moveTile` or `openTile`. + +```ts +type WorkspaceDragPayload = + | { kind: 'sidebar-entity'; entityUrl: string } + | { kind: 'tile'; tileId: string; sourceGroupId: string } +``` + +`` is one element per group, absolutely positioned, with +five segments that highlight on hover — same visual language as +Cursor's chat-pane drop zones. + +### 3.7 The `…` menu (matches the screenshots) + +Component: `SplitMenu.tsx` using existing Base UI `Menu`. + +``` +View ► [for each view in listViews(entity):] + Chat ⌘1 + State Explorer ⌘2 + … + ───── + Switch view in place ► Chat + State Explorer + … +───── +Split Right ⌘D (duplicates current tile) +Split Down ⇧⌘D +Split Left +Split Up +───── +Move tile to ► New group right / below / Group N +───── +Copy URL · Pin +───── +Close tile ⌘W +Close group ⇧⌘W +``` + +Each `View ▸ {leaf}` is itself a sub-menu, answering the brief's +"second level option to specify open in a split to the side or under": + +``` +View ▸ State Explorer ▸ Open here (replaces current tile) + Split right (matches today's drawer) + Split down + Split left + Split up +``` + +Each leaf calls one of `setTileView(viewId)` or +`splitTileWithView(viewId, direction)`. Clicking the parent `View ▸ +{viewId}` row directly uses `defaultSplit` from the registry (so +clicking `View ▸ State Explorer` matches today's drawer behaviour +without forcing the user into the sub-menu). + +If Base UI's three-level submenu rendering proves janky in practice +we'll flatten the second level (e.g. `View ▸ State Explorer here` / +`View ▸ State Explorer (split right)` / …) — the registry-driven +generation makes this a one-line change. + +### 3.8 State Explorer migration + +Today the State Explorer is a right-drawer toggled from `EntityHeader` +(`router.tsx` 211-250). After the refactor the `Database` icon and +toggle disappear from `EntityHeader`; instead: + +- "Open State Explorer" from the `…` menu calls + `splitTileWithView('state-explorer', 'right')` — its default split + direction matches today's drawer because we set + `defaultSplit: 'right'` in its registration. +- Existing `` is rendered unchanged inside + `` (a thin `ViewProps` wrapper) inside `TileBody`; + we just remove the splitter / width state from the route component. + +### 3.9 Persistence (light) + +Save `Workspace` to `localStorage` +(`electric-agents-ui.workspace.v1`) on every change behind a 250 ms +debounce. Skip restoring tiles whose `entityUrl` no longer exists in +the live `entitiesCollection` (silent prune). Defer per-server-keyed +persistence to a follow-up. + +### 3.10 Adding a future view ("Logs", say) + +```tsx +// src/components/views/LogsView.tsx +export function LogsView({ entityUrl, baseUrl }: ViewProps) { … } + +// src/lib/workspace/registerViews.ts +import { ScrollText } from 'lucide-react' +import { LogsView } from '../../components/views/LogsView' + +registerView({ + id: 'logs', + label: 'Logs', + icon: ScrollText, + description: 'Process and child-process logs', + defaultSplit: 'down', + Component: LogsView, +}) +``` + +That's the entire diff. The view shows up in the `View ▸` submenu, in +the tab-strip's `+` "new view" picker, gets a deep-link +(`?view=logs`), and is draggable between groups — all for free. + +--- + +## 4. Migration in stages + +Ship in **5 small PRs** rather than one big bang, so each stage is +reviewable and we can stop at any of them with a working app. + +| # | Stage | What ships | What deletes | +| --- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 1 | **View registry + extract `ChatView` / `StateExplorerView`** | `viewRegistry.tsx`, `ChatView.tsx`, `StateExplorerView.tsx`, registration. `EntityPage` still renders one view at a time, but goes through the registry. `?view=…` query-param deep-linking. State Explorer toggle in `EntityHeader` becomes "switch to State Explorer view" / "back to Chat" — already a ship-able UX improvement on its own. | – | +| 2 | **Workspace skeleton + reducer** | `lib/workspace/*` types + reducer + tests, `WorkspaceProvider`, ``. Single-tile by default; visually identical to stage 1. Active-tile URL sync (one-way: workspace → URL, with `push` vs `replace` rules from §3.4). Generic ``. | Bespoke `statePanelWidth` splitter in `router.tsx`. | +| 3 | **`SplitMenu` (Split Right/Down + hotkeys) and the `View ▸` submenu** | Power users can split, swap views in place, or split-with-view in one step. State Explorer regains its "drawer to the right" UX as the _default_ action of `View ▸ State Explorer` thanks to `defaultSplit: 'right'`. | Old `EntityHeader` State-Explorer button (already moved in stage 1; this fully removes the drawer mode). | +| 4 | **Drag-and-drop** | Sidebar rows + tabs draggable; 5-zone drop overlay; close tile/group; tab strip with click-to-activate + middle-click-to-close. | – | +| 5 | **Persistence + polish (incl. shareable layouts)** | localStorage workspace persistence (debounced, server-keyed, schema-versioned), layout DSL encoder/decoder + tests, `?layout=…` import + auto-strip, "Copy layout link" menu item, `⌘1..9` group focus, `⌘W` / `⇧⌘W`, prune-on-load, mobile fallback. | – | + +Each PR keeps the build green and ships value on its own. + +--- + +## 5. Risks & open questions + +1. **Performance with N tiles** — each tile mounts its own + `useEntityTimeline`, which subscribes to a Durable Stream. With 4 + tiles on the same entity that's 4 subscriptions. _Mitigation:_ hoist + the timeline subscription into a shared cache keyed by `entityUrl` + (similar pattern to the existing `electricAgents` provider) so + opening a second view of the same entity is free. Land this in + stage 2 or 3. +2. **Deep-linking semantics** — _resolved by §3.4 hybrid strategy._ + External URL changes that target an entity already present in the + workspace just refocus that tile; if the entity isn't present we + add a tile to the active group rather than wiping the layout. + `?layout=…` is the explicit "replace my layout with this" affordance. +3. **Nested submenu reliability** — Base UI `Menu` supports nested + triggers, but the third level (`View ▸ State Explorer ▸ Split +right`) is unusual. If it feels janky on first build we'll flatten + to two levels (`View ▸ State Explorer here / State Explorer split +right / State Explorer split down`). The registry-driven generation + makes this a one-line change. +4. **Mobile / narrow viewports** — splits don't make sense below + ~700 px. We'll degrade to "single active tile, tabs become a select + dropdown" rather than try to make splits work on mobile. +5. **View IDs for sub-types** — should `chat` actually be + `chat-coding-session` vs `chat-generic` (matching the current + `CODING_SESSION_ENTITY_TYPE` branch in `router.tsx`), or keep one + `chat` view that internally branches? **Decision:** one `chat` view, + internally polymorphic — keeps the user-facing menu simple. +6. **Default opened view** — when a sidebar row is clicked, do we + always open `chat`, or remember the last view used for that entity? + VS Code remembers per-file. **Decision:** start with always-open-chat + for simplicity; revisit once telemetry is in. +7. **Preview-tab semantics** — copy VS Code's "single click = preview + tab that gets replaced; double-click = pin"? **Decision:** v1 + always replaces the active tile; preview-tab is a follow-up. +8. **Workspace persistence scope** — global, or per-server (sidebar + already has `useServerConnection`)? **Decision:** start global; + per-server is a follow-up. + +--- + +## 6. Acceptance checklist + +- [ ] Open two different agents side-by-side, each scrolling + independently. +- [ ] Open the same agent's chat + state explorer side-by-side and + they stay in sync. +- [ ] Drag a sidebar entity into the right edge of an existing group + → new vertical split. +- [ ] Drag a tab from group A to group B → tile moves; group A is + removed if empty. +- [ ] `⌘D` on focused tile splits it right; `⇧⌘D` splits down. +- [ ] `View ▸ State Explorer` (parent click) splits right; sub-items + open in place / split in chosen direction. +- [ ] Switch view in place leaves the layout untouched (only the + tile's `viewId` changes). +- [ ] Close the last tile → empty workspace shows `NewSessionPage`. +- [ ] Reload the page → previous layout is restored (skipping deleted + entities). +- [ ] "Copy layout link" produces a `?layout=…` URL that, when opened + in another browser/incognito, hydrates the same workspace and + then strips the param so the address bar settles to the active + tile. +- [ ] Resizing splitters or rearranging tiles does **not** create + browser history entries; switching active tile **does**. +- [ ] Hotkeys `⌘1..9` focus group N; `⌘W` closes the active tile. +- [ ] No regressions in existing keyboard shortcuts (`⌘B` sidebar, + `⌘K` palette, `⌘N` new session). +- [ ] Adding a new view requires only a `registerView({…})` call and + a new `*View.tsx` file — no edits to `Workspace`, `SplitMenu`, + or routing. From 241bd3de26d0f803866025702a50bf8fadbddde7 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 4 May 2026 20:53:41 +0100 Subject: [PATCH 02/57] feat(agents-server-ui): introduce view registry, extract Chat & StateExplorer views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the tile-based layout refactor (see TILE_LAYOUT_PLAN.md). Views are now first-class, registered into a tiny in-memory registry at app boot: - 'chat' → ChatView (polymorphic on entity type; embeds CodingSessionView when applicable, else generic timeline) - 'state-explorer' → StateExplorerView (thin wrapper over the existing StateExplorerPanel) Adding a new view is a single registerView({…}) call plus a *View.tsx file — no changes to routing or chrome. EntityHeader's bespoke 'Show state explorer' toggle is replaced by a generic, registry-driven view-switcher: an inline icon strip plus matching menu items, generated automatically from listViews(entity). The /entity/$splat route gains a ?view= query param so non-default views are deep-linkable. The default view (chat) is implicit and never shown in the URL. Stage 1 still renders one view at a time — splits arrive in stage 3. The bespoke right-drawer / statePanelWidth splitter in router.tsx is removed; the State Explorer temporarily opens in-place via view-swap until the workspace skeleton (stage 2) and SplitMenu (stage 3) bring proper splits back. Typecheck + tests green. Co-authored-by: Cursor --- .../src/components/EntityHeader.tsx | 81 +++++---- .../src/components/views/ChatView.tsx | 105 +++++++++++ .../components/views/StateExplorerView.tsx | 14 ++ .../src/lib/workspace/registerViews.ts | 34 ++++ .../src/lib/workspace/viewRegistry.tsx | 83 +++++++++ packages/agents-server-ui/src/main.tsx | 4 + .../agents-server-ui/src/router.module.css | 16 -- packages/agents-server-ui/src/router.tsx | 171 +++++++----------- 8 files changed, 350 insertions(+), 158 deletions(-) create mode 100644 packages/agents-server-ui/src/components/views/ChatView.tsx create mode 100644 packages/agents-server-ui/src/components/views/StateExplorerView.tsx create mode 100644 packages/agents-server-ui/src/lib/workspace/registerViews.ts create mode 100644 packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx diff --git a/packages/agents-server-ui/src/components/EntityHeader.tsx b/packages/agents-server-ui/src/components/EntityHeader.tsx index 7c1053f23e..ebc1512ac4 100644 --- a/packages/agents-server-ui/src/components/EntityHeader.tsx +++ b/packages/agents-server-ui/src/components/EntityHeader.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react' import { Check, Copy, - Database, Eye, GitFork, MoreHorizontal, @@ -23,6 +22,7 @@ import { } from '../ui' import type { BadgeTone } from '../ui' import { MainHeader } from './MainHeader' +import { listViews, type ViewId } from '../lib/workspace/viewRegistry' import styles from './EntityHeader.module.css' import type { ElectricEntity } from '../lib/ElectricAgentsProvider' @@ -43,8 +43,10 @@ type EntityHeaderProps = { killError?: string | null forkError?: string | null forking?: boolean - stateExplorerOpen?: boolean - onToggleStateExplorer?: () => void + /** ID of the currently-rendered view for this entity. */ + currentViewId?: ViewId + /** Switch the rendered view in-place (no layout change). */ + onSetView?: (viewId: ViewId) => void } /** @@ -137,12 +139,21 @@ function EntityActions({ onFork, onKill, forking, - stateExplorerOpen, - onToggleStateExplorer, + currentViewId, + onSetView, }: EntityHeaderProps): React.ReactElement { const [showInspect, setShowInspect] = useState(false) const [showKillConfirm, setShowKillConfirm] = useState(false) const { title: instanceName } = getEntityDisplayTitle(entity) + // The view registry is the source of truth for which view buttons / + // menu items appear. `defaultViewId` is the first registered view + // (`chat`) and is treated as implicit when no current view is set. + const availableViews = onSetView ? listViews(entity) : [] + const defaultViewId = availableViews[0]?.id + const activeViewId = currentViewId ?? defaultViewId + // Only show the inline view-switcher buttons when there's more than + // one view available — otherwise the strip is just visual noise. + const showViewStrip = onSetView && availableViews.length > 1 return ( @@ -154,26 +165,26 @@ function EntityActions({ {entity.status} - {onToggleStateExplorer && ( - - - - - - )} + {showViewStrip && + availableViews.map((view) => { + const Icon = view.icon + const active = view.id === activeViewId + return ( + + onSetView!(view.id)} + aria-label={view.label} + aria-pressed={active} + className={active ? styles.activeBg : undefined} + > + + + + ) + })} Inspect - {onToggleStateExplorer && ( - - - - {stateExplorerOpen ? `Hide state explorer` : `State explorer`} - - - )} + {onSetView && + availableViews + .filter((v) => v.id !== activeViewId) + .map((view) => { + const Icon = view.icon + return ( + onSetView(view.id)}> + + {view.label} + + ) + })} { void navigator.clipboard.writeText(entity.url) diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx new file mode 100644 index 0000000000..15500b9963 --- /dev/null +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { CODING_SESSION_ENTITY_TYPE } from '@electric-ax/agents-runtime' +import { useEntityTimeline } from '../../hooks/useEntityTimeline' +import { EntityTimeline } from '../EntityTimeline' +import { MessageInput } from '../MessageInput' +import { EntityContextDrawer } from '../EntityContextDrawer' +import { CodingSessionView } from '../CodingSessionView' +import type { ViewProps } from '../../lib/workspace/viewRegistry' + +/** + * The default view: chat / timeline + message composer. + * + * Internally polymorphic on `entity.type`: + * - `CODING_SESSION_ENTITY_TYPE` → `` (specialised + * timeline that pulls events from the coding-session stream). + * - Anything else → generic timeline driven by `useEntityTimeline`. + * + * Both branches share the same `MessageInput` composer at the bottom. + * + * The polymorphism is hidden inside this view so the rest of the + * workspace (registry, menu, tab strip) doesn't need to care about + * entity sub-types — there's just one user-facing "Chat" view. + */ +export function ChatView({ + baseUrl, + entityUrl, + entity, + entityStopped, + isSpawning, +}: ViewProps): React.ReactElement { + // While `spawning`, the entity has no inbox yet — `connectUrl` is null + // so `useEntityTimeline` doesn't try to subscribe and we render an empty + // timeline / disabled composer. + const connectUrl = isSpawning ? null : entityUrl + + if (entity.type === CODING_SESSION_ENTITY_TYPE && connectUrl) { + return ( + + ) + } + + return ( + + ) +} + +function GenericChatBody({ + baseUrl, + entityUrl, + entity, + entityStopped, + isSpawning, +}: { + baseUrl: string + entityUrl: string | null + entity: ViewProps[`entity`] + entityStopped: boolean + isSpawning: boolean +}): React.ReactElement { + const { entries, db, loading, error } = useEntityTimeline( + baseUrl || null, + entityUrl + ) + const navigate = useNavigate() + + // If the timeline subscription errors out for an entity that isn't + // currently spawning (so the failure isn't transient), bounce back to + // the new-session screen — same behaviour as the previous in-route + // implementation. + useEffect(() => { + if (error && !isSpawning) { + void navigate({ to: `/` }) + } + }, [error, navigate, isSpawning]) + + return ( + <> + + } + /> + + ) +} diff --git a/packages/agents-server-ui/src/components/views/StateExplorerView.tsx b/packages/agents-server-ui/src/components/views/StateExplorerView.tsx new file mode 100644 index 0000000000..3c3bd4e4f3 --- /dev/null +++ b/packages/agents-server-ui/src/components/views/StateExplorerView.tsx @@ -0,0 +1,14 @@ +import { StateExplorerPanel } from '../stateExplorer/StateExplorerPanel' +import type { ViewProps } from '../../lib/workspace/viewRegistry' + +/** + * Thin `ViewProps` adapter around `` so it can be + * registered in the view registry without leaking the registry's prop + * shape into the panel itself. + */ +export function StateExplorerView({ + baseUrl, + entityUrl, +}: ViewProps): React.ReactElement { + return +} diff --git a/packages/agents-server-ui/src/lib/workspace/registerViews.ts b/packages/agents-server-ui/src/lib/workspace/registerViews.ts new file mode 100644 index 0000000000..232d57626f --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/registerViews.ts @@ -0,0 +1,34 @@ +import { Database, MessageSquare } from 'lucide-react' +import { registerView } from './viewRegistry' +import { ChatView } from '../../components/views/ChatView' +import { StateExplorerView } from '../../components/views/StateExplorerView' + +/** + * Register all built-in views. Imported once from `main.tsx` so the + * side-effect runs before the app mounts. + * + * Order matters: it controls the order of items in the `View ▸` submenu + * and the default tab when an entity is opened (first registered view + * is the default). + */ +registerView({ + id: `chat`, + label: `Chat`, + icon: MessageSquare, + description: `Conversation timeline and message composer`, + Component: ChatView, +}) + +registerView({ + id: `state-explorer`, + label: `State Explorer`, + icon: Database, + description: `Inspect shared state and the event log`, + // Match today's UX: clicking the parent menu row pops it out to the + // right, just like the old drawer. + defaultSplit: `right`, + Component: StateExplorerView, +}) + +/** No-op export so the file is treated as a module by `import './registerViews'`. */ +export const VIEWS_REGISTERED = true diff --git a/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx b/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx new file mode 100644 index 0000000000..67c674f717 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx @@ -0,0 +1,83 @@ +import type { ComponentType } from 'react' +import type { LucideIcon } from 'lucide-react' +import type { ElectricEntity } from '../ElectricAgentsProvider' + +/** + * `ViewId` is a free-form string rather than a string-literal union so the + * registry stays the single source of truth — adding a new view is a + * `registerView({ id: 'logs', … })` call, not a type edit. Type-safety is + * enforced at the registration site instead, and `getView(id)` returns + * `undefined` for unknown ids so callers must explicitly handle the missing + * case. + */ +export type ViewId = string + +/** + * Props every view receives. The `tileId` is included so views can scope + * local state (scroll position, selected row, etc.) per-tile rather than + * per-entity, matching VS Code's behaviour where two splits of the same file + * scroll independently. + */ +export type ViewProps = { + baseUrl: string + entityUrl: string + entity: ElectricEntity + entityStopped: boolean + isSpawning: boolean + tileId: string +} + +export type ViewDefinition = { + id: ViewId + /** Human label shown in the View ▸ submenu and on tabs. */ + label: string + /** Tab/menu icon. */ + icon: LucideIcon + /** Optional shorter label for narrow tabs (defaults to `label`). */ + shortLabel?: string + /** Optional helper text rendered as a hint in the View ▸ menu. */ + description?: string + /** + * Per-entity availability gate. Used to hide views that don't apply to + * this entity type (e.g. a Coding-session-only timeline view). When + * omitted the view is considered available for every entity. + */ + isAvailable?: (entity: ElectricEntity) => boolean + /** + * Default split direction when the user clicks the parent `View ▸ X` + * menu row directly (rather than picking a sub-action). `'right'` matches + * the muscle-memory of "drawer pops out to the right" for the State + * Explorer; `undefined` falls back to `'open here'`. + */ + defaultSplit?: `right` | `down` + Component: ComponentType +} + +const registry = new Map() + +export function registerView(def: ViewDefinition): void { + registry.set(def.id, def) +} + +export function getView(id: ViewId): ViewDefinition | undefined { + return registry.get(id) +} + +/** + * List every registered view, optionally filtered by per-entity availability. + * Stable order = insertion order, matching `Map`'s iteration semantics, so + * registration order in `registerViews.ts` controls the menu ordering. + */ +export function listViews(entity?: ElectricEntity): Array { + const all = Array.from(registry.values()) + return entity ? all.filter((v) => v.isAvailable?.(entity) ?? true) : all +} + +/** + * Test-only escape hatch — clears the registry between Vitest suites so + * registrations from one test don't bleed into the next. Production code + * should never call this. + */ +export function __resetViewRegistryForTesting(): void { + registry.clear() +} diff --git a/packages/agents-server-ui/src/main.tsx b/packages/agents-server-ui/src/main.tsx index f6d4f7a390..dc494a5c75 100644 --- a/packages/agents-server-ui/src/main.tsx +++ b/packages/agents-server-ui/src/main.tsx @@ -15,6 +15,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './ui' import './markdown.css' +// Side-effect import: registers all built-in views (chat, state explorer, +// …) into the view registry before the app mounts. Adding a new view is +// a single new `registerView({…})` call inside this module. +import './lib/workspace/registerViews' import { App } from './App' // ngrok's free tier intercepts browser requests with an HTML warning page diff --git a/packages/agents-server-ui/src/router.module.css b/packages/agents-server-ui/src/router.module.css index 8d1ef695b3..b2de2d6ef1 100644 --- a/packages/agents-server-ui/src/router.module.css +++ b/packages/agents-server-ui/src/router.module.css @@ -44,22 +44,6 @@ } } -.statePanel { - min-width: 0; - overflow: hidden; -} - -.splitter { - width: 4px; - cursor: col-resize; - flex-shrink: 0; - background: var(--ds-gray-a5); -} - -.splitter:hover { - background: var(--ds-accent-a6); -} - .placeholder { flex: 1; } diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index db3f1f5692..bc6cc5a314 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useState } from 'react' import { Outlet, createHashHistory, @@ -7,15 +7,14 @@ import { createRouter, useNavigate, useParams, + useSearch, } from '@tanstack/react-router' import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' -import { CODING_SESSION_ENTITY_TYPE } from '@electric-ax/agents-runtime' +import { z } from 'zod' import { useServerConnection } from './hooks/useServerConnection' import { usePinnedEntities } from './hooks/usePinnedEntities' import { useElectricAgents } from './lib/ElectricAgentsProvider' -import type { ElectricEntity } from './lib/ElectricAgentsProvider' -import { useEntityTimeline } from './hooks/useEntityTimeline' import { SidebarCollapsedProvider, useSidebarCollapsed, @@ -28,12 +27,8 @@ import { import { Sidebar } from './components/Sidebar' import { SearchPalette } from './components/SearchPalette' import { EntityHeader } from './components/EntityHeader' -import { EntityTimeline } from './components/EntityTimeline' -import { EntityContextDrawer } from './components/EntityContextDrawer' -import { MessageInput } from './components/MessageInput' -import { StateExplorerPanel } from './components/stateExplorer/StateExplorerPanel' -import { CodingSessionView } from './components/CodingSessionView' import { NewSessionPage } from './components/NewSessionPage' +import { getView, listViews, type ViewId } from './lib/workspace/viewRegistry' import { Stack } from './ui' import styles from './router.module.css' @@ -104,6 +99,17 @@ function RootShell(): React.ReactElement { ) } +/** + * Search-param schema for the entity route. `view` is optional and + * defaults to the first registered view (`chat`) when absent — that way + * the URL stays clean (`/entity/foo`) for the common case and only + * surfaces the param when the user is on a non-default view + * (`/entity/foo?view=state-explorer`). + */ +const entitySearchSchema = z.object({ + view: z.string().optional(), +}) + function EntityPage(): React.ReactElement { const { _splat } = useParams({ from: `/entity/$` }) const entityUrl = `/${_splat}` @@ -111,6 +117,7 @@ function EntityPage(): React.ReactElement { const { pinnedUrls, togglePin } = usePinnedEntities() const { entitiesCollection, forkEntity, killEntity } = useElectricAgents() const navigate = useNavigate() + const search = useSearch({ from: `/entity/$` }) const { data: matchingEntities = [] } = useLiveQuery( (query) => { @@ -125,9 +132,31 @@ function EntityPage(): React.ReactElement { const isSpawning = selectedEntity?.status === `spawning` const entityStopped = selectedEntity?.status === `stopped` - const [stateExplorerOpen, setStateExplorerOpen] = useState(false) - const [statePanelWidth, setStatePanelWidth] = useState(0.5) - const containerRef = useRef(null) + // Resolve the active view from the URL. The first registered view + // (`chat`) is the implicit default when the param is absent or + // points at an unknown id — we never fail closed. + const requestedViewId = search.view as ViewId | undefined + const availableViews = selectedEntity ? listViews(selectedEntity) : [] + const defaultViewId = availableViews[0]?.id ?? `chat` + const activeViewId: ViewId = + requestedViewId && availableViews.some((v) => v.id === requestedViewId) + ? requestedViewId + : defaultViewId + + const setActiveView = useCallback( + (viewId: ViewId) => { + // Omit the param from the URL when it matches the default view — + // keeps `/entity/foo` clean for the chat case rather than always + // showing `?view=chat`. + void navigate({ + to: `/entity/$`, + params: { _splat }, + search: viewId === defaultViewId ? {} : { view: viewId }, + }) + }, + [navigate, _splat, defaultViewId] + ) + const [killError, setKillError] = useState(null) const [forkError, setForkError] = useState(null) const [forking, setForking] = useState(false) @@ -174,7 +203,7 @@ function EntityPage(): React.ReactElement { } const baseUrl = activeServer?.url ?? `` - const connectUrl = isSpawning ? null : entityUrl + const ViewComponent = getView(activeViewId)?.Component return ( @@ -187,117 +216,40 @@ function EntityPage(): React.ReactElement { onFork={forkEntity && !selectedEntity.parent ? handleFork : undefined} forkError={forkError} forking={forking} - stateExplorerOpen={stateExplorerOpen} - onToggleStateExplorer={() => setStateExplorerOpen((prev) => !prev)} + currentViewId={activeViewId} + onSetView={setActiveView} /> - + - {selectedEntity.type === CODING_SESSION_ENTITY_TYPE && connectUrl ? ( - - ) : ( - - )} - - {stateExplorerOpen && ( - <> -
{ - e.preventDefault() - const container = containerRef.current - if (!container) return - const startX = e.clientX - const startWidth = statePanelWidth - const rect = container.getBoundingClientRect() - const onMouseMove = (ev: MouseEvent) => { - const dx = startX - ev.clientX - const newWidth = Math.min( - 0.7, - Math.max(0.2, startWidth + dx / rect.width) - ) - setStatePanelWidth(newWidth) - } - const onMouseUp = () => { - document.removeEventListener(`mousemove`, onMouseMove) - document.removeEventListener(`mouseup`, onMouseUp) - document.body.style.cursor = `` - document.body.style.userSelect = `` - } - document.body.style.cursor = `col-resize` - document.body.style.userSelect = `none` - document.addEventListener(`mousemove`, onMouseMove) - document.addEventListener(`mouseup`, onMouseUp) - }} - /> + ) : ( - + Unknown view: {activeViewId} - - )} + )} + ) } -function GenericEntityBody({ - baseUrl, - entityUrl, - entity, - entityStopped, - isSpawning, -}: { - baseUrl: string - entityUrl: string | null - entity: ElectricEntity - entityStopped: boolean - isSpawning: boolean -}): React.ReactElement { - const { entries, db, loading, error } = useEntityTimeline( - baseUrl || null, - entityUrl - ) - const navigate = useNavigate() - - useEffect(() => { - if (error && !isSpawning) { - navigate({ to: `/` }) - } - }, [error, navigate, isSpawning]) - - return ( - <> - - } - /> - - ) -} - const rootRoute = createRootRoute({ component: RootLayout }) const indexRoute = createRoute({ @@ -310,6 +262,7 @@ const entityRoute = createRoute({ getParentRoute: () => rootRoute, path: `/entity/$`, component: EntityPage, + validateSearch: entitySearchSchema, }) const routeTree = rootRoute.addChildren([indexRoute, entityRoute]) From 4a648e365c759c20a3f8625243ea03c08144e772 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 4 May 2026 21:01:23 +0100 Subject: [PATCH 03/57] feat(agents-server-ui): workspace tree, reducer, and tile renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the tile-based layout refactor (see TILE_LAYOUT_PLAN.md). Introduces the recursive workspace data model and renders entities through it, replacing the bespoke route handler that previously rendered a single entity directly. Data model (src/lib/workspace/types.ts): - Workspace { root: WorkspaceNode | null, activeGroupId } - WorkspaceNode = Split | Group - Split { direction, children: { node, size }[] } - Group { tiles: Tile[], activeTileId } - Tile { entityUrl, viewId } Reducer (workspaceReducer.ts) is pure and side-effect-free, with invariants enforced on every mutation: - splits with ≤1 child collapse / unwrap - nested same-direction splits flatten - empty groups are removed; sibling sizes re-normalised - activeGroupId always references a group present in the tree Covered by 15 Vitest cases for the tricky paths (open / close last tile / move-tile / split-with-view / resize / active bookkeeping). Components (src/components/workspace/): - Workspace — top-level renderer + URL ↔ workspace sync - NodeRenderer — pure dispatch from node kind to container - SplitContainer — N panes + N-1 splitters, fractional sizing - Splitter — drag-to-resize with px → fraction conversion - GroupContainer — tab strip + active tile body, with focus-follows-click group activation - TabStrip — tabs (hidden when group has only one tile), middle-click closes useWorkspace (src/hooks/useWorkspace.tsx): - WorkspaceProvider — wraps useReducer - useWorkspace — exposes { workspace, dispatch, helpers } - helpers wraps dispatch for ergonomics + computes activeTile Router (src/router.tsx): - WorkspaceProvider mounted under SearchPaletteProvider - /entity/$splat ?view= route delegates entirely to ; the route component is now a one-liner returning URL behaviour preserved from Stage 1 (single-tile workspaces look identical to before): URL → workspace effect refocuses the matching tile, swaps view in place when same-entity-different-view, or opens a new tile in the active group otherwise. Workspace → URL effect mirrors the active tile back to the URL with replace:true to avoid double-pushing history entries. Stage 2 ships with single-tile workspaces by default — splits become user-driven in Stage 3 via the SplitMenu. Typecheck + tests green (21 passing). Co-authored-by: Cursor --- .../workspace/GroupContainer.module.css | 30 + .../components/workspace/GroupContainer.tsx | 159 ++++++ .../src/components/workspace/NodeRenderer.tsx | 20 + .../workspace/SplitContainer.module.css | 22 + .../components/workspace/SplitContainer.tsx | 83 +++ .../components/workspace/Splitter.module.css | 21 + .../src/components/workspace/Splitter.tsx | 74 +++ .../components/workspace/TabStrip.module.css | 82 +++ .../src/components/workspace/TabStrip.tsx | 106 ++++ .../components/workspace/Workspace.module.css | 15 + .../src/components/workspace/Workspace.tsx | 156 +++++ .../src/hooks/useWorkspace.tsx | 214 +++++++ .../src/lib/workspace/types.ts | 97 ++++ .../lib/workspace/workspaceReducer.test.ts | 343 +++++++++++ .../src/lib/workspace/workspaceReducer.ts | 540 ++++++++++++++++++ packages/agents-server-ui/src/router.tsx | 163 +----- 16 files changed, 1978 insertions(+), 147 deletions(-) create mode 100644 packages/agents-server-ui/src/components/workspace/GroupContainer.module.css create mode 100644 packages/agents-server-ui/src/components/workspace/GroupContainer.tsx create mode 100644 packages/agents-server-ui/src/components/workspace/NodeRenderer.tsx create mode 100644 packages/agents-server-ui/src/components/workspace/SplitContainer.module.css create mode 100644 packages/agents-server-ui/src/components/workspace/SplitContainer.tsx create mode 100644 packages/agents-server-ui/src/components/workspace/Splitter.module.css create mode 100644 packages/agents-server-ui/src/components/workspace/Splitter.tsx create mode 100644 packages/agents-server-ui/src/components/workspace/TabStrip.module.css create mode 100644 packages/agents-server-ui/src/components/workspace/TabStrip.tsx create mode 100644 packages/agents-server-ui/src/components/workspace/Workspace.module.css create mode 100644 packages/agents-server-ui/src/components/workspace/Workspace.tsx create mode 100644 packages/agents-server-ui/src/hooks/useWorkspace.tsx create mode 100644 packages/agents-server-ui/src/lib/workspace/types.ts create mode 100644 packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts create mode 100644 packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts diff --git a/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css b/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css new file mode 100644 index 0000000000..224ab72c10 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css @@ -0,0 +1,30 @@ +.group { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--ds-bg); +} + +.body { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + + /* Shared chat-column geometry — kept in sync with router.module.css's + * .entityMain so chat tiles render the same regardless of which + * group they're in. */ + --chat-col-width: 68ch; + --chat-surface-width: calc(var(--chat-col-width) + 24px); +} + +@media (min-width: 1100px) { + .body { + --chat-col-width: 80ch; + } +} diff --git a/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx b/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx new file mode 100644 index 0000000000..900640a0f9 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx @@ -0,0 +1,159 @@ +import { useCallback, useEffect } from 'react' +import { useLiveQuery } from '@tanstack/react-db' +import { eq } from '@tanstack/db' +import { useNavigate } from '@tanstack/react-router' +import { useElectricAgents } from '../../lib/ElectricAgentsProvider' +import { useServerConnection } from '../../hooks/useServerConnection' +import { usePinnedEntities } from '../../hooks/usePinnedEntities' +import { useWorkspace } from '../../hooks/useWorkspace' +import { getView } from '../../lib/workspace/viewRegistry' +import { EntityHeader } from '../EntityHeader' +import { Stack } from '../../ui' +import { TabStrip } from './TabStrip' +import type { Group, Tile } from '../../lib/workspace/types' +import type { ViewId } from '../../lib/workspace/viewRegistry' +import styles from './GroupContainer.module.css' + +/** + * Renders one Group: tab strip + the active tile's header + body. + * + * Loads the entity for the active tile via `useLiveQuery` so the header + * is always in sync with the live entity data. The body delegates to + * the registered view component. + */ +export function GroupContainer({ + group, +}: { + group: Group +}): React.ReactElement { + const { helpers, workspace } = useWorkspace() + const isActiveGroup = workspace.activeGroupId === group.id + + // Click anywhere inside the group's chrome to make it the active + // group. Wired on the outer wrapper rather than just the tab strip so + // a click on the body counts too — matches VS Code's "focus follows + // last click" group-activation behaviour. + const onActivate = useCallback(() => { + if (!isActiveGroup) helpers.setActiveGroup(group.id) + }, [helpers, group.id, isActiveGroup]) + + const activeTile = + group.tiles.find((t) => t.id === group.activeTileId) ?? group.tiles[0] + + return ( +
+ + {activeTile ? ( + + ) : null} +
+ ) +} + +function ActiveTileBody({ + groupId, + tile, +}: { + groupId: string + tile: Tile +}): React.ReactElement { + const { activeServer } = useServerConnection() + const { pinnedUrls, togglePin } = usePinnedEntities() + const { entitiesCollection, forkEntity, killEntity } = useElectricAgents() + const { helpers } = useWorkspace() + const navigate = useNavigate() + + const { data: matches = [] } = useLiveQuery( + (q) => { + if (!entitiesCollection) return undefined + return q + .from({ e: entitiesCollection }) + .where(({ e }) => eq(e.url, tile.entityUrl)) + }, + [entitiesCollection, tile.entityUrl] + ) + const entity = matches.at(0) ?? null + const isSpawning = entity?.status === `spawning` + const entityStopped = entity?.status === `stopped` + + // Kill / fork are tile-local actions; the errors shown are the + // tile's own concern, so they live as state inside the tile body + // rather than at workspace level. + const handleKill = useCallback(() => { + if (!killEntity) return + const tx = killEntity(tile.entityUrl) + tx.isPersisted.promise.catch(() => { + // Errors surface in EntityHeader's error bar via prop wiring, + // but Stage 2 omits the error-passing for brevity since the kill + // surface is unchanged from before; revisit if it regresses. + }) + }, [killEntity, tile.entityUrl]) + + const handleFork = useCallback(() => { + if (!forkEntity) return + forkEntity(tile.entityUrl) + .then((root) => { + navigate({ + to: `/entity/$`, + params: { _splat: root.url.replace(/^\//, ``) }, + }) + }) + .catch(() => {}) + }, [forkEntity, tile.entityUrl, navigate]) + + const setView = useCallback( + (viewId: ViewId) => helpers.setTileView(tile.id, viewId), + [helpers, tile.id] + ) + + // If the entity disappears entirely (e.g. user killed it elsewhere), + // close this tile so the workspace doesn't keep dead references. + useEffect(() => { + if (matches.length === 0 && entitiesCollection) { + // Defer one tick so we don't race the initial query resolution. + const t = setTimeout(() => { + if (matches.length === 0) helpers.closeTile(tile.id) + }, 250) + return () => clearTimeout(t) + } + }, [matches.length, entitiesCollection, helpers, tile.id]) + + if (!entity) { + return ( + + Loading entity... + + ) + } + + const baseUrl = activeServer?.url ?? `` + const View = getView(tile.viewId)?.Component + + return ( + + togglePin(tile.entityUrl)} + onKill={handleKill} + onFork={forkEntity && !entity.parent ? handleFork : undefined} + currentViewId={tile.viewId} + onSetView={setView} + /> + {View ? ( + + ) : ( + + Unknown view: {tile.viewId} + + )} + + ) +} diff --git a/packages/agents-server-ui/src/components/workspace/NodeRenderer.tsx b/packages/agents-server-ui/src/components/workspace/NodeRenderer.tsx new file mode 100644 index 0000000000..fc9bc6027e --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/NodeRenderer.tsx @@ -0,0 +1,20 @@ +import { GroupContainer } from './GroupContainer' +import { SplitContainer } from './SplitContainer' +import type { WorkspaceNode } from '../../lib/workspace/types' + +/** + * Pure dispatch from a node in the workspace tree to the right + * container component. Recurses naturally (`SplitContainer` calls back + * into `NodeRenderer` for each of its children). + */ +export function NodeRenderer({ + node, +}: { + node: WorkspaceNode +}): React.ReactElement { + return node.kind === `split` ? ( + + ) : ( + + ) +} diff --git a/packages/agents-server-ui/src/components/workspace/SplitContainer.module.css b/packages/agents-server-ui/src/components/workspace/SplitContainer.module.css new file mode 100644 index 0000000000..cd6c112ef5 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/SplitContainer.module.css @@ -0,0 +1,22 @@ +.split { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + overflow: hidden; +} + +.horizontal { + flex-direction: row; +} + +.vertical { + flex-direction: column; +} + +.pane { + display: flex; + min-width: 0; + min-height: 0; + overflow: hidden; +} diff --git a/packages/agents-server-ui/src/components/workspace/SplitContainer.tsx b/packages/agents-server-ui/src/components/workspace/SplitContainer.tsx new file mode 100644 index 0000000000..c0c79a8717 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/SplitContainer.tsx @@ -0,0 +1,83 @@ +import { Fragment, useCallback, useRef } from 'react' +import { useWorkspace } from '../../hooks/useWorkspace' +import type { Split } from '../../lib/workspace/types' +import { NodeRenderer } from './NodeRenderer' +import { Splitter } from './Splitter' +import styles from './SplitContainer.module.css' + +/** + * Renders a split node — `n` panes (sized by their `size` fraction) + * separated by `n-1` ``s. + * + * Resize is fully controlled: the splitter calls `onResize(delta)` with + * a fractional delta (0..1) and we dispatch `resize-split` to the + * reducer. The reducer normalises sibling sizes so this stays well- + * formed even after pathological drags. + */ +export function SplitContainer({ + split, +}: { + split: Split +}): React.ReactElement { + const { helpers } = useWorkspace() + const containerRef = useRef(null) + + // Used by `` to convert a px delta into a fractional one. + // Re-measured on each drag start to handle window resizes between + // drags without state. + const measureContainer = useCallback(() => { + const el = containerRef.current + if (!el) return 0 + return split.direction === `horizontal` ? el.clientWidth : el.clientHeight + }, [split.direction]) + + const onResizeAt = useCallback( + (boundaryIndex: number, deltaFraction: number) => { + // Re-balance only the two siblings adjacent to the dragged + // boundary — keeps the rest of the row stable when there are + // 3+ panes (matches VS Code). + const sizes = split.children.map((c) => c.size) + const left = sizes[boundaryIndex] + const right = sizes[boundaryIndex + 1] + const min = 0.05 // never shrink a pane below 5% of the split + let delta = deltaFraction + if (left + delta < min) delta = min - left + if (right - delta < min) delta = right - min + sizes[boundaryIndex] = left + delta + sizes[boundaryIndex + 1] = right - delta + helpers.resizeSplit(split.id, sizes) + }, + [split.children, split.id, helpers] + ) + + return ( +
+ {split.children.map((child, i) => ( + + {i > 0 && ( + onResizeAt(i - 1, delta)} + /> + )} +
+ +
+
+ ))} +
+ ) +} diff --git a/packages/agents-server-ui/src/components/workspace/Splitter.module.css b/packages/agents-server-ui/src/components/workspace/Splitter.module.css new file mode 100644 index 0000000000..15b1e46fe4 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/Splitter.module.css @@ -0,0 +1,21 @@ +.splitter { + flex-shrink: 0; + background: var(--ds-gray-a5); + position: relative; + z-index: 1; +} + +.horizontal { + width: 4px; + cursor: col-resize; +} + +.vertical { + height: 4px; + cursor: row-resize; +} + +.splitter:hover, +.active { + background: var(--ds-accent-a6); +} diff --git a/packages/agents-server-ui/src/components/workspace/Splitter.tsx b/packages/agents-server-ui/src/components/workspace/Splitter.tsx new file mode 100644 index 0000000000..717e93edce --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/Splitter.tsx @@ -0,0 +1,74 @@ +import { useCallback, useState } from 'react' +import styles from './Splitter.module.css' + +/** + * Pure draggable divider between two children of a Split. + * + * The parent owns the sizing state (it lives in the workspace tree) + * and passes a `onResize(deltaPx, totalPx)` callback. We compute the + * percentage delta inside the callback so the parent only has to call + * `dispatch({ type: 'resize-split', sizes: [...] })` with normalised + * fractions. + */ +export function Splitter({ + direction, + onResize, + /** + * Total length of the parent split (in px) at drag start. Used to + * convert the drag delta into a fractional change. Re-measured on + * each `mousedown` via the callback rather than passed through props + * so it always reflects the live container size. + */ + measureContainer, +}: { + direction: `horizontal` | `vertical` + measureContainer: () => number + onResize: (deltaFraction: number) => void +}): React.ReactElement { + const [active, setActive] = useState(false) + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const start = direction === `horizontal` ? e.clientX : e.clientY + const total = measureContainer() + if (total <= 0) return + setActive(true) + const move = (ev: MouseEvent) => { + const cur = direction === `horizontal` ? ev.clientX : ev.clientY + const delta = (cur - start) / total + onResize(delta) + } + const up = () => { + document.removeEventListener(`mousemove`, move) + document.removeEventListener(`mouseup`, up) + document.body.style.cursor = `` + document.body.style.userSelect = `` + setActive(false) + } + document.body.style.cursor = + direction === `horizontal` ? `col-resize` : `row-resize` + document.body.style.userSelect = `none` + document.addEventListener(`mousemove`, move) + document.addEventListener(`mouseup`, up) + }, + [direction, measureContainer, onResize] + ) + + const cls = [ + styles.splitter, + direction === `horizontal` ? styles.horizontal : styles.vertical, + active ? styles.active : null, + ] + .filter(Boolean) + .join(` `) + + return ( +
+ ) +} diff --git a/packages/agents-server-ui/src/components/workspace/TabStrip.module.css b/packages/agents-server-ui/src/components/workspace/TabStrip.module.css new file mode 100644 index 0000000000..5247c8c729 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/TabStrip.module.css @@ -0,0 +1,82 @@ +.strip { + display: flex; + flex-direction: row; + align-items: stretch; + min-height: 32px; + background: var(--ds-gray-a2); + border-bottom: 1px solid var(--ds-gray-a4); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + flex-shrink: 0; +} + +.tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 8px 0 10px; + border: 0; + background: transparent; + cursor: pointer; + color: var(--ds-gray-12); + font-size: var(--ds-font-size-2, 13px); + border-right: 1px solid var(--ds-gray-a4); + position: relative; + white-space: nowrap; + height: 100%; + max-width: 220px; + min-width: 0; +} + +.tab:hover { + background: var(--ds-gray-a3); +} + +.activeTab { + background: var(--ds-bg); + color: var(--ds-gray-12); +} + +.activeTab::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: var(--ds-bg); +} + +.label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.closeBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: 0; + background: transparent; + border-radius: 3px; + cursor: pointer; + color: var(--ds-gray-11); + flex-shrink: 0; + margin-left: 2px; + opacity: 0; +} + +.tab:hover .closeBtn, +.activeTab .closeBtn { + opacity: 1; +} + +.closeBtn:hover { + background: var(--ds-gray-a4); +} diff --git a/packages/agents-server-ui/src/components/workspace/TabStrip.tsx b/packages/agents-server-ui/src/components/workspace/TabStrip.tsx new file mode 100644 index 0000000000..90f0022e0d --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/TabStrip.tsx @@ -0,0 +1,106 @@ +import { X } from 'lucide-react' +import { useLiveQuery } from '@tanstack/react-db' +import { eq } from '@tanstack/db' +import { useElectricAgents } from '../../lib/ElectricAgentsProvider' +import { useWorkspace } from '../../hooks/useWorkspace' +import { getView } from '../../lib/workspace/viewRegistry' +import { getEntityDisplayTitle } from '../../lib/entityDisplay' +import type { Group, Tile } from '../../lib/workspace/types' +import styles from './TabStrip.module.css' + +/** + * The tab strip across the top of a Group. Renders one button per tile + * with the entity's short display title (or the entity URL when the + * entity isn't loaded yet) plus a small close `×`. + * + * Stage 2 has no DnD on tabs yet — clicking activates, middle-click + * closes. Drag-to-rearrange and drop targets land in Stage 4. + * + * The strip is hidden entirely when a group has only one tile, matching + * the look of the pre-tile UI: a single tile reads as "the page" and a + * tab labelled identically would be visual noise. + */ +export function TabStrip({ + group, +}: { + group: Group +}): React.ReactElement | null { + const { helpers } = useWorkspace() + + if (group.tiles.length <= 1) return null + + const onMiddleClickClose = (e: React.MouseEvent, tileId: string) => { + if (e.button === 1) { + e.preventDefault() + helpers.closeTile(tileId) + } + } + + return ( +
+ {group.tiles.map((tile) => { + const active = tile.id === group.activeTileId + return ( + + ) + })} +
+ ) +} + +/** + * Resolves the display label for a single tab. Looks up the entity by + * `entityUrl` so we can show its short title (e.g. "foo-123") rather + * than the raw URL. + * + * If a view defines a non-default `shortLabel` it's appended in + * parentheses so two tabs of the same entity but different views are + * distinguishable: `foo-123 (State Explorer)`. + */ +function TabLabel({ tile }: { tile: Tile }): React.ReactElement { + const { entitiesCollection } = useElectricAgents() + const { data: matches = [] } = useLiveQuery( + (q) => { + if (!entitiesCollection) return undefined + return q + .from({ e: entitiesCollection }) + .where(({ e }) => eq(e.url, tile.entityUrl)) + }, + [entitiesCollection, tile.entityUrl] + ) + const entity = matches.at(0) + const baseLabel = entity + ? getEntityDisplayTitle(entity).title + : tile.entityUrl.replace(/^\//, ``) + const view = getView(tile.viewId) + const showViewLabel = view && view.id !== `chat` + const display = showViewLabel + ? `${baseLabel} (${view.shortLabel ?? view.label})` + : baseLabel + return ( + + {display} + + ) +} diff --git a/packages/agents-server-ui/src/components/workspace/Workspace.module.css b/packages/agents-server-ui/src/components/workspace/Workspace.module.css new file mode 100644 index 0000000000..5ba55ee5ff --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/Workspace.module.css @@ -0,0 +1,15 @@ +.workspace { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + background: var(--ds-bg); + overflow: hidden; +} + +.empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/packages/agents-server-ui/src/components/workspace/Workspace.tsx b/packages/agents-server-ui/src/components/workspace/Workspace.tsx new file mode 100644 index 0000000000..909f87e960 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/Workspace.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef } from 'react' +import { useNavigate, useParams, useSearch } from '@tanstack/react-router' +import { useWorkspace } from '../../hooks/useWorkspace' +import { listViews } from '../../lib/workspace/viewRegistry' +import { useElectricAgents } from '../../lib/ElectricAgentsProvider' +import { useLiveQuery } from '@tanstack/react-db' +import { eq } from '@tanstack/db' +import { NodeRenderer } from './NodeRenderer' +import styles from './Workspace.module.css' +import type { ViewId } from '../../lib/workspace/viewRegistry' + +/** + * Top-level workspace renderer. Owns: + * + * - Reading the URL (entity splat + ?view) and reflecting it into the + * workspace state on the way *in* (one-way: URL → workspace). + * - Reflecting the active tile back out into the URL (one-way: + * workspace → URL) so deep-links still work. + * + * Stage 2 keeps the workspace single-tile by default — multi-tile + * arrives in stages 3 and 4 via the `…` menu and drag-and-drop. The + * URL ↔ workspace contract here is the foundation those stages build + * on, and the rules in §3.4 of the plan are encoded as effects below. + */ +export function Workspace(): React.ReactElement { + const { workspace, helpers } = useWorkspace() + const params = useParams({ strict: false }) + const search = useSearch({ strict: false }) as { view?: string } + const navigate = useNavigate() + const splat = (params as Record)._splat + const entityUrl = splat ? `/${splat}` : null + const requestedViewId = (search.view as ViewId | undefined) ?? null + + const { entitiesCollection } = useElectricAgents() + const { data: entityMatches = [] } = useLiveQuery( + (q) => { + if (!entitiesCollection || !entityUrl) return undefined + return q + .from({ e: entitiesCollection }) + .where(({ e }) => eq(e.url, entityUrl)) + }, + [entitiesCollection, entityUrl] + ) + const entity = entityMatches.at(0) ?? null + + // ---- URL → workspace ------------------------------------------------- + // Whenever the URL points at an entity, ensure it has a tile in the + // workspace and that that tile is active. If the entity is already + // present in some tile, just refocus it (no layout disruption); + // otherwise insert a new tile in the active group, replacing its + // current active tile (matches Stage 1 behaviour: the URL drives + // what's visible). + // + // The `lastSyncedKey` ref dedupes redundant syncs — without it, the + // workspace → URL effect below would echo back into this one and + // create infinite open-tile dispatches. + const lastSyncedKey = useRef(null) + useEffect(() => { + if (!entityUrl) return + const availableViews = entity ? listViews(entity) : [] + const defaultViewId = availableViews[0]?.id ?? `chat` + const desiredViewId = + requestedViewId && availableViews.some((v) => v.id === requestedViewId) + ? requestedViewId + : defaultViewId + const key = `${entityUrl}::${desiredViewId}` + if (lastSyncedKey.current === key) return + + // Look for an existing tile that already matches. + const groups: Array<{ + id: string + tiles: Array<{ id: string; entityUrl: string; viewId: string }> + }> = [] + if (workspace.root) { + const collect = (node: typeof workspace.root): void => { + if (!node) return + if (node.kind === `group`) { + groups.push(node) + } else { + for (const c of node.children) collect(c.node) + } + } + collect(workspace.root) + } + const exactMatch = groups + .flatMap((g) => g.tiles.map((t) => ({ groupId: g.id, tile: t }))) + .find( + (m) => m.tile.entityUrl === entityUrl && m.tile.viewId === desiredViewId + ) + + if (exactMatch) { + helpers.setActiveTile(exactMatch.tile.id) + } else { + // No matching tile — open one. If a tile of the same entity + // already exists in the active group, switch its view in place + // rather than adding a new tile. + const activeGroup = + groups.find((g) => g.id === workspace.activeGroupId) ?? groups[0] + const sameEntityInActive = activeGroup?.tiles.find( + (t) => t.entityUrl === entityUrl + ) + if (sameEntityInActive) { + helpers.setActiveTile(sameEntityInActive.id) + helpers.setTileView(sameEntityInActive.id, desiredViewId) + } else { + helpers.openEntity(entityUrl, { viewId: desiredViewId }) + } + } + lastSyncedKey.current = key + }, [ + entityUrl, + requestedViewId, + entity, + workspace.root, + workspace.activeGroupId, + helpers, + ]) + + // ---- Workspace → URL ------------------------------------------------- + // Whenever the active tile changes, mirror its (entityUrl, viewId) + // into the route. We use `replace: true` for the URL update because + // the *user* navigations that change the active tile (clicking a + // sidebar row, opening a new tile, switching views) already pushed a + // history entry through their own `navigate({})` calls — this effect + // runs *after* the dispatch and is just keeping the URL in sync, so + // pushing again would double up. + useEffect(() => { + const tile = helpers.activeTile + if (!tile) return + const expectedKey = `${tile.entityUrl}::${tile.viewId}` + if (lastSyncedKey.current === expectedKey) return + lastSyncedKey.current = expectedKey + void navigate({ + to: `/entity/$`, + params: { _splat: tile.entityUrl.replace(/^\//, ``) }, + search: tile.viewId === `chat` ? {} : { view: tile.viewId }, + replace: true, + }) + }, [helpers.activeTile, navigate]) + + if (!workspace.root) { + return ( +
+
+ Loading workspace... +
+
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/packages/agents-server-ui/src/hooks/useWorkspace.tsx b/packages/agents-server-ui/src/hooks/useWorkspace.tsx new file mode 100644 index 0000000000..77972d28a0 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useWorkspace.tsx @@ -0,0 +1,214 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, +} from 'react' +import type { Dispatch, ReactNode } from 'react' +import { + workspaceReducer, + findGroupContainingTile, + findTile, + listGroups, +} from '../lib/workspace/workspaceReducer' +import { EMPTY_WORKSPACE, dropPositionFromSplit } from '../lib/workspace/types' +import type { + DropTarget, + SplitDirection, + Tile, + Workspace, +} from '../lib/workspace/types' +import type { ViewId } from '../lib/workspace/viewRegistry' +import type { WorkspaceAction } from '../lib/workspace/workspaceReducer' + +type WorkspaceContextValue = { + workspace: Workspace + dispatch: Dispatch + /** Memoised helper API — wraps `dispatch` for ergonomics in components. */ + helpers: WorkspaceHelpers +} + +export type WorkspaceHelpers = { + /** Open `entityUrl` (with `viewId`) — defaults to active group, replace. */ + openEntity: ( + entityUrl: string, + options?: { viewId?: ViewId; target?: DropTarget } + ) => void + /** Close a tile by id — collapses empty groups / splits. */ + closeTile: (tileId: string) => void + /** Move a tile to a different position (drag-and-drop primitive). */ + moveTile: (tileId: string, target: DropTarget) => void + /** Set a tile as active inside its group, plus mark its group active. */ + setActiveTile: (tileId: string) => void + setActiveGroup: (groupId: string) => void + /** Swap a tile's view in place — no layout change. */ + setTileView: (tileId: string, viewId: ViewId) => void + /** Split the active tile and put the named view in the new group. */ + splitTileWithView: ( + tileId: string, + viewId: ViewId, + direction: SplitDirection + ) => void + /** Convenience: split the active tile, keeping the same view. */ + splitTile: (tileId: string, direction: SplitDirection) => void + /** Resize a split's children. */ + resizeSplit: (splitId: string, sizes: Array) => void + /** Replace the entire workspace (used by URL/persistence hydration). */ + replaceWorkspace: (workspace: Workspace) => void + + // ---- Read-side conveniences (computed from the latest workspace). ---- + /** Active tile in the active group, or `null` for an empty workspace. */ + activeTile: Tile | null + /** Active group, or `null` for an empty workspace. */ + activeGroupId: string | null +} + +const WorkspaceContext = createContext(null) + +export function WorkspaceProvider({ + initial = EMPTY_WORKSPACE, + children, +}: { + initial?: Workspace + children: ReactNode +}): React.ReactElement { + const [workspace, dispatch] = useReducer(workspaceReducer, initial) + + const openEntity = useCallback( + (entityUrl, options) => { + dispatch({ + type: `open-tile`, + tile: { entityUrl, viewId: options?.viewId ?? `chat` }, + target: options?.target, + }) + }, + [] + ) + + const closeTile = useCallback((tileId) => { + dispatch({ type: `close-tile`, tileId }) + }, []) + + const moveTile = useCallback( + (tileId, target) => { + dispatch({ type: `move-tile`, tileId, target }) + }, + [] + ) + + const setActiveTile = useCallback( + (tileId) => { + dispatch({ type: `set-active-tile`, tileId }) + }, + [] + ) + + const setActiveGroup = useCallback( + (groupId) => { + dispatch({ type: `set-active-group`, groupId }) + }, + [] + ) + + const setTileView = useCallback( + (tileId, viewId) => { + dispatch({ type: `set-tile-view`, tileId, viewId }) + }, + [] + ) + + const splitTileWithView = useCallback( + (tileId, viewId, direction) => { + dispatch({ type: `split-tile-with-view`, tileId, viewId, direction }) + }, + [] + ) + + const splitTile = useCallback( + (tileId, direction) => { + const tile = findTile(workspace.root, tileId) + if (!tile) return + dispatch({ + type: `split-tile-with-view`, + tileId, + viewId: tile.viewId, + direction, + }) + }, + [workspace.root] + ) + + const resizeSplit = useCallback( + (splitId, sizes) => { + dispatch({ type: `resize-split`, splitId, sizes }) + }, + [] + ) + + const replaceWorkspace = useCallback( + (next) => { + dispatch({ type: `replace-workspace`, workspace: next }) + }, + [] + ) + + const helpers = useMemo(() => { + const groups = listGroups(workspace.root) + const activeGroup = + groups.find((g) => g.id === workspace.activeGroupId) ?? groups[0] ?? null + const activeTile = + activeGroup?.tiles.find((t) => t.id === activeGroup.activeTileId) ?? null + return { + openEntity, + closeTile, + moveTile, + setActiveTile, + setActiveGroup, + setTileView, + splitTileWithView, + splitTile, + resizeSplit, + replaceWorkspace, + activeTile, + activeGroupId: workspace.activeGroupId, + } + }, [ + workspace, + openEntity, + closeTile, + moveTile, + setActiveTile, + setActiveGroup, + setTileView, + splitTileWithView, + splitTile, + resizeSplit, + replaceWorkspace, + ]) + + const value = useMemo( + () => ({ workspace, dispatch, helpers }), + [workspace, helpers] + ) + + return ( + + {children} + + ) +} + +export function useWorkspace(): WorkspaceContextValue { + const ctx = useContext(WorkspaceContext) + if (!ctx) { + throw new Error(`useWorkspace must be called inside a `) + } + return ctx +} + +// Re-export tree walkers for convenience (some components call them +// against the snapshot returned from `useWorkspace`). +export { findGroupContainingTile, findTile, listGroups } +// Re-export the position helper so component-level imports stay shallow. +export { dropPositionFromSplit } diff --git a/packages/agents-server-ui/src/lib/workspace/types.ts b/packages/agents-server-ui/src/lib/workspace/types.ts new file mode 100644 index 0000000000..4985b4aeea --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/types.ts @@ -0,0 +1,97 @@ +import type { ViewId } from './viewRegistry' + +/** + * A `Tile` is the unit that gets rendered in a leaf area of the + * workspace. It binds an entity to a view; the same entity can be open + * in multiple tiles (e.g. chat + state-explorer side-by-side). + * + * The `id` is a stable nanoid that survives renders and lets us key + * React state per-tile (so two splits of the same entity scroll + * independently — see `tileId` in `ViewProps`). + */ +export type Tile = { + id: string + entityUrl: string + viewId: ViewId +} + +/** + * A `Group` is a leaf node in the workspace tree. It holds one or more + * tiles (the tab strip across the top of the group), with exactly one + * marked active. An empty group is invalid — when the last tile is + * closed the group itself is removed by the reducer. + */ +export type Group = { + kind: `group` + id: string + tiles: Array + /** Must always reference an id from `tiles`; reducer enforces this. */ + activeTileId: string +} + +/** + * A `Split` is an internal node containing two or more children laid + * out horizontally (side-by-side) or vertically (stacked). Each child + * carries its own size as a fraction in [0, 1]; sizes across siblings + * sum to ~1. Splits with one child are illegal — the reducer collapses + * them on every mutation. + */ +export type Split = { + kind: `split` + id: string + direction: `horizontal` | `vertical` + children: Array<{ node: WorkspaceNode; size: number }> +} + +export type WorkspaceNode = Split | Group + +/** + * The full workspace state. `root === null` represents the empty + * workspace (the new-session screen). `activeGroupId` always points + * at a group that exists in the tree (or `null` when the workspace + * is empty); reducer enforces this on every mutation. + */ +export type Workspace = { + root: WorkspaceNode | null + activeGroupId: string | null +} + +/** The empty workspace — the initial state on first load. */ +export const EMPTY_WORKSPACE: Workspace = { + root: null, + activeGroupId: null, +} + +/** + * Where to put a tile when opening / moving it. `replace` and `append` + * target an existing group; the four `split-*` directions create a new + * sibling group on that side of the target. + */ +export type DropPosition = + | `replace` + | `append` + | `split-right` + | `split-down` + | `split-left` + | `split-up` + +export type DropTarget = { + groupId: string + position: DropPosition +} + +/** Convenience alias used by the menu / hotkeys. */ +export type SplitDirection = `right` | `down` | `left` | `up` + +export function dropPositionFromSplit(dir: SplitDirection): DropPosition { + switch (dir) { + case `right`: + return `split-right` + case `down`: + return `split-down` + case `left`: + return `split-left` + case `up`: + return `split-up` + } +} diff --git a/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts new file mode 100644 index 0000000000..5bc3e978f2 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from 'vitest' +import { + findGroupContainingTile, + findTile, + listGroups, + workspaceReducer, +} from './workspaceReducer' +import { EMPTY_WORKSPACE } from './types' +import type { Workspace } from './types' + +// --------------------------------------------------------------------------- +// Tiny driver — applies a sequence of actions in order so each test reads +// like a script of user steps. Returns the final workspace. +// --------------------------------------------------------------------------- + +function run( + initial: Workspace, + ...actions: Array[1]> +): Workspace { + return actions.reduce(workspaceReducer, initial) +} + +describe(`workspaceReducer`, () => { + describe(`open-tile`, () => { + it(`bootstraps an empty workspace into a single-tile single-group`, () => { + const ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + expect(ws.root).not.toBeNull() + expect(ws.root!.kind).toBe(`group`) + expect(ws.activeGroupId).toBe(ws.root!.id) + const group = ws.root as Extract + expect(group.tiles).toHaveLength(1) + expect(group.tiles[0].entityUrl).toBe(`/horton/foo`) + expect(group.tiles[0].viewId).toBe(`chat`) + expect(group.activeTileId).toBe(group.tiles[0].id) + }) + + it(`with no target opens into the active group, replacing the active tile`, () => { + const after1 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const ws = workspaceReducer(after1, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + }) + expect(listGroups(ws.root)).toHaveLength(1) + const group = listGroups(ws.root)[0] + expect(group.tiles).toHaveLength(1) + expect(group.tiles[0].entityUrl).toBe(`/horton/bar`) + }) + + it(`with append target adds a new tab and activates it`, () => { + const after1 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const groupId = listGroups(after1.root)[0].id + const ws = workspaceReducer(after1, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { groupId, position: `append` }, + }) + const group = listGroups(ws.root)[0] + expect(group.tiles).toHaveLength(2) + expect(group.tiles[1].entityUrl).toBe(`/horton/bar`) + expect(group.activeTileId).toBe(group.tiles[1].id) + }) + + it(`split-right wraps the existing group in a horizontal split`, () => { + const after1 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const groupId = listGroups(after1.root)[0].id + const ws = workspaceReducer(after1, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { groupId, position: `split-right` }, + }) + expect(ws.root!.kind).toBe(`split`) + const split = ws.root as Extract + expect(split.direction).toBe(`horizontal`) + expect(split.children).toHaveLength(2) + expect(split.children[0].size + split.children[1].size).toBeCloseTo(1) + // The new tile sits on the right of the existing one. + const right = split.children[1].node as Extract< + (typeof split.children)[1][`node`], + { kind: `group` } + > + expect(right.tiles[0].entityUrl).toBe(`/horton/bar`) + }) + + it(`split-up places the new tile *above* the existing one`, () => { + const after1 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const groupId = listGroups(after1.root)[0].id + const ws = workspaceReducer(after1, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { groupId, position: `split-up` }, + }) + const split = ws.root as Extract + expect(split.direction).toBe(`vertical`) + const top = split.children[0].node as Extract< + (typeof split.children)[0][`node`], + { kind: `group` } + > + expect(top.tiles[0].entityUrl).toBe(`/horton/bar`) + }) + }) + + describe(`close-tile`, () => { + it(`removes a non-last tile and re-picks an active`, () => { + const ws0 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const groupId = listGroups(ws0.root)[0].id + const ws1 = workspaceReducer(ws0, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { groupId, position: `append` }, + }) + // Close the active (newer) tile. + const group1 = listGroups(ws1.root)[0] + const ws2 = workspaceReducer(ws1, { + type: `close-tile`, + tileId: group1.activeTileId, + }) + const group2 = listGroups(ws2.root)[0] + expect(group2.tiles).toHaveLength(1) + expect(group2.tiles[0].entityUrl).toBe(`/horton/foo`) + expect(group2.activeTileId).toBe(group2.tiles[0].id) + }) + + it(`removes the group when its last tile is closed`, () => { + const ws0 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const tileId = listGroups(ws0.root)[0].tiles[0].id + const ws1 = workspaceReducer(ws0, { type: `close-tile`, tileId }) + expect(ws1.root).toBeNull() + expect(ws1.activeGroupId).toBeNull() + }) + + it(`unwraps a split when one of two groups is emptied`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const fooGroupId = listGroups(ws.root)[0].id + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { groupId: fooGroupId, position: `split-right` }, + }) + // Now there's a horizontal split with two groups. Close the + // bar tile (right side). + const groups = listGroups(ws.root) + const barGroup = groups.find((g) => + g.tiles.some((t) => t.entityUrl === `/horton/bar`) + )! + ws = workspaceReducer(ws, { + type: `close-tile`, + tileId: barGroup.tiles[0].id, + }) + // Split should have collapsed; root is back to a single group. + expect(ws.root!.kind).toBe(`group`) + const remaining = ws.root as Extract + expect(remaining.tiles[0].entityUrl).toBe(`/horton/foo`) + }) + }) + + describe(`move-tile`, () => { + it(`moves a tile from one group to another`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const fooGroupId = listGroups(ws.root)[0].id + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { groupId: fooGroupId, position: `split-right` }, + }) + // Two groups exist. Move the bar tile back into the foo group as + // an appended tab. + const barGroup = listGroups(ws.root).find((g) => + g.tiles.some((t) => t.entityUrl === `/horton/bar`) + )! + const barTileId = barGroup.tiles[0].id + ws = workspaceReducer(ws, { + type: `move-tile`, + tileId: barTileId, + target: { groupId: fooGroupId, position: `append` }, + }) + // Single group remains with two tiles; split collapsed. + expect(ws.root!.kind).toBe(`group`) + const finalGroup = ws.root as Extract + expect(finalGroup.tiles).toHaveLength(2) + expect(finalGroup.tiles.map((t) => t.entityUrl).sort()).toEqual([ + `/horton/bar`, + `/horton/foo`, + ]) + }) + + it(`survives moving the only tile of a group into a new split of itself`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const groupId = listGroups(ws.root)[0].id + const tileId = listGroups(ws.root)[0].tiles[0].id + ws = workspaceReducer(ws, { + type: `move-tile`, + tileId, + target: { groupId, position: `split-right` }, + }) + // The reducer's safety net inserts the orphaned tile into a fresh + // root group rather than losing it. + expect(ws.root).not.toBeNull() + expect(findTile(ws.root, tileId)).not.toBeNull() + }) + }) + + describe(`set-tile-view / split-tile-with-view`, () => { + it(`set-tile-view swaps in place without changing layout`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const tileId = listGroups(ws.root)[0].tiles[0].id + ws = workspaceReducer(ws, { + type: `set-tile-view`, + tileId, + viewId: `state-explorer`, + }) + expect(ws.root!.kind).toBe(`group`) + const tile = findTile(ws.root, tileId)! + expect(tile.viewId).toBe(`state-explorer`) + }) + + it(`split-tile-with-view creates a new group with a different view of the same entity`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const tileId = listGroups(ws.root)[0].tiles[0].id + ws = workspaceReducer(ws, { + type: `split-tile-with-view`, + tileId, + viewId: `state-explorer`, + direction: `right`, + }) + const groups = listGroups(ws.root) + expect(groups).toHaveLength(2) + const states = groups.flatMap((g) => + g.tiles.map((t) => ({ entityUrl: t.entityUrl, viewId: t.viewId })) + ) + expect(states).toContainEqual({ + entityUrl: `/horton/foo`, + viewId: `chat`, + }) + expect(states).toContainEqual({ + entityUrl: `/horton/foo`, + viewId: `state-explorer`, + }) + // Active group should be the new one. + const activeGroup = groups.find((g) => g.id === ws.activeGroupId) + expect(activeGroup?.tiles[0].viewId).toBe(`state-explorer`) + }) + }) + + describe(`resize-split`, () => { + it(`normalises sizes to sum to 1`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const groupId = listGroups(ws.root)[0].id + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { groupId, position: `split-right` }, + }) + const split = ws.root as Extract + ws = workspaceReducer(ws, { + type: `resize-split`, + splitId: split.id, + sizes: [3, 1], + }) + const next = ws.root as Extract + expect(next.children[0].size).toBeCloseTo(0.75) + expect(next.children[1].size).toBeCloseTo(0.25) + }) + }) + + describe(`active group bookkeeping`, () => { + it(`updates activeGroupId when the previously active group is destroyed`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const fooGroupId = listGroups(ws.root)[0].id + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { groupId: fooGroupId, position: `split-right` }, + }) + const barGroup = listGroups(ws.root).find((g) => + g.tiles.some((t) => t.entityUrl === `/horton/bar`) + )! + ws = workspaceReducer(ws, { + type: `set-active-group`, + groupId: barGroup.id, + }) + expect(ws.activeGroupId).toBe(barGroup.id) + // Now close the active group's tile — it should disappear and + // the active should fall back to the remaining group. + ws = workspaceReducer(ws, { + type: `close-tile`, + tileId: barGroup.tiles[0].id, + }) + expect(ws.activeGroupId).toBe(fooGroupId) + }) + }) + + describe(`findGroupContainingTile`, () => { + it(`returns null for unknown tile ids`, () => { + const ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + expect(findGroupContainingTile(ws.root, `nope`)).toBeNull() + }) + }) +}) diff --git a/packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts new file mode 100644 index 0000000000..05b43a98a7 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts @@ -0,0 +1,540 @@ +import { nanoid } from 'nanoid' +import type { + DropTarget, + Group, + Split, + Tile, + Workspace, + WorkspaceNode, +} from './types' +import type { ViewId } from './viewRegistry' + +// --------------------------------------------------------------------------- +// Pure reducer for the workspace tree. Every operation returns a *new* +// `Workspace` value — no in-place mutation — so `useReducer` can drive +// React updates by reference identity. The shape of the tree +// (Split → Group → Tile) is enforced by the reducer's invariants: +// +// 1. A `Split` always has ≥2 children. After any mutation that could +// leave it with 1 child, the split is unwrapped — its single child +// takes the split's place in the parent. +// 2. A `Group` always has ≥1 tile. After any mutation that could leave +// it empty, the group is removed; its parent split unwraps too if +// that drops it to 1 child. +// 3. `activeGroupId` always references a group present in the tree, or +// `null` iff `root === null`. +// 4. Sibling sizes inside a `Split` are normalised so they sum to ~1. +// --------------------------------------------------------------------------- + +export type WorkspaceAction = + | { + type: `open-tile` + tile: { entityUrl: string; viewId: ViewId } + target?: DropTarget + } + | { type: `close-tile`; tileId: string } + | { type: `move-tile`; tileId: string; target: DropTarget } + | { type: `set-active-tile`; tileId: string } + | { type: `set-active-group`; groupId: string } + | { type: `set-tile-view`; tileId: string; viewId: ViewId } + | { + type: `split-tile-with-view` + tileId: string + viewId: ViewId + direction: `right` | `down` | `left` | `up` + } + | { + type: `resize-split` + splitId: string + sizes: Array + } + | { type: `replace-workspace`; workspace: Workspace } + +export function workspaceReducer( + state: Workspace, + action: WorkspaceAction +): Workspace { + switch (action.type) { + case `open-tile`: + return openTile(state, action.tile, action.target) + case `close-tile`: + return closeTile(state, action.tileId) + case `move-tile`: + return moveTile(state, action.tileId, action.target) + case `set-active-tile`: + return setActiveTile(state, action.tileId) + case `set-active-group`: + return state.activeGroupId === action.groupId + ? state + : { ...state, activeGroupId: action.groupId } + case `set-tile-view`: + return setTileView(state, action.tileId, action.viewId) + case `split-tile-with-view`: + return splitTileWithView( + state, + action.tileId, + action.viewId, + action.direction + ) + case `resize-split`: + return resizeSplit(state, action.splitId, action.sizes) + case `replace-workspace`: + return action.workspace + } +} + +// --------------------------------------------------------------------------- +// Public ID factories. Exposed so callers (DnD payloads, URL hydration) +// can mint ids before dispatching. +// --------------------------------------------------------------------------- + +export function makeTileId(): string { + return `tile_${nanoid(10)}` +} +export function makeGroupId(): string { + return `grp_${nanoid(8)}` +} +export function makeSplitId(): string { + return `spl_${nanoid(8)}` +} + +export function makeTile(entityUrl: string, viewId: ViewId): Tile { + return { id: makeTileId(), entityUrl, viewId } +} + +// --------------------------------------------------------------------------- +// Tree walkers (read-only). These traverse without copying so they're +// safe to call inside reducer cases for lookups. +// --------------------------------------------------------------------------- + +export function findGroup( + node: WorkspaceNode | null, + groupId: string +): Group | null { + if (node === null) return null + if (node.kind === `group`) return node.id === groupId ? node : null + for (const child of node.children) { + const found = findGroup(child.node, groupId) + if (found) return found + } + return null +} + +export function findGroupContainingTile( + node: WorkspaceNode | null, + tileId: string +): Group | null { + if (node === null) return null + if (node.kind === `group`) { + return node.tiles.some((t) => t.id === tileId) ? node : null + } + for (const child of node.children) { + const found = findGroupContainingTile(child.node, tileId) + if (found) return found + } + return null +} + +export function findTile( + node: WorkspaceNode | null, + tileId: string +): Tile | null { + const group = findGroupContainingTile(node, tileId) + return group?.tiles.find((t) => t.id === tileId) ?? null +} + +export function listGroups(node: WorkspaceNode | null): Array { + if (node === null) return [] + if (node.kind === `group`) return [node] + return node.children.flatMap((c) => listGroups(c.node)) +} + +// --------------------------------------------------------------------------- +// Open tile +// --------------------------------------------------------------------------- + +function openTile( + state: Workspace, + tile: { entityUrl: string; viewId: ViewId }, + target?: DropTarget +): Workspace { + const newTile = makeTile(tile.entityUrl, tile.viewId) + + // Empty workspace → bootstrap a single group with the new tile. + if (state.root === null) { + const group: Group = { + kind: `group`, + id: makeGroupId(), + tiles: [newTile], + activeTileId: newTile.id, + } + return { root: group, activeGroupId: group.id } + } + + // No explicit target → default to the active group. Fall back to the + // first group in the tree if `activeGroupId` is somehow stale. + const targetGroupId = + target?.groupId ?? + state.activeGroupId ?? + listGroups(state.root)[0]?.id ?? + null + if (targetGroupId === null) return state + + const position = target?.position ?? `replace` + return applyToGroup(state, targetGroupId, (group) => + insertTileIntoGroup(group, newTile, position) + ) +} + +/** + * Apply a transformation to a target group inside the tree. The + * transformation receives the existing group and returns a replacement + * subtree (Group, Split, or `null` to delete the group entirely). + * + * Walks back up the tree normalising splits (collapse single-child, + * keep sibling sizes summing to ~1). + */ +function applyToGroup( + state: Workspace, + groupId: string, + fn: (group: Group) => WorkspaceNode | null +): Workspace { + if (state.root === null) return state + const replaced = replaceGroupInTree(state.root, groupId, fn) + return finaliseWorkspace(state, replaced) +} + +function replaceGroupInTree( + node: WorkspaceNode, + groupId: string, + fn: (group: Group) => WorkspaceNode | null +): WorkspaceNode | null { + if (node.kind === `group`) { + if (node.id !== groupId) return node + return fn(node) + } + const newChildren: Split[`children`] = [] + let changed = false + for (const child of node.children) { + const replacement = replaceGroupInTree(child.node, groupId, fn) + if (replacement !== child.node) changed = true + if (replacement !== null) { + newChildren.push({ node: replacement, size: child.size }) + } else { + changed = true + } + } + if (!changed) return node + return collapseSplit({ ...node, children: newChildren }) +} + +/** + * Insert / replace / split. Returns the new subtree that should sit in + * the parent's slot — either the same group (mutated tiles), the same + * group + a sibling under a new split, or just a different group. + */ +function insertTileIntoGroup( + group: Group, + tile: Tile, + position: DropTarget[`position`] +): WorkspaceNode { + switch (position) { + case `append`: { + // Add as a new tab in the strip; activate it. + return { + ...group, + tiles: [...group.tiles, tile], + activeTileId: tile.id, + } + } + case `replace`: { + // Replace the active tile in place. If there's only one tile this + // is the same as `append` semantically. + const newTiles = group.tiles.map((t) => + t.id === group.activeTileId ? tile : t + ) + return { + ...group, + tiles: newTiles, + activeTileId: tile.id, + } + } + case `split-right`: + case `split-down`: + case `split-left`: + case `split-up`: { + const newGroup: Group = { + kind: `group`, + id: makeGroupId(), + tiles: [tile], + activeTileId: tile.id, + } + return wrapInSplit(group, newGroup, position) + } + } +} + +function wrapInSplit( + existing: WorkspaceNode, + incoming: WorkspaceNode, + position: `split-right` | `split-down` | `split-left` | `split-up` +): Split { + const direction: Split[`direction`] = + position === `split-right` || position === `split-left` + ? `horizontal` + : `vertical` + const incomingFirst = position === `split-left` || position === `split-up` + const children: Split[`children`] = incomingFirst + ? [ + { node: incoming, size: 0.5 }, + { node: existing, size: 0.5 }, + ] + : [ + { node: existing, size: 0.5 }, + { node: incoming, size: 0.5 }, + ] + return { + kind: `split`, + id: makeSplitId(), + direction, + children, + } +} + +// --------------------------------------------------------------------------- +// Close tile +// --------------------------------------------------------------------------- + +function closeTile(state: Workspace, tileId: string): Workspace { + if (state.root === null) return state + const group = findGroupContainingTile(state.root, tileId) + if (!group) return state + if (group.tiles.length === 1) { + // Closing the last tile removes the group entirely. + return applyToGroup(state, group.id, () => null) + } + // Otherwise drop the tile and pick the next-best active. + const tileIndex = group.tiles.findIndex((t) => t.id === tileId) + const newTiles = group.tiles.filter((t) => t.id !== tileId) + const wasActive = group.activeTileId === tileId + const nextActiveId = wasActive + ? (newTiles[Math.max(0, tileIndex - 1)] ?? newTiles[0]).id + : group.activeTileId + return applyToGroup(state, group.id, (g) => ({ + ...g, + tiles: newTiles, + activeTileId: nextActiveId, + })) +} + +// --------------------------------------------------------------------------- +// Move tile (drag-and-drop primitive) +// --------------------------------------------------------------------------- + +function moveTile( + state: Workspace, + tileId: string, + target: DropTarget +): Workspace { + if (state.root === null) return state + const sourceGroup = findGroupContainingTile(state.root, tileId) + if (!sourceGroup) return state + const tile = sourceGroup.tiles.find((t) => t.id === tileId) + if (!tile) return state + + // No-op self-move (drop on the same group with the same tile and + // position 'append' or 'replace' on the active tile). + if ( + sourceGroup.id === target.groupId && + sourceGroup.tiles.length === 1 && + (target.position === `append` || target.position === `replace`) + ) { + return state + } + + // Detach the tile from its source first. + const detached = closeTile(state, tileId) + // Re-find the target group in the post-detach tree (the source group + // may have collapsed if `tileId` was its last tile). + const targetGroupExists = findGroup(detached.root, target.groupId) !== null + if (!targetGroupExists) { + // Target collapsed during detach — re-insert as a fresh single-tile + // group at the root to avoid losing the tile. + if (detached.root === null) { + const group: Group = { + kind: `group`, + id: makeGroupId(), + tiles: [tile], + activeTileId: tile.id, + } + return { root: group, activeGroupId: group.id } + } + return detached + } + return applyToGroup(detached, target.groupId, (group) => + insertTileIntoGroup(group, tile, target.position) + ) +} + +// --------------------------------------------------------------------------- +// Set active tile / group / view +// --------------------------------------------------------------------------- + +function setActiveTile(state: Workspace, tileId: string): Workspace { + if (state.root === null) return state + const group = findGroupContainingTile(state.root, tileId) + if (!group) return state + if (group.activeTileId === tileId && state.activeGroupId === group.id) { + return state + } + return applyToGroup({ ...state, activeGroupId: group.id }, group.id, (g) => ({ + ...g, + activeTileId: tileId, + })) +} + +function setTileView( + state: Workspace, + tileId: string, + viewId: ViewId +): Workspace { + if (state.root === null) return state + const group = findGroupContainingTile(state.root, tileId) + if (!group) return state + return applyToGroup(state, group.id, (g) => ({ + ...g, + tiles: g.tiles.map((t) => (t.id === tileId ? { ...t, viewId } : t)), + })) +} + +function splitTileWithView( + state: Workspace, + tileId: string, + viewId: ViewId, + direction: `right` | `down` | `left` | `up` +): Workspace { + const tile = findTile(state.root, tileId) + if (!tile) return state + const group = findGroupContainingTile(state.root, tileId) + if (!group) return state + const newTile = makeTile(tile.entityUrl, viewId) + const next = applyToGroup(state, group.id, (g) => + insertTileIntoGroup(g, newTile, `split-${direction}`) + ) + // The new tile's group is whatever the latest open created. + const newGroup = findGroupContainingTile(next.root, newTile.id) + return newGroup ? { ...next, activeGroupId: newGroup.id } : next +} + +// --------------------------------------------------------------------------- +// Resize split +// --------------------------------------------------------------------------- + +function resizeSplit( + state: Workspace, + splitId: string, + sizes: Array +): Workspace { + if (state.root === null) return state + const updated = updateSplitInTree(state.root, splitId, sizes) + return updated === state.root ? state : { ...state, root: updated } +} + +function updateSplitInTree( + node: WorkspaceNode, + splitId: string, + sizes: Array +): WorkspaceNode { + if (node.kind === `group`) return node + if (node.id === splitId) { + if (node.children.length !== sizes.length) return node + const total = sizes.reduce((a, b) => a + b, 0) + if (total <= 0) return node + return { + ...node, + children: node.children.map((child, i) => ({ + ...child, + size: sizes[i] / total, + })), + } + } + let changed = false + const newChildren = node.children.map((child) => { + const replacement = updateSplitInTree(child.node, splitId, sizes) + if (replacement !== child.node) { + changed = true + return { ...child, node: replacement } + } + return child + }) + return changed ? { ...node, children: newChildren } : node +} + +// --------------------------------------------------------------------------- +// Tree normalisation helpers +// --------------------------------------------------------------------------- + +/** + * Collapse a split with ≤1 child: + * - 0 children → returns `null` (caller removes from parent). + * - 1 child → returns that child directly (the split disappears). + * - 2+ → normalises sibling sizes so they sum to 1. + * + * Also flattens nested splits with matching directions: + * `H(a, H(b, c))` → `H(a, b, c)`. This keeps the tree shallow and + * the splitter UI predictable. + */ +function collapseSplit(split: Split): WorkspaceNode | null { + if (split.children.length === 0) return null + if (split.children.length === 1) return split.children[0].node + + // Flatten nested same-direction splits. + const flat: Split[`children`] = [] + for (const child of split.children) { + if ( + child.node.kind === `split` && + child.node.direction === split.direction + ) { + // Distribute this child's `size` across its grandchildren proportionally. + const inner = child.node + const innerTotal = inner.children.reduce((a, c) => a + c.size, 0) + for (const grand of inner.children) { + flat.push({ + node: grand.node, + size: child.size * (grand.size / (innerTotal || 1)), + }) + } + } else { + flat.push(child) + } + } + + const total = flat.reduce((a, c) => a + c.size, 0) + const normalised: Split[`children`] = flat.map((c) => ({ + ...c, + size: total > 0 ? c.size / total : 1 / flat.length, + })) + return { ...split, children: normalised } +} + +/** + * After a structural mutation, fix up `activeGroupId` to ensure it + * points at a group that still exists. Picks the first remaining group + * if the previous active was removed. + */ +function finaliseWorkspace( + prev: Workspace, + newRoot: WorkspaceNode | null +): Workspace { + if (newRoot === null) { + return { root: null, activeGroupId: null } + } + const groups = listGroups(newRoot) + const stillThere = + prev.activeGroupId !== null && + groups.some((g) => g.id === prev.activeGroupId) + return { + root: newRoot, + activeGroupId: stillThere ? prev.activeGroupId : groups[0].id, + } +} diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index bc6cc5a314..d31c17c3ce 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback } from 'react' import { Outlet, createHashHistory, @@ -7,14 +7,9 @@ import { createRouter, useNavigate, useParams, - useSearch, } from '@tanstack/react-router' -import { useLiveQuery } from '@tanstack/react-db' -import { eq } from '@tanstack/db' import { z } from 'zod' -import { useServerConnection } from './hooks/useServerConnection' import { usePinnedEntities } from './hooks/usePinnedEntities' -import { useElectricAgents } from './lib/ElectricAgentsProvider' import { SidebarCollapsedProvider, useSidebarCollapsed, @@ -24,19 +19,20 @@ import { SearchPaletteProvider, useSearchPalette, } from './hooks/useSearchPalette' +import { WorkspaceProvider } from './hooks/useWorkspace' import { Sidebar } from './components/Sidebar' import { SearchPalette } from './components/SearchPalette' -import { EntityHeader } from './components/EntityHeader' import { NewSessionPage } from './components/NewSessionPage' -import { getView, listViews, type ViewId } from './lib/workspace/viewRegistry' -import { Stack } from './ui' +import { Workspace } from './components/workspace/Workspace' import styles from './router.module.css' function RootLayout(): React.ReactElement { return ( - + + + ) @@ -110,144 +106,17 @@ const entitySearchSchema = z.object({ view: z.string().optional(), }) +/** + * Thin route component — all the rendering work happens inside + * ``, which reads the route params (entity splat + ?view) + * via TanStack Router hooks and reflects them into the workspace + * tree. Keeping the route handler this small means the component tree + * underneath stays the same regardless of which entity is selected, + * which lets per-tile state (scroll, selection, etc.) survive + * navigation between entities. + */ function EntityPage(): React.ReactElement { - const { _splat } = useParams({ from: `/entity/$` }) - const entityUrl = `/${_splat}` - const { activeServer } = useServerConnection() - const { pinnedUrls, togglePin } = usePinnedEntities() - const { entitiesCollection, forkEntity, killEntity } = useElectricAgents() - const navigate = useNavigate() - const search = useSearch({ from: `/entity/$` }) - - const { data: matchingEntities = [] } = useLiveQuery( - (query) => { - if (!entitiesCollection) return undefined - return query - .from({ e: entitiesCollection }) - .where(({ e }) => eq(e.url, entityUrl)) - }, - [entitiesCollection, entityUrl] - ) - const selectedEntity = matchingEntities.at(0) ?? null - const isSpawning = selectedEntity?.status === `spawning` - const entityStopped = selectedEntity?.status === `stopped` - - // Resolve the active view from the URL. The first registered view - // (`chat`) is the implicit default when the param is absent or - // points at an unknown id — we never fail closed. - const requestedViewId = search.view as ViewId | undefined - const availableViews = selectedEntity ? listViews(selectedEntity) : [] - const defaultViewId = availableViews[0]?.id ?? `chat` - const activeViewId: ViewId = - requestedViewId && availableViews.some((v) => v.id === requestedViewId) - ? requestedViewId - : defaultViewId - - const setActiveView = useCallback( - (viewId: ViewId) => { - // Omit the param from the URL when it matches the default view — - // keeps `/entity/foo` clean for the chat case rather than always - // showing `?view=chat`. - void navigate({ - to: `/entity/$`, - params: { _splat }, - search: viewId === defaultViewId ? {} : { view: viewId }, - }) - }, - [navigate, _splat, defaultViewId] - ) - - const [killError, setKillError] = useState(null) - const [forkError, setForkError] = useState(null) - const [forking, setForking] = useState(false) - - const handleKill = useCallback(() => { - if (!killEntity) return - setKillError(null) - const tx = killEntity(entityUrl) - tx.isPersisted.promise.catch((err: Error) => { - setKillError(err.message) - }) - }, [killEntity, entityUrl]) - - const handleFork = useCallback(() => { - if (!forkEntity || forking) return - setForkError(null) - setForking(true) - forkEntity(entityUrl) - .then((root) => { - navigate({ - to: `/entity/$`, - params: { _splat: root.url.replace(/^\//, ``) }, - }) - }) - .catch((err: Error) => { - setForkError(err.message) - }) - .finally(() => { - setForking(false) - }) - }, [entityUrl, forkEntity, forking, navigate]) - - if (!selectedEntity) { - return ( - - Loading entity... - - ) - } - - const baseUrl = activeServer?.url ?? `` - const ViewComponent = getView(activeViewId)?.Component - - return ( - - togglePin(entityUrl)} - onKill={handleKill} - killError={killError} - onFork={forkEntity && !selectedEntity.parent ? handleFork : undefined} - forkError={forkError} - forking={forking} - currentViewId={activeViewId} - onSetView={setActiveView} - /> - - - {ViewComponent ? ( - - ) : ( - - Unknown view: {activeViewId} - - )} - - - - ) + return } const rootRoute = createRootRoute({ component: RootLayout }) From c979152eafb620bc9ff0a907f89df3ed97856429 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 4 May 2026 21:08:45 +0100 Subject: [PATCH 04/57] =?UTF-8?q?feat(agents-server-ui):=20SplitMenu,=20Vi?= =?UTF-8?q?ew=20=E2=96=B8=20submenu,=20and=20workspace=20hotkeys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the tile-based layout refactor (see TILE_LAYOUT_PLAN.md). Adds the unified per-tile '…' menu (SplitMenu), driven entirely by two reducer primitives — setTileView() and splitTileWithView() — so every menu item composes from those. SplitMenu structure (matches the Cursor screenshot): - Inspect (entity JSON dialog) - View ▸ ▸ Open here / Split right / down / left / up - parent-row click runs each view's defaultSplit (e.g. 'right' for State Explorer, restoring the muscle-memory of "drawer pops out to the right" from before stage 1) - Split right / down / left / up (duplicates the active tile) - Move tile to ▸ Group N (only shown when ≥2 groups) - Copy URL · Pin · Fork subtree (entity-level actions) - Close tile (⌘W) - Kill entity (with confirmation dialog) EntityHeader is now display-only: - title + status + view-toggle icon strip - a generic `menu` slot that the workspace fills with The Inspect / Kill confirm dialogs and all entity-action props (onKill, onFork, pin) move into SplitMenu. EntityHeader no longer knows about tiles, groups, or splits. Workspace hotkeys (useWorkspaceHotkeys, mounted in RootShell): - ⌘D Split active tile right - ⇧⌘D Split active tile down - ⌘W Close active tile - ⌘\ Cycle to next group - ⌘1..9 Focus group N State Explorer regains its "drawer to the right" UX as the *default* action of `View ▸ State Explorer` thanks to defaultSplit: 'right' on its registry entry — clicking the parent row splits it right; the deeper sub-menu lets power users put it elsewhere or open in place. Typecheck + tests green. Co-authored-by: Cursor --- .../src/components/EntityHeader.tsx | 203 ++-------- .../components/workspace/GroupContainer.tsx | 37 +- .../components/workspace/SplitMenu.module.css | 49 +++ .../src/components/workspace/SplitMenu.tsx | 379 ++++++++++++++++++ .../src/hooks/useWorkspaceHotkeys.ts | 93 +++++ packages/agents-server-ui/src/router.tsx | 3 + 6 files changed, 567 insertions(+), 197 deletions(-) create mode 100644 packages/agents-server-ui/src/components/workspace/SplitMenu.module.css create mode 100644 packages/agents-server-ui/src/components/workspace/SplitMenu.tsx create mode 100644 packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts diff --git a/packages/agents-server-ui/src/components/EntityHeader.tsx b/packages/agents-server-ui/src/components/EntityHeader.tsx index ebc1512ac4..50dfed3fa1 100644 --- a/packages/agents-server-ui/src/components/EntityHeader.tsx +++ b/packages/agents-server-ui/src/components/EntityHeader.tsx @@ -1,25 +1,7 @@ -import { useEffect, useRef, useState } from 'react' -import { - Check, - Copy, - Eye, - GitFork, - MoreHorizontal, - Pin, - PinOff, - Trash2, -} from 'lucide-react' +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { Check, Copy } from 'lucide-react' import { getEntityDisplayTitle } from '../lib/entityDisplay' -import { - Badge, - Button, - Dialog, - IconButton, - Menu, - Stack, - Text, - Tooltip, -} from '../ui' +import { Badge, IconButton, Text, Tooltip } from '../ui' import type { BadgeTone } from '../ui' import { MainHeader } from './MainHeader' import { listViews, type ViewId } from '../lib/workspace/viewRegistry' @@ -36,41 +18,50 @@ const STATUS_TONE: Record = { type EntityHeaderProps = { entity: ElectricEntity - pinned: boolean - onTogglePin: () => void - onFork?: () => void - onKill: () => void - killError?: string | null - forkError?: string | null - forking?: boolean /** ID of the currently-rendered view for this entity. */ currentViewId?: ViewId /** Switch the rendered view in-place (no layout change). */ onSetView?: (viewId: ViewId) => void + /** + * Optional slot for the tile menu (the `…` button at the right edge). + * The workspace passes its `` here. Kept generic so the + * header doesn't need to know about tiles / groups / splits. + */ + menu?: ReactNode + /** Optional banner of error messages displayed below the strip. */ + errors?: Array } /** - * Top of the entity-page column. A flat header strip with the session - * name + id on the left and an actions cluster on the right, plus a - * thin error strip below when kill / fork surface errors. + * Top of an entity tile. A flat strip with the session name + id on + * the left and an actions cluster on the right (view-toggle icons + + * caller-supplied menu), plus a thin error strip below when actions + * surface errors. * * No border-bottom — the strip shares the chat background so the * header reads as part of the same surface as the conversation below. */ -export function EntityHeader( - props: EntityHeaderProps -): React.ReactElement | null { - const { entity, killError, forkError } = props - const errors = [killError, forkError].filter( - (e): e is string => typeof e === `string` && e.length > 0 - ) +export function EntityHeader({ + entity, + currentViewId, + onSetView, + menu, + errors, +}: EntityHeaderProps): React.ReactElement | null { return ( <> } - actions={} + actions={ + + } /> - {errors.length > 0 && ( + {errors && errors.length > 0 && (
{errors.map((msg, i) => ( @@ -134,20 +125,16 @@ function EntityTitle({ function EntityActions({ entity, - pinned, - onTogglePin, - onFork, - onKill, - forking, currentViewId, onSetView, -}: EntityHeaderProps): React.ReactElement { - const [showInspect, setShowInspect] = useState(false) - const [showKillConfirm, setShowKillConfirm] = useState(false) - const { title: instanceName } = getEntityDisplayTitle(entity) - // The view registry is the source of truth for which view buttons / - // menu items appear. `defaultViewId` is the first registered view - // (`chat`) and is treated as implicit when no current view is set. + menu, +}: Pick< + EntityHeaderProps, + `entity` | `currentViewId` | `onSetView` | `menu` +>): React.ReactElement { + // The view registry is the source of truth for which view buttons + // appear. `defaultViewId` is the first registered view (`chat`) and + // is treated as implicit when no current view is set. const availableViews = onSetView ? listViews(entity) : [] const defaultViewId = availableViews[0]?.id const activeViewId = currentViewId ?? defaultViewId @@ -186,117 +173,7 @@ function EntityActions({ ) })} - - - - - } - /> - - setShowInspect(true)}> - - Inspect - - {onSetView && - availableViews - .filter((v) => v.id !== activeViewId) - .map((view) => { - const Icon = view.icon - return ( - onSetView(view.id)}> - - {view.label} - - ) - })} - { - void navigator.clipboard.writeText(entity.url) - }} - > - - Copy URL - - - {pinned ? : } - {pinned ? `Unpin` : `Pin`} - - {onFork && ( - - - {forking ? `Forking…` : `Fork subtree`} - - )} - {entity.status !== `stopped` && ( - <> - - {/* Destructive intent is communicated by the verb ("Kill") - + the confirm dialog that follows — not by tinting the - icon red. Keeps the menu uniformly neutral. */} - setShowKillConfirm(true)}> - - Kill - - - )} - - - - - - Entity details -
-            {JSON.stringify(entity, null, 2)}
-          
- - - Close - - } - /> - -
-
- - - - Kill entity - - Are you sure you want to kill {instanceName}? The entity will stop - processing and its stream will become read-only. - - - - Cancel - - } - /> - - - - + {menu} ) } diff --git a/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx b/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx index 900640a0f9..1cc7fc15b5 100644 --- a/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx +++ b/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx @@ -1,15 +1,14 @@ import { useCallback, useEffect } from 'react' import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' -import { useNavigate } from '@tanstack/react-router' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' import { useServerConnection } from '../../hooks/useServerConnection' -import { usePinnedEntities } from '../../hooks/usePinnedEntities' import { useWorkspace } from '../../hooks/useWorkspace' import { getView } from '../../lib/workspace/viewRegistry' import { EntityHeader } from '../EntityHeader' import { Stack } from '../../ui' import { TabStrip } from './TabStrip' +import { SplitMenu } from './SplitMenu' import type { Group, Tile } from '../../lib/workspace/types' import type { ViewId } from '../../lib/workspace/viewRegistry' import styles from './GroupContainer.module.css' @@ -58,10 +57,8 @@ function ActiveTileBody({ tile: Tile }): React.ReactElement { const { activeServer } = useServerConnection() - const { pinnedUrls, togglePin } = usePinnedEntities() - const { entitiesCollection, forkEntity, killEntity } = useElectricAgents() + const { entitiesCollection } = useElectricAgents() const { helpers } = useWorkspace() - const navigate = useNavigate() const { data: matches = [] } = useLiveQuery( (q) => { @@ -76,31 +73,6 @@ function ActiveTileBody({ const isSpawning = entity?.status === `spawning` const entityStopped = entity?.status === `stopped` - // Kill / fork are tile-local actions; the errors shown are the - // tile's own concern, so they live as state inside the tile body - // rather than at workspace level. - const handleKill = useCallback(() => { - if (!killEntity) return - const tx = killEntity(tile.entityUrl) - tx.isPersisted.promise.catch(() => { - // Errors surface in EntityHeader's error bar via prop wiring, - // but Stage 2 omits the error-passing for brevity since the kill - // surface is unchanged from before; revisit if it regresses. - }) - }, [killEntity, tile.entityUrl]) - - const handleFork = useCallback(() => { - if (!forkEntity) return - forkEntity(tile.entityUrl) - .then((root) => { - navigate({ - to: `/entity/$`, - params: { _splat: root.url.replace(/^\//, ``) }, - }) - }) - .catch(() => {}) - }, [forkEntity, tile.entityUrl, navigate]) - const setView = useCallback( (viewId: ViewId) => helpers.setTileView(tile.id, viewId), [helpers, tile.id] @@ -133,12 +105,9 @@ function ActiveTileBody({ togglePin(tile.entityUrl)} - onKill={handleKill} - onFork={forkEntity && !entity.parent ? handleFork : undefined} currentViewId={tile.viewId} onSetView={setView} + menu={} /> {View ? ( = [ + { dir: `right`, label: `Split right`, shortcut: modKeyLabel(`d`) }, + { + dir: `down`, + label: `Split down`, + shortcut: modKeyLabel({ letter: `d`, shift: true }), + }, + { dir: `left`, label: `Split left` }, + { dir: `up`, label: `Split up` }, +] + +/** + * Per-tile workspace menu. Shown in the tile header (replacing the + * old "more actions" menu) and contains: + * + * - **View ▸ {viewId}** switch the active tile's view in + * place; each leaf is itself a sub-menu + * with `Open here / Split right / Split + * down / Split left / Split up`. + * - **Split right / down / left / up** duplicates the active tile into a + * new group split in that direction. + * - **Copy URL · Pin · Fork · Kill** entity-level actions, mirroring the + * actions previously surfaced from + * `EntityHeader`. + * - **Close tile / Close group** layout cleanup. + * + * Splitting and view-switching share two primitives — `setTileView` and + * `splitTileWithView` — so every menu item composes from those. + */ +export function SplitMenu({ + tile, + groupId, + entity, +}: { + tile: Tile + groupId: string + entity: ElectricEntity +}): React.ReactElement { + const { workspace, helpers } = useWorkspace() + const { forkEntity, killEntity } = useElectricAgents() + const { pinnedUrls, togglePin } = usePinnedEntities() + const navigate = useNavigate() + const pinned = pinnedUrls.includes(tile.entityUrl) + const [showInspect, setShowInspect] = useState(false) + const [showKillConfirm, setShowKillConfirm] = useState(false) + const { title: instanceName } = getEntityDisplayTitle(entity) + + // Look up the group's siblings to enable "Move tile to → Group N". + const groups = useMemo(() => { + const out: Array<{ id: string; idx: number }> = [] + if (!workspace.root) return out + const collect = (node: typeof workspace.root, idx = { n: 0 }): void => { + if (!node) return + if (node.kind === `group`) { + out.push({ id: node.id, idx: ++idx.n }) + } else { + for (const c of node.children) collect(c.node, idx) + } + } + collect(workspace.root) + return out + }, [workspace.root]) + const otherGroups = groups.filter((g) => g.id !== groupId) + const groupCount = groups.length + + const handleSplit = (dir: SplitDirection) => { + helpers.splitTile(tile.id, dir) + } + + const availableViews = listViews(entity) + + const handleFork = () => { + if (!forkEntity) return + void forkEntity(tile.entityUrl) + .then((root) => + navigate({ + to: `/entity/$`, + params: { _splat: root.url.replace(/^\//, ``) }, + }) + ) + .catch(() => {}) + } + + const handleKill = () => { + if (!killEntity) return + const tx = killEntity(tile.entityUrl) + tx.isPersisted.promise.catch(() => {}) + } + + return ( + + + + + } + /> + + setShowInspect(true)}> + + Inspect + + + + + {/* ---- View ▸ ----------------------------------------------- */} + + + View + + + + {availableViews.map((view) => { + const Icon = view.icon + return ( + } + description={view.description} + defaultSplit={view.defaultSplit} + isActive={view.id === tile.viewId} + onOpenHere={() => helpers.setTileView(tile.id, view.id)} + onSplit={(dir) => + helpers.splitTileWithView(tile.id, view.id, dir) + } + /> + ) + })} + + + + + + {/* ---- Split current tile ----------------------------------- */} + {SPLIT_DIRECTIONS.map(({ dir, label, shortcut }) => ( + handleSplit(dir)}> + {dir === `right` || dir === `left` ? ( + + ) : ( + + )} + {label} + {shortcut && {shortcut}} + + ))} + + {/* ---- Move tile to → another group ------------------------- */} + {otherGroups.length > 0 && ( + <> + + + + Move tile to + + + + {otherGroups.map((g) => ( + + helpers.moveTile(tile.id, { + groupId: g.id, + position: `append`, + }) + } + > + Group {g.idx} + + ))} + + + + )} + + + + {/* ---- Entity actions --------------------------------------- */} + { + void navigator.clipboard.writeText(tile.entityUrl) + }} + > + + Copy URL + + togglePin(tile.entityUrl)}> + {pinned ? : } + {pinned ? `Unpin` : `Pin`} + + {forkEntity && !entity.parent && ( + + + Fork subtree + + )} + + + + {/* ---- Layout cleanup --------------------------------------- */} + helpers.closeTile(tile.id)} + tone={groupCount === 1 ? `default` : `default`} + > + + Close tile + {modKeyLabel(`w`)} + + + {entity.status !== `stopped` && killEntity && ( + <> + + setShowKillConfirm(true)} tone="danger"> + + Kill entity + + + )} + + + + + Entity details +
+            {JSON.stringify(entity, null, 2)}
+          
+ + + Close + + } + /> + +
+
+ + + + Kill entity + + Are you sure you want to kill {instanceName}? The entity will stop + processing and its stream will become read-only. + + + + Cancel + + } + /> + + + + +
+ ) +} + +function ViewSubmenu({ + label, + icon, + description, + defaultSplit, + isActive, + onOpenHere, + onSplit, +}: { + label: string + icon: React.ReactNode + description?: string + defaultSplit?: `right` | `down` + isActive: boolean + onOpenHere: () => void + onSplit: (dir: SplitDirection) => void +}): React.ReactElement { + // Clicking the parent row directly dispatches the view's preferred + // action — `defaultSplit` if it's set, else "open here". This is what + // makes `View ▸ State Explorer` keep the "drawer pops out to the right" + // muscle-memory without forcing the user into the deeper menu. + const onParentSelect = () => { + if (defaultSplit) onSplit(defaultSplit) + else onOpenHere() + } + + return ( + + + {icon} + {label} + {isActive && ( + + ✓ + + )} + + + + {description && ( + + + {description} + + + )} + + Open here + + + {([`right`, `down`, `left`, `up`] as const).map((dir) => ( + onSplit(dir)}> + Split {dir} + {dir === defaultSplit && ( + default + )} + + ))} + + + ) +} diff --git a/packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts b/packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts new file mode 100644 index 0000000000..fbdcdc4d6f --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts @@ -0,0 +1,93 @@ +import { useCallback } from 'react' +import { useHotkey } from './useHotkey' +import { useWorkspace, listGroups } from './useWorkspace' + +const GROUP_FOCUS_INDICES = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const + +/** + * Workspace-level keyboard shortcuts. Mounted once near the top of the + * tree (inside `WorkspaceProvider`) so they're active on every screen. + * + * The keymap mirrors the on-screen menu items in `` so users + * who learn one channel can use the other. + * + * - `⌘D` Split active tile right + * - `⇧⌘D` Split active tile down + * - `⌘W` Close active tile + * - `⌘1..9` Focus group N (1-indexed) + * - `⌘\` Cycle to the next group + * + * Hotkeys are skipped when focus is in a text input (handled by + * `useHotkey`'s default `ignoreInputs: true` behaviour). + */ +export function useWorkspaceHotkeys(): void { + const { workspace, helpers } = useWorkspace() + + useHotkey(`mod+d`, (e) => { + if (!helpers.activeTile) return + e.preventDefault() + helpers.splitTile(helpers.activeTile.id, `right`) + }) + + useHotkey(`mod+shift+d`, (e) => { + if (!helpers.activeTile) return + e.preventDefault() + helpers.splitTile(helpers.activeTile.id, `down`) + }) + + useHotkey(`mod+w`, (e) => { + if (!helpers.activeTile) return + e.preventDefault() + helpers.closeTile(helpers.activeTile.id) + }) + + useHotkey(`mod+\\`, (e) => { + e.preventDefault() + const groups = listGroups(workspace.root) + if (groups.length < 2) return + const currentIdx = groups.findIndex((g) => g.id === workspace.activeGroupId) + const next = groups[(currentIdx + 1) % groups.length] + helpers.setActiveGroup(next.id) + }) + + // The 9 group-focus hotkeys are registered as separate hook calls + // (rather than a `for` loop) so the call count is statically obvious + // — keeping React's rules-of-hooks happy without an eslint-disable. + // Each handler closes over its own `n` via the const array above. + const focusGroup = useCallback( + (n: number, e: KeyboardEvent) => { + const groups = listGroups(workspace.root) + if (groups.length < n) return + e.preventDefault() + helpers.setActiveGroup(groups[n - 1].id) + }, + [workspace.root, helpers] + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[0]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[0], e) + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[1]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[1], e) + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[2]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[2], e) + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[3]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[3], e) + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[4]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[4], e) + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[5]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[5], e) + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[6]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[6], e) + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[7]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[7], e) + ) + useHotkey(`mod+${GROUP_FOCUS_INDICES[8]}`, (e) => + focusGroup(GROUP_FOCUS_INDICES[8], e) + ) +} diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index d31c17c3ce..ed511feec5 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -20,6 +20,7 @@ import { useSearchPalette, } from './hooks/useSearchPalette' import { WorkspaceProvider } from './hooks/useWorkspace' +import { useWorkspaceHotkeys } from './hooks/useWorkspaceHotkeys' import { Sidebar } from './components/Sidebar' import { SearchPalette } from './components/SearchPalette' import { NewSessionPage } from './components/NewSessionPage' @@ -65,6 +66,8 @@ function RootShell(): React.ReactElement { useHotkey(`mod+n`, openNewSession) useHotkey(`mod+shift+o`, openNewSession) + useWorkspaceHotkeys() + const navigateToEntity = useCallback( (entityUrl: string) => { navigate({ From 473992e28cee6db835a2970752f936f30e1d0b9c Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 4 May 2026 21:14:31 +0100 Subject: [PATCH 05/57] feat(agents-server-ui): drag-and-drop tiles between groups (5-zone overlay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4 of the tile-based layout refactor (see TILE_LAYOUT_PLAN.md). Native HTML5 drag-and-drop, no react-dnd. Two payload kinds carried under a custom `application/vnd.electric-tile+json` MIME type: - sidebar-entity { entityUrl } - tile { tileId, sourceGroupId } DropOverlay (per-group): - mounted on every group, position:absolute - pointer-events default to none; window-level dragstart/dragend listeners arm/disarm the overlay so splitter drags / text selection in the body aren't interrupted when no drag is in progress - on dragover, computes the active zone (centre 25% inset square + 4 edge slabs joined at the centre) and highlights it - on drop, dispatches: - moveTile(tileId, { groupId, position }) for tile drags - openEntity(entityUrl, { target: { groupId, position }}) for sidebar drags - silently no-ops when dropping a tile back onto its source group's centre (avoids a redundant reducer round-trip) Sidebar rows: - now `draggable` with the sidebar-entity payload - ⌘/Ctrl-click + middle-click open the entity in a new split right of the active group (matches VS Code's "open to side") - routed through `helpers.openEntity({ target: { position: 'split-right' }})` in RootShell Tabs (TabStrip): - now `draggable` with the tile payload - middle-click already closes (preserved from stage 2) - click activates (preserved from stage 2) GroupContainer: - gains a position:relative wrapper for the overlay - adds a subtle inset ring on the active group when there's >1 group (so the user knows where new tiles will land for a sidebar click) Drop semantics summary: - centre → append as new tab (or replace if dropping on self) - north → new horizontal split, this tile on top - east → new vertical split, this tile on the right - south → new horizontal split, this tile on the bottom - west → new vertical split, this tile on the left Typecheck + tests green. Co-authored-by: Cursor --- .../src/components/Sidebar.tsx | 9 + .../src/components/SidebarRow.tsx | 44 ++++- .../src/components/SidebarTree.tsx | 9 + .../workspace/DropOverlay.module.css | 66 +++++++ .../src/components/workspace/DropOverlay.tsx | 167 ++++++++++++++++++ .../workspace/GroupContainer.module.css | 11 ++ .../components/workspace/GroupContainer.tsx | 26 ++- .../src/components/workspace/TabStrip.tsx | 9 + .../src/lib/workspace/dragPayload.ts | 99 +++++++++++ packages/agents-server-ui/src/router.tsx | 22 ++- 10 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 packages/agents-server-ui/src/components/workspace/DropOverlay.module.css create mode 100644 packages/agents-server-ui/src/components/workspace/DropOverlay.tsx create mode 100644 packages/agents-server-ui/src/lib/workspace/dragPayload.ts diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index d03d7c71f4..1d64c766c8 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -44,11 +44,18 @@ function useSidebarWidth(): readonly [number, (w: number) => void] { export function Sidebar({ selectedEntityUrl, onSelectEntity, + onOpenEntityInSplit, pinnedUrls, onTogglePin, }: { selectedEntityUrl: string | null onSelectEntity: (url: string) => void + /** + * Optional ⌘/Ctrl-click + middle-click handler — opens an entity in + * a new split rather than replacing the active tile. Routed through + * the workspace helpers in `RootShell`. + */ + onOpenEntityInSplit?: (url: string) => void pinnedUrls: Array onTogglePin: (url: string) => void }): React.ReactElement { @@ -177,6 +184,7 @@ export function Sidebar({ childrenByParent={childrenByParent} selectedEntityUrl={selectedEntityUrl} onSelectEntity={onSelectEntity} + onOpenEntityInSplit={onOpenEntityInSplit} pinnedUrls={pinnedUrls} onTogglePin={onTogglePin} hoverHandle={hoverHandle} @@ -194,6 +202,7 @@ export function Sidebar({ childrenByParent={childrenByParent} selectedEntityUrl={selectedEntityUrl} onSelectEntity={onSelectEntity} + onOpenEntityInSplit={onOpenEntityInSplit} pinnedUrls={pinnedUrls} onTogglePin={onTogglePin} hoverHandle={hoverHandle} diff --git a/packages/agents-server-ui/src/components/SidebarRow.tsx b/packages/agents-server-ui/src/components/SidebarRow.tsx index c674880347..e812c5baf4 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.tsx +++ b/packages/agents-server-ui/src/components/SidebarRow.tsx @@ -4,6 +4,7 @@ import { StatusDot } from './StatusDot' import { HoverCard, Text } from '../ui' import { getEntityDisplayTitle } from '../lib/entityDisplay' import { formatAbsoluteDateTime, formatRelativeTime } from '../lib/formatTime' +import { setDragPayload } from '../lib/workspace/dragPayload' import styles from './SidebarRow.module.css' import type { ElectricEntity } from '../lib/ElectricAgentsProvider' @@ -32,7 +33,20 @@ type HoverCardHandle = ReturnType< type SidebarRowProps = { entity: ElectricEntity selected: boolean + /** + * Triggered for plain clicks. The row dispatches a different action + * for modifier-clicks (e.g. ⌘-click → open in new split); those go + * through `onOpenInSplit` instead so the sidebar can decide on a + * per-app basis what those modifiers mean. + */ onSelect: () => void + /** + * Optional: triggered when the user ⌘/Ctrl-clicks the row (or + * middle-clicks). Used to open the entity in a new split rather + * than replacing the active tile. The sidebar wires this to + * `helpers.openEntity(url, { target: { groupId, position: 'split-right' }})`. + */ + onOpenInSplit?: () => void depth?: number /** Number of immediate children. 0 means no expand affordance. */ childCount?: number @@ -79,6 +93,7 @@ export const SidebarRow = memo(function SidebarRow({ entity, selected, onSelect, + onOpenInSplit, depth = 0, childCount = 0, expanded = false, @@ -115,7 +130,34 @@ export const SidebarRow = memo(function SidebarRow({ role="button" tabIndex={0} className={className} - onClick={onSelect} + draggable + onDragStart={(e) => { + setDragPayload(e, { + kind: `sidebar-entity`, + entityUrl: entity.url, + }) + }} + onClick={(e) => { + // ⌘/Ctrl-click or middle-click → open in new split (when + // the sidebar wired up an `onOpenInSplit` handler); + // otherwise plain selection. Matches VS Code's + // ⌘-click-on-file-tree → open to side. + if (onOpenInSplit && (e.metaKey || e.ctrlKey || e.button === 1)) { + e.preventDefault() + onOpenInSplit() + return + } + onSelect() + }} + onAuxClick={(e) => { + // Middle-click also opens in split (button 1 doesn't always + // fire onClick on every browser; onAuxClick is the + // canonical handler). + if (onOpenInSplit && e.button === 1) { + e.preventDefault() + onOpenInSplit() + } + }} onKeyDown={(e) => { if (e.key === `Enter` || e.key === ` `) { e.preventDefault() diff --git a/packages/agents-server-ui/src/components/SidebarTree.tsx b/packages/agents-server-ui/src/components/SidebarTree.tsx index 606233f2e0..732a1c3a0b 100644 --- a/packages/agents-server-ui/src/components/SidebarTree.tsx +++ b/packages/agents-server-ui/src/components/SidebarTree.tsx @@ -11,6 +11,8 @@ type SidebarTreeProps = { childrenByParent: Map> selectedEntityUrl: string | null onSelectEntity: (url: string) => void + /** Optional ⌘/Ctrl-click handler — opens the entity in a new split. */ + onOpenEntityInSplit?: (url: string) => void pinnedUrls: ReadonlyArray onTogglePin: (url: string) => void depth?: number @@ -65,6 +67,7 @@ export const SidebarTree = memo(function SidebarTree({ childrenByParent, selectedEntityUrl, onSelectEntity, + onOpenEntityInSplit, pinnedUrls, onTogglePin, depth = 0, @@ -90,6 +93,11 @@ export const SidebarTree = memo(function SidebarTree({ entity={entity} selected={entity.url === selectedEntityUrl} onSelect={() => onSelectEntity(entity.url)} + onOpenInSplit={ + onOpenEntityInSplit + ? () => onOpenEntityInSplit(entity.url) + : undefined + } depth={depth} childCount={children.length} expanded={expanded} @@ -107,6 +115,7 @@ export const SidebarTree = memo(function SidebarTree({ childrenByParent={childrenByParent} selectedEntityUrl={selectedEntityUrl} onSelectEntity={onSelectEntity} + onOpenEntityInSplit={onOpenEntityInSplit} pinnedUrls={pinnedUrls} onTogglePin={onTogglePin} depth={depth + 1} diff --git a/packages/agents-server-ui/src/components/workspace/DropOverlay.module.css b/packages/agents-server-ui/src/components/workspace/DropOverlay.module.css new file mode 100644 index 0000000000..95ffa3e1d6 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/DropOverlay.module.css @@ -0,0 +1,66 @@ +/* The overlay is always mounted on every group but pointer-events:none + * by default — only the active drag turns events on. Without that + * toggle, splitter drags in adjacent panes get hijacked. */ +.overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10; +} + +.armed { + pointer-events: auto; +} + +.zone { + position: absolute; + background: var(--ds-accent-a4); + border: 1px solid var(--ds-accent-a8); + opacity: 0; + transition: + opacity 0.08s ease-out, + background 0.08s ease-out; + border-radius: 4px; + pointer-events: none; +} + +.zoneActive { + opacity: 1; + background: var(--ds-accent-a6); +} + +/* Five named zones — geometry computed in JS so the same component + * works for any group size. The visual rectangles mirror Cursor's chat + * pane drop targets: a centre square that "appends as new tab" plus + * four edge slabs that create new splits. */ +.center { + inset: 25%; +} + +.north { + top: 0; + left: 0; + right: 0; + height: 25%; +} + +.east { + top: 0; + right: 0; + bottom: 0; + width: 25%; +} + +.south { + bottom: 0; + left: 0; + right: 0; + height: 25%; +} + +.west { + top: 0; + left: 0; + bottom: 0; + width: 25%; +} diff --git a/packages/agents-server-ui/src/components/workspace/DropOverlay.tsx b/packages/agents-server-ui/src/components/workspace/DropOverlay.tsx new file mode 100644 index 0000000000..dd2a8ff5b4 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/DropOverlay.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useWorkspace } from '../../hooks/useWorkspace' +import { + isWorkspaceDrag, + readDragPayload, +} from '../../lib/workspace/dragPayload' +import type { DropPosition } from '../../lib/workspace/types' +import styles from './DropOverlay.module.css' + +/** + * Visualises and resolves the 5-zone drop target on top of a Group. + * + * Wraps a containing relative element. When a workspace drag starts + * anywhere in the document we "arm" — pointer-events flip on so this + * element can intercept `dragover`/`drop` events. While armed, we + * compute which of the 5 zones the cursor is in (using the group's + * client rect + a 25% inset for the centre square) and highlight that + * zone. On `drop` we either: + * + * - move an existing tile into this group (`tile` payload), or + * - open a sidebar entity into this group (`sidebar-entity` payload). + * + * The overlay is the only DnD-aware element per group — keeping the + * pointer-events toggle here means splitter drags / text selection in + * the body aren't affected when no drag is in progress. + */ +export function DropOverlay({ + groupId, + containerRef, +}: { + groupId: string + containerRef: React.RefObject +}): React.ReactElement { + const { helpers } = useWorkspace() + const [armed, setArmed] = useState(false) + const [hoverZone, setHoverZone] = useState(null) + const overlayRef = useRef(null) + + // Arm whenever a workspace drag starts anywhere in the window. Use + // window-level listeners (rather than wiring `dragstart` on every + // draggable source) so adding a new draggable source doesn't need + // changes here. + useEffect(() => { + const onStart = (e: DragEvent) => { + if (!isWorkspaceDrag(e)) return + setArmed(true) + } + const onEnd = () => { + setArmed(false) + setHoverZone(null) + } + window.addEventListener(`dragstart`, onStart) + window.addEventListener(`dragend`, onEnd) + window.addEventListener(`drop`, onEnd) + return () => { + window.removeEventListener(`dragstart`, onStart) + window.removeEventListener(`dragend`, onEnd) + window.removeEventListener(`drop`, onEnd) + } + }, []) + + const computeZone = useCallback( + (e: React.DragEvent): Zone | null => { + const el = containerRef.current + if (!el) return null + const rect = el.getBoundingClientRect() + const x = (e.clientX - rect.left) / rect.width + const y = (e.clientY - rect.top) / rect.height + // Outside (e.g. cursor wandered off): no zone. + if (x < 0 || x > 1 || y < 0 || y > 1) return null + // Centre square (matches CSS .center inset:25%) + if (x >= 0.25 && x <= 0.75 && y >= 0.25 && y <= 0.75) return `center` + // Pick the dominant edge by relative distance from the centre. + // We compare normalised |x-.5| vs |y-.5| so square groups map + // cleanly into 4 triangle slabs joined at the centre. + const dx = Math.abs(x - 0.5) + const dy = Math.abs(y - 0.5) + if (dx > dy) return x < 0.5 ? `west` : `east` + return y < 0.5 ? `north` : `south` + }, + [containerRef] + ) + + const onDragOver = useCallback( + (e: React.DragEvent) => { + if (!isWorkspaceDrag(e)) return + e.preventDefault() + e.dataTransfer.dropEffect = `move` + const z = computeZone(e) + setHoverZone(z) + }, + [computeZone] + ) + + const onDragLeave = useCallback((e: React.DragEvent) => { + // Only clear when leaving the overlay element itself, not when + // the cursor crosses a child element inside it. + if (e.currentTarget === overlayRef.current) { + setHoverZone(null) + } + }, []) + + const onDrop = useCallback( + (e: React.DragEvent) => { + const z = computeZone(e) + const payload = readDragPayload(e) + setHoverZone(null) + setArmed(false) + if (!z || !payload) return + e.preventDefault() + const position = ZONE_TO_POSITION[z] + + if (payload.kind === `tile`) { + // No-op when dropping a tile back onto its source group's + // centre — the reducer's same-group append handles this, but + // skipping the dispatch saves a render. + if ( + payload.sourceGroupId === groupId && + (position === `append` || position === `replace`) + ) { + return + } + helpers.moveTile(payload.tileId, { groupId, position }) + } else { + helpers.openEntity(payload.entityUrl, { + viewId: payload.viewId, + target: { groupId, position }, + }) + } + }, + [computeZone, helpers, groupId] + ) + + const cls = [styles.overlay, armed ? styles.armed : null] + .filter(Boolean) + .join(` `) + + return ( +
+ {([`center`, `north`, `east`, `south`, `west`] as const).map((z) => ( +
+ ))} +
+ ) +} + +type Zone = `center` | `north` | `east` | `south` | `west` + +const ZONE_TO_POSITION: Record = { + center: `append`, + north: `split-up`, + east: `split-right`, + south: `split-down`, + west: `split-left`, +} diff --git a/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css b/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css index 224ab72c10..146d374d8f 100644 --- a/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css +++ b/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css @@ -6,6 +6,17 @@ min-height: 0; overflow: hidden; background: var(--ds-bg); + /* Required for `` (position: absolute; inset: 0) to + * cover only this group and not bleed into adjacent groups. */ + position: relative; +} + +.activeGroup { + /* Subtle outline on the active group so the user knows where new + * tiles will land when they click a sidebar entity. Inset to avoid + * pushing surrounding splitters around (border would change layout + * by 1px). */ + box-shadow: inset 0 0 0 1px var(--ds-accent-a6); } .body { diff --git a/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx b/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx index 1cc7fc15b5..769e191920 100644 --- a/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx +++ b/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' @@ -9,6 +9,7 @@ import { EntityHeader } from '../EntityHeader' import { Stack } from '../../ui' import { TabStrip } from './TabStrip' import { SplitMenu } from './SplitMenu' +import { DropOverlay } from './DropOverlay' import type { Group, Tile } from '../../lib/workspace/types' import type { ViewId } from '../../lib/workspace/viewRegistry' import styles from './GroupContainer.module.css' @@ -27,6 +28,7 @@ export function GroupContainer({ }): React.ReactElement { const { helpers, workspace } = useWorkspace() const isActiveGroup = workspace.activeGroupId === group.id + const groupRef = useRef(null) // Click anywhere inside the group's chrome to make it the active // group. Wired on the outer wrapper rather than just the tab strip so @@ -39,16 +41,36 @@ export function GroupContainer({ const activeTile = group.tiles.find((t) => t.id === group.activeTileId) ?? group.tiles[0] + // Active-group ring is only shown when there's more than one group — + // otherwise the ring is just visual noise (every solo tile would be + // ringed always). Matches VS Code's behaviour for single-group + // workbenches. + const groupCount = countGroups(workspace.root) + const showActiveRing = isActiveGroup && groupCount > 1 + return ( -
+
{activeTile ? ( ) : null} +
) } +function countGroups( + node: import(`../../lib/workspace/types`).WorkspaceNode | null +): number { + if (!node) return 0 + if (node.kind === `group`) return 1 + return node.children.reduce((acc, c) => acc + countGroups(c.node), 0) +} + function ActiveTileBody({ groupId, tile, diff --git a/packages/agents-server-ui/src/components/workspace/TabStrip.tsx b/packages/agents-server-ui/src/components/workspace/TabStrip.tsx index 90f0022e0d..464033ed18 100644 --- a/packages/agents-server-ui/src/components/workspace/TabStrip.tsx +++ b/packages/agents-server-ui/src/components/workspace/TabStrip.tsx @@ -5,6 +5,7 @@ import { useElectricAgents } from '../../lib/ElectricAgentsProvider' import { useWorkspace } from '../../hooks/useWorkspace' import { getView } from '../../lib/workspace/viewRegistry' import { getEntityDisplayTitle } from '../../lib/entityDisplay' +import { setDragPayload } from '../../lib/workspace/dragPayload' import type { Group, Tile } from '../../lib/workspace/types' import styles from './TabStrip.module.css' @@ -47,6 +48,14 @@ export function TabStrip({ role="tab" aria-selected={active} className={`${styles.tab} ${active ? styles.activeTab : ``}`} + draggable + onDragStart={(e) => + setDragPayload(e, { + kind: `tile`, + tileId: tile.id, + sourceGroupId: group.id, + }) + } onClick={() => helpers.setActiveTile(tile.id)} onMouseDown={(e) => onMiddleClickClose(e, tile.id)} > diff --git a/packages/agents-server-ui/src/lib/workspace/dragPayload.ts b/packages/agents-server-ui/src/lib/workspace/dragPayload.ts new file mode 100644 index 0000000000..adbe4dc706 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/dragPayload.ts @@ -0,0 +1,99 @@ +import type { ViewId } from './viewRegistry' + +/** + * Payload carried by every workspace drag operation. Encoded as JSON + * into the `dataTransfer` slot under our custom MIME type so the browser + * doesn't try to interpret it as text/plain or a URL. + * + * Two kinds today: + * - `sidebar-entity` — the user dragged an entity row out of the sidebar. + * No `viewId` is carried because the receiving group decides how to + * render it (defaults to `chat`). + * - `tile` — the user dragged an existing tile (from a tab or a tile + * header). Carries the source group id so the reducer can detect a + * no-op (drop-on-self) and skip the round-trip. + */ +export type WorkspaceDragPayload = + | { + kind: `sidebar-entity` + entityUrl: string + viewId?: ViewId + } + | { + kind: `tile` + tileId: string + sourceGroupId: string + } + +/** + * Custom MIME type for our payload. Browsers expose drag types in + * lowercase, so we never check this string with case-sensitivity. The + * `application/vnd.electric-tile+json` form follows RFC 6838 vendor + * tree conventions to make it obvious this is our app's private wire + * format. + */ +export const DRAG_MIME = `application/vnd.electric-tile+json` + +export function setDragPayload( + e: DragEvent | React.DragEvent, + payload: WorkspaceDragPayload +): void { + const dt = (e as DragEvent).dataTransfer + if (!dt) return + dt.setData(DRAG_MIME, JSON.stringify(payload)) + // Some browsers (Safari especially) only honour text/plain — set a + // human-readable fallback too. The receiver always reads the typed + // form first. + dt.setData(`text/plain`, describePayload(payload)) + dt.effectAllowed = `move` +} + +export function readDragPayload( + e: DragEvent | React.DragEvent +): WorkspaceDragPayload | null { + const dt = (e as DragEvent).dataTransfer + if (!dt) return null + const raw = dt.getData(DRAG_MIME) + if (!raw) return null + try { + const parsed = JSON.parse(raw) as WorkspaceDragPayload + if ( + parsed.kind === `sidebar-entity` && + typeof parsed.entityUrl === `string` + ) { + return parsed + } + if ( + parsed.kind === `tile` && + typeof parsed.tileId === `string` && + typeof parsed.sourceGroupId === `string` + ) { + return parsed + } + } catch { + // Malformed payload — silently ignore; the drop becomes a no-op. + } + return null +} + +/** + * Sniff the dataTransfer types list during `dragover` (when the actual + * payload data isn't readable for security reasons in most browsers, + * only the type list is). Used to gate `dragover`'s `preventDefault` + * so we only intercept drags that originated inside the workspace. + */ +export function isWorkspaceDrag(e: DragEvent | React.DragEvent): boolean { + const dt = (e as DragEvent).dataTransfer + if (!dt) return false + // dt.types is a DOMStringList (or string[]) depending on browser. + for (let i = 0; i < dt.types.length; i++) { + if (dt.types[i].toLowerCase() === DRAG_MIME) return true + } + return false +} + +function describePayload(p: WorkspaceDragPayload): string { + return p.kind === `sidebar-entity` + ? `entity: ${p.entityUrl}` + : `tile: ${p.tileId}` +} diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index ed511feec5..387fca464a 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -19,7 +19,7 @@ import { SearchPaletteProvider, useSearchPalette, } from './hooks/useSearchPalette' -import { WorkspaceProvider } from './hooks/useWorkspace' +import { WorkspaceProvider, useWorkspace } from './hooks/useWorkspace' import { useWorkspaceHotkeys } from './hooks/useWorkspaceHotkeys' import { Sidebar } from './components/Sidebar' import { SearchPalette } from './components/SearchPalette' @@ -44,6 +44,7 @@ function RootShell(): React.ReactElement { const navigate = useNavigate() const { collapsed, toggle } = useSidebarCollapsed() const search = useSearchPalette() + const { helpers } = useWorkspace() useHotkey(`mod+b`, toggle) useHotkey(`mod+k`, (e) => { @@ -78,6 +79,24 @@ function RootShell(): React.ReactElement { [navigate] ) + // ⌘/Ctrl-click + middle-click on a sidebar row → open the entity in + // a new split to the right of the active group, rather than replacing + // the active tile (matches VS Code's "open to side" gesture). + const openEntityInSplit = useCallback( + (entityUrl: string) => { + const groupId = helpers.activeGroupId + if (!groupId) { + // Empty workspace — fall through to plain navigation. + navigateToEntity(entityUrl) + return + } + helpers.openEntity(entityUrl, { + target: { groupId, position: `split-right` }, + }) + }, + [helpers, navigateToEntity] + ) + const params = useParams({ strict: false }) const splat = (params as Record)._splat const selectedEntityUrl = splat ? `/${splat}` : null @@ -88,6 +107,7 @@ function RootShell(): React.ReactElement { From 7d6f83a564e4e8ec2504baf8f7697da05756020c Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 4 May 2026 21:22:41 +0100 Subject: [PATCH 06/57] feat(agents-server-ui): workspace persistence + shareable layout URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 5 of the tile-based layout refactor (see TILE_LAYOUT_PLAN.md). Implements the §3.4 hybrid URL strategy end-to-end. Layout codec (lib/workspace/layoutCodec.ts): Compact, human-readable, URL-safe DSL for serialising the workspace tree. Distinct separators remove parse ambiguity: ',' = split-sibling ';' = group-tab '.' = entityUrl/viewId Examples (canonical encoded form): horton%2Ffoo.chat horton%2Ffoo.chat;horton%2Ffoo.state-explorer@1 H(horton%2Ffoo.chat:60,horton%2Ffoo.state-explorer:40) H(horton%2Ffoo.chat,V(horton%2Fbar.chat,horton%2Fbaz.logs)) Encoder strips the conventional leading '/' on entity URLs and omits sizes that match the natural even share — keeps URLs short. Decoder mints fresh ids (so two decodes don't collide) and renormalises malformed sizes. 10 Vitest cases cover round-trips, nesting, error paths, and id freshness. Persistence (hooks/useWorkspacePersistence.ts): - key: `electric-agents-ui.workspace..v1` - value: `{ v: 1, workspace: }` (versioned envelope) - 250ms debounced write on every workspace change - one-shot hydration per (server, mount); restores only when the current workspace is empty so it doesn't fight the URL → workspace effect - prune-on-load: tiles whose entity is no longer in the live entitiesCollection are dropped; cascade-collapses empty groups / single-child splits / dead root - silently no-ops in environments where localStorage throws (Safari private browsing, sandboxed iframes) URL hydration (Workspace.tsx): - ?layout= takes priority over localStorage; once consumed we navigate({ replace: true }) to strip the param so the address bar settles back to "active tile only" — passes the open-shared-link-then-clean-URL acceptance check from the plan - entity route's validateSearch now accepts both `view` and `layout` Copy layout link (SplitMenu): - new menu item between "Copy URL" and the separator - builds a `?layout=` URL relative to the current hash history; copies to clipboard Wired up in RootShell: - useWorkspacePersistence() runs alongside useWorkspaceHotkeys() Typecheck + tests green (31 passing). Build passes. Co-authored-by: Cursor --- .../components/workspace/GroupContainer.tsx | 8 +- .../src/components/workspace/SplitMenu.tsx | 23 ++ .../src/components/workspace/Workspace.tsx | 30 +- .../src/hooks/useWorkspacePersistence.ts | 196 ++++++++++++ .../src/lib/workspace/layoutCodec.test.ts | 201 ++++++++++++ .../src/lib/workspace/layoutCodec.ts | 302 ++++++++++++++++++ packages/agents-server-ui/src/router.tsx | 17 +- 7 files changed, 766 insertions(+), 11 deletions(-) create mode 100644 packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts create mode 100644 packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts create mode 100644 packages/agents-server-ui/src/lib/workspace/layoutCodec.ts diff --git a/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx b/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx index 769e191920..5c12f2473a 100644 --- a/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx +++ b/packages/agents-server-ui/src/components/workspace/GroupContainer.tsx @@ -10,7 +10,7 @@ import { Stack } from '../../ui' import { TabStrip } from './TabStrip' import { SplitMenu } from './SplitMenu' import { DropOverlay } from './DropOverlay' -import type { Group, Tile } from '../../lib/workspace/types' +import type { Group, Tile, WorkspaceNode } from '../../lib/workspace/types' import type { ViewId } from '../../lib/workspace/viewRegistry' import styles from './GroupContainer.module.css' @@ -63,12 +63,10 @@ export function GroupContainer({ ) } -function countGroups( - node: import(`../../lib/workspace/types`).WorkspaceNode | null -): number { +function countGroups(node: WorkspaceNode | null): number { if (!node) return 0 if (node.kind === `group`) return 1 - return node.children.reduce((acc, c) => acc + countGroups(c.node), 0) + return node.children.reduce((acc: number, c) => acc + countGroups(c.node), 0) } function ActiveTileBody({ diff --git a/packages/agents-server-ui/src/components/workspace/SplitMenu.tsx b/packages/agents-server-ui/src/components/workspace/SplitMenu.tsx index 9a09acef29..bcb7aa448f 100644 --- a/packages/agents-server-ui/src/components/workspace/SplitMenu.tsx +++ b/packages/agents-server-ui/src/components/workspace/SplitMenu.tsx @@ -4,6 +4,7 @@ import { Copy, Eye, GitFork, + Link2, MoreHorizontal, Pin, PinOff, @@ -17,6 +18,7 @@ import { useWorkspace } from '../../hooks/useWorkspace' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' import { usePinnedEntities } from '../../hooks/usePinnedEntities' import { listViews } from '../../lib/workspace/viewRegistry' +import { encodeLayout } from '../../lib/workspace/layoutCodec' import { Button, Dialog, IconButton, Menu, Stack, Text } from '../../ui' import { modKeyLabel } from '../../lib/keyLabels' import { getEntityDisplayTitle } from '../../lib/entityDisplay' @@ -117,6 +119,23 @@ export function SplitMenu({ tx.isPersisted.promise.catch(() => {}) } + const handleCopyLayoutLink = () => { + // Encode the workspace into the DSL and append it as `?layout=…` + // to the current URL. The receiving window's picks it + // up, hydrates, and strips the param so its address bar settles + // back to "active tile only" — see §3.4 of the plan. + const encoded = encodeLayout(workspace) + const url = new URL(window.location.href) + const hash = url.hash.replace(/^#/, ``) + const [path, query = ``] = hash.split(`?`) + const params = new URLSearchParams(query) + if (encoded) params.set(`layout`, encoded) + else params.delete(`layout`) + const newQuery = params.toString() + url.hash = `#` + path + (newQuery ? `?` + newQuery : ``) + void navigator.clipboard.writeText(url.toString()) + } + return ( Copy URL + + + Copy layout link + togglePin(tile.entityUrl)}> {pinned ? : } {pinned ? `Unpin` : `Pin`} diff --git a/packages/agents-server-ui/src/components/workspace/Workspace.tsx b/packages/agents-server-ui/src/components/workspace/Workspace.tsx index 909f87e960..73dfaae646 100644 --- a/packages/agents-server-ui/src/components/workspace/Workspace.tsx +++ b/packages/agents-server-ui/src/components/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { listViews } from '../../lib/workspace/viewRegistry' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' +import { decodeLayout } from '../../lib/workspace/layoutCodec' import { NodeRenderer } from './NodeRenderer' import styles from './Workspace.module.css' import type { ViewId } from '../../lib/workspace/viewRegistry' @@ -25,11 +26,38 @@ import type { ViewId } from '../../lib/workspace/viewRegistry' export function Workspace(): React.ReactElement { const { workspace, helpers } = useWorkspace() const params = useParams({ strict: false }) - const search = useSearch({ strict: false }) as { view?: string } + const search = useSearch({ strict: false }) as { + view?: string + layout?: string + } const navigate = useNavigate() const splat = (params as Record)._splat const entityUrl = splat ? `/${splat}` : null const requestedViewId = (search.view as ViewId | undefined) ?? null + const layoutParam = (search.layout as string | undefined) ?? null + + // ---- ?layout= import ------------------------------------------- + // Highest-priority hydration source: pasting a `?layout=…` URL + // replaces the workspace then strips the param so the address bar + // settles to the active tile (per §3.4 of the plan). Only fires once + // per param value — guarded by `lastLayoutParam.current`. + const lastLayoutParam = useRef(null) + useEffect(() => { + if (!layoutParam || layoutParam === lastLayoutParam.current) return + lastLayoutParam.current = layoutParam + const decoded = decodeLayout(layoutParam) + if (decoded.kind === `ok` && decoded.workspace.root) { + helpers.replaceWorkspace(decoded.workspace) + } + // Strip the ?layout= param regardless of decode success — a bad + // payload shouldn't sit in the address bar nagging the user. + void navigate({ + to: `/entity/$`, + params: { _splat: splat ?? `` }, + search: requestedViewId ? { view: requestedViewId } : {}, + replace: true, + }) + }, [layoutParam, helpers, navigate, splat, requestedViewId]) const { entitiesCollection } = useElectricAgents() const { data: entityMatches = [] } = useLiveQuery( diff --git a/packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts b/packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts new file mode 100644 index 0000000000..acbebb9a47 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts @@ -0,0 +1,196 @@ +import { useEffect, useRef } from 'react' +import { useWorkspace, listGroups } from './useWorkspace' +import { useServerConnection } from './useServerConnection' +import { useElectricAgents } from '../lib/ElectricAgentsProvider' +import { useLiveQuery } from '@tanstack/react-db' +import type { Workspace, WorkspaceNode } from '../lib/workspace/types' + +/** + * Workspace persistence: serialise the current workspace tree to + * localStorage (debounced) and restore it on next load. + * + * Storage shape (envelope so future revisions can migrate or fall + * back without crashing the UI): + * + * key: `electric-agents-ui.workspace..v1` + * value: { v: 1, workspace: } + * + * Server-keyed because two different Electric servers each remember + * their own layout — switching servers shouldn't drag the previous + * server's tile tree along. + * + * Hydration order on first mount: + * 1. If persisted workspace exists for the active server, restore it + * and prune any tiles whose entity has gone missing in the live + * `entitiesCollection`. + * 2. Otherwise: leave the workspace empty (the URL → workspace + * effect in `` will populate it). + * + * Persistence write: debounced 250ms after every workspace change so + * we don't beat localStorage with one write per splitter pixel. + */ +const SCHEMA_VERSION = 1 +const DEBOUNCE_MS = 250 + +type Envelope = { + v: number + workspace: Workspace +} + +function storageKey(serverId: string | null): string | null { + if (!serverId) return null + return `electric-agents-ui.workspace.${serverId}.v${SCHEMA_VERSION}` +} + +export function useWorkspacePersistence(): void { + const { workspace, helpers } = useWorkspace() + const { activeServer } = useServerConnection() + const { entitiesCollection } = useElectricAgents() + // Use the server's URL (URI-safe-encoded) as the persistence key + // namespace — the user-facing `name` could be edited or duplicated, + // but the URL is the stable identity that the rest of the app uses + // when wiring shapes / storing per-server preferences. + const serverId = activeServer?.url + ? encodeURIComponent(activeServer.url) + : null + + // Mark the workspace as hydrated for the current server. We only + // restore once per (server, mount) — subsequent workspace changes + // are user-driven and shouldn't get blown away by a re-hydration. + const hydratedFor = useRef(null) + // Materialise the live entities once so prune-on-load can drop dead + // tiles. Re-running `useLiveQuery` on every render is fine — TanStack + // memoises by query identity. + const { data: liveEntities = [] } = useLiveQuery( + (q) => { + if (!entitiesCollection) return undefined + return q.from({ e: entitiesCollection }) + }, + [entitiesCollection] + ) + const liveUrls = useRef>(new Set()) + liveUrls.current = new Set(liveEntities.map((e) => e.url)) + + useEffect(() => { + const key = storageKey(serverId) + if (!key) return + if (hydratedFor.current === serverId) return + hydratedFor.current = serverId + + let raw: string | null = null + try { + raw = window.localStorage.getItem(key) + } catch { + // Some embedded contexts (file://, sandboxed iframes) deny + // localStorage. Fail silently — we still work, just without + // persistence. + return + } + if (!raw) return + + try { + const env = JSON.parse(raw) as Envelope + if (!env || env.v !== SCHEMA_VERSION || !env.workspace) return + // Prune entities that are no longer alive on the server. We do + // this against `liveUrls` *as of first hydration*; subsequent + // entity disappearances are handled by ``'s + // close-on-disappear effect. If the entities collection hasn't + // populated yet we skip the prune (the close-on-disappear + // effect will handle it on next render). + const pruned = + liveUrls.current.size === 0 + ? env.workspace + : pruneWorkspace(env.workspace, liveUrls.current) + // Don't override an existing non-empty workspace — that would + // wipe out the tile that the URL → workspace effect just opened + // for the current route. We only restore when the workspace is + // currently empty (the common case on cold load). + if (workspace.root === null && pruned.root !== null) { + helpers.replaceWorkspace(pruned) + } + } catch { + // Malformed envelope — ignore and start fresh. + } + // Note: we intentionally *don't* depend on `workspace` here; + // hydration is a one-shot per (server, mount) tied to the + // hydratedFor ref. Including `workspace` would cause hydration to + // try to fire after every state change. Reading the latest + // `workspace.root` via the live closure rather than declaring it + // a dep is what we want for this read-once-on-mount semantics. + }, [serverId, helpers, workspace.root]) + + // Debounced write on workspace change. We always write; even + // workspace.root === null is a meaningful state to remember (so + // closing all tiles persists). Wrap in try/catch because Safari's + // private browsing throws on every setItem. + useEffect(() => { + const key = storageKey(serverId) + if (!key) return + const handle = setTimeout(() => { + try { + const env: Envelope = { v: SCHEMA_VERSION, workspace } + window.localStorage.setItem(key, JSON.stringify(env)) + } catch { + /* ignore */ + } + }, DEBOUNCE_MS) + return () => clearTimeout(handle) + }, [serverId, workspace]) +} + +/** + * Drop tiles whose entity URL isn't present in `liveUrls`. Empty + * groups cascade-collapse; empty splits collapse to their lone child; + * the root collapses to `null` if every tile is dead. + * + * activeGroupId is reset to whichever group survives the prune (first + * one in tree order) when the previous active is gone. + */ +function pruneWorkspace( + workspace: Workspace, + liveUrls: Set +): Workspace { + const root = pruneNode(workspace.root, liveUrls) + if (!root) return { root: null, activeGroupId: null } + const groups = listGroups(root) + const stillThere = + workspace.activeGroupId !== null && + groups.some((g) => g.id === workspace.activeGroupId) + return { + root, + activeGroupId: stillThere + ? workspace.activeGroupId + : (groups[0]?.id ?? null), + } +} + +function pruneNode( + node: WorkspaceNode | null, + liveUrls: Set +): WorkspaceNode | null { + if (!node) return null + if (node.kind === `group`) { + const tiles = node.tiles.filter((t) => liveUrls.has(t.entityUrl)) + if (tiles.length === 0) return null + const activeStillThere = tiles.some((t) => t.id === node.activeTileId) + return { + ...node, + tiles, + activeTileId: activeStillThere ? node.activeTileId : tiles[0].id, + } + } + const newChildren: typeof node.children = [] + for (const child of node.children) { + const pruned = pruneNode(child.node, liveUrls) + if (pruned) newChildren.push({ ...child, node: pruned }) + } + if (newChildren.length === 0) return null + if (newChildren.length === 1) return newChildren[0].node + // Re-normalise sizes so they sum to 1 again after dropping siblings. + const total = newChildren.reduce((a, c) => a + c.size, 0) + const normalised = newChildren.map((c) => ({ + ...c, + size: total > 0 ? c.size / total : 1 / newChildren.length, + })) + return { ...node, children: normalised } +} diff --git a/packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts b/packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts new file mode 100644 index 0000000000..5baecf9cfd --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { decodeLayout, encodeLayout } from './layoutCodec' +import type { Group, Split, Workspace, WorkspaceNode } from './types' + +/** + * Round-trips through the codec strip generated ids (split / group / + * tile ids are minted fresh on decode), so we compare on the + * structural projection that's part of the wire format. + */ +function structureOf(ws: Workspace): unknown { + if (!ws.root) return null + const visit = (node: WorkspaceNode): unknown => { + if (node.kind === `group`) { + return { + kind: `group`, + tiles: node.tiles.map((t) => ({ + entityUrl: t.entityUrl, + viewId: t.viewId, + })), + activeIdx: node.tiles.findIndex((t) => t.id === node.activeTileId), + } + } + return { + kind: `split`, + direction: node.direction, + children: node.children.map((c) => ({ + node: visit(c.node), + size: Math.round(c.size * 100) / 100, + })), + } + } + return visit(ws.root) +} + +describe(`layoutCodec`, () => { + it(`encodes and decodes a single tile`, () => { + const encoded = `horton%2Ffoo.chat` + const decoded = decodeLayout(encoded) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + expect(structureOf(decoded.workspace)).toEqual({ + kind: `group`, + tiles: [{ entityUrl: `/horton/foo`, viewId: `chat` }], + activeIdx: 0, + }) + expect(encodeLayout(decoded.workspace)).toBe(encoded) + }) + + it(`encodes the active tile index when not 0`, () => { + const encoded = `horton%2Ffoo.chat;horton%2Ffoo.state-explorer@1` + const decoded = decodeLayout(encoded) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + expect(structureOf(decoded.workspace)).toEqual({ + kind: `group`, + tiles: [ + { entityUrl: `/horton/foo`, viewId: `chat` }, + { entityUrl: `/horton/foo`, viewId: `state-explorer` }, + ], + activeIdx: 1, + }) + expect(encodeLayout(decoded.workspace)).toBe(encoded) + }) + + it(`encodes a horizontal split with explicit sizes`, () => { + const encoded = `H(horton%2Ffoo.chat:60,horton%2Ffoo.state-explorer:40)` + const decoded = decodeLayout(encoded) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + expect(structureOf(decoded.workspace)).toEqual({ + kind: `split`, + direction: `horizontal`, + children: [ + { + node: { + kind: `group`, + tiles: [{ entityUrl: `/horton/foo`, viewId: `chat` }], + activeIdx: 0, + }, + size: 0.6, + }, + { + node: { + kind: `group`, + tiles: [{ entityUrl: `/horton/foo`, viewId: `state-explorer` }], + activeIdx: 0, + }, + size: 0.4, + }, + ], + }) + expect(encodeLayout(decoded.workspace)).toBe(encoded) + }) + + it(`omits sizes when they are the natural even share`, () => { + const encoded = `H(horton%2Ffoo.chat,horton%2Fbar.chat)` + const decoded = decodeLayout(encoded) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + // Re-encoded form has no `:50`s — both are even share. + expect(encodeLayout(decoded.workspace)).toBe(encoded) + }) + + it(`handles nested splits`, () => { + const encoded = `H(horton%2Ffoo.chat,V(horton%2Fbar.chat,horton%2Fbaz.chat))` + const decoded = decodeLayout(encoded) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + expect(structureOf(decoded.workspace)).toEqual({ + kind: `split`, + direction: `horizontal`, + children: [ + { + node: { + kind: `group`, + tiles: [{ entityUrl: `/horton/foo`, viewId: `chat` }], + activeIdx: 0, + }, + size: 0.5, + }, + { + node: { + kind: `split`, + direction: `vertical`, + children: [ + { + node: { + kind: `group`, + tiles: [{ entityUrl: `/horton/bar`, viewId: `chat` }], + activeIdx: 0, + }, + size: 0.5, + }, + { + node: { + kind: `group`, + tiles: [{ entityUrl: `/horton/baz`, viewId: `chat` }], + activeIdx: 0, + }, + size: 0.5, + }, + ], + }, + size: 0.5, + }, + ], + }) + expect(encodeLayout(decoded.workspace)).toBe(encoded) + }) + + it(`returns ok with empty workspace for empty input`, () => { + const decoded = decodeLayout(``) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind === `ok`) { + expect(decoded.workspace.root).toBeNull() + } + }) + + it(`reports an error for malformed input`, () => { + expect(decodeLayout(`H(`).kind).toBe(`error`) + expect(decodeLayout(`H(foo)`).kind).toBe(`error`) // single child + expect(decodeLayout(`foo`).kind).toBe(`error`) // no '.' + expect(decodeLayout(`foo.`).kind).toBe(`error`) // empty viewId + }) + + it(`mints fresh ids on decode (so two decodes yield distinct ids)`, () => { + const a = decodeLayout(`horton%2Ffoo.chat`) + const b = decodeLayout(`horton%2Ffoo.chat`) + expect(a.kind).toBe(`ok`) + expect(b.kind).toBe(`ok`) + if (a.kind !== `ok` || b.kind !== `ok`) return + const ag = a.workspace.root as Group + const bg = b.workspace.root as Group + expect(ag.id).not.toBe(bg.id) + expect(ag.tiles[0].id).not.toBe(bg.tiles[0].id) + }) + + it(`round-trips a layout produced by encodeLayout()`, () => { + const original = decodeLayout( + `H(horton%2Ffoo.chat:70,V(horton%2Fbar.state-explorer,horton%2Fbaz.chat;horton%2Fqux.chat@1):30)` + ) + expect(original.kind).toBe(`ok`) + if (original.kind !== `ok`) return + const encoded = encodeLayout(original.workspace) + const reDecoded = decodeLayout(encoded) + expect(reDecoded.kind).toBe(`ok`) + if (reDecoded.kind !== `ok`) return + expect(structureOf(reDecoded.workspace)).toEqual( + structureOf(original.workspace) + ) + }) + + it(`renormalises sizes that exceed 100%`, () => { + const decoded = decodeLayout(`H(horton%2Fa.chat:80,horton%2Fb.chat:80)`) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + const split = decoded.workspace.root as Split + const sum = split.children.reduce((acc: number, c) => acc + c.size, 0) + expect(sum).toBeCloseTo(1) + }) +}) diff --git a/packages/agents-server-ui/src/lib/workspace/layoutCodec.ts b/packages/agents-server-ui/src/lib/workspace/layoutCodec.ts new file mode 100644 index 0000000000..1e94a6b324 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/layoutCodec.ts @@ -0,0 +1,302 @@ +import type { Group, Split, Tile, Workspace, WorkspaceNode } from './types' +import { makeGroupId, makeSplitId, makeTileId } from './workspaceReducer' +import type { ViewId } from './viewRegistry' + +// --------------------------------------------------------------------------- +// Compact layout encoding for shareable `?layout=` URLs (see §3.4 of +// TILE_LAYOUT_PLAN.md). +// +// Grammar: +// node := group | hsplit | vsplit +// hsplit := 'H' '(' sized (',' sized)+ ')' // horizontal = side-by-side +// vsplit := 'V' '(' sized (',' sized)+ ')' // vertical = stacked +// sized := node (':' int)? // size as percentage; default = even +// group := tile (';' tile)* ('@' int)? // @int = active tile index, default 0 +// tile := '.' viewId // entityPath is urlEncoded, +// with the conventional +// leading '/' stripped +// +// `,` is reserved for split-siblings and `;` for group-tabs so the +// grammar is unambiguous to a single-character lookahead — without +// that distinction, parsing `H(a,V(b,c,d@1):30)` is ambiguous (does +// the second `,` start a new sibling at the H, or another tab inside +// the V's group?). Both `,` and `;` are URL-safe sub-delims so neither +// needs percent-encoding in a query value. +// +// Examples (canonical forms produced by `encodeLayout`): +// horton%2Ffoo.chat +// horton%2Ffoo.chat;horton%2Ffoo.state-explorer@1 +// H(horton%2Ffoo.chat:60,horton%2Ffoo.state-explorer:40) +// H(horton%2Ffoo.chat,V(horton%2Fbar.chat,horton%2Fbaz.logs)) +// +// Tile ids / group ids / split ids are *not* part of the wire format — +// the decoder mints fresh ones via the same factories the reducer uses. +// IDs being ephemeral is the right thing here: a layout link should +// always paste cleanly into another window without colliding with that +// window's existing IDs. +// +// Entity URLs always start with `/` everywhere else in the codebase +// (see `Tile.entityUrl`). The codec strips the leading slash on +// encode and re-adds it on decode purely for URL aesthetics — saves +// `%2F` characters per tile. +// --------------------------------------------------------------------------- + +export function encodeLayout(workspace: Workspace): string { + if (!workspace.root) return `` + return encodeNode(workspace.root) +} + +function encodeNode(node: WorkspaceNode): string { + return node.kind === `split` ? encodeSplit(node) : encodeGroup(node) +} + +function encodeSplit(split: Split): string { + const inner = split.children + .map((c) => { + const node = encodeNode(c.node) + // Round to whole percentages for a compact URL — sub-percent + // precision isn't useful for shared layouts (the receiving + // window will likely be a different size anyway). + const pct = Math.round(c.size * 100) + // Omit the size when it's the natural even share — saves bytes. + const evenShare = Math.round(100 / split.children.length) + const sizePart = pct === evenShare ? `` : `:${pct}` + return `${node}${sizePart}` + }) + .join(`,`) + return `${split.direction === `horizontal` ? `H` : `V`}(${inner})` +} + +function encodeGroup(group: Group): string { + const tilesPart = group.tiles.map(encodeTile).join(`;`) + const activeIdx = group.tiles.findIndex((t) => t.id === group.activeTileId) + const activePart = activeIdx > 0 ? `@${activeIdx}` : `` + return `${tilesPart}${activePart}` +} + +function encodeTile(tile: Tile): string { + // Strip the conventional leading `/` so the canonical form is + // `horton%2Ffoo.chat` instead of `%2Fhorton%2Ffoo.chat`. Decoder + // adds it back. If for some reason an entityUrl doesn't start with + // `/`, we encode it as-is — the decoder is symmetrical in only + // *prepending* a slash when the decoded path doesn't already have one. + const path = tile.entityUrl.startsWith(`/`) + ? tile.entityUrl.slice(1) + : tile.entityUrl + return `${encodeURIComponent(path)}.${tile.viewId}` +} + +// --------------------------------------------------------------------------- +// Decoder +// --------------------------------------------------------------------------- + +export type DecodeError = { kind: `error`; message: string; at: number } +export type DecodeResult = { kind: `ok`; workspace: Workspace } | DecodeError + +export function decodeLayout(input: string): DecodeResult { + if (input.length === 0) + return { kind: `ok`, workspace: { root: null, activeGroupId: null } } + const p = new Parser(input) + try { + const node = p.parseNode() + p.expectEnd() + // Pick the first group as active by default. Future: encode the + // active group too, e.g. with a leading marker like `*` on the + // group; deferred until we have a use-case. + const firstGroup = findFirstGroup(node) + return { + kind: `ok`, + workspace: { root: node, activeGroupId: firstGroup?.id ?? null }, + } + } catch (e) { + return e instanceof ParseError + ? { kind: `error`, message: e.message, at: e.at } + : { + kind: `error`, + message: e instanceof Error ? e.message : String(e), + at: p.pos, + } + } +} + +class ParseError extends Error { + constructor( + message: string, + public at: number + ) { + super(message) + this.name = `ParseError` + } +} + +class Parser { + pos = 0 + constructor(public src: string) {} + + parseNode(): WorkspaceNode { + if (this.peek() === `H` && this.src[this.pos + 1] === `(`) { + return this.parseSplit(`horizontal`) + } + if (this.peek() === `V` && this.src[this.pos + 1] === `(`) { + return this.parseSplit(`vertical`) + } + return this.parseGroup() + } + + parseSplit(direction: `horizontal` | `vertical`): Split { + this.pos += 2 // skip 'H(' or 'V(' + const children: Split[`children`] = [] + let totalDeclared = 0 + let countWithExplicitSize = 0 + while (true) { + const node = this.parseNode() + let size: number | null = null + if (this.peek() === `:`) { + this.pos += 1 + size = this.parseInt() / 100 + totalDeclared += size + countWithExplicitSize++ + } + children.push({ node, size: size ?? -1 }) + if (this.peek() === `,`) { + this.pos += 1 + continue + } + if (this.peek() === `)`) { + this.pos += 1 + break + } + throw new ParseError( + `expected ',' or ')' inside split, got ${describeChar(this.peek())}`, + this.pos + ) + } + if (children.length < 2) { + throw new ParseError(`splits must have at least 2 children`, this.pos) + } + // Fill in the implicit (no-':') sizes by distributing the remaining + // share evenly across them. If the explicit shares already exceed + // 1, we renormalise below regardless. + const remaining = Math.max(0, 1 - totalDeclared) + const implicitCount = children.length - countWithExplicitSize + const implicitShare = implicitCount > 0 ? remaining / implicitCount : 0 + for (const c of children) { + if (c.size === -1) c.size = implicitShare + } + // Normalise so all sizes sum to 1 (handles the user-error case + // where a `?layout=` URL declares >100% total). + const total = children.reduce((a, c) => a + c.size, 0) + if (total > 0) { + for (const c of children) c.size = c.size / total + } else { + const even = 1 / children.length + for (const c of children) c.size = even + } + return { + kind: `split`, + id: makeSplitId(), + direction, + children, + } + } + + parseGroup(): Group { + const tiles: Array = [] + while (true) { + tiles.push(this.parseTile()) + // Group tab separator is `;` (split-sibling separator is `,`) + // — the two-symbol grammar removes the lookahead ambiguity that + // a single shared `,` would create. See header comment. + if (this.peek() === `;`) { + this.pos += 1 + continue + } + break + } + let activeIdx = 0 + if (this.peek() === `@`) { + this.pos += 1 + activeIdx = this.parseInt() + if (activeIdx >= tiles.length) activeIdx = 0 + } + return { + kind: `group`, + id: makeGroupId(), + tiles, + activeTileId: tiles[activeIdx].id, + } + } + + parseTile(): Tile { + // Tile = '.' + // We grab everything up to the LAST '.' before a control char as + // the entity url; the suffix is the viewId. Control chars are + // ',' ';' '(' ')' '@' ':'. Both halves accept alphanumerics + + // url-encoded escapes (decoded via `decodeURIComponent`). + const start = this.pos + while (this.pos < this.src.length && !isControlChar(this.src[this.pos])) { + this.pos++ + } + const raw = this.src.slice(start, this.pos) + const dot = raw.lastIndexOf(`.`) + if (dot < 0) { + throw new ParseError( + `expected '.' separator in tile spec '${raw}'`, + start + ) + } + const decoded = decodeURIComponent(raw.slice(0, dot)) + // Re-add the conventional leading `/` if the wire form omitted it + // (canonical encoded form does — see encodeTile() comment). If + // the wire form already includes one we don't double up. + const entityUrl = decoded.startsWith(`/`) ? decoded : `/${decoded}` + const viewId: ViewId = raw.slice(dot + 1) + if (!viewId) { + throw new ParseError(`empty viewId in tile spec '${raw}'`, start) + } + return { id: makeTileId(), entityUrl, viewId } + } + + parseInt(): number { + const start = this.pos + while (this.pos < this.src.length && /[0-9]/.test(this.src[this.pos])) { + this.pos++ + } + if (this.pos === start) { + throw new ParseError(`expected integer at position ${this.pos}`, this.pos) + } + return Number(this.src.slice(start, this.pos)) + } + + peek(): string { + return this.src[this.pos] ?? `` + } + + expectEnd(): void { + if (this.pos !== this.src.length) { + throw new ParseError( + `unexpected trailing input at position ${this.pos}: '${this.src.slice(this.pos)}'`, + this.pos + ) + } + } +} + +function isControlChar(c: string): boolean { + return ( + c === `,` || c === `;` || c === `(` || c === `)` || c === `@` || c === `:` + ) +} + +function describeChar(c: string): string { + return c.length === 0 ? `` : `'${c}'` +} + +function findFirstGroup(node: WorkspaceNode): Group | null { + if (node.kind === `group`) return node + for (const c of node.children) { + const found = findFirstGroup(c.node) + if (found) return found + } + return null +} diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index 387fca464a..bf39ad0895 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -21,6 +21,7 @@ import { } from './hooks/useSearchPalette' import { WorkspaceProvider, useWorkspace } from './hooks/useWorkspace' import { useWorkspaceHotkeys } from './hooks/useWorkspaceHotkeys' +import { useWorkspacePersistence } from './hooks/useWorkspacePersistence' import { Sidebar } from './components/Sidebar' import { SearchPalette } from './components/SearchPalette' import { NewSessionPage } from './components/NewSessionPage' @@ -68,6 +69,7 @@ function RootShell(): React.ReactElement { useHotkey(`mod+shift+o`, openNewSession) useWorkspaceHotkeys() + useWorkspacePersistence() const navigateToEntity = useCallback( (entityUrl: string) => { @@ -119,14 +121,19 @@ function RootShell(): React.ReactElement { } /** - * Search-param schema for the entity route. `view` is optional and - * defaults to the first registered view (`chat`) when absent — that way - * the URL stays clean (`/entity/foo`) for the common case and only - * surfaces the param when the user is on a non-default view - * (`/entity/foo?view=state-explorer`). + * Search-param schema for the entity route. + * + * - `view` optional view id (e.g. `state-explorer`). Omitted from + * the URL when it matches the default view (`chat`) so + * `/entity/foo` stays clean for the common case. + * - `layout` optional shareable layout payload. When present we + * hydrate the workspace from it and *strip the param* + * (see ``'s ?layout effect) so the address bar + * settles back to "active tile only". */ const entitySearchSchema = z.object({ view: z.string().optional(), + layout: z.string().optional(), }) /** From 7e4aa32fdf4fdf3be856599f6bc00382b2b6bcff Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 4 May 2026 23:24:23 +0100 Subject: [PATCH 07/57] feat(agents-server-ui): tile-based workspace, standalone new-session tile Replace the group-and-tabs layout model with a flat Split | Tile tree: each leaf is a single tile, no tabs within a tile, and dividers / drop targets share the sidebar's hairline + accent-on-hover styling. The new-session screen is now a first-class standalone tile rather than a separate route page. View registry distinguishes entity views (chat, state-explorer) from standalone views (new-session); standalone tiles carry entityUrl: null and render a tile chrome (header + split menu + drop overlay) just like an entity tile. Both / and /entity/$ mount the same Workspace component and the URL <-> workspace sync maps standalone tiles back to /. Drag-and-drop covers all three sources (sidebar entity row, sidebar new-session button, existing tile header) into the four edge quadrants of any tile. openTile now focuses the freshly-created tile in all paths so drop-to-side gives immediate visual feedback and the URL follows. Multiple new-session tiles can coexist via drag (the click flow keeps focus-existing-or-replace semantics). The SplitMenu's view rows render inline with [->][down] icon buttons for split-this-view-to-the-side; entity-only items (Inspect, Pin, Fork, Kill, Copy URL) and "Close tile" (when sole tile) are hidden contextually. The State Explorer's draggable divider switched to the shared Splitter component for visual consistency. Persistence layer: SCHEMA_VERSION bumped to v2; pruneNode keeps standalone tiles intact. Layout codec encodes standalone tiles as ".viewId" (empty entity-path segment) so layout links can carry mixed standalone+entity workspaces. Co-authored-by: Cursor --- packages/agents-server-ui/TILE_LAYOUT_PLAN.md | 620 ------------------ .../src/components/NewSessionPage.module.css | 52 ++ .../src/components/Sidebar.tsx | 10 + .../StateExplorerPanel.module.css | 13 +- .../stateExplorer/StateExplorerPanel.tsx | 39 +- .../NewSessionView.tsx} | 124 ++-- .../workspace/DropOverlay.module.css | 28 +- .../src/components/workspace/DropOverlay.tsx | 59 +- .../components/workspace/GroupContainer.tsx | 148 ----- .../src/components/workspace/NodeRenderer.tsx | 4 +- .../components/workspace/SplitMenu.module.css | 71 +- .../src/components/workspace/SplitMenu.tsx | 585 ++++++++--------- .../components/workspace/Splitter.module.css | 50 +- .../components/workspace/TabStrip.module.css | 82 --- .../src/components/workspace/TabStrip.tsx | 115 ---- ...er.module.css => TileContainer.module.css} | 25 +- .../components/workspace/TileContainer.tsx | 199 ++++++ .../src/components/workspace/Workspace.tsx | 169 +++-- .../src/hooks/useWorkspace.tsx | 64 +- .../src/hooks/useWorkspaceHotkeys.ts | 59 +- .../src/hooks/useWorkspacePersistence.ts | 48 +- .../src/lib/workspace/dragPayload.ts | 45 +- .../src/lib/workspace/layoutCodec.test.ts | 131 ++-- .../src/lib/workspace/layoutCodec.ts | 131 ++-- .../src/lib/workspace/registerViews.ts | 31 +- .../src/lib/workspace/types.ts | 65 +- .../src/lib/workspace/viewRegistry.tsx | 78 ++- .../lib/workspace/workspaceReducer.test.ts | 345 +++++----- .../src/lib/workspace/workspaceReducer.ts | 313 +++------ packages/agents-server-ui/src/router.tsx | 44 +- 30 files changed, 1520 insertions(+), 2227 deletions(-) delete mode 100644 packages/agents-server-ui/TILE_LAYOUT_PLAN.md rename packages/agents-server-ui/src/components/{NewSessionPage.tsx => views/NewSessionView.tsx} (79%) delete mode 100644 packages/agents-server-ui/src/components/workspace/GroupContainer.tsx delete mode 100644 packages/agents-server-ui/src/components/workspace/TabStrip.module.css delete mode 100644 packages/agents-server-ui/src/components/workspace/TabStrip.tsx rename packages/agents-server-ui/src/components/workspace/{GroupContainer.module.css => TileContainer.module.css} (55%) create mode 100644 packages/agents-server-ui/src/components/workspace/TileContainer.tsx diff --git a/packages/agents-server-ui/TILE_LAYOUT_PLAN.md b/packages/agents-server-ui/TILE_LAYOUT_PLAN.md deleted file mode 100644 index ccc58f4e5d..0000000000 --- a/packages/agents-server-ui/TILE_LAYOUT_PLAN.md +++ /dev/null @@ -1,620 +0,0 @@ -# Tile-Based Layout Refactor — Agents Server UI - -Follow-up to the Radix → Base UI migration. With Base UI + CSS Modules in -place, the next foundation is a **VS Code / Cursor-style splittable -workspace** so multiple agents — and multiple **views** of an agent — can -be open side-by-side. - ---- - -## 1. What we're building - -### Concepts - -1. **Workspace** — the root pane container that fills the area to the - right of the sidebar. Holds a single root `Split` node (or nothing). -2. **Split** — a horizontal _or_ vertical container of children. - Children are either other `Split` nodes or `Group`s. -3. **Group** (a.k.a. _editor group_ in VS Code) — a leaf area that - holds one or more `Tile`s, only one of which is "active" at a time, - with a tab strip across the top. -4. **Tile** — what's rendered. A tile is `{ entityUrl, viewId }`. The - same entity can be open in multiple tiles (e.g. chat + state explorer - side-by-side). -5. **View** — a pluggable renderer registered against an `id` (e.g. - `chat`, `state-explorer`, future `logs`, `inspector`, `metrics`, - etc.). Splitting and view-switching are **orthogonal** primitives. - -### User-facing behaviour - -| Action | Result | -| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Click an entity in the sidebar | Opens it as a new tile in the **active group**, replacing the current tile. (v1: always replace; preview-tab semantics deferred.) | -| Cmd/Ctrl-click sidebar entity | Open in a new group split to the right. | -| Drag entity from sidebar onto workspace | Shows split-zone overlay; drop into one of 5 zones (centre/N/E/S/W of an existing group, or onto a tab strip). | -| Drag a tab between groups | Move tile to that group. Same 5-zone overlay. | -| `…` menu → **Split Right / Split Down / Split Left / Split Up** | Duplicates the active tile into a new group split in that direction (matches Cursor's chat-pane menu). | -| `…` menu → **View ▸ {viewId} ▸ Open here / Split right / Split down / Split left / Split up** | Opens an additional view of the _same_ entity. | -| Close last tile in a group | Group is removed; sibling expands. | -| Close last group | Workspace returns to the empty `NewSessionPage`. | -| Drag the divider between groups | Resizes the split. | -| Hotkeys | `⌘D` Split Right · `⇧⌘D` Split Down · `⌘W` close active tile · `⌘\` switch active group · `⌘1..9` focus group N. | - -### Out of scope for this work - -- Floating windows / detach to OS window. -- Pinned tabs ordering / drag-to-reorder _within_ a tab strip (v1: drop - on tab strip = append). -- "Preview" (italic) tab semantics — start with "always replace active - tile" then iterate. -- Per-server-keyed workspace persistence (start global; revisit later). - ---- - -## 2. Why a new layout model (and not just CSS flex) - -The current `EntityPage` (`src/router.tsx`) hardcodes: - -- one entity at a time (URL-driven), -- a single optional right drawer for the State Explorer with a - hand-rolled splitter, -- the State Explorer toggle living on `EntityHeader`. - -To support arbitrary nested splits we need a **recursive tree data -structure** as the source of truth, not URL + boolean flags. Once the -tree exists, the URL becomes a _projection_ of it (one of the tiles is -"focused" and its url shows up in the address bar) instead of the -source. - ---- - -## 3. Architecture - -### 3.1 Data model - -`src/lib/workspace/types.ts`: - -```ts -export type ViewId = string // 'chat' | 'state-explorer' | 'logs' | ... - -export type Tile = { - id: string // nanoid; stable across renders - entityUrl: string // '/horton/foo-123' - viewId: ViewId -} - -export type Group = { - kind: 'group' - id: string - tiles: Tile[] - activeTileId: string -} - -export type Split = { - kind: 'split' - id: string - direction: 'horizontal' | 'vertical' // horizontal = side-by-side - // Each child carries its own size as a fraction (sums to ~1). - children: { node: WorkspaceNode; size: number }[] -} - -export type WorkspaceNode = Split | Group - -export type Workspace = { - root: WorkspaceNode | null // null = empty / new-session - activeGroupId: string | null -} -``` - -`ViewId` is a plain string instead of a string-literal union, because -the registry (§3.3) is the source of truth and grows over time. We -type-check at the registration site. - -### 3.2 Reducer + provider - -`src/lib/workspace/workspaceReducer.ts` — pure operations: - -- `openTile(state, { entityUrl, viewId, target: { groupId, position } })` - where `position` is `'replace' | 'append' | 'split-right' | 'split-down' | 'split-left' | 'split-up'`. -- `closeTile(state, tileId)` — collapses empty groups; unwraps - single-child splits. -- `moveTile(state, tileId, target)` — drag-and-drop primitive. -- `setActive(state, { groupId, tileId? })`. -- `setTileView(state, tileId, viewId)` — view switching in place. -- `resizeSplit(state, splitId, sizes[])`. -- `splitTileWithView(state, tileId, viewId, direction)` — composition - helper used by the menu (split + setTileView in one). - -`src/hooks/useWorkspace.tsx` — `WorkspaceProvider` (wraps `useReducer`) -and `useWorkspace()` returning `{ workspace, dispatch, helpers }`. -Helpers wrap dispatch for ergonomics, e.g. -`helpers.openEntity(url, { in: 'active' | 'split-right' | … })`. - -> **Implementation note:** the reducer must be **synchronous and -> side-effect-free** so it's cheap to unit-test. Vitest suite in -> `src/lib/workspace/workspaceReducer.test.ts` covers the tricky bits -> (closing the last tile, splitting an only-tile group, normalising -> sizes after a delete, view-switching during a drag). - -### 3.3 View registry - -`src/lib/workspace/viewRegistry.tsx`: - -```ts -import type { ComponentType } from 'react' -import type { LucideIcon } from 'lucide-react' -import type { ElectricEntity } from '../ElectricAgentsProvider' - -export type ViewProps = { - baseUrl: string - entityUrl: string - entity: ElectricEntity - entityStopped: boolean - isSpawning: boolean - // The tile id is passed so views can scope local state (e.g. scroll - // position, selected row) per-tile rather than per-entity, matching - // VS Code editor behaviour where two splits of the same file scroll - // independently. - tileId: string -} - -export type ViewDefinition = { - id: ViewId - label: string // 'Chat', 'State Explorer' - icon: LucideIcon // shown in tab + menu - shortLabel?: string // shown in narrow tabs - description?: string // shown as menu hint - // Whether this view applies to a given entity. Lets us hide views - // that don't make sense (e.g. a Coding-session-only timeline view). - isAvailable?: (entity: ElectricEntity) => boolean - // Default split direction when the menu's "View ▸ X" leaf is clicked - // directly (not its sub-items). Preserves muscle-memory like - // "State Explorer pops out to the right". - defaultSplit?: 'right' | 'down' - Component: ComponentType -} - -const registry = new Map() - -export function registerView(def: ViewDefinition): void { - registry.set(def.id, def) -} -export function getView(id: ViewId): ViewDefinition | undefined { - return registry.get(id) -} -export function listViews(entity?: ElectricEntity): ViewDefinition[] { - const all = Array.from(registry.values()) - return entity ? all.filter((v) => v.isAvailable?.(entity) ?? true) : all -} -``` - -Registration happens once at app boot in -`src/lib/workspace/registerViews.ts`: - -```ts -import { MessageSquare, Database } from 'lucide-react' -import { registerView } from './viewRegistry' -import { ChatView } from '../../components/views/ChatView' -import { StateExplorerView } from '../../components/views/StateExplorerView' - -registerView({ - id: 'chat', - label: 'Chat', - icon: MessageSquare, - Component: ChatView, -}) - -registerView({ - id: 'state-explorer', - label: 'State Explorer', - icon: Database, - description: 'Inspect shared state and event log', - defaultSplit: 'right', - Component: StateExplorerView, -}) -``` - -`registerViews` is imported once from `main.tsx` so the side-effect -runs before the app mounts. - -#### Why splits and views stay orthogonal - -| Operation | Affects layout? | Affects view? | -| ------------------------------------- | ---------------------- | ---------------------------- | -| `Split Right` (`⌘D`) | yes — adds a new group | no — duplicates current view | -| `View ▸ State Explorer ▸ Open here` | no | yes — swaps in-place | -| `View ▸ State Explorer ▸ Split right` | yes | yes — both, in one step | -| Drag tab to another group | yes | no | -| Click a tab in the strip | no | yes (changes active tile) | - -Two clean primitives (`split`, `setTileView`) compose to express every -menu item. - -### 3.4 URL ↔ workspace sync (hybrid strategy) - -The app uses `createHashHistory` (`src/router.tsx`), so URLs look like -`https://app/#/entity/foo`. We adopt a **hybrid model**: - -1. **Default URL = active tile only.** Clean and human-readable; - matches a user's mental model of "I'm looking at X right now". -2. **Layout state lives in localStorage**, keyed by server (so two - different Electric servers each remember their own layout). -3. **`?layout=…` is an opt-in import param** for sharing — - pasting/visiting one hydrates the workspace; we then strip the - param so the URL settles back to "active tile only". - -Examples: - -``` -#/ → empty workspace -#/entity/horton/foo → single chat tile -#/entity/horton/foo?view=state-explorer → single State Explorer tile -#/entity/horton/foo → multi-tile workspace where - 'foo' is the active tile; - full layout from localStorage -#/entity/horton/foo?layout=H(…) → explicit layout import; we - hydrate then strip the param -``` - -#### Layout encoding mini-DSL - -Compact, human-debuggable, URL-safe (no encoding needed for -parens/commas/colons/dot/at): - -``` -node := group | hsplit | vsplit -hsplit := 'H' '(' sized (',' sized)+ ')' // horizontal = side-by-side -vsplit := 'V' '(' sized (',' sized)+ ')' // vertical = stacked -sized := node (':' int)? // size as percentage; default = even -group := tile (',' tile)* ('@' int)? // @int = active tile index, default 0 -tile := '.' viewId -``` - -| Layout | Encoded | -| ------------------------------------ | ------------------------------------------------------------- | -| Single tile | `horton%2Ffoo.chat` | -| Two tabs in one group, second active | `horton%2Ffoo.chat,horton%2Ffoo.state-explorer@1` | -| Chat 60% + State 40% side-by-side | `H(horton%2Ffoo.chat:60,horton%2Ffoo.state-explorer:40)` | -| Chat left, two stacked right | `H(horton%2Ffoo.chat,V(horton%2Fbar.chat,horton%2Fbaz.logs))` | - -Encoder/decoder lives in `src/lib/workspace/layoutCodec.ts` with a -Vitest suite covering round-trips. We deliberately avoid base64+JSON -because the DSL is ~2× shorter and visually parseable in the URL bar -when something goes wrong. - -#### URL → workspace (hydration order on load / external nav) - -1. If `?layout=…` is present → parse it, replace the workspace, - `navigate({ replace: true })` to strip the param. -2. Else if a serialized workspace exists in - `localStorage[electric-agents-ui.workspace..v1]` **and** - it contains a tile matching the URL's `entity`+`view` → restore it - and mark that tile active. -3. Else → fresh workspace with a single tile from the URL (current - behaviour). - -If the URL points at an entity that's missing from the restored -layout we **insert a new tile in the active group** rather than -discarding the layout — preserves "open this link" semantics while -respecting existing splits. - -#### Workspace → URL (sync after every state change) - -Critical rule: the **active tile drives the URL**, and we use -`push` vs `replace` carefully so back/forward maps to user intent -rather than splitter drags. - -| Change | History | -| ------------------------------------------------------------------------ | --------------------------------------------------------- | -| Active tile changes (click tab, click sidebar row, switch view in place) | `push` — back/forward navigates between tiles | -| Open new tile in active group | `push` — it became the new active tile | -| Split (active → new group) | `push` — focus follows split, becomes the new active tile | -| Resize splitter | none (no URL change) | -| Move/drag tile, close non-active tile | `replace` — no URL change, but localStorage update fires | -| Close active tile | `push` — the new active tile becomes the URL | -| Drag sidebar entity into a non-active group | `replace` — active tile didn't change | - -#### Persistence - -- Debounced 250 ms write of the whole `Workspace` to - `localStorage[electric-agents-ui.workspace..v1]`. -- Prune-on-load entities that no longer exist in `entitiesCollection`; - if pruning empties a group, collapse the group; if it empties the - workspace, reset to single-tile from the URL. -- A schema-version-stamped envelope (`{ v: 1, workspace: {…} }`) so - a future format change can either migrate or fall back to a fresh - workspace. - -#### "Copy layout link" affordance - -A menu item in the workspace `…` menu (or a workspace-level menu in -the sidebar header): - -```ts -function copyLayoutLink() { - const encoded = encodeLayout(workspace) // 'H(...)' - const url = new URL(window.location.href) - const [path, query = ''] = url.hash.replace(/^#/, '').split('?') - const params = new URLSearchParams(query) - params.set('layout', encoded) - url.hash = '#' + path + '?' + params.toString() - navigator.clipboard.writeText(url.toString()) -} -``` - -The URL is long but only when explicitly requested; the address bar -during normal use stays clean. - -### 3.5 Component tree - -``` - - ├── (existing; gets DnD source bindings) - └── (new — replaces EntityPage's body) - └── - └── if Split: with s + s - └── if Group: - ├── - ├── (header + … menu, wraps EntityHeader) - └── (resolves view from registry) - └── (drag-target zones, rendered into a portal) -``` - -`TileBody`: - -```tsx -const def = getView(tile.viewId) -if (!def) return -const View = def.Component -return ( - -) -``` - -#### Files added - -- `src/components/workspace/Workspace.tsx` -- `src/components/workspace/NodeRenderer.tsx` -- `src/components/workspace/SplitContainer.tsx` + `.module.css` -- `src/components/workspace/Splitter.tsx` + `.module.css` - (extract & generalise the existing `EntityPage` splitter) -- `src/components/workspace/GroupContainer.tsx` + `.module.css` -- `src/components/workspace/TabStrip.tsx` + `.module.css` -- `src/components/workspace/TileChrome.tsx` + `.module.css` -- `src/components/workspace/DropOverlay.tsx` + `.module.css` -- `src/components/workspace/SplitMenu.tsx` (the `…` menu) -- `src/components/views/ChatView.tsx` -- `src/components/views/StateExplorerView.tsx` -- `src/lib/workspace/types.ts` -- `src/lib/workspace/workspaceReducer.ts` (+ test) -- `src/lib/workspace/viewRegistry.tsx` -- `src/lib/workspace/registerViews.ts` -- `src/hooks/useWorkspace.tsx` - -#### Files changed - -- `router.tsx` — replace `EntityPage`'s ad-hoc body with ``; - add the URL-sync effect; remove `statePanelWidth` / bespoke splitter. -- `EntityHeader.tsx` — strip the State-Explorer toggle (it moves into - `SplitMenu` as `View ▸ State Explorer ▸ …`); the per-tile `…` menu - becomes the new actions cluster. -- `Sidebar.tsx` / `SidebarRow.tsx` / `SidebarTree.tsx` — make rows - draggable (HTML5 `draggable=true` + `dragstart` payload); allow - `Cmd/Ctrl+click` and `Middle-click` shortcuts to open in a new - split. - -### 3.6 Drag-and-drop strategy - -Use **native HTML5 DnD** (no `react-dnd`). The surface is small -(sidebar rows, tabs, group-body drop zones), and Cursor-style overlays -are pure CSS once we have the geometry. A thin abstraction: - -- `useDraggable({ payload: WorkspaceDragPayload })` — wires - `draggable`, `dragstart`/`dragend`, sets `dataTransfer` (JSON via - `application/x-electric-tile`). -- `useDropTarget(groupId)` — on `dragover`, computes which of the 5 - zones the cursor is in (centre / N / E / S / W using a 25% inset), - shows the overlay, and on `drop` dispatches `moveTile` or `openTile`. - -```ts -type WorkspaceDragPayload = - | { kind: 'sidebar-entity'; entityUrl: string } - | { kind: 'tile'; tileId: string; sourceGroupId: string } -``` - -`` is one element per group, absolutely positioned, with -five segments that highlight on hover — same visual language as -Cursor's chat-pane drop zones. - -### 3.7 The `…` menu (matches the screenshots) - -Component: `SplitMenu.tsx` using existing Base UI `Menu`. - -``` -View ► [for each view in listViews(entity):] - Chat ⌘1 - State Explorer ⌘2 - … - ───── - Switch view in place ► Chat - State Explorer - … -───── -Split Right ⌘D (duplicates current tile) -Split Down ⇧⌘D -Split Left -Split Up -───── -Move tile to ► New group right / below / Group N -───── -Copy URL · Pin -───── -Close tile ⌘W -Close group ⇧⌘W -``` - -Each `View ▸ {leaf}` is itself a sub-menu, answering the brief's -"second level option to specify open in a split to the side or under": - -``` -View ▸ State Explorer ▸ Open here (replaces current tile) - Split right (matches today's drawer) - Split down - Split left - Split up -``` - -Each leaf calls one of `setTileView(viewId)` or -`splitTileWithView(viewId, direction)`. Clicking the parent `View ▸ -{viewId}` row directly uses `defaultSplit` from the registry (so -clicking `View ▸ State Explorer` matches today's drawer behaviour -without forcing the user into the sub-menu). - -If Base UI's three-level submenu rendering proves janky in practice -we'll flatten the second level (e.g. `View ▸ State Explorer here` / -`View ▸ State Explorer (split right)` / …) — the registry-driven -generation makes this a one-line change. - -### 3.8 State Explorer migration - -Today the State Explorer is a right-drawer toggled from `EntityHeader` -(`router.tsx` 211-250). After the refactor the `Database` icon and -toggle disappear from `EntityHeader`; instead: - -- "Open State Explorer" from the `…` menu calls - `splitTileWithView('state-explorer', 'right')` — its default split - direction matches today's drawer because we set - `defaultSplit: 'right'` in its registration. -- Existing `` is rendered unchanged inside - `` (a thin `ViewProps` wrapper) inside `TileBody`; - we just remove the splitter / width state from the route component. - -### 3.9 Persistence (light) - -Save `Workspace` to `localStorage` -(`electric-agents-ui.workspace.v1`) on every change behind a 250 ms -debounce. Skip restoring tiles whose `entityUrl` no longer exists in -the live `entitiesCollection` (silent prune). Defer per-server-keyed -persistence to a follow-up. - -### 3.10 Adding a future view ("Logs", say) - -```tsx -// src/components/views/LogsView.tsx -export function LogsView({ entityUrl, baseUrl }: ViewProps) { … } - -// src/lib/workspace/registerViews.ts -import { ScrollText } from 'lucide-react' -import { LogsView } from '../../components/views/LogsView' - -registerView({ - id: 'logs', - label: 'Logs', - icon: ScrollText, - description: 'Process and child-process logs', - defaultSplit: 'down', - Component: LogsView, -}) -``` - -That's the entire diff. The view shows up in the `View ▸` submenu, in -the tab-strip's `+` "new view" picker, gets a deep-link -(`?view=logs`), and is draggable between groups — all for free. - ---- - -## 4. Migration in stages - -Ship in **5 small PRs** rather than one big bang, so each stage is -reviewable and we can stop at any of them with a working app. - -| # | Stage | What ships | What deletes | -| --- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| 1 | **View registry + extract `ChatView` / `StateExplorerView`** | `viewRegistry.tsx`, `ChatView.tsx`, `StateExplorerView.tsx`, registration. `EntityPage` still renders one view at a time, but goes through the registry. `?view=…` query-param deep-linking. State Explorer toggle in `EntityHeader` becomes "switch to State Explorer view" / "back to Chat" — already a ship-able UX improvement on its own. | – | -| 2 | **Workspace skeleton + reducer** | `lib/workspace/*` types + reducer + tests, `WorkspaceProvider`, ``. Single-tile by default; visually identical to stage 1. Active-tile URL sync (one-way: workspace → URL, with `push` vs `replace` rules from §3.4). Generic ``. | Bespoke `statePanelWidth` splitter in `router.tsx`. | -| 3 | **`SplitMenu` (Split Right/Down + hotkeys) and the `View ▸` submenu** | Power users can split, swap views in place, or split-with-view in one step. State Explorer regains its "drawer to the right" UX as the _default_ action of `View ▸ State Explorer` thanks to `defaultSplit: 'right'`. | Old `EntityHeader` State-Explorer button (already moved in stage 1; this fully removes the drawer mode). | -| 4 | **Drag-and-drop** | Sidebar rows + tabs draggable; 5-zone drop overlay; close tile/group; tab strip with click-to-activate + middle-click-to-close. | – | -| 5 | **Persistence + polish (incl. shareable layouts)** | localStorage workspace persistence (debounced, server-keyed, schema-versioned), layout DSL encoder/decoder + tests, `?layout=…` import + auto-strip, "Copy layout link" menu item, `⌘1..9` group focus, `⌘W` / `⇧⌘W`, prune-on-load, mobile fallback. | – | - -Each PR keeps the build green and ships value on its own. - ---- - -## 5. Risks & open questions - -1. **Performance with N tiles** — each tile mounts its own - `useEntityTimeline`, which subscribes to a Durable Stream. With 4 - tiles on the same entity that's 4 subscriptions. _Mitigation:_ hoist - the timeline subscription into a shared cache keyed by `entityUrl` - (similar pattern to the existing `electricAgents` provider) so - opening a second view of the same entity is free. Land this in - stage 2 or 3. -2. **Deep-linking semantics** — _resolved by §3.4 hybrid strategy._ - External URL changes that target an entity already present in the - workspace just refocus that tile; if the entity isn't present we - add a tile to the active group rather than wiping the layout. - `?layout=…` is the explicit "replace my layout with this" affordance. -3. **Nested submenu reliability** — Base UI `Menu` supports nested - triggers, but the third level (`View ▸ State Explorer ▸ Split -right`) is unusual. If it feels janky on first build we'll flatten - to two levels (`View ▸ State Explorer here / State Explorer split -right / State Explorer split down`). The registry-driven generation - makes this a one-line change. -4. **Mobile / narrow viewports** — splits don't make sense below - ~700 px. We'll degrade to "single active tile, tabs become a select - dropdown" rather than try to make splits work on mobile. -5. **View IDs for sub-types** — should `chat` actually be - `chat-coding-session` vs `chat-generic` (matching the current - `CODING_SESSION_ENTITY_TYPE` branch in `router.tsx`), or keep one - `chat` view that internally branches? **Decision:** one `chat` view, - internally polymorphic — keeps the user-facing menu simple. -6. **Default opened view** — when a sidebar row is clicked, do we - always open `chat`, or remember the last view used for that entity? - VS Code remembers per-file. **Decision:** start with always-open-chat - for simplicity; revisit once telemetry is in. -7. **Preview-tab semantics** — copy VS Code's "single click = preview - tab that gets replaced; double-click = pin"? **Decision:** v1 - always replaces the active tile; preview-tab is a follow-up. -8. **Workspace persistence scope** — global, or per-server (sidebar - already has `useServerConnection`)? **Decision:** start global; - per-server is a follow-up. - ---- - -## 6. Acceptance checklist - -- [ ] Open two different agents side-by-side, each scrolling - independently. -- [ ] Open the same agent's chat + state explorer side-by-side and - they stay in sync. -- [ ] Drag a sidebar entity into the right edge of an existing group - → new vertical split. -- [ ] Drag a tab from group A to group B → tile moves; group A is - removed if empty. -- [ ] `⌘D` on focused tile splits it right; `⇧⌘D` splits down. -- [ ] `View ▸ State Explorer` (parent click) splits right; sub-items - open in place / split in chosen direction. -- [ ] Switch view in place leaves the layout untouched (only the - tile's `viewId` changes). -- [ ] Close the last tile → empty workspace shows `NewSessionPage`. -- [ ] Reload the page → previous layout is restored (skipping deleted - entities). -- [ ] "Copy layout link" produces a `?layout=…` URL that, when opened - in another browser/incognito, hydrates the same workspace and - then strips the param so the address bar settles to the active - tile. -- [ ] Resizing splitters or rearranging tiles does **not** create - browser history entries; switching active tile **does**. -- [ ] Hotkeys `⌘1..9` focus group N; `⌘W` closes the active tile. -- [ ] No regressions in existing keyboard shortcuts (`⌘B` sidebar, - `⌘K` palette, `⌘N` new session). -- [ ] Adding a new view requires only a `registerView({…})` call and - a new `*View.tsx` file — no edits to `Workspace`, `SplitMenu`, - or routing. diff --git a/packages/agents-server-ui/src/components/NewSessionPage.module.css b/packages/agents-server-ui/src/components/NewSessionPage.module.css index cf109e1b7e..a20ad4c770 100644 --- a/packages/agents-server-ui/src/components/NewSessionPage.module.css +++ b/packages/agents-server-ui/src/components/NewSessionPage.module.css @@ -5,6 +5,58 @@ flex-direction: column; background: var(--ds-bg); overflow: hidden; + /* Anchor for the .dropHint overlay below — without this the + * absolutely-positioned hint would escape the page and cover the + * sidebar too. */ + position: relative; +} + +/* ---- Drag-and-drop targeting ----------------------------------- */ +/* When a workspace drag begins anywhere in the document we render a + * single full-page drop hint over the new-session content. The hint + * has two visual states: + * - .dropArmed (a workspace drag is in flight) → soft outline + * - .dropOver (the cursor is over this page) → strong outline + * + label visible + * Sidebar entity drops navigate to that entity, which bootstraps the + * workspace as a single tile via the URL → workspace effect in + * . */ +.dropHint { + position: absolute; + inset: var(--ds-space-3); + pointer-events: none; + border: 2px dashed var(--ds-accent-a6); + border-radius: var(--ds-radius-4); + background: var(--ds-accent-a2); + display: flex; + align-items: center; + justify-content: center; + opacity: 0.45; + transition: + opacity 0.12s ease-out, + background 0.12s ease-out, + border-color 0.12s ease-out; + z-index: 20; +} + +.dropHintActive { + opacity: 1; + background: var(--ds-accent-a4); + border-color: var(--ds-accent-a9); +} + +.dropHintLabel { + padding: 6px 12px; + border-radius: var(--ds-radius-2); + background: var(--ds-accent-a3); + color: var(--ds-accent-11); + font-size: var(--ds-text-sm); + opacity: 0; + transition: opacity 0.12s ease-out; +} + +.dropHintActive .dropHintLabel { + opacity: 1; } .body { diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 1d64c766c8..3bf0cc2ca1 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { useNavigate } from '@tanstack/react-router' import { useElectricAgents } from '../lib/ElectricAgentsProvider' import { HoverCard, ScrollArea, Stack, Text } from '../ui' import { NewSessionKey } from '../lib/keyLabels' +import { setDragPayload } from '../lib/workspace/dragPayload' import { SidebarHeader } from './SidebarHeader' import { SidebarRowInfo } from './SidebarRow' import type { SidebarRowInfoPayload } from './SidebarRow' @@ -159,6 +160,15 @@ export function Sidebar({ - } - /> - - - + {hasEntity && entity && ( + + + Entity details +
+              {JSON.stringify(entity, null, 2)}
+            
+ + + Close + + } + /> + +
+
+ )} - - - Kill entity - - Are you sure you want to kill {instanceName}? The entity will stop - processing and its stream will become read-only. - - - - Cancel - - } - /> - - - - -
+ {hasEntity && entity && ( + + + Kill entity + + Are you sure you want to kill {instanceName}? The entity will stop + processing and its stream will become read-only. + + + + Cancel + + } + /> + + + + + )} + ) } -function ViewSubmenu({ - label, - icon, - description, - defaultSplit, +/** + * One inline row in the View section of the tile menu. + * + * Layout: `[icon] Label [✓?] [→][↓]` + * + * - Click anywhere on the row body → swap this view into the current + * tile (Menu.Item activation). + * - Click `[→]` → split right with this view. + * - Click `[↓]` → split down with this view. + * + * Implementation note: the row IS a Menu.Item (not a custom div) so + * Base UI's keyboard navigation, hover styling and focus management + * treat it the same as any other menu entry. The trailing icon + * buttons stop propagation so the row's `onSelect` doesn't fire when + * the user is targeting one of them — they then call their own + * handler (which also closes the controlled menu via `run()` in the + * parent). + */ +function ViewRow({ + view, isActive, onOpenHere, - onSplit, + onSplitRight, + onSplitDown, }: { - label: string - icon: React.ReactNode - description?: string - defaultSplit?: `right` | `down` + view: EntityViewDefinition isActive: boolean onOpenHere: () => void - onSplit: (dir: SplitDirection) => void + onSplitRight: () => void + onSplitDown: () => void }): React.ReactElement { - // Clicking the parent row directly dispatches the view's preferred - // action — `defaultSplit` if it's set, else "open here". This is what - // makes `View ▸ State Explorer` keep the "drawer pops out to the right" - // muscle-memory without forcing the user into the deeper menu. - const onParentSelect = () => { - if (defaultSplit) onSplit(defaultSplit) - else onOpenHere() + const Icon = view.icon + // Each icon-button stops propagation so the row's Menu.Item never + // sees the click — otherwise the row's `onSelect` (open here) would + // fire alongside the split. We then manually invoke the split + // handler, which also closes the menu via the parent's `run()`. + const stopAndDo = (fn: () => void) => (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + fn() } - return ( - - - {icon} - {label} - {isActive && ( - - ✓ - - )} - - - {description && ( - - - {description} - - - )} - - Open here - - - {([`right`, `down`, `left`, `up`] as const).map((dir) => ( - onSplit(dir)}> - Split {dir} - {dir === defaultSplit && ( - default - )} - - ))} - - + )} + + + + ) } diff --git a/packages/agents-server-ui/src/components/workspace/Splitter.module.css b/packages/agents-server-ui/src/components/workspace/Splitter.module.css index 15b1e46fe4..eef5432c9f 100644 --- a/packages/agents-server-ui/src/components/workspace/Splitter.module.css +++ b/packages/agents-server-ui/src/components/workspace/Splitter.module.css @@ -1,21 +1,59 @@ +/* Pane divider — matches the sidebar resize handle: + * + * - The element itself renders as a single 1px line in `--ds-border-1`, + * so the resting visual is identical to the sidebar's right border. + * - A `::before` pseudo-element extends 3px past the line on each side + * to form a 7px hit-target (1px line + 3px overflow each side). + * It's transparent by default so it doesn't visually thicken the + * line; on hover or while dragging it fills with `--ds-accent-a6` + * and overlays the line so the user sees a 7px accent strip — same + * dimensions and colour as the sidebar's `.resizeHandleActive`. + * + * The parent `.split` has `overflow: hidden`, but the ::before only + * extends 3px into adjacent panes (still inside `.split`'s box) so + * nothing is clipped. Adjacent panes also have `overflow: hidden` but + * the ::before isn't a descendant of theirs, so it paints over them + * cleanly via z-index. */ + .splitter { flex-shrink: 0; - background: var(--ds-gray-a5); + background: var(--ds-border-1); position: relative; - z-index: 1; } .horizontal { - width: 4px; + width: 1px; cursor: col-resize; } .vertical { - height: 4px; + height: 1px; cursor: row-resize; } -.splitter:hover, -.active { +.splitter::before { + content: ''; + position: absolute; + background: transparent; + transition: background 0.15s ease; + z-index: 1; +} + +.horizontal::before { + top: 0; + bottom: 0; + left: -3px; + right: -3px; +} + +.vertical::before { + left: 0; + right: 0; + top: -3px; + bottom: -3px; +} + +.splitter:hover::before, +.active::before { background: var(--ds-accent-a6); } diff --git a/packages/agents-server-ui/src/components/workspace/TabStrip.module.css b/packages/agents-server-ui/src/components/workspace/TabStrip.module.css deleted file mode 100644 index 5247c8c729..0000000000 --- a/packages/agents-server-ui/src/components/workspace/TabStrip.module.css +++ /dev/null @@ -1,82 +0,0 @@ -.strip { - display: flex; - flex-direction: row; - align-items: stretch; - min-height: 32px; - background: var(--ds-gray-a2); - border-bottom: 1px solid var(--ds-gray-a4); - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; - flex-shrink: 0; -} - -.tab { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0 8px 0 10px; - border: 0; - background: transparent; - cursor: pointer; - color: var(--ds-gray-12); - font-size: var(--ds-font-size-2, 13px); - border-right: 1px solid var(--ds-gray-a4); - position: relative; - white-space: nowrap; - height: 100%; - max-width: 220px; - min-width: 0; -} - -.tab:hover { - background: var(--ds-gray-a3); -} - -.activeTab { - background: var(--ds-bg); - color: var(--ds-gray-12); -} - -.activeTab::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: -1px; - height: 1px; - background: var(--ds-bg); -} - -.label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.closeBtn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - padding: 0; - border: 0; - background: transparent; - border-radius: 3px; - cursor: pointer; - color: var(--ds-gray-11); - flex-shrink: 0; - margin-left: 2px; - opacity: 0; -} - -.tab:hover .closeBtn, -.activeTab .closeBtn { - opacity: 1; -} - -.closeBtn:hover { - background: var(--ds-gray-a4); -} diff --git a/packages/agents-server-ui/src/components/workspace/TabStrip.tsx b/packages/agents-server-ui/src/components/workspace/TabStrip.tsx deleted file mode 100644 index 464033ed18..0000000000 --- a/packages/agents-server-ui/src/components/workspace/TabStrip.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { X } from 'lucide-react' -import { useLiveQuery } from '@tanstack/react-db' -import { eq } from '@tanstack/db' -import { useElectricAgents } from '../../lib/ElectricAgentsProvider' -import { useWorkspace } from '../../hooks/useWorkspace' -import { getView } from '../../lib/workspace/viewRegistry' -import { getEntityDisplayTitle } from '../../lib/entityDisplay' -import { setDragPayload } from '../../lib/workspace/dragPayload' -import type { Group, Tile } from '../../lib/workspace/types' -import styles from './TabStrip.module.css' - -/** - * The tab strip across the top of a Group. Renders one button per tile - * with the entity's short display title (or the entity URL when the - * entity isn't loaded yet) plus a small close `×`. - * - * Stage 2 has no DnD on tabs yet — clicking activates, middle-click - * closes. Drag-to-rearrange and drop targets land in Stage 4. - * - * The strip is hidden entirely when a group has only one tile, matching - * the look of the pre-tile UI: a single tile reads as "the page" and a - * tab labelled identically would be visual noise. - */ -export function TabStrip({ - group, -}: { - group: Group -}): React.ReactElement | null { - const { helpers } = useWorkspace() - - if (group.tiles.length <= 1) return null - - const onMiddleClickClose = (e: React.MouseEvent, tileId: string) => { - if (e.button === 1) { - e.preventDefault() - helpers.closeTile(tileId) - } - } - - return ( -
- {group.tiles.map((tile) => { - const active = tile.id === group.activeTileId - return ( - - ) - })} -
- ) -} - -/** - * Resolves the display label for a single tab. Looks up the entity by - * `entityUrl` so we can show its short title (e.g. "foo-123") rather - * than the raw URL. - * - * If a view defines a non-default `shortLabel` it's appended in - * parentheses so two tabs of the same entity but different views are - * distinguishable: `foo-123 (State Explorer)`. - */ -function TabLabel({ tile }: { tile: Tile }): React.ReactElement { - const { entitiesCollection } = useElectricAgents() - const { data: matches = [] } = useLiveQuery( - (q) => { - if (!entitiesCollection) return undefined - return q - .from({ e: entitiesCollection }) - .where(({ e }) => eq(e.url, tile.entityUrl)) - }, - [entitiesCollection, tile.entityUrl] - ) - const entity = matches.at(0) - const baseLabel = entity - ? getEntityDisplayTitle(entity).title - : tile.entityUrl.replace(/^\//, ``) - const view = getView(tile.viewId) - const showViewLabel = view && view.id !== `chat` - const display = showViewLabel - ? `${baseLabel} (${view.shortLabel ?? view.label})` - : baseLabel - return ( - - {display} - - ) -} diff --git a/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css b/packages/agents-server-ui/src/components/workspace/TileContainer.module.css similarity index 55% rename from packages/agents-server-ui/src/components/workspace/GroupContainer.module.css rename to packages/agents-server-ui/src/components/workspace/TileContainer.module.css index 146d374d8f..f056fd8fe3 100644 --- a/packages/agents-server-ui/src/components/workspace/GroupContainer.module.css +++ b/packages/agents-server-ui/src/components/workspace/TileContainer.module.css @@ -1,4 +1,4 @@ -.group { +.tile { display: flex; flex-direction: column; flex: 1; @@ -7,18 +7,10 @@ overflow: hidden; background: var(--ds-bg); /* Required for `` (position: absolute; inset: 0) to - * cover only this group and not bleed into adjacent groups. */ + * cover only this tile and not bleed into adjacent tiles. */ position: relative; } -.activeGroup { - /* Subtle outline on the active group so the user knows where new - * tiles will land when they click a sidebar entity. Inset to avoid - * pushing surrounding splitters around (border would change layout - * by 1px). */ - box-shadow: inset 0 0 0 1px var(--ds-accent-a6); -} - .body { display: flex; flex-direction: column; @@ -28,8 +20,8 @@ overflow: hidden; /* Shared chat-column geometry — kept in sync with router.module.css's - * .entityMain so chat tiles render the same regardless of which - * group they're in. */ + * .entityMain so chat tiles render the same regardless of where they + * sit in the workspace. */ --chat-col-width: 68ch; --chat-surface-width: calc(var(--chat-col-width) + 24px); } @@ -39,3 +31,12 @@ --chat-col-width: 80ch; } } + +/* Title slot for a standalone (entity-less) tile — icon + label, sized + * to match how `` lays out an entity title. */ +.standaloneTitle { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--ds-text-1); +} diff --git a/packages/agents-server-ui/src/components/workspace/TileContainer.tsx b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx new file mode 100644 index 0000000000..06bf86c685 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx @@ -0,0 +1,199 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useLiveQuery } from '@tanstack/react-db' +import { eq } from '@tanstack/db' +import { useElectricAgents } from '../../lib/ElectricAgentsProvider' +import { useServerConnection } from '../../hooks/useServerConnection' +import { useWorkspace } from '../../hooks/useWorkspace' +import { getView } from '../../lib/workspace/viewRegistry' +import { setDragPayload } from '../../lib/workspace/dragPayload' +import { EntityHeader } from '../EntityHeader' +import { MainHeader } from '../MainHeader' +import { Stack, Text } from '../../ui' +import { SplitMenu } from './SplitMenu' +import { DropOverlay } from './DropOverlay' +import type { Tile } from '../../lib/workspace/types' +import type { ViewId } from '../../lib/workspace/viewRegistry' +import styles from './TileContainer.module.css' + +/** + * Renders a single Tile (a leaf in the workspace tree). + * + * Branches on whether the tile has an `entityUrl`: + * - entity tile → load entity, render `` + the + * registered entity view body. + * - standalone tile → no entity load. Render `` with + * the view's label and the `SplitMenu`, then + * the registered standalone view body. + * + * Click anywhere inside makes this the active tile (mouse-down-capture + * so it fires before the body's own handlers). + */ +export function TileContainer({ tile }: { tile: Tile }): React.ReactElement { + const { workspace, helpers } = useWorkspace() + const isActive = workspace.activeTileId === tile.id + const tileRef = useRef(null) + + const onActivate = useCallback(() => { + if (!isActive) helpers.setActiveTile(tile.id) + }, [isActive, tile.id, helpers]) + + return ( +
+ {tile.entityUrl !== null ? ( + + ) : ( + + )} + +
+ ) +} + +function EntityTileBody({ + tile, + entityUrl, +}: { + tile: Tile + entityUrl: string +}): React.ReactElement { + const { activeServer } = useServerConnection() + const { entitiesCollection } = useElectricAgents() + const { helpers } = useWorkspace() + + const { data: matches = [] } = useLiveQuery( + (q) => { + if (!entitiesCollection) return undefined + return q + .from({ e: entitiesCollection }) + .where(({ e }) => eq(e.url, entityUrl)) + }, + [entitiesCollection, entityUrl] + ) + const entity = matches.at(0) ?? null + const isSpawning = entity?.status === `spawning` + const entityStopped = entity?.status === `stopped` + + const setView = useCallback( + (viewId: ViewId) => helpers.setTileView(tile.id, viewId), + [helpers, tile.id] + ) + + // If the entity disappears entirely (e.g. user killed it elsewhere), + // close this tile so the workspace doesn't keep dead references. + useEffect(() => { + if (matches.length === 0 && entitiesCollection) { + const t = setTimeout(() => { + if (matches.length === 0) helpers.closeTile(tile.id) + }, 250) + return () => clearTimeout(t) + } + }, [matches.length, entitiesCollection, helpers, tile.id]) + + if (!entity) { + return ( + + Loading entity... + + ) + } + + const baseUrl = activeServer?.url ?? `` + const viewDef = getView(tile.viewId) + // Only render the view body if it's an *entity* view. If we ever land + // here with a standalone view id (shouldn't happen — entityUrl !== null + // is checked one frame above) we fall through to the unknown-view + // placeholder to avoid passing an entity into a view that doesn't + // expect one. + const View = viewDef?.kind === `entity` ? viewDef.Component : undefined + + // The header is the drag handle for this tile. The browser only + // dispatches `dragstart` after the cursor moves, so the title's + // copy-on-click button still works for clicks-without-movement. + const onHeaderDragStart = (e: React.DragEvent) => { + setDragPayload(e, { kind: `tile`, tileId: tile.id }) + } + + return ( + + } + /> + {View ? ( + + ) : ( + + Unknown view: {tile.viewId} + + )} + + ) +} + +/** + * Body for tiles that don't bind to an entity (the new-session tile + * is the only one today). Renders the standalone view's component + * inside a generic `MainHeader` chrome with the SplitMenu so the + * tile participates in splits / drops / "..." actions just like an + * entity tile. + */ +function StandaloneTileBody({ tile }: { tile: Tile }): React.ReactElement { + const { activeServer } = useServerConnection() + const viewDef = getView(tile.viewId) + const Icon = viewDef?.icon + const baseUrl = activeServer?.url ?? `` + + // Same drag-by-header trick as the entity tile body — the whole + // surface is draggable, but the actual `dragstart` doesn't fire + // until the cursor moves, so clicks on inner controls (the agent + // picker buttons, the composer) still work. + const onHeaderDragStart = (e: React.DragEvent) => { + setDragPayload(e, { kind: `tile`, tileId: tile.id }) + } + + if (!viewDef || viewDef.kind !== `standalone`) { + return ( + + Unknown view: {tile.viewId} + + ) + } + + const View = viewDef.Component + + return ( + + + {Icon && } + {viewDef.label} +
+ } + actions={} + /> + + + ) +} diff --git a/packages/agents-server-ui/src/components/workspace/Workspace.tsx b/packages/agents-server-ui/src/components/workspace/Workspace.tsx index 73dfaae646..da8ea7c117 100644 --- a/packages/agents-server-ui/src/components/workspace/Workspace.tsx +++ b/packages/agents-server-ui/src/components/workspace/Workspace.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef } from 'react' import { useNavigate, useParams, useSearch } from '@tanstack/react-router' import { useWorkspace } from '../../hooks/useWorkspace' +import { listTiles } from '../../lib/workspace/workspaceReducer' import { listViews } from '../../lib/workspace/viewRegistry' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' import { decodeLayout } from '../../lib/workspace/layoutCodec' +import { NEW_SESSION_VIEW_ID } from '../../lib/workspace/types' import { NodeRenderer } from './NodeRenderer' import styles from './Workspace.module.css' import type { ViewId } from '../../lib/workspace/viewRegistry' @@ -18,10 +20,9 @@ import type { ViewId } from '../../lib/workspace/viewRegistry' * - Reflecting the active tile back out into the URL (one-way: * workspace → URL) so deep-links still work. * - * Stage 2 keeps the workspace single-tile by default — multi-tile - * arrives in stages 3 and 4 via the `…` menu and drag-and-drop. The - * URL ↔ workspace contract here is the foundation those stages build - * on, and the rules in §3.4 of the plan are encoded as effects below. + * The URL ↔ workspace contract is the foundation that drag-and-drop, + * the SplitMenu and the layout-codec build on. The rules in §3.4 of + * the plan are encoded as effects below. */ export function Workspace(): React.ReactElement { const { workspace, helpers } = useWorkspace() @@ -41,6 +42,11 @@ export function Workspace(): React.ReactElement { // replaces the workspace then strips the param so the address bar // settles to the active tile (per §3.4 of the plan). Only fires once // per param value — guarded by `lastLayoutParam.current`. + // + // After the strip, the workspace → URL effect below takes over and + // navigates to whichever tile is active (could be either route), + // so we just need to remove the `?layout=` query without forcing + // either path here. const lastLayoutParam = useRef(null) useEffect(() => { if (!layoutParam || layoutParam === lastLayoutParam.current) return @@ -51,13 +57,17 @@ export function Workspace(): React.ReactElement { } // Strip the ?layout= param regardless of decode success — a bad // payload shouldn't sit in the address bar nagging the user. - void navigate({ - to: `/entity/$`, - params: { _splat: splat ?? `` }, - search: requestedViewId ? { view: requestedViewId } : {}, - replace: true, - }) - }, [layoutParam, helpers, navigate, splat, requestedViewId]) + if (entityUrl) { + void navigate({ + to: `/entity/$`, + params: { _splat: splat ?? `` }, + search: requestedViewId ? { view: requestedViewId } : {}, + replace: true, + }) + } else { + void navigate({ to: `/`, replace: true }) + } + }, [layoutParam, helpers, navigate, splat, requestedViewId, entityUrl]) const { entitiesCollection } = useElectricAgents() const { data: entityMatches = [] } = useLiveQuery( @@ -72,19 +82,49 @@ export function Workspace(): React.ReactElement { const entity = entityMatches.at(0) ?? null // ---- URL → workspace ------------------------------------------------- - // Whenever the URL points at an entity, ensure it has a tile in the - // workspace and that that tile is active. If the entity is already - // present in some tile, just refocus it (no layout disruption); - // otherwise insert a new tile in the active group, replacing its - // current active tile (matches Stage 1 behaviour: the URL drives - // what's visible). + // Three distinct cases handled below: + // + // A. URL is `/` → ensure the active tile is a new-session + // tile (focus existing one, swap the + // active tile's view, or bootstrap a + // fresh tile in an empty workspace). + // B. URL is `/entity/$` → ensure that (entityUrl, view) has a + // tile and is active. Three sub-cases: + // B1. exact (entity, view) match + // anywhere in the tree → refocus. + // B2. active tile is the same entity + // (different view) → swap in place. + // B3. otherwise → replace the active + // tile (or bootstrap if empty). // // The `lastSyncedKey` ref dedupes redundant syncs — without it, the // workspace → URL effect below would echo back into this one and - // create infinite open-tile dispatches. + // create infinite open-tile dispatches. The key intentionally uses + // the empty string for null entityUrl so the sentinel space doesn't + // accidentally collide with a real entity URL. const lastSyncedKey = useRef(null) useEffect(() => { - if (!entityUrl) return + // Case A — URL is the index route. We want the active tile to be + // a new-session tile. + if (!entityUrl) { + const key = `::${NEW_SESSION_VIEW_ID}` + if (lastSyncedKey.current === key) return + const tiles = listTiles(workspace.root) + const existing = tiles.find( + (t) => t.entityUrl === null && t.viewId === NEW_SESSION_VIEW_ID + ) + if (existing) { + helpers.setActiveTile(existing.id) + } else { + // No new-session tile yet — replace the active tile (or + // bootstrap if the workspace is empty). `openNewSession` with + // no target defaults to 'replace' on the active tile. + helpers.openNewSession() + } + lastSyncedKey.current = key + return + } + // Case B — entity URL. const availableViews = entity ? listViews(entity) : [] const defaultViewId = availableViews[0]?.id ?? `chat` const desiredViewId = @@ -94,70 +134,57 @@ export function Workspace(): React.ReactElement { const key = `${entityUrl}::${desiredViewId}` if (lastSyncedKey.current === key) return - // Look for an existing tile that already matches. - const groups: Array<{ - id: string - tiles: Array<{ id: string; entityUrl: string; viewId: string }> - }> = [] - if (workspace.root) { - const collect = (node: typeof workspace.root): void => { - if (!node) return - if (node.kind === `group`) { - groups.push(node) - } else { - for (const c of node.children) collect(c.node) - } - } - collect(workspace.root) - } - const exactMatch = groups - .flatMap((g) => g.tiles.map((t) => ({ groupId: g.id, tile: t }))) - .find( - (m) => m.tile.entityUrl === entityUrl && m.tile.viewId === desiredViewId - ) + const tiles = listTiles(workspace.root) + // B1. Exact (entity, view) match anywhere in the tree → refocus. + const exactMatch = tiles.find( + (t) => t.entityUrl === entityUrl && t.viewId === desiredViewId + ) if (exactMatch) { - helpers.setActiveTile(exactMatch.tile.id) - } else { - // No matching tile — open one. If a tile of the same entity - // already exists in the active group, switch its view in place - // rather than adding a new tile. - const activeGroup = - groups.find((g) => g.id === workspace.activeGroupId) ?? groups[0] - const sameEntityInActive = activeGroup?.tiles.find( - (t) => t.entityUrl === entityUrl - ) - if (sameEntityInActive) { - helpers.setActiveTile(sameEntityInActive.id) - helpers.setTileView(sameEntityInActive.id, desiredViewId) - } else { - helpers.openEntity(entityUrl, { viewId: desiredViewId }) - } + helpers.setActiveTile(exactMatch.id) + lastSyncedKey.current = key + return + } + + // B2. Active tile is the same entity (different view) → swap view + // in place. Preserves the tile id (and any per-view UI state we + // might want to keep). + const activeTile = helpers.activeTile + if (activeTile && activeTile.entityUrl === entityUrl) { + helpers.setTileView(activeTile.id, desiredViewId) + lastSyncedKey.current = key + return } + + // B3. New entity entirely → replace the active tile (or bootstrap + // the empty workspace). `openEntity` with no target defaults to + // 'replace' on the active tile. + helpers.openEntity(entityUrl, { viewId: desiredViewId }) lastSyncedKey.current = key - }, [ - entityUrl, - requestedViewId, - entity, - workspace.root, - workspace.activeGroupId, - helpers, - ]) + }, [entityUrl, requestedViewId, entity, workspace.root, helpers]) // ---- Workspace → URL ------------------------------------------------- // Whenever the active tile changes, mirror its (entityUrl, viewId) - // into the route. We use `replace: true` for the URL update because - // the *user* navigations that change the active tile (clicking a - // sidebar row, opening a new tile, switching views) already pushed a - // history entry through their own `navigate({})` calls — this effect - // runs *after* the dispatch and is just keeping the URL in sync, so - // pushing again would double up. + // into the route. Standalone tiles (new-session) map back to `/`, + // entity tiles to `/entity/$splat`. We use `replace: true` for the + // URL update because the *user* navigations that change the active + // tile (clicking a sidebar row, opening a new tile, switching views) + // already pushed a history entry through their own `navigate({})` + // calls — this effect runs *after* the dispatch and is just keeping + // the URL in sync, so pushing again would double up. useEffect(() => { const tile = helpers.activeTile if (!tile) return - const expectedKey = `${tile.entityUrl}::${tile.viewId}` + const expectedKey = + tile.entityUrl === null + ? `::${tile.viewId}` + : `${tile.entityUrl}::${tile.viewId}` if (lastSyncedKey.current === expectedKey) return lastSyncedKey.current = expectedKey + if (tile.entityUrl === null) { + void navigate({ to: `/`, replace: true }) + return + } void navigate({ to: `/entity/$`, params: { _splat: tile.entityUrl.replace(/^\//, ``) }, diff --git a/packages/agents-server-ui/src/hooks/useWorkspace.tsx b/packages/agents-server-ui/src/hooks/useWorkspace.tsx index 77972d28a0..3edb11859e 100644 --- a/packages/agents-server-ui/src/hooks/useWorkspace.tsx +++ b/packages/agents-server-ui/src/hooks/useWorkspace.tsx @@ -8,9 +8,8 @@ import { import type { Dispatch, ReactNode } from 'react' import { workspaceReducer, - findGroupContainingTile, findTile, - listGroups, + listTiles, } from '../lib/workspace/workspaceReducer' import { EMPTY_WORKSPACE, dropPositionFromSplit } from '../lib/workspace/types' import type { @@ -25,43 +24,45 @@ import type { WorkspaceAction } from '../lib/workspace/workspaceReducer' type WorkspaceContextValue = { workspace: Workspace dispatch: Dispatch - /** Memoised helper API — wraps `dispatch` for ergonomics in components. */ helpers: WorkspaceHelpers } export type WorkspaceHelpers = { - /** Open `entityUrl` (with `viewId`) — defaults to active group, replace. */ + /** Open `entityUrl` (with `viewId`) — defaults to replacing the active tile. */ openEntity: ( entityUrl: string, options?: { viewId?: ViewId; target?: DropTarget } ) => void - /** Close a tile by id — collapses empty groups / splits. */ + /** + * Open a standalone "new session" tile — defaults to replacing the + * active tile. Used by the index route, the `⌘N` hotkey and the + * sidebar's "New session" button. Pass an explicit `target` to put + * the new-session tile in a split instead. + */ + openNewSession: (options?: { target?: DropTarget }) => void + /** Close a tile by id — collapses parent splits if needed. */ closeTile: (tileId: string) => void /** Move a tile to a different position (drag-and-drop primitive). */ moveTile: (tileId: string, target: DropTarget) => void - /** Set a tile as active inside its group, plus mark its group active. */ + /** Mark a tile as the active tile (drives URL sync + ⌘W target). */ setActiveTile: (tileId: string) => void - setActiveGroup: (groupId: string) => void - /** Swap a tile's view in place — no layout change. */ + /** Swap a tile's view in place — preserves tile id (and per-tile state). */ setTileView: (tileId: string, viewId: ViewId) => void - /** Split the active tile and put the named view in the new group. */ + /** Split a tile and put a different view in the new tile. */ splitTileWithView: ( tileId: string, viewId: ViewId, direction: SplitDirection ) => void - /** Convenience: split the active tile, keeping the same view. */ + /** Convenience: split a tile, copying its current view into the new one. */ splitTile: (tileId: string, direction: SplitDirection) => void - /** Resize a split's children. */ resizeSplit: (splitId: string, sizes: Array) => void - /** Replace the entire workspace (used by URL/persistence hydration). */ replaceWorkspace: (workspace: Workspace) => void // ---- Read-side conveniences (computed from the latest workspace). ---- - /** Active tile in the active group, or `null` for an empty workspace. */ + /** The active tile, or `null` for an empty workspace. */ activeTile: Tile | null - /** Active group, or `null` for an empty workspace. */ - activeGroupId: string | null + activeTileId: string | null } const WorkspaceContext = createContext(null) @@ -86,6 +87,13 @@ export function WorkspaceProvider({ [] ) + const openNewSession = useCallback( + (options) => { + dispatch({ type: `open-new-session-tile`, target: options?.target }) + }, + [] + ) + const closeTile = useCallback((tileId) => { dispatch({ type: `close-tile`, tileId }) }, []) @@ -104,13 +112,6 @@ export function WorkspaceProvider({ [] ) - const setActiveGroup = useCallback( - (groupId) => { - dispatch({ type: `set-active-group`, groupId }) - }, - [] - ) - const setTileView = useCallback( (tileId, viewId) => { dispatch({ type: `set-tile-view`, tileId, viewId }) @@ -154,32 +155,32 @@ export function WorkspaceProvider({ ) const helpers = useMemo(() => { - const groups = listGroups(workspace.root) - const activeGroup = - groups.find((g) => g.id === workspace.activeGroupId) ?? groups[0] ?? null const activeTile = - activeGroup?.tiles.find((t) => t.id === activeGroup.activeTileId) ?? null + (workspace.activeTileId && + findTile(workspace.root, workspace.activeTileId)) || + listTiles(workspace.root)[0] || + null return { openEntity, + openNewSession, closeTile, moveTile, setActiveTile, - setActiveGroup, setTileView, splitTileWithView, splitTile, resizeSplit, replaceWorkspace, activeTile, - activeGroupId: workspace.activeGroupId, + activeTileId: workspace.activeTileId, } }, [ workspace, openEntity, + openNewSession, closeTile, moveTile, setActiveTile, - setActiveGroup, setTileView, splitTileWithView, splitTile, @@ -207,8 +208,5 @@ export function useWorkspace(): WorkspaceContextValue { return ctx } -// Re-export tree walkers for convenience (some components call them -// against the snapshot returned from `useWorkspace`). -export { findGroupContainingTile, findTile, listGroups } -// Re-export the position helper so component-level imports stay shallow. +export { findTile, listTiles } export { dropPositionFromSplit } diff --git a/packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts b/packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts index fbdcdc4d6f..f696f0c9cc 100644 --- a/packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts +++ b/packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts @@ -1,8 +1,5 @@ -import { useCallback } from 'react' import { useHotkey } from './useHotkey' -import { useWorkspace, listGroups } from './useWorkspace' - -const GROUP_FOCUS_INDICES = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const +import { useWorkspace, listTiles } from './useWorkspace' /** * Workspace-level keyboard shortcuts. Mounted once near the top of the @@ -14,8 +11,7 @@ const GROUP_FOCUS_INDICES = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const * - `⌘D` Split active tile right * - `⇧⌘D` Split active tile down * - `⌘W` Close active tile - * - `⌘1..9` Focus group N (1-indexed) - * - `⌘\` Cycle to the next group + * - `⌘\` Cycle to the next tile (tree order) * * Hotkeys are skipped when focus is in a text input (handled by * `useHotkey`'s default `ignoreInputs: true` behaviour). @@ -43,51 +39,10 @@ export function useWorkspaceHotkeys(): void { useHotkey(`mod+\\`, (e) => { e.preventDefault() - const groups = listGroups(workspace.root) - if (groups.length < 2) return - const currentIdx = groups.findIndex((g) => g.id === workspace.activeGroupId) - const next = groups[(currentIdx + 1) % groups.length] - helpers.setActiveGroup(next.id) + const tiles = listTiles(workspace.root) + if (tiles.length < 2) return + const currentIdx = tiles.findIndex((t) => t.id === workspace.activeTileId) + const next = tiles[(currentIdx + 1) % tiles.length] + helpers.setActiveTile(next.id) }) - - // The 9 group-focus hotkeys are registered as separate hook calls - // (rather than a `for` loop) so the call count is statically obvious - // — keeping React's rules-of-hooks happy without an eslint-disable. - // Each handler closes over its own `n` via the const array above. - const focusGroup = useCallback( - (n: number, e: KeyboardEvent) => { - const groups = listGroups(workspace.root) - if (groups.length < n) return - e.preventDefault() - helpers.setActiveGroup(groups[n - 1].id) - }, - [workspace.root, helpers] - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[0]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[0], e) - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[1]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[1], e) - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[2]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[2], e) - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[3]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[3], e) - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[4]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[4], e) - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[5]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[5], e) - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[6]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[6], e) - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[7]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[7], e) - ) - useHotkey(`mod+${GROUP_FOCUS_INDICES[8]}`, (e) => - focusGroup(GROUP_FOCUS_INDICES[8], e) - ) } diff --git a/packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts b/packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts index acbebb9a47..bdb183d5ef 100644 --- a/packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts +++ b/packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { useWorkspace, listGroups } from './useWorkspace' +import { useWorkspace, listTiles } from './useWorkspace' import { useServerConnection } from './useServerConnection' import { useElectricAgents } from '../lib/ElectricAgentsProvider' import { useLiveQuery } from '@tanstack/react-db' @@ -12,8 +12,11 @@ import type { Workspace, WorkspaceNode } from '../lib/workspace/types' * Storage shape (envelope so future revisions can migrate or fall * back without crashing the UI): * - * key: `electric-agents-ui.workspace..v1` - * value: { v: 1, workspace: } + * key: `electric-agents-ui.workspace..v2` + * value: { v: 2, workspace: } + * + * Schema bumped from v1 → v2 when the data model dropped the Group + * concept; v1 envelopes are silently ignored on hydration. * * Server-keyed because two different Electric servers each remember * their own layout — switching servers shouldn't drag the previous @@ -29,7 +32,7 @@ import type { Workspace, WorkspaceNode } from '../lib/workspace/types' * Persistence write: debounced 250ms after every workspace change so * we don't beat localStorage with one write per splitter pixel. */ -const SCHEMA_VERSION = 1 +const SCHEMA_VERSION = 2 const DEBOUNCE_MS = 250 type Envelope = { @@ -93,7 +96,7 @@ export function useWorkspacePersistence(): void { if (!env || env.v !== SCHEMA_VERSION || !env.workspace) return // Prune entities that are no longer alive on the server. We do // this against `liveUrls` *as of first hydration*; subsequent - // entity disappearances are handled by ``'s + // entity disappearances are handled by ``'s // close-on-disappear effect. If the entities collection hasn't // populated yet we skip the prune (the close-on-disappear // effect will handle it on next render). @@ -140,10 +143,11 @@ export function useWorkspacePersistence(): void { /** * Drop tiles whose entity URL isn't present in `liveUrls`. Empty - * groups cascade-collapse; empty splits collapse to their lone child; - * the root collapses to `null` if every tile is dead. + * splits cascade-collapse to `null` (or to their sole survivor when + * one child remains); the root collapses to `null` if every tile is + * dead. * - * activeGroupId is reset to whichever group survives the prune (first + * activeTileId is reset to whichever tile survives the prune (first * one in tree order) when the previous active is gone. */ function pruneWorkspace( @@ -151,16 +155,14 @@ function pruneWorkspace( liveUrls: Set ): Workspace { const root = pruneNode(workspace.root, liveUrls) - if (!root) return { root: null, activeGroupId: null } - const groups = listGroups(root) + if (!root) return { root: null, activeTileId: null } + const tiles = listTiles(root) const stillThere = - workspace.activeGroupId !== null && - groups.some((g) => g.id === workspace.activeGroupId) + workspace.activeTileId !== null && + tiles.some((t) => t.id === workspace.activeTileId) return { root, - activeGroupId: stillThere - ? workspace.activeGroupId - : (groups[0]?.id ?? null), + activeTileId: stillThere ? workspace.activeTileId : (tiles[0]?.id ?? null), } } @@ -169,15 +171,11 @@ function pruneNode( liveUrls: Set ): WorkspaceNode | null { if (!node) return null - if (node.kind === `group`) { - const tiles = node.tiles.filter((t) => liveUrls.has(t.entityUrl)) - if (tiles.length === 0) return null - const activeStillThere = tiles.some((t) => t.id === node.activeTileId) - return { - ...node, - tiles, - activeTileId: activeStillThere ? node.activeTileId : tiles[0].id, - } + if (node.kind === `tile`) { + // Standalone tiles (e.g. new-session) have no entity to validate + // against — they always survive the prune. + if (node.entityUrl === null) return node + return liveUrls.has(node.entityUrl) ? node : null } const newChildren: typeof node.children = [] for (const child of node.children) { @@ -187,7 +185,7 @@ function pruneNode( if (newChildren.length === 0) return null if (newChildren.length === 1) return newChildren[0].node // Re-normalise sizes so they sum to 1 again after dropping siblings. - const total = newChildren.reduce((a, c) => a + c.size, 0) + const total = newChildren.reduce((a: number, c) => a + c.size, 0) const normalised = newChildren.map((c) => ({ ...c, size: total > 0 ? c.size / total : 1 / newChildren.length, diff --git a/packages/agents-server-ui/src/lib/workspace/dragPayload.ts b/packages/agents-server-ui/src/lib/workspace/dragPayload.ts index adbe4dc706..149aac3efe 100644 --- a/packages/agents-server-ui/src/lib/workspace/dragPayload.ts +++ b/packages/agents-server-ui/src/lib/workspace/dragPayload.ts @@ -5,13 +5,20 @@ import type { ViewId } from './viewRegistry' * into the `dataTransfer` slot under our custom MIME type so the browser * doesn't try to interpret it as text/plain or a URL. * - * Two kinds today: - * - `sidebar-entity` — the user dragged an entity row out of the sidebar. - * No `viewId` is carried because the receiving group decides how to - * render it (defaults to `chat`). - * - `tile` — the user dragged an existing tile (from a tab or a tile - * header). Carries the source group id so the reducer can detect a - * no-op (drop-on-self) and skip the round-trip. + * Three kinds today: + * - `sidebar-entity` — the user dragged an entity row out of the + * sidebar. No `viewId` is carried because the + * receiver decides how to render it (defaults + * to `chat`). + * - `sidebar-new-session` — the user dragged the "New session" button + * out of the sidebar. Drops always create a + * fresh standalone new-session tile in the + * target quadrant (so the workspace can hold + * multiple new-session tiles at once, e.g. + * one per agent type the user is comparing). + * - `tile` — the user dragged an existing tile by its + * header. The reducer detects drop-on-self + * via tile id directly. */ export type WorkspaceDragPayload = | { @@ -19,10 +26,12 @@ export type WorkspaceDragPayload = entityUrl: string viewId?: ViewId } + | { + kind: `sidebar-new-session` + } | { kind: `tile` tileId: string - sourceGroupId: string } /** @@ -63,11 +72,10 @@ export function readDragPayload( ) { return parsed } - if ( - parsed.kind === `tile` && - typeof parsed.tileId === `string` && - typeof parsed.sourceGroupId === `string` - ) { + if (parsed.kind === `sidebar-new-session`) { + return parsed + } + if (parsed.kind === `tile` && typeof parsed.tileId === `string`) { return parsed } } catch { @@ -93,7 +101,12 @@ export function isWorkspaceDrag(e: DragEvent | React.DragEvent): boolean { } function describePayload(p: WorkspaceDragPayload): string { - return p.kind === `sidebar-entity` - ? `entity: ${p.entityUrl}` - : `tile: ${p.tileId}` + switch (p.kind) { + case `sidebar-entity`: + return `entity: ${p.entityUrl}` + case `sidebar-new-session`: + return `new session` + case `tile`: + return `tile: ${p.tileId}` + } } diff --git a/packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts b/packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts index 5baecf9cfd..fef4e35ba6 100644 --- a/packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts +++ b/packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts @@ -1,23 +1,20 @@ import { describe, expect, it } from 'vitest' import { decodeLayout, encodeLayout } from './layoutCodec' -import type { Group, Split, Workspace, WorkspaceNode } from './types' +import type { Split, Tile, Workspace, WorkspaceNode } from './types' /** - * Round-trips through the codec strip generated ids (split / group / - * tile ids are minted fresh on decode), so we compare on the - * structural projection that's part of the wire format. + * Round-trips through the codec strip generated ids (split / tile ids + * are minted fresh on decode), so we compare on the structural + * projection that's part of the wire format. */ function structureOf(ws: Workspace): unknown { if (!ws.root) return null const visit = (node: WorkspaceNode): unknown => { - if (node.kind === `group`) { + if (node.kind === `tile`) { return { - kind: `group`, - tiles: node.tiles.map((t) => ({ - entityUrl: t.entityUrl, - viewId: t.viewId, - })), - activeIdx: node.tiles.findIndex((t) => t.id === node.activeTileId), + kind: `tile`, + entityUrl: node.entityUrl, + viewId: node.viewId, } } return { @@ -39,25 +36,9 @@ describe(`layoutCodec`, () => { expect(decoded.kind).toBe(`ok`) if (decoded.kind !== `ok`) return expect(structureOf(decoded.workspace)).toEqual({ - kind: `group`, - tiles: [{ entityUrl: `/horton/foo`, viewId: `chat` }], - activeIdx: 0, - }) - expect(encodeLayout(decoded.workspace)).toBe(encoded) - }) - - it(`encodes the active tile index when not 0`, () => { - const encoded = `horton%2Ffoo.chat;horton%2Ffoo.state-explorer@1` - const decoded = decodeLayout(encoded) - expect(decoded.kind).toBe(`ok`) - if (decoded.kind !== `ok`) return - expect(structureOf(decoded.workspace)).toEqual({ - kind: `group`, - tiles: [ - { entityUrl: `/horton/foo`, viewId: `chat` }, - { entityUrl: `/horton/foo`, viewId: `state-explorer` }, - ], - activeIdx: 1, + kind: `tile`, + entityUrl: `/horton/foo`, + viewId: `chat`, }) expect(encodeLayout(decoded.workspace)).toBe(encoded) }) @@ -73,17 +54,17 @@ describe(`layoutCodec`, () => { children: [ { node: { - kind: `group`, - tiles: [{ entityUrl: `/horton/foo`, viewId: `chat` }], - activeIdx: 0, + kind: `tile`, + entityUrl: `/horton/foo`, + viewId: `chat`, }, size: 0.6, }, { node: { - kind: `group`, - tiles: [{ entityUrl: `/horton/foo`, viewId: `state-explorer` }], - activeIdx: 0, + kind: `tile`, + entityUrl: `/horton/foo`, + viewId: `state-explorer`, }, size: 0.4, }, @@ -112,9 +93,9 @@ describe(`layoutCodec`, () => { children: [ { node: { - kind: `group`, - tiles: [{ entityUrl: `/horton/foo`, viewId: `chat` }], - activeIdx: 0, + kind: `tile`, + entityUrl: `/horton/foo`, + viewId: `chat`, }, size: 0.5, }, @@ -125,17 +106,17 @@ describe(`layoutCodec`, () => { children: [ { node: { - kind: `group`, - tiles: [{ entityUrl: `/horton/bar`, viewId: `chat` }], - activeIdx: 0, + kind: `tile`, + entityUrl: `/horton/bar`, + viewId: `chat`, }, size: 0.5, }, { node: { - kind: `group`, - tiles: [{ entityUrl: `/horton/baz`, viewId: `chat` }], - activeIdx: 0, + kind: `tile`, + entityUrl: `/horton/baz`, + viewId: `chat`, }, size: 0.5, }, @@ -169,15 +150,14 @@ describe(`layoutCodec`, () => { expect(a.kind).toBe(`ok`) expect(b.kind).toBe(`ok`) if (a.kind !== `ok` || b.kind !== `ok`) return - const ag = a.workspace.root as Group - const bg = b.workspace.root as Group - expect(ag.id).not.toBe(bg.id) - expect(ag.tiles[0].id).not.toBe(bg.tiles[0].id) + const at = a.workspace.root as Tile + const bt = b.workspace.root as Tile + expect(at.id).not.toBe(bt.id) }) it(`round-trips a layout produced by encodeLayout()`, () => { const original = decodeLayout( - `H(horton%2Ffoo.chat:70,V(horton%2Fbar.state-explorer,horton%2Fbaz.chat;horton%2Fqux.chat@1):30)` + `H(horton%2Ffoo.chat:70,V(horton%2Fbar.state-explorer,horton%2Fbaz.chat):30)` ) expect(original.kind).toBe(`ok`) if (original.kind !== `ok`) return @@ -198,4 +178,55 @@ describe(`layoutCodec`, () => { const sum = split.children.reduce((acc: number, c) => acc + c.size, 0) expect(sum).toBeCloseTo(1) }) + + it(`active tile after decode is the first leaf in tree order`, () => { + const decoded = decodeLayout( + `H(horton%2Ffoo.chat,V(horton%2Fbar.chat,horton%2Fbaz.chat))` + ) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + const split = decoded.workspace.root as Split + const firstTile = split.children[0].node as Tile + expect(decoded.workspace.activeTileId).toBe(firstTile.id) + }) + + it(`encodes a standalone tile (null entityUrl) with empty path segment`, () => { + const decoded = decodeLayout(`.new-session`) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + const tile = decoded.workspace.root as Tile + expect(tile.entityUrl).toBeNull() + expect(tile.viewId).toBe(`new-session`) + expect(encodeLayout(decoded.workspace)).toBe(`.new-session`) + }) + + it(`mixes standalone and entity tiles in the same split`, () => { + const encoded = `H(.new-session,horton%2Fbar.chat)` + const decoded = decodeLayout(encoded) + expect(decoded.kind).toBe(`ok`) + if (decoded.kind !== `ok`) return + expect(structureOf(decoded.workspace)).toEqual({ + kind: `split`, + direction: `horizontal`, + children: [ + { + node: { + kind: `tile`, + entityUrl: null, + viewId: `new-session`, + }, + size: 0.5, + }, + { + node: { + kind: `tile`, + entityUrl: `/horton/bar`, + viewId: `chat`, + }, + size: 0.5, + }, + ], + }) + expect(encodeLayout(decoded.workspace)).toBe(encoded) + }) }) diff --git a/packages/agents-server-ui/src/lib/workspace/layoutCodec.ts b/packages/agents-server-ui/src/lib/workspace/layoutCodec.ts index 1e94a6b324..4eb997936d 100644 --- a/packages/agents-server-ui/src/lib/workspace/layoutCodec.ts +++ b/packages/agents-server-ui/src/lib/workspace/layoutCodec.ts @@ -1,5 +1,5 @@ -import type { Group, Split, Tile, Workspace, WorkspaceNode } from './types' -import { makeGroupId, makeSplitId, makeTileId } from './workspaceReducer' +import type { Split, Tile, Workspace, WorkspaceNode } from './types' +import { makeSplitId, makeTileId } from './workspaceReducer' import type { ViewId } from './viewRegistry' // --------------------------------------------------------------------------- @@ -7,33 +7,35 @@ import type { ViewId } from './viewRegistry' // TILE_LAYOUT_PLAN.md). // // Grammar: -// node := group | hsplit | vsplit +// node := tile | hsplit | vsplit // hsplit := 'H' '(' sized (',' sized)+ ')' // horizontal = side-by-side // vsplit := 'V' '(' sized (',' sized)+ ')' // vertical = stacked // sized := node (':' int)? // size as percentage; default = even -// group := tile (';' tile)* ('@' int)? // @int = active tile index, default 0 -// tile := '.' viewId // entityPath is urlEncoded, +// tile := ? '.' viewId // entityPath is urlEncoded, // with the conventional -// leading '/' stripped -// -// `,` is reserved for split-siblings and `;` for group-tabs so the -// grammar is unambiguous to a single-character lookahead — without -// that distinction, parsing `H(a,V(b,c,d@1):30)` is ambiguous (does -// the second `,` start a new sibling at the H, or another tab inside -// the V's group?). Both `,` and `;` are URL-safe sub-delims so neither -// needs percent-encoding in a query value. +// leading '/' stripped. +// Empty entityPath ('.viewId') +// encodes a standalone tile — +// e.g. the new-session tile. // // Examples (canonical forms produced by `encodeLayout`): // horton%2Ffoo.chat -// horton%2Ffoo.chat;horton%2Ffoo.state-explorer@1 +// .new-session // H(horton%2Ffoo.chat:60,horton%2Ffoo.state-explorer:40) +// H(.new-session,horton%2Fbar.chat) // H(horton%2Ffoo.chat,V(horton%2Fbar.chat,horton%2Fbaz.logs)) // -// Tile ids / group ids / split ids are *not* part of the wire format — -// the decoder mints fresh ones via the same factories the reducer uses. -// IDs being ephemeral is the right thing here: a layout link should -// always paste cleanly into another window without colliding with that -// window's existing IDs. +// Every leaf is a tile (no groups, no tabs). Tile / split ids are *not* +// part of the wire format — the decoder mints fresh ones via the same +// factories the reducer uses. IDs being ephemeral is the right thing +// here: a layout link should always paste cleanly into another window +// without colliding with that window's existing IDs. +// +// The active tile is *not* encoded either. The receiving window picks +// the first tile (tree order) as active during decode, then the +// workspace ↔ URL effect immediately overrides it with whatever +// (entity, view) the URL also carries — which is the typical "share +// link" workflow. // // Entity URLs always start with `/` everywhere else in the codebase // (see `Tile.entityUrl`). The codec strips the leading slash on @@ -47,7 +49,7 @@ export function encodeLayout(workspace: Workspace): string { } function encodeNode(node: WorkspaceNode): string { - return node.kind === `split` ? encodeSplit(node) : encodeGroup(node) + return node.kind === `split` ? encodeSplit(node) : encodeTile(node) } function encodeSplit(split: Split): string { @@ -67,14 +69,10 @@ function encodeSplit(split: Split): string { return `${split.direction === `horizontal` ? `H` : `V`}(${inner})` } -function encodeGroup(group: Group): string { - const tilesPart = group.tiles.map(encodeTile).join(`;`) - const activeIdx = group.tiles.findIndex((t) => t.id === group.activeTileId) - const activePart = activeIdx > 0 ? `@${activeIdx}` : `` - return `${tilesPart}${activePart}` -} - function encodeTile(tile: Tile): string { + // Standalone tile (e.g. new-session) → empty entityPath segment, so + // the canonical form is `.new-session`. + if (tile.entityUrl === null) return `.${tile.viewId}` // Strip the conventional leading `/` so the canonical form is // `horton%2Ffoo.chat` instead of `%2Fhorton%2Ffoo.chat`. Decoder // adds it back. If for some reason an entityUrl doesn't start with @@ -95,18 +93,15 @@ export type DecodeResult = { kind: `ok`; workspace: Workspace } | DecodeError export function decodeLayout(input: string): DecodeResult { if (input.length === 0) - return { kind: `ok`, workspace: { root: null, activeGroupId: null } } + return { kind: `ok`, workspace: { root: null, activeTileId: null } } const p = new Parser(input) try { const node = p.parseNode() p.expectEnd() - // Pick the first group as active by default. Future: encode the - // active group too, e.g. with a leading marker like `*` on the - // group; deferred until we have a use-case. - const firstGroup = findFirstGroup(node) + const firstTile = findFirstTile(node) return { kind: `ok`, - workspace: { root: node, activeGroupId: firstGroup?.id ?? null }, + workspace: { root: node, activeTileId: firstTile?.id ?? null }, } } catch (e) { return e instanceof ParseError @@ -140,7 +135,7 @@ class Parser { if (this.peek() === `V` && this.src[this.pos + 1] === `(`) { return this.parseSplit(`vertical`) } - return this.parseGroup() + return this.parseTile() } parseSplit(direction: `horizontal` | `vertical`): Split { @@ -185,7 +180,7 @@ class Parser { } // Normalise so all sizes sum to 1 (handles the user-error case // where a `?layout=` URL declares >100% total). - const total = children.reduce((a, c) => a + c.size, 0) + const total = children.reduce((a: number, c) => a + c.size, 0) if (total > 0) { for (const c of children) c.size = c.size / total } else { @@ -200,39 +195,16 @@ class Parser { } } - parseGroup(): Group { - const tiles: Array = [] - while (true) { - tiles.push(this.parseTile()) - // Group tab separator is `;` (split-sibling separator is `,`) - // — the two-symbol grammar removes the lookahead ambiguity that - // a single shared `,` would create. See header comment. - if (this.peek() === `;`) { - this.pos += 1 - continue - } - break - } - let activeIdx = 0 - if (this.peek() === `@`) { - this.pos += 1 - activeIdx = this.parseInt() - if (activeIdx >= tiles.length) activeIdx = 0 - } - return { - kind: `group`, - id: makeGroupId(), - tiles, - activeTileId: tiles[activeIdx].id, - } - } - parseTile(): Tile { - // Tile = '.' + // Tile = ? '.' // We grab everything up to the LAST '.' before a control char as // the entity url; the suffix is the viewId. Control chars are - // ',' ';' '(' ')' '@' ':'. Both halves accept alphanumerics + - // url-encoded escapes (decoded via `decodeURIComponent`). + // ',' '(' ')' ':'. Both halves accept alphanumerics + url-encoded + // escapes (decoded via `decodeURIComponent`). + // + // An empty entity path ('.viewId') is the wire form for a + // standalone tile (no entity attached) — currently the new-session + // tile. const start = this.pos while (this.pos < this.src.length && !isControlChar(this.src[this.pos])) { this.pos++ @@ -245,16 +217,23 @@ class Parser { start ) } - const decoded = decodeURIComponent(raw.slice(0, dot)) - // Re-add the conventional leading `/` if the wire form omitted it - // (canonical encoded form does — see encodeTile() comment). If - // the wire form already includes one we don't double up. - const entityUrl = decoded.startsWith(`/`) ? decoded : `/${decoded}` + const rawPath = raw.slice(0, dot) const viewId: ViewId = raw.slice(dot + 1) if (!viewId) { throw new ParseError(`empty viewId in tile spec '${raw}'`, start) } - return { id: makeTileId(), entityUrl, viewId } + let entityUrl: string | null + if (rawPath.length === 0) { + // Standalone tile. + entityUrl = null + } else { + const decoded = decodeURIComponent(rawPath) + // Re-add the conventional leading `/` if the wire form omitted it + // (canonical encoded form does — see encodeTile() comment). If + // the wire form already includes one we don't double up. + entityUrl = decoded.startsWith(`/`) ? decoded : `/${decoded}` + } + return { kind: `tile`, id: makeTileId(), entityUrl, viewId } } parseInt(): number { @@ -283,19 +262,17 @@ class Parser { } function isControlChar(c: string): boolean { - return ( - c === `,` || c === `;` || c === `(` || c === `)` || c === `@` || c === `:` - ) + return c === `,` || c === `(` || c === `)` || c === `:` } function describeChar(c: string): string { return c.length === 0 ? `` : `'${c}'` } -function findFirstGroup(node: WorkspaceNode): Group | null { - if (node.kind === `group`) return node +function findFirstTile(node: WorkspaceNode): Tile | null { + if (node.kind === `tile`) return node for (const c of node.children) { - const found = findFirstGroup(c.node) + const found = findFirstTile(c.node) if (found) return found } return null diff --git a/packages/agents-server-ui/src/lib/workspace/registerViews.ts b/packages/agents-server-ui/src/lib/workspace/registerViews.ts index 232d57626f..e887aeb9bb 100644 --- a/packages/agents-server-ui/src/lib/workspace/registerViews.ts +++ b/packages/agents-server-ui/src/lib/workspace/registerViews.ts @@ -1,17 +1,21 @@ -import { Database, MessageSquare } from 'lucide-react' +import { Database, MessageSquare, SquarePen } from 'lucide-react' import { registerView } from './viewRegistry' +import { NEW_SESSION_VIEW_ID } from './types' import { ChatView } from '../../components/views/ChatView' import { StateExplorerView } from '../../components/views/StateExplorerView' +import { NewSessionView } from '../../components/views/NewSessionView' /** * Register all built-in views. Imported once from `main.tsx` so the * side-effect runs before the app mounts. * - * Order matters: it controls the order of items in the `View ▸` submenu - * and the default tab when an entity is opened (first registered view - * is the default). + * Order matters for entity views: it controls the order of items in + * the View section of the tile menu, the icon-strip in the tile + * header, and the default view when an entity is opened (first + * registered entity view is the default). */ registerView({ + kind: `entity`, id: `chat`, label: `Chat`, icon: MessageSquare, @@ -20,15 +24,28 @@ registerView({ }) registerView({ + kind: `entity`, id: `state-explorer`, label: `State Explorer`, icon: Database, description: `Inspect shared state and the event log`, - // Match today's UX: clicking the parent menu row pops it out to the - // right, just like the old drawer. - defaultSplit: `right`, Component: StateExplorerView, }) +/** + * Standalone view: "new session". Doesn't belong to an entity, so it + * never appears in the per-entity view-switcher. The workspace mounts + * a tile with this view as its empty state and to host the new-session + * picker (which can be split / dragged like any other tile). + */ +registerView({ + kind: `standalone`, + id: NEW_SESSION_VIEW_ID, + label: `New session`, + icon: SquarePen, + description: `Pick an agent type to start a new session`, + Component: NewSessionView, +}) + /** No-op export so the file is treated as a module by `import './registerViews'`. */ export const VIEWS_REGISTERED = true diff --git a/packages/agents-server-ui/src/lib/workspace/types.ts b/packages/agents-server-ui/src/lib/workspace/types.ts index 4985b4aeea..65728a6dd4 100644 --- a/packages/agents-server-ui/src/lib/workspace/types.ts +++ b/packages/agents-server-ui/src/lib/workspace/types.ts @@ -1,33 +1,37 @@ import type { ViewId } from './viewRegistry' /** - * A `Tile` is the unit that gets rendered in a leaf area of the - * workspace. It binds an entity to a view; the same entity can be open - * in multiple tiles (e.g. chat + state-explorer side-by-side). + * A `Tile` is a leaf in the workspace tree, rendered through one view. + * Tiles do not group: each leaf is its own thing. Two tiles can show + * the same entity through different views (chat + state-explorer + * side-by-side); they're independent leaves. * - * The `id` is a stable nanoid that survives renders and lets us key - * React state per-tile (so two splits of the same entity scroll - * independently — see `tileId` in `ViewProps`). + * `entityUrl` is null for *standalone* tiles — currently the + * "new-session" tile is the only example. Standalone tiles render a + * view that doesn't depend on a specific entity (the view registry + * marks them with `kind: 'standalone'`). Most reducer / codec / + * persistence code paths care about identity (the tile's `id`) rather + * than the entity URL, so the null is a small, contained change. + * + * The `id` is a stable nanoid that survives renders so React keying + * and per-tile state (scroll position, etc.) is preserved across + * re-orderings. */ export type Tile = { + kind: `tile` id: string - entityUrl: string + entityUrl: string | null viewId: ViewId } /** - * A `Group` is a leaf node in the workspace tree. It holds one or more - * tiles (the tab strip across the top of the group), with exactly one - * marked active. An empty group is invalid — when the last tile is - * closed the group itself is removed by the reducer. + * Sentinel viewId for the standalone "new-session" tile. Lives here + * (rather than in the registry file) so it can be referenced from + * pure-data layers — the codec, the URL ↔ workspace sync, the + * persistence prune — without dragging the registry's React imports + * along. */ -export type Group = { - kind: `group` - id: string - tiles: Array - /** Must always reference an id from `tiles`; reducer enforces this. */ - activeTileId: string -} +export const NEW_SESSION_VIEW_ID = `new-session` /** * A `Split` is an internal node containing two or more children laid @@ -43,40 +47,45 @@ export type Split = { children: Array<{ node: WorkspaceNode; size: number }> } -export type WorkspaceNode = Split | Group +export type WorkspaceNode = Split | Tile /** * The full workspace state. `root === null` represents the empty - * workspace (the new-session screen). `activeGroupId` always points - * at a group that exists in the tree (or `null` when the workspace + * workspace (the new-session screen). `activeTileId` always points + * at a tile that exists in the tree (or `null` when the workspace * is empty); reducer enforces this on every mutation. */ export type Workspace = { root: WorkspaceNode | null - activeGroupId: string | null + activeTileId: string | null } /** The empty workspace — the initial state on first load. */ export const EMPTY_WORKSPACE: Workspace = { root: null, - activeGroupId: null, + activeTileId: null, } /** - * Where to put a tile when opening / moving it. `replace` and `append` - * target an existing group; the four `split-*` directions create a new - * sibling group on that side of the target. + * Where to put a tile when opening or moving it. + * + * - `replace` : take over the target tile's slot (used by URL + * navigation and click-on-sidebar — the active tile + * gets a new (entity, view)). Not exposed as a drop + * zone. + * - `split-{dir}` : create a new split with the new tile on the named + * side of the target. The four drop edges. */ export type DropPosition = | `replace` - | `append` | `split-right` | `split-down` | `split-left` | `split-up` export type DropTarget = { - groupId: string + /** The id of the tile being targeted. */ + tileId: string position: DropPosition } diff --git a/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx b/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx index 67c674f717..c7203207f2 100644 --- a/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx +++ b/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx @@ -13,12 +13,12 @@ import type { ElectricEntity } from '../ElectricAgentsProvider' export type ViewId = string /** - * Props every view receives. The `tileId` is included so views can scope + * Props an *entity* view receives. The `tileId` is included so views can scope * local state (scroll position, selected row, etc.) per-tile rather than * per-entity, matching VS Code's behaviour where two splits of the same file * scroll independently. */ -export type ViewProps = { +export type EntityViewProps = { baseUrl: string entityUrl: string entity: ElectricEntity @@ -27,15 +27,25 @@ export type ViewProps = { tileId: string } -export type ViewDefinition = { +/** + * Props a *standalone* view receives. No entity bound to the tile — + * just a baseUrl (for any server-relative API calls) and the tile id. + */ +export type StandaloneViewProps = { + baseUrl: string + tileId: string +} + +/** Discriminated union — `kind` distinguishes which props shape applies. */ +export type ViewProps = EntityViewProps + +export type EntityViewDefinition = { + kind: `entity` id: ViewId - /** Human label shown in the View ▸ submenu and on tabs. */ + /** Human label shown in the menu and on the inline view-strip. */ label: string - /** Tab/menu icon. */ icon: LucideIcon - /** Optional shorter label for narrow tabs (defaults to `label`). */ shortLabel?: string - /** Optional helper text rendered as a hint in the View ▸ menu. */ description?: string /** * Per-entity availability gate. Used to hide views that don't apply to @@ -43,16 +53,21 @@ export type ViewDefinition = { * omitted the view is considered available for every entity. */ isAvailable?: (entity: ElectricEntity) => boolean - /** - * Default split direction when the user clicks the parent `View ▸ X` - * menu row directly (rather than picking a sub-action). `'right'` matches - * the muscle-memory of "drawer pops out to the right" for the State - * Explorer; `undefined` falls back to `'open here'`. - */ - defaultSplit?: `right` | `down` - Component: ComponentType + Component: ComponentType } +export type StandaloneViewDefinition = { + kind: `standalone` + id: ViewId + label: string + icon: LucideIcon + shortLabel?: string + description?: string + Component: ComponentType +} + +export type ViewDefinition = EntityViewDefinition | StandaloneViewDefinition + const registry = new Map() export function registerView(def: ViewDefinition): void { @@ -64,13 +79,34 @@ export function getView(id: ViewId): ViewDefinition | undefined { } /** - * List every registered view, optionally filtered by per-entity availability. - * Stable order = insertion order, matching `Map`'s iteration semantics, so - * registration order in `registerViews.ts` controls the menu ordering. + * List registered views. + * + * - `listViews(entity)` entity views available for that entity, in + * registration order. Standalone views are + * excluded — they don't belong in an entity + * tile's view-switcher. + * - `listViews()` every entity view (for entity-less callers + * like the Workspace bootstrap that just need + * a default view id). + * - `listStandaloneViews()` standalone views (currently just + * "new-session"); used to render their tile + * chrome / placeholder UI. */ -export function listViews(entity?: ElectricEntity): Array { - const all = Array.from(registry.values()) - return entity ? all.filter((v) => v.isAvailable?.(entity) ?? true) : all +export function listViews( + entity?: ElectricEntity +): Array { + const entityViews = Array.from(registry.values()).filter( + (v): v is EntityViewDefinition => v.kind === `entity` + ) + return entity + ? entityViews.filter((v) => v.isAvailable?.(entity) ?? true) + : entityViews +} + +export function listStandaloneViews(): Array { + return Array.from(registry.values()).filter( + (v): v is StandaloneViewDefinition => v.kind === `standalone` + ) } /** diff --git a/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts index 5bc3e978f2..19b2a67a8b 100644 --- a/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts +++ b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts @@ -1,17 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - findGroupContainingTile, - findTile, - listGroups, - workspaceReducer, -} from './workspaceReducer' +import { findTile, listTiles, workspaceReducer } from './workspaceReducer' import { EMPTY_WORKSPACE } from './types' -import type { Workspace } from './types' - -// --------------------------------------------------------------------------- -// Tiny driver — applies a sequence of actions in order so each test reads -// like a script of user steps. Returns the final workspace. -// --------------------------------------------------------------------------- +import type { Split, Tile, Workspace } from './types' function run( initial: Workspace, @@ -20,249 +10,217 @@ function run( return actions.reduce(workspaceReducer, initial) } +function rootAsTile(ws: Workspace): Tile { + expect(ws.root?.kind).toBe(`tile`) + return ws.root as Tile +} + +function rootAsSplit(ws: Workspace): Split { + expect(ws.root?.kind).toBe(`split`) + return ws.root as Split +} + describe(`workspaceReducer`, () => { describe(`open-tile`, () => { - it(`bootstraps an empty workspace into a single-tile single-group`, () => { + it(`bootstraps an empty workspace into a single root tile`, () => { const ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - expect(ws.root).not.toBeNull() - expect(ws.root!.kind).toBe(`group`) - expect(ws.activeGroupId).toBe(ws.root!.id) - const group = ws.root as Extract - expect(group.tiles).toHaveLength(1) - expect(group.tiles[0].entityUrl).toBe(`/horton/foo`) - expect(group.tiles[0].viewId).toBe(`chat`) - expect(group.activeTileId).toBe(group.tiles[0].id) - }) - - it(`with no target opens into the active group, replacing the active tile`, () => { - const after1 = run(EMPTY_WORKSPACE, { - type: `open-tile`, - tile: { entityUrl: `/horton/foo`, viewId: `chat` }, - }) - const ws = workspaceReducer(after1, { - type: `open-tile`, - tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - }) - expect(listGroups(ws.root)).toHaveLength(1) - const group = listGroups(ws.root)[0] - expect(group.tiles).toHaveLength(1) - expect(group.tiles[0].entityUrl).toBe(`/horton/bar`) + const tile = rootAsTile(ws) + expect(tile.entityUrl).toBe(`/horton/foo`) + expect(tile.viewId).toBe(`chat`) + expect(ws.activeTileId).toBe(tile.id) }) - it(`with append target adds a new tab and activates it`, () => { + it(`with no target replaces the active tile in place`, () => { const after1 = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const groupId = listGroups(after1.root)[0].id const ws = workspaceReducer(after1, { type: `open-tile`, tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - target: { groupId, position: `append` }, }) - const group = listGroups(ws.root)[0] - expect(group.tiles).toHaveLength(2) - expect(group.tiles[1].entityUrl).toBe(`/horton/bar`) - expect(group.activeTileId).toBe(group.tiles[1].id) + const tile = rootAsTile(ws) + expect(tile.entityUrl).toBe(`/horton/bar`) + expect(ws.activeTileId).toBe(tile.id) + expect(listTiles(ws.root)).toHaveLength(1) }) - it(`split-right wraps the existing group in a horizontal split`, () => { + it(`split-right wraps the existing tile in a horizontal split`, () => { const after1 = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const groupId = listGroups(after1.root)[0].id + const fooId = rootAsTile(after1).id const ws = workspaceReducer(after1, { type: `open-tile`, tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - target: { groupId, position: `split-right` }, + target: { tileId: fooId, position: `split-right` }, }) - expect(ws.root!.kind).toBe(`split`) - const split = ws.root as Extract + const split = rootAsSplit(ws) expect(split.direction).toBe(`horizontal`) expect(split.children).toHaveLength(2) expect(split.children[0].size + split.children[1].size).toBeCloseTo(1) - // The new tile sits on the right of the existing one. - const right = split.children[1].node as Extract< - (typeof split.children)[1][`node`], - { kind: `group` } - > - expect(right.tiles[0].entityUrl).toBe(`/horton/bar`) + const right = split.children[1].node as Tile + expect(right.entityUrl).toBe(`/horton/bar`) }) - it(`split-up places the new tile *above* the existing one`, () => { + it(`split-up places the new tile above the existing one`, () => { const after1 = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const groupId = listGroups(after1.root)[0].id + const fooId = rootAsTile(after1).id const ws = workspaceReducer(after1, { type: `open-tile`, tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - target: { groupId, position: `split-up` }, + target: { tileId: fooId, position: `split-up` }, }) - const split = ws.root as Extract + const split = rootAsSplit(ws) expect(split.direction).toBe(`vertical`) - const top = split.children[0].node as Extract< - (typeof split.children)[0][`node`], - { kind: `group` } - > - expect(top.tiles[0].entityUrl).toBe(`/horton/bar`) + const top = split.children[0].node as Tile + expect(top.entityUrl).toBe(`/horton/bar`) }) }) describe(`close-tile`, () => { - it(`removes a non-last tile and re-picks an active`, () => { + it(`closing the only tile empties the workspace`, () => { const ws0 = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const groupId = listGroups(ws0.root)[0].id - const ws1 = workspaceReducer(ws0, { - type: `open-tile`, - tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - target: { groupId, position: `append` }, - }) - // Close the active (newer) tile. - const group1 = listGroups(ws1.root)[0] - const ws2 = workspaceReducer(ws1, { - type: `close-tile`, - tileId: group1.activeTileId, - }) - const group2 = listGroups(ws2.root)[0] - expect(group2.tiles).toHaveLength(1) - expect(group2.tiles[0].entityUrl).toBe(`/horton/foo`) - expect(group2.activeTileId).toBe(group2.tiles[0].id) + const tileId = rootAsTile(ws0).id + const ws = workspaceReducer(ws0, { type: `close-tile`, tileId }) + expect(ws.root).toBeNull() + expect(ws.activeTileId).toBeNull() }) - it(`removes the group when its last tile is closed`, () => { - const ws0 = run(EMPTY_WORKSPACE, { + it(`closing one tile in a 2-tile split unwraps the split`, () => { + let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const tileId = listGroups(ws0.root)[0].tiles[0].id - const ws1 = workspaceReducer(ws0, { type: `close-tile`, tileId }) - expect(ws1.root).toBeNull() - expect(ws1.activeGroupId).toBeNull() + const fooId = rootAsTile(ws).id + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { tileId: fooId, position: `split-right` }, + }) + const barId = listTiles(ws.root).find( + (t) => t.entityUrl === `/horton/bar` + )!.id + ws = workspaceReducer(ws, { type: `close-tile`, tileId: barId }) + const remaining = rootAsTile(ws) + expect(remaining.entityUrl).toBe(`/horton/foo`) }) - it(`unwraps a split when one of two groups is emptied`, () => { + it(`reassigns activeTileId when the active tile is closed`, () => { let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const fooGroupId = listGroups(ws.root)[0].id + const fooId = rootAsTile(ws).id ws = workspaceReducer(ws, { type: `open-tile`, tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - target: { groupId: fooGroupId, position: `split-right` }, - }) - // Now there's a horizontal split with two groups. Close the - // bar tile (right side). - const groups = listGroups(ws.root) - const barGroup = groups.find((g) => - g.tiles.some((t) => t.entityUrl === `/horton/bar`) - )! - ws = workspaceReducer(ws, { - type: `close-tile`, - tileId: barGroup.tiles[0].id, - }) - // Split should have collapsed; root is back to a single group. - expect(ws.root!.kind).toBe(`group`) - const remaining = ws.root as Extract - expect(remaining.tiles[0].entityUrl).toBe(`/horton/foo`) + target: { tileId: fooId, position: `split-right` }, + }) + // Make foo active explicitly, then close foo. + ws = workspaceReducer(ws, { type: `set-active-tile`, tileId: fooId }) + ws = workspaceReducer(ws, { type: `close-tile`, tileId: fooId }) + expect(ws.activeTileId).not.toBeNull() + const remaining = rootAsTile(ws) + expect(ws.activeTileId).toBe(remaining.id) }) }) describe(`move-tile`, () => { - it(`moves a tile from one group to another`, () => { + it(`moves a tile to the other side of an existing tile`, () => { let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const fooGroupId = listGroups(ws.root)[0].id + const fooId = rootAsTile(ws).id ws = workspaceReducer(ws, { type: `open-tile`, tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - target: { groupId: fooGroupId, position: `split-right` }, - }) - // Two groups exist. Move the bar tile back into the foo group as - // an appended tab. - const barGroup = listGroups(ws.root).find((g) => - g.tiles.some((t) => t.entityUrl === `/horton/bar`) - )! - const barTileId = barGroup.tiles[0].id + target: { tileId: fooId, position: `split-right` }, + }) + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/baz`, viewId: `chat` }, + target: { tileId: fooId, position: `split-down` }, + }) + // Now move baz to the right of foo. + const bazId = listTiles(ws.root).find( + (t) => t.entityUrl === `/horton/baz` + )!.id ws = workspaceReducer(ws, { type: `move-tile`, - tileId: barTileId, - target: { groupId: fooGroupId, position: `append` }, - }) - // Single group remains with two tiles; split collapsed. - expect(ws.root!.kind).toBe(`group`) - const finalGroup = ws.root as Extract - expect(finalGroup.tiles).toHaveLength(2) - expect(finalGroup.tiles.map((t) => t.entityUrl).sort()).toEqual([ - `/horton/bar`, - `/horton/foo`, - ]) + tileId: bazId, + target: { tileId: fooId, position: `split-right` }, + }) + // Tile structure changed but baz still present. + expect(findTile(ws.root, bazId)).not.toBeNull() + expect(listTiles(ws.root)).toHaveLength(3) }) - it(`survives moving the only tile of a group into a new split of itself`, () => { + it(`drops on self are no-ops`, () => { let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const groupId = listGroups(ws.root)[0].id - const tileId = listGroups(ws.root)[0].tiles[0].id + const tileId = rootAsTile(ws).id + const before = ws ws = workspaceReducer(ws, { type: `move-tile`, tileId, - target: { groupId, position: `split-right` }, + target: { tileId, position: `split-right` }, }) - // The reducer's safety net inserts the orphaned tile into a fresh - // root group rather than losing it. - expect(ws.root).not.toBeNull() - expect(findTile(ws.root, tileId)).not.toBeNull() + expect(ws).toBe(before) }) }) - describe(`set-tile-view / split-tile-with-view`, () => { - it(`set-tile-view swaps in place without changing layout`, () => { + describe(`set-tile-view`, () => { + it(`swaps the view in place without changing layout`, () => { let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const tileId = listGroups(ws.root)[0].tiles[0].id + const tileId = rootAsTile(ws).id ws = workspaceReducer(ws, { type: `set-tile-view`, tileId, viewId: `state-explorer`, }) - expect(ws.root!.kind).toBe(`group`) - const tile = findTile(ws.root, tileId)! + const tile = rootAsTile(ws) + // Same tile id (state preserved across view swap). + expect(tile.id).toBe(tileId) expect(tile.viewId).toBe(`state-explorer`) }) + }) - it(`split-tile-with-view creates a new group with a different view of the same entity`, () => { + describe(`split-tile-with-view`, () => { + it(`opens a different view of the same entity in a new split`, () => { let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const tileId = listGroups(ws.root)[0].tiles[0].id + const tileId = rootAsTile(ws).id ws = workspaceReducer(ws, { type: `split-tile-with-view`, tileId, viewId: `state-explorer`, direction: `right`, }) - const groups = listGroups(ws.root) - expect(groups).toHaveLength(2) - const states = groups.flatMap((g) => - g.tiles.map((t) => ({ entityUrl: t.entityUrl, viewId: t.viewId })) - ) + const tiles = listTiles(ws.root) + expect(tiles).toHaveLength(2) + const states = tiles.map((t) => ({ + entityUrl: t.entityUrl, + viewId: t.viewId, + })) expect(states).toContainEqual({ entityUrl: `/horton/foo`, viewId: `chat`, @@ -271,9 +229,9 @@ describe(`workspaceReducer`, () => { entityUrl: `/horton/foo`, viewId: `state-explorer`, }) - // Active group should be the new one. - const activeGroup = groups.find((g) => g.id === ws.activeGroupId) - expect(activeGroup?.tiles[0].viewId).toBe(`state-explorer`) + // Active follows the new tile. + const activeTile = findTile(ws.root, ws.activeTileId!) + expect(activeTile?.viewId).toBe(`state-explorer`) }) }) @@ -283,61 +241,102 @@ describe(`workspaceReducer`, () => { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const groupId = listGroups(ws.root)[0].id + const fooId = rootAsTile(ws).id ws = workspaceReducer(ws, { type: `open-tile`, tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - target: { groupId, position: `split-right` }, + target: { tileId: fooId, position: `split-right` }, }) - const split = ws.root as Extract + const split = rootAsSplit(ws) ws = workspaceReducer(ws, { type: `resize-split`, splitId: split.id, sizes: [3, 1], }) - const next = ws.root as Extract + const next = rootAsSplit(ws) expect(next.children[0].size).toBeCloseTo(0.75) expect(next.children[1].size).toBeCloseTo(0.25) }) }) - describe(`active group bookkeeping`, () => { - it(`updates activeGroupId when the previously active group is destroyed`, () => { + describe(`open-new-session-tile`, () => { + it(`bootstraps an empty workspace into a standalone new-session tile`, () => { + const ws = run(EMPTY_WORKSPACE, { type: `open-new-session-tile` }) + const tile = rootAsTile(ws) + expect(tile.entityUrl).toBeNull() + expect(tile.viewId).toBe(`new-session`) + expect(ws.activeTileId).toBe(tile.id) + }) + + it(`replaces the active entity tile when no target is given`, () => { let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const fooGroupId = listGroups(ws.root)[0].id - ws = workspaceReducer(ws, { + ws = workspaceReducer(ws, { type: `open-new-session-tile` }) + const tile = rootAsTile(ws) + expect(tile.entityUrl).toBeNull() + expect(listTiles(ws.root)).toHaveLength(1) + }) + + it(`opens a standalone tile in a split when given a target`, () => { + let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, - tile: { entityUrl: `/horton/bar`, viewId: `chat` }, - target: { groupId: fooGroupId, position: `split-right` }, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - const barGroup = listGroups(ws.root).find((g) => - g.tiles.some((t) => t.entityUrl === `/horton/bar`) - )! + const fooId = rootAsTile(ws).id ws = workspaceReducer(ws, { - type: `set-active-group`, - groupId: barGroup.id, + type: `open-new-session-tile`, + target: { tileId: fooId, position: `split-right` }, }) - expect(ws.activeGroupId).toBe(barGroup.id) - // Now close the active group's tile — it should disappear and - // the active should fall back to the remaining group. + const split = rootAsSplit(ws) + expect(split.direction).toBe(`horizontal`) + const right = split.children[1].node as Tile + expect(right.entityUrl).toBeNull() + expect(right.viewId).toBe(`new-session`) + // Focus follows the freshly-dropped tile so the user sees the + // placeholder they just placed (matches drop-to-side semantics + // in VS Code). + expect(ws.activeTileId).toBe(right.id) + }) + + it(`allows multiple new-session tiles in the same workspace`, () => { + let ws = run(EMPTY_WORKSPACE, { type: `open-new-session-tile` }) + const firstId = rootAsTile(ws).id ws = workspaceReducer(ws, { - type: `close-tile`, - tileId: barGroup.tiles[0].id, + type: `open-new-session-tile`, + target: { tileId: firstId, position: `split-right` }, }) - expect(ws.activeGroupId).toBe(fooGroupId) + const tiles = listTiles(ws.root) + expect(tiles).toHaveLength(2) + expect(tiles.every((t) => t.entityUrl === null)).toBe(true) + expect(tiles.every((t) => t.viewId === `new-session`)).toBe(true) }) }) - describe(`findGroupContainingTile`, () => { - it(`returns null for unknown tile ids`, () => { - const ws = run(EMPTY_WORKSPACE, { + describe(`flattening`, () => { + it(`flattens nested same-direction splits`, () => { + // Build H(foo, bar), then split foo-right with baz → should + // produce H(foo, baz, bar) with no nested H. + let ws = run(EMPTY_WORKSPACE, { type: `open-tile`, tile: { entityUrl: `/horton/foo`, viewId: `chat` }, }) - expect(findGroupContainingTile(ws.root, `nope`)).toBeNull() + const fooId = rootAsTile(ws).id + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { tileId: fooId, position: `split-right` }, + }) + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/baz`, viewId: `chat` }, + target: { tileId: fooId, position: `split-right` }, + }) + const split = rootAsSplit(ws) + expect(split.direction).toBe(`horizontal`) + expect(split.children).toHaveLength(3) + expect(split.children.every((c) => c.node.kind === `tile`)).toBe(true) }) }) }) diff --git a/packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts index 05b43a98a7..be03e616a9 100644 --- a/packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts +++ b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts @@ -1,41 +1,36 @@ import { nanoid } from 'nanoid' -import type { - DropTarget, - Group, - Split, - Tile, - Workspace, - WorkspaceNode, -} from './types' +import { NEW_SESSION_VIEW_ID } from './types' +import type { DropTarget, Split, Tile, Workspace, WorkspaceNode } from './types' import type { ViewId } from './viewRegistry' // --------------------------------------------------------------------------- // Pure reducer for the workspace tree. Every operation returns a *new* // `Workspace` value — no in-place mutation — so `useReducer` can drive // React updates by reference identity. The shape of the tree -// (Split → Group → Tile) is enforced by the reducer's invariants: +// (Split → Tile, no groups) is enforced by the reducer's invariants: // // 1. A `Split` always has ≥2 children. After any mutation that could // leave it with 1 child, the split is unwrapped — its single child // takes the split's place in the parent. -// 2. A `Group` always has ≥1 tile. After any mutation that could leave -// it empty, the group is removed; its parent split unwraps too if -// that drops it to 1 child. -// 3. `activeGroupId` always references a group present in the tree, or +// 2. `activeTileId` always references a tile present in the tree, or // `null` iff `root === null`. -// 4. Sibling sizes inside a `Split` are normalised so they sum to ~1. +// 3. Sibling sizes inside a `Split` are normalised so they sum to ~1. +// 4. Nested same-direction splits flatten: H(a, H(b, c)) → H(a, b, c). // --------------------------------------------------------------------------- export type WorkspaceAction = | { type: `open-tile` - tile: { entityUrl: string; viewId: ViewId } + tile: { entityUrl: string | null; viewId: ViewId } + target?: DropTarget + } + | { + type: `open-new-session-tile` target?: DropTarget } | { type: `close-tile`; tileId: string } | { type: `move-tile`; tileId: string; target: DropTarget } | { type: `set-active-tile`; tileId: string } - | { type: `set-active-group`; groupId: string } | { type: `set-tile-view`; tileId: string; viewId: ViewId } | { type: `split-tile-with-view` @@ -57,16 +52,18 @@ export function workspaceReducer( switch (action.type) { case `open-tile`: return openTile(state, action.tile, action.target) + case `open-new-session-tile`: + return openTile( + state, + { entityUrl: null, viewId: NEW_SESSION_VIEW_ID }, + action.target + ) case `close-tile`: return closeTile(state, action.tileId) case `move-tile`: return moveTile(state, action.tileId, action.target) case `set-active-tile`: return setActiveTile(state, action.tileId) - case `set-active-group`: - return state.activeGroupId === action.groupId - ? state - : { ...state, activeGroupId: action.groupId } case `set-tile-view`: return setTileView(state, action.tileId, action.viewId) case `split-tile-with-view`: @@ -91,15 +88,17 @@ export function workspaceReducer( export function makeTileId(): string { return `tile_${nanoid(10)}` } -export function makeGroupId(): string { - return `grp_${nanoid(8)}` -} export function makeSplitId(): string { return `spl_${nanoid(8)}` } -export function makeTile(entityUrl: string, viewId: ViewId): Tile { - return { id: makeTileId(), entityUrl, viewId } +export function makeTile(entityUrl: string | null, viewId: ViewId): Tile { + return { kind: `tile`, id: makeTileId(), entityUrl, viewId } +} + +/** Returns true iff the tile is a standalone (no entity attached). */ +export function isStandaloneTile(tile: Tile): boolean { + return tile.entityUrl === null } // --------------------------------------------------------------------------- @@ -107,46 +106,23 @@ export function makeTile(entityUrl: string, viewId: ViewId): Tile { // safe to call inside reducer cases for lookups. // --------------------------------------------------------------------------- -export function findGroup( - node: WorkspaceNode | null, - groupId: string -): Group | null { - if (node === null) return null - if (node.kind === `group`) return node.id === groupId ? node : null - for (const child of node.children) { - const found = findGroup(child.node, groupId) - if (found) return found - } - return null -} - -export function findGroupContainingTile( +export function findTile( node: WorkspaceNode | null, tileId: string -): Group | null { +): Tile | null { if (node === null) return null - if (node.kind === `group`) { - return node.tiles.some((t) => t.id === tileId) ? node : null - } + if (node.kind === `tile`) return node.id === tileId ? node : null for (const child of node.children) { - const found = findGroupContainingTile(child.node, tileId) + const found = findTile(child.node, tileId) if (found) return found } return null } -export function findTile( - node: WorkspaceNode | null, - tileId: string -): Tile | null { - const group = findGroupContainingTile(node, tileId) - return group?.tiles.find((t) => t.id === tileId) ?? null -} - -export function listGroups(node: WorkspaceNode | null): Array { +export function listTiles(node: WorkspaceNode | null): Array { if (node === null) return [] - if (node.kind === `group`) return [node] - return node.children.flatMap((c) => listGroups(c.node)) + if (node.kind === `tile`) return [node] + return node.children.flatMap((c) => listTiles(c.node)) } // --------------------------------------------------------------------------- @@ -155,68 +131,69 @@ export function listGroups(node: WorkspaceNode | null): Array { function openTile( state: Workspace, - tile: { entityUrl: string; viewId: ViewId }, + tile: { entityUrl: string | null; viewId: ViewId }, target?: DropTarget ): Workspace { const newTile = makeTile(tile.entityUrl, tile.viewId) - // Empty workspace → bootstrap a single group with the new tile. + // Empty workspace → bootstrap with the new tile as the root. if (state.root === null) { - const group: Group = { - kind: `group`, - id: makeGroupId(), - tiles: [newTile], - activeTileId: newTile.id, - } - return { root: group, activeGroupId: group.id } + return { root: newTile, activeTileId: newTile.id } } - // No explicit target → default to the active group. Fall back to the - // first group in the tree if `activeGroupId` is somehow stale. - const targetGroupId = - target?.groupId ?? - state.activeGroupId ?? - listGroups(state.root)[0]?.id ?? - null - if (targetGroupId === null) return state + // No explicit target → default to replacing the active tile (URL + // navigation / sidebar click semantics: "show this here"). + const targetTileId = + target?.tileId ?? state.activeTileId ?? listTiles(state.root)[0]?.id ?? null + if (targetTileId === null) return state const position = target?.position ?? `replace` - return applyToGroup(state, targetGroupId, (group) => - insertTileIntoGroup(group, newTile, position) + const next = applyToTile(state, targetTileId, (existing) => + insertTileAt(existing, newTile, position) ) + // Focus follows opening: the freshly-created tile becomes active so + // both replace ("show this here") and split ("drop into a quadrant") + // give immediate visual + URL feedback. Mirrors VS Code's + // drop-to-side behaviour. Guard against pathological inserts that + // dropped the tile (e.g. target gone) by checking it actually + // landed in the tree. + if (findTile(next.root, newTile.id)) { + return { ...next, activeTileId: newTile.id } + } + return next } /** - * Apply a transformation to a target group inside the tree. The - * transformation receives the existing group and returns a replacement - * subtree (Group, Split, or `null` to delete the group entirely). + * Apply a transformation to a target tile inside the tree. The + * transformation receives the existing tile and returns a replacement + * subtree (Tile, Split, or `null` to delete the tile entirely). * * Walks back up the tree normalising splits (collapse single-child, * keep sibling sizes summing to ~1). */ -function applyToGroup( +function applyToTile( state: Workspace, - groupId: string, - fn: (group: Group) => WorkspaceNode | null + tileId: string, + fn: (tile: Tile) => WorkspaceNode | null ): Workspace { if (state.root === null) return state - const replaced = replaceGroupInTree(state.root, groupId, fn) + const replaced = replaceTileInTree(state.root, tileId, fn) return finaliseWorkspace(state, replaced) } -function replaceGroupInTree( +function replaceTileInTree( node: WorkspaceNode, - groupId: string, - fn: (group: Group) => WorkspaceNode | null + tileId: string, + fn: (tile: Tile) => WorkspaceNode | null ): WorkspaceNode | null { - if (node.kind === `group`) { - if (node.id !== groupId) return node + if (node.kind === `tile`) { + if (node.id !== tileId) return node return fn(node) } const newChildren: Split[`children`] = [] let changed = false for (const child of node.children) { - const replacement = replaceGroupInTree(child.node, groupId, fn) + const replacement = replaceTileInTree(child.node, tileId, fn) if (replacement !== child.node) changed = true if (replacement !== null) { newChildren.push({ node: replacement, size: child.size }) @@ -229,49 +206,18 @@ function replaceGroupInTree( } /** - * Insert / replace / split. Returns the new subtree that should sit in - * the parent's slot — either the same group (mutated tiles), the same - * group + a sibling under a new split, or just a different group. + * Place `incoming` at the named position relative to the existing + * `target` tile. Returns the subtree that should sit in the parent's + * slot — either the incoming tile alone (replace) or a fresh split + * wrapping both. */ -function insertTileIntoGroup( - group: Group, - tile: Tile, +function insertTileAt( + target: Tile, + incoming: Tile, position: DropTarget[`position`] ): WorkspaceNode { - switch (position) { - case `append`: { - // Add as a new tab in the strip; activate it. - return { - ...group, - tiles: [...group.tiles, tile], - activeTileId: tile.id, - } - } - case `replace`: { - // Replace the active tile in place. If there's only one tile this - // is the same as `append` semantically. - const newTiles = group.tiles.map((t) => - t.id === group.activeTileId ? tile : t - ) - return { - ...group, - tiles: newTiles, - activeTileId: tile.id, - } - } - case `split-right`: - case `split-down`: - case `split-left`: - case `split-up`: { - const newGroup: Group = { - kind: `group`, - id: makeGroupId(), - tiles: [tile], - activeTileId: tile.id, - } - return wrapInSplit(group, newGroup, position) - } - } + if (position === `replace`) return incoming + return wrapInSplit(target, incoming, position) } function wrapInSplit( @@ -307,24 +253,11 @@ function wrapInSplit( function closeTile(state: Workspace, tileId: string): Workspace { if (state.root === null) return state - const group = findGroupContainingTile(state.root, tileId) - if (!group) return state - if (group.tiles.length === 1) { - // Closing the last tile removes the group entirely. - return applyToGroup(state, group.id, () => null) + // Sole tile → workspace becomes empty. + if (state.root.kind === `tile` && state.root.id === tileId) { + return { root: null, activeTileId: null } } - // Otherwise drop the tile and pick the next-best active. - const tileIndex = group.tiles.findIndex((t) => t.id === tileId) - const newTiles = group.tiles.filter((t) => t.id !== tileId) - const wasActive = group.activeTileId === tileId - const nextActiveId = wasActive - ? (newTiles[Math.max(0, tileIndex - 1)] ?? newTiles[0]).id - : group.activeTileId - return applyToGroup(state, group.id, (g) => ({ - ...g, - tiles: newTiles, - activeTileId: nextActiveId, - })) + return applyToTile(state, tileId, () => null) } // --------------------------------------------------------------------------- @@ -337,60 +270,35 @@ function moveTile( target: DropTarget ): Workspace { if (state.root === null) return state - const sourceGroup = findGroupContainingTile(state.root, tileId) - if (!sourceGroup) return state - const tile = sourceGroup.tiles.find((t) => t.id === tileId) + if (tileId === target.tileId) return state // dropping on self + const tile = findTile(state.root, tileId) if (!tile) return state - // No-op self-move (drop on the same group with the same tile and - // position 'append' or 'replace' on the active tile). - if ( - sourceGroup.id === target.groupId && - sourceGroup.tiles.length === 1 && - (target.position === `append` || target.position === `replace`) - ) { - return state - } - // Detach the tile from its source first. const detached = closeTile(state, tileId) - // Re-find the target group in the post-detach tree (the source group - // may have collapsed if `tileId` was its last tile). - const targetGroupExists = findGroup(detached.root, target.groupId) !== null - if (!targetGroupExists) { - // Target collapsed during detach — re-insert as a fresh single-tile - // group at the root to avoid losing the tile. + // The target tile may have collapsed into a different parent during + // detach, but its id is stable so we can still find it. + if (!findTile(detached.root, target.tileId)) { + // Target gone — fall back to inserting at the root so we never + // silently drop a tile. if (detached.root === null) { - const group: Group = { - kind: `group`, - id: makeGroupId(), - tiles: [tile], - activeTileId: tile.id, - } - return { root: group, activeGroupId: group.id } + return { root: tile, activeTileId: tile.id } } return detached } - return applyToGroup(detached, target.groupId, (group) => - insertTileIntoGroup(group, tile, target.position) + return applyToTile(detached, target.tileId, (existing) => + insertTileAt(existing, tile, target.position) ) } // --------------------------------------------------------------------------- -// Set active tile / group / view +// Set active tile / view // --------------------------------------------------------------------------- function setActiveTile(state: Workspace, tileId: string): Workspace { - if (state.root === null) return state - const group = findGroupContainingTile(state.root, tileId) - if (!group) return state - if (group.activeTileId === tileId && state.activeGroupId === group.id) { - return state - } - return applyToGroup({ ...state, activeGroupId: group.id }, group.id, (g) => ({ - ...g, - activeTileId: tileId, - })) + if (state.activeTileId === tileId) return state + if (!findTile(state.root, tileId)) return state + return { ...state, activeTileId: tileId } } function setTileView( @@ -398,13 +306,9 @@ function setTileView( tileId: string, viewId: ViewId ): Workspace { - if (state.root === null) return state - const group = findGroupContainingTile(state.root, tileId) - if (!group) return state - return applyToGroup(state, group.id, (g) => ({ - ...g, - tiles: g.tiles.map((t) => (t.id === tileId ? { ...t, viewId } : t)), - })) + return applyToTile(state, tileId, (tile) => + tile.viewId === viewId ? tile : { ...tile, viewId } + ) } function splitTileWithView( @@ -415,15 +319,12 @@ function splitTileWithView( ): Workspace { const tile = findTile(state.root, tileId) if (!tile) return state - const group = findGroupContainingTile(state.root, tileId) - if (!group) return state const newTile = makeTile(tile.entityUrl, viewId) - const next = applyToGroup(state, group.id, (g) => - insertTileIntoGroup(g, newTile, `split-${direction}`) + const next = applyToTile(state, tileId, (existing) => + wrapInSplit(existing, newTile, `split-${direction}`) ) - // The new tile's group is whatever the latest open created. - const newGroup = findGroupContainingTile(next.root, newTile.id) - return newGroup ? { ...next, activeGroupId: newGroup.id } : next + // Focus follows split. + return { ...next, activeTileId: newTile.id } } // --------------------------------------------------------------------------- @@ -445,7 +346,7 @@ function updateSplitInTree( splitId: string, sizes: Array ): WorkspaceNode { - if (node.kind === `group`) return node + if (node.kind === `tile`) return node if (node.id === splitId) { if (node.children.length !== sizes.length) return node const total = sizes.reduce((a, b) => a + b, 0) @@ -495,7 +396,6 @@ function collapseSplit(split: Split): WorkspaceNode | null { child.node.kind === `split` && child.node.direction === split.direction ) { - // Distribute this child's `size` across its grandchildren proportionally. const inner = child.node const innerTotal = inner.children.reduce((a, c) => a + c.size, 0) for (const grand of inner.children) { @@ -518,23 +418,22 @@ function collapseSplit(split: Split): WorkspaceNode | null { } /** - * After a structural mutation, fix up `activeGroupId` to ensure it - * points at a group that still exists. Picks the first remaining group - * if the previous active was removed. + * After a structural mutation, fix up `activeTileId` to ensure it + * points at a tile that still exists. Picks the first remaining tile + * (tree order) if the previous active was removed. */ function finaliseWorkspace( prev: Workspace, newRoot: WorkspaceNode | null ): Workspace { if (newRoot === null) { - return { root: null, activeGroupId: null } + return { root: null, activeTileId: null } } - const groups = listGroups(newRoot) + const tiles = listTiles(newRoot) const stillThere = - prev.activeGroupId !== null && - groups.some((g) => g.id === prev.activeGroupId) + prev.activeTileId !== null && tiles.some((t) => t.id === prev.activeTileId) return { root: newRoot, - activeGroupId: stillThere ? prev.activeGroupId : groups[0].id, + activeTileId: stillThere ? prev.activeTileId : tiles[0].id, } } diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index bf39ad0895..692ea229bf 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -24,7 +24,6 @@ import { useWorkspaceHotkeys } from './hooks/useWorkspaceHotkeys' import { useWorkspacePersistence } from './hooks/useWorkspacePersistence' import { Sidebar } from './components/Sidebar' import { SearchPalette } from './components/SearchPalette' -import { NewSessionPage } from './components/NewSessionPage' import { Workspace } from './components/workspace/Workspace' import styles from './router.module.css' @@ -58,6 +57,11 @@ function RootShell(): React.ReactElement { // we fall back to a combo that isn't claimed by the chrome). // The displayed shortcut hint switches per environment via // `NewSessionKey` / `newSessionLabel`. + // + // Navigating to `/` is the simplest trigger: the URL → workspace + // effect in `` then focuses an existing new-session + // tile or replaces the active tile with a fresh one. Going through + // the URL means the persistence layer sees the change too. const openNewSession = useCallback( (e: KeyboardEvent) => { e.preventDefault() @@ -81,19 +85,20 @@ function RootShell(): React.ReactElement { [navigate] ) - // ⌘/Ctrl-click + middle-click on a sidebar row → open the entity in - // a new split to the right of the active group, rather than replacing - // the active tile (matches VS Code's "open to side" gesture). + // ⌘/Ctrl-click + middle-click on a sidebar row → open the entity to + // the right of the active tile, rather than replacing it (matches + // VS Code's "open to side" gesture). const openEntityInSplit = useCallback( (entityUrl: string) => { - const groupId = helpers.activeGroupId - if (!groupId) { - // Empty workspace — fall through to plain navigation. + const tileId = helpers.activeTileId + if (!tileId) { + // Empty workspace — fall through to plain navigation, which + // will bootstrap the workspace's first tile. navigateToEntity(entityUrl) return } helpers.openEntity(entityUrl, { - target: { groupId, position: `split-right` }, + target: { tileId, position: `split-right` }, }) }, [helpers, navigateToEntity] @@ -121,7 +126,7 @@ function RootShell(): React.ReactElement { } /** - * Search-param schema for the entity route. + * Search-param schema for the workspace routes. * * - `view` optional view id (e.g. `state-explorer`). Omitted from * the URL when it matches the default view (`chat`) so @@ -130,8 +135,12 @@ function RootShell(): React.ReactElement { * hydrate the workspace from it and *strip the param* * (see ``'s ?layout effect) so the address bar * settles back to "active tile only". + * + * Both index (`/`) and entity routes share this schema because both + * accept `?layout=` (a layout link can land on either route — the + * decoder restores the full tree regardless). */ -const entitySearchSchema = z.object({ +const workspaceSearchSchema = z.object({ view: z.string().optional(), layout: z.string().optional(), }) @@ -141,11 +150,11 @@ const entitySearchSchema = z.object({ * ``, which reads the route params (entity splat + ?view) * via TanStack Router hooks and reflects them into the workspace * tree. Keeping the route handler this small means the component tree - * underneath stays the same regardless of which entity is selected, - * which lets per-tile state (scroll, selection, etc.) survive - * navigation between entities. + * underneath stays the same regardless of which entity is selected + * (or whether the new-session tile is active), which lets per-tile + * state (scroll, selection, etc.) survive navigation between tiles. */ -function EntityPage(): React.ReactElement { +function WorkspacePage(): React.ReactElement { return } @@ -154,14 +163,15 @@ const rootRoute = createRootRoute({ component: RootLayout }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: `/`, - component: NewSessionPage, + component: WorkspacePage, + validateSearch: workspaceSearchSchema, }) const entityRoute = createRoute({ getParentRoute: () => rootRoute, path: `/entity/$`, - component: EntityPage, - validateSearch: entitySearchSchema, + component: WorkspacePage, + validateSearch: workspaceSearchSchema, }) const routeTree = rootRoute.addChildren([indexRoute, entityRoute]) From a19c3bc5a4b3466e7f6c98617fc3d886ded6705f Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 12:39:12 +0100 Subject: [PATCH 08/57] feat(agents-ui): align UI theme with Electric website + centralise hover/chip surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the agents UI's typography and palette with the marketing site (self-hosted OpenSauceOne + Source Code Pro, website surface ladder mapped onto --ds-bg / --ds-bg-subtle / --ds-surface / --ds-surface-raised, website text/accent values). Introduces two centralised semantic tokens to fix muddy dark-mode surfaces: --ds-chip-bg — solid raised surface for pill triggers, inline code chips, kbd keys, code wells etc. (matches the marketing site's --vp-code-bg pattern) --ds-bg-hover — universal interactive hover lift. Per-theme: light = --ds-gray-a3 (alpha-black tint composes cleanly on warm-white surfaces), dark = solid #2d3142 (one clean cool-grey step above --ds-surface-raised, no muddy compositing on navy page bg). Routes drop-down items, sidebar rows, search palette, ghost icon buttons (Button.module.css ghost variant + tone-neutral soft fill), chip triggers, code wells and similar through these tokens so every surface in the same family reads consistently. Ports chat-log + markdown rhythm refinements from the projects branch (without bringing across the 13→14px body bump or the Figtree font swap): - EntityTimeline statusPill switches from a centred chip to a left-bordered log line; jump-to-bottom moves to bottom-right as a small bordered surface - Composer gets a hairline shadow + border-1 edge; user bubble border lightened - Markdown rhythm: container gap 12→14, list padding 1.5→1.75em, li gap 4→6, heading top margins bumped (h1 4→10, h2 4→8, h3 2→6) - Tool blocks get a softer shadow, mono header strip with a faint band, recessed code well, new .sectionLabel small-caps style (consumed by ToolCallView for Command/Output/Content/Input) - MarkdownCodeBlock strips the trailing empty line Shiki appends when source ends with a newline Sidebar rows: type label drops to 10px lowercase (cap-height ≈ title x-height) with a 1px translateY so it shares a baseline with the title; title line-height bumped to 1.3 so descenders aren't clipped by the ellipsis box. Also drops the projects/tagging feature from this branch (App, Sidebar, NewSessionPage, useProjects hook) — this branch is being reset to a fresh-app baseline before re-landing those features. Co-authored-by: Cursor --- packages/agents-server-ui/src/App.tsx | 5 +- .../src/components/AgentResponse.module.css | 6 +- .../components/EntityContextDrawer.module.css | 2 +- .../src/components/EntityHeader.module.css | 2 +- .../src/components/EntityTimeline.module.css | 39 ++- .../src/components/EntityTimeline.tsx | 4 +- .../src/components/MarkdownCodeBlock.tsx | 62 ++-- .../src/components/MessageInput.module.css | 21 +- .../src/components/NewSessionPage.module.css | 64 +--- .../src/components/NewSessionPage.tsx | 118 +------- .../src/components/SearchPalette.module.css | 2 +- .../src/components/ServerPicker.module.css | 2 +- .../src/components/Sidebar.module.css | 37 +-- .../src/components/Sidebar.tsx | 101 +------ .../src/components/SidebarRow.module.css | 19 +- .../src/components/ToolCallView.tsx | 24 +- .../src/components/UserMessage.module.css | 2 +- .../stateExplorer/TypeList.module.css | 2 +- .../src/components/toolBlock.module.css | 55 +++- .../src/fonts/OpenSauceOne-Black.woff2 | Bin 0 -> 24296 bytes .../src/fonts/OpenSauceOne-Bold.woff2 | Bin 0 -> 23420 bytes .../src/fonts/OpenSauceOne-BoldItalic.woff2 | Bin 0 -> 24472 bytes .../src/fonts/OpenSauceOne-ExtraBold.woff2 | Bin 0 -> 24140 bytes .../src/fonts/OpenSauceOne-Italic.woff2 | Bin 0 -> 23840 bytes .../src/fonts/OpenSauceOne-Light.woff2 | Bin 0 -> 22988 bytes .../src/fonts/OpenSauceOne-LightItalic.woff2 | Bin 0 -> 23900 bytes .../src/fonts/OpenSauceOne-Medium.woff2 | Bin 0 -> 23556 bytes .../src/fonts/OpenSauceOne-MediumItalic.woff2 | Bin 0 -> 24660 bytes .../src/fonts/OpenSauceOne-Regular.woff2 | Bin 0 -> 22864 bytes .../src/fonts/SourceCodePro-Regular.woff2 | Bin 0 -> 64948 bytes .../src/hooks/useProjects.tsx | 111 ------- packages/agents-server-ui/src/markdown.css | 51 ++-- .../agents-server-ui/src/ui/Button.module.css | 23 +- .../agents-server-ui/src/ui/Code.module.css | 5 +- .../agents-server-ui/src/ui/Menu.module.css | 2 +- .../agents-server-ui/src/ui/Select.module.css | 9 +- packages/agents-server-ui/src/ui/fonts.css | 95 ++++++ packages/agents-server-ui/src/ui/index.ts | 1 + packages/agents-server-ui/src/ui/tokens.css | 284 +++++++++++++----- 39 files changed, 541 insertions(+), 607 deletions(-) create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-Black.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-Bold.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-BoldItalic.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-ExtraBold.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-Italic.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-Light.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-LightItalic.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-Medium.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-MediumItalic.woff2 create mode 100644 packages/agents-server-ui/src/fonts/OpenSauceOne-Regular.woff2 create mode 100755 packages/agents-server-ui/src/fonts/SourceCodePro-Regular.woff2 delete mode 100644 packages/agents-server-ui/src/hooks/useProjects.tsx create mode 100644 packages/agents-server-ui/src/ui/fonts.css diff --git a/packages/agents-server-ui/src/App.tsx b/packages/agents-server-ui/src/App.tsx index 2725a2179f..ff65a57822 100644 --- a/packages/agents-server-ui/src/App.tsx +++ b/packages/agents-server-ui/src/App.tsx @@ -4,7 +4,6 @@ import { useServerConnection, } from './hooks/useServerConnection' import { PinnedEntitiesProvider } from './hooks/usePinnedEntities' -import { ProjectsProvider } from './hooks/useProjects' import { ElectricAgentsProvider } from './lib/ElectricAgentsProvider' import { DarkModeProvider, useDarkModeContext } from './hooks/useDarkMode' import { ThemeProvider } from './ui' @@ -16,9 +15,7 @@ function AppInner(): React.ReactElement { return ( - - - + ) diff --git a/packages/agents-server-ui/src/components/AgentResponse.module.css b/packages/agents-server-ui/src/components/AgentResponse.module.css index 57102548a6..10ea0b383e 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.module.css +++ b/packages/agents-server-ui/src/components/AgentResponse.module.css @@ -12,9 +12,11 @@ } .doneText { - opacity: 0.5; + color: var(--ds-text-4, var(--ds-text-3)); + opacity: 0.8; } .timeText { - opacity: 0.4; + color: var(--ds-text-4, var(--ds-text-3)); + opacity: 0.7; } diff --git a/packages/agents-server-ui/src/components/EntityContextDrawer.module.css b/packages/agents-server-ui/src/components/EntityContextDrawer.module.css index 9370132e00..84cc8a9622 100644 --- a/packages/agents-server-ui/src/components/EntityContextDrawer.module.css +++ b/packages/agents-server-ui/src/components/EntityContextDrawer.module.css @@ -89,7 +89,7 @@ transition: background-color 0.12s ease; } .row:hover { - background: var(--ds-gray-a3); + background: var(--ds-bg-hover); } .row:focus-visible { outline: 2px solid var(--ds-accent-a6); diff --git a/packages/agents-server-ui/src/components/EntityHeader.module.css b/packages/agents-server-ui/src/components/EntityHeader.module.css index 4413aef205..42d485dd70 100644 --- a/packages/agents-server-ui/src/components/EntityHeader.module.css +++ b/packages/agents-server-ui/src/components/EntityHeader.module.css @@ -98,7 +98,7 @@ } .inspectPre { - background: var(--ds-gray-a3); + background: var(--ds-chip-bg); padding: 16px; border-radius: 8px; overflow: auto; diff --git a/packages/agents-server-ui/src/components/EntityTimeline.module.css b/packages/agents-server-ui/src/components/EntityTimeline.module.css index b39c9de612..14020b5b2e 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.module.css +++ b/packages/agents-server-ui/src/components/EntityTimeline.module.css @@ -55,7 +55,7 @@ * CSS variables so EntityTimeline + MessageInput can stay perfectly * aligned without duplicating constants. */ .content { - padding: 32px 40px; + padding: 36px 40px; max-width: calc(var(--chat-surface-width) + 80px); margin: 0 auto; overflow-anchor: none; @@ -63,10 +63,13 @@ width: 100%; } +/* "spawned" / "stopped" status markers — left-bordered text block + that sits flush with the message column rather than as a centered + chip floating above it. Reads as a quiet log entry, not a label. */ .statusPill { - padding: 4px 14px; - border-radius: 12px; - opacity: 0.5; + padding: 2px 0 2px 10px; + border-left: 2px solid var(--ds-gray-a3); + color: var(--ds-text-4, var(--ds-text-3)); letter-spacing: 0.02em; } @@ -86,25 +89,33 @@ width: 100%; } +/* Jump-to-bottom affordance — small bordered surface tucked into the + bottom-right corner so it doesn't compete with the centred chat + column. Opaque enough to read against the bottom-fade mask but + uses the page's normal surface tones rather than an inverted + high-contrast pill. */ .jumpToBottom { position: absolute; bottom: 24px; - left: 50%; - transform: translateX(-50%); + right: 24px; z-index: 10; display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; - border: none; + width: 28px; + height: 28px; + border: 1px solid var(--ds-gray-a3); border-radius: 9999px; - background: var(--ds-gray-12); - color: var(--ds-gray-1); + background: var(--ds-surface); + color: var(--ds-gray-9); cursor: pointer; - box-shadow: var(--ds-shadow-3); - transition: background 120ms ease; + box-shadow: var(--ds-shadow-2); + opacity: 0.85; + transition: + opacity 120ms ease, + background 120ms ease; } .jumpToBottom:hover { - background: var(--ds-gray-11); + opacity: 1; + background: var(--ds-gray-2); } diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index eaaa31948a..b0a06a424b 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -414,7 +414,7 @@ export function EntityTimeline({ scrollbars="vertical" >
- + {spawnTime ? ( @@ -477,7 +477,7 @@ export function EntityTimeline({ )} {entityStopped && ( - + stopped diff --git a/packages/agents-server-ui/src/components/MarkdownCodeBlock.tsx b/packages/agents-server-ui/src/components/MarkdownCodeBlock.tsx index 3ee5d90727..040a1c11f8 100644 --- a/packages/agents-server-ui/src/components/MarkdownCodeBlock.tsx +++ b/packages/agents-server-ui/src/components/MarkdownCodeBlock.tsx @@ -223,22 +223,32 @@ function FencedCodeBlock({
           {tokens ? (
             
-              {tokens.tokens.map((line, i) => (
-                
-                  {line.length === 0
-                    ? // Empty lines need at least a non-breaking space
-                      // so the row still has a baseline + visible height.
-                      `\u00A0`
-                    : line.map((token, j) => (
-                        
-                          {token.content}
-                        
-                      ))}
-                
-              ))}
+              {tokens.tokens
+                // Strip a single trailing empty line — Shiki appends
+                // one when the source ends in a newline, which would
+                // otherwise render as a blank row at the bottom of
+                // every fenced block.
+                .filter(
+                  (line, i) =>
+                    !(i === tokens.tokens.length - 1 && line.length === 0)
+                )
+                .map((line, i) => (
+                  
+                    {line.length === 0
+                      ? // Empty lines need at least a non-breaking
+                        // space so the row keeps a baseline + visible
+                        // height.
+                        `\u00A0`
+                      : line.map((token, j) => (
+                          
+                            {token.content}
+                          
+                        ))}
+                  
+                ))}
             
           ) : (
             {codeText}
@@ -424,16 +434,24 @@ function MermaidBlock({
 
 // Shiki returns per-token colours via `htmlStyle` (an inline-style
 // object with `--shiki-light`, `--shiki-dark`, etc. CSS variables)
-// plus a fallback `color`. CSS in `markdown.css` reads those vars to
-// switch colours per theme; we just have to forward them as
-// inline-style props.
+// AND a literal `color: var(--shiki-light)` fallback baked in.
+// We deliberately strip the `color` field — both the one Shiki
+// stamps into `htmlStyle` and the standalone `token.color` — and
+// let the CSS rules in `markdown.css` decide which `--shiki-*`
+// variable to read per theme. Otherwise the inline `color` (which
+// always points at the light theme) wins over the dark-mode CSS
+// rule (inline > author CSS in specificity), and dark mode renders
+// the light syntax-highlighting theme.
 function tokenStyle(
-  color: string | undefined,
+  _color: string | undefined,
   htmlStyle: Record | undefined
 ): React.CSSProperties {
   const out: Record = {}
-  if (color) out.color = color
-  if (htmlStyle) Object.assign(out, htmlStyle)
+  if (htmlStyle) {
+    for (const [k, v] of Object.entries(htmlStyle)) {
+      if (k !== `color`) out[k] = v
+    }
+  }
   return out as React.CSSProperties
 }
 
diff --git a/packages/agents-server-ui/src/components/MessageInput.module.css b/packages/agents-server-ui/src/components/MessageInput.module.css
index efcc980b16..4f9bc2d0a7 100644
--- a/packages/agents-server-ui/src/components/MessageInput.module.css
+++ b/packages/agents-server-ui/src/components/MessageInput.module.css
@@ -27,15 +27,20 @@
 }
 
 .composer {
-  /* Use the raised-surface token (solid in both themes) rather than
-     `--ds-input-bg`. The latter is translucent in dark mode by
-     design — a deliberate choice for inline form inputs that sit on
-     popovers / panels. The composer is different: it docks at the
-     bottom of a scrolling chat surface and chat content must NOT
-     bleed through it. Solid raised surface is the right semantic. */
+  /* Solid raised-surface fill (also what `--ds-input-bg` resolves to,
+     since both inherit from `--ds-surface-raised`). The composer
+     docks at the bottom of a scrolling chat surface and chat content
+     must NOT bleed through it. Solid raised surface is the right
+     semantic. */
   background: var(--ds-surface-raised);
-  border: 1px solid var(--ds-gray-a4);
+  border: 1px solid var(--ds-border-1);
   border-radius: 12px;
+  /* Soft drop shadow lifts the composer slightly off the chat
+     surface so the docked feel reads even when the bottom-fade mask
+     thins out the chat content right above it. */
+  box-shadow:
+    0 1px 3px rgba(15, 15, 30, 0.04),
+    0 1px 1px rgba(15, 15, 30, 0.02);
   /* 12px on all sides — same as the user-message bubble (`Stack p={3}`
      in UserMessage.tsx, where `--ds-space-3 = 12px`). Keeping the
      two surfaces on the same padding makes the textarea text column
@@ -97,7 +102,7 @@
 }
 
 .sendIcon {
-  color: var(--ds-gray-8);
+  color: var(--ds-gray-7);
   cursor: default;
   transition: color 0.15s ease;
   flex-shrink: 0;
diff --git a/packages/agents-server-ui/src/components/NewSessionPage.module.css b/packages/agents-server-ui/src/components/NewSessionPage.module.css
index fa7b5ddc3a..86dc6559d5 100644
--- a/packages/agents-server-ui/src/components/NewSessionPage.module.css
+++ b/packages/agents-server-ui/src/components/NewSessionPage.module.css
@@ -67,7 +67,7 @@
     border-color 0.12s ease;
 }
 .typeCard:hover {
-  background: var(--ds-gray-a2);
+  background: var(--ds-surface-raised);
   border-color: var(--ds-border-2);
 }
 .typeCardName {
@@ -227,14 +227,19 @@
   border-radius: var(--ds-radius-2);
   font-size: var(--ds-text-xs);
   color: var(--ds-text-2);
-  background: var(--ds-gray-a3);
+  /* Shared chip surface — same elevation as inputs, ``, code
+     chips and the Select pill trigger. Avoids the muddy mid-grey
+     produced by an alpha-white tint over the dark page bg. */
+  background: var(--ds-chip-bg);
   cursor: pointer;
   transition:
     background 0.1s ease,
     color 0.1s ease;
 }
 .pill:hover {
-  background: var(--ds-gray-a4);
+  /* Universal hover lift — clean cool-grey one step above
+     `--ds-chip-bg`, shared with drop-down items, sidebar rows etc. */
+  background: var(--ds-bg-hover);
   color: var(--ds-text-1);
 }
 .pillButton {
@@ -273,59 +278,6 @@
   color: var(--ds-accent-10);
 }
 
-/* Project picker -------------------------------------------------- */
-
-.projectPicker {
-  display: flex;
-  flex-direction: column;
-  gap: var(--ds-space-1);
-}
-.projectPickerLabel {
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-  font-size: 10px;
-}
-.projectPickerRow {
-  display: flex;
-  align-items: center;
-  gap: var(--ds-space-2);
-  flex-wrap: wrap;
-}
-.projectCreateForm {
-  display: flex;
-  align-items: center;
-  gap: var(--ds-space-2);
-}
-.projectCreateInput {
-  border: 1px solid var(--ds-border-1);
-  border-radius: var(--ds-radius-2);
-  padding: 4px 8px;
-  font-size: var(--ds-text-sm);
-  font-family: var(--ds-font-body);
-  color: var(--ds-text-1);
-  background: var(--ds-input-bg);
-  outline: none;
-  height: 28px;
-}
-.projectCreateInput:focus {
-  border-color: var(--ds-accent-a6);
-}
-.projectCreateBtn {
-  all: unset;
-  cursor: pointer;
-  font-size: var(--ds-text-sm);
-  color: var(--ds-accent-9);
-  padding: 4px 8px;
-  border-radius: var(--ds-radius-2);
-}
-.projectCreateBtn:hover {
-  background: var(--ds-accent-a2);
-}
-.projectCreateBtn:disabled {
-  cursor: not-allowed;
-  opacity: 0.5;
-}
-
 /* Other-agents section under the composer ----------------------- */
 
 .otherAgents {
diff --git a/packages/agents-server-ui/src/components/NewSessionPage.tsx b/packages/agents-server-ui/src/components/NewSessionPage.tsx
index 14cd5f1c10..bf24d549b5 100644
--- a/packages/agents-server-ui/src/components/NewSessionPage.tsx
+++ b/packages/agents-server-ui/src/components/NewSessionPage.tsx
@@ -6,7 +6,6 @@ import { useNavigate } from '@tanstack/react-router'
 import { nanoid } from 'nanoid'
 import { useElectricAgents } from '../lib/ElectricAgentsProvider'
 import { useServerConnection } from '../hooks/useServerConnection'
-import { useProjects } from '../hooks/useProjects'
 import { Select, Stack, Text } from '../ui'
 import { MainHeader } from './MainHeader'
 import { SchemaForm, hasSchemaProperties, isObjectSchema } from './SchemaForm'
@@ -44,8 +43,6 @@ export function NewSessionPage(): React.ReactElement {
   const navigate = useNavigate()
   const { entityTypesCollection, spawnEntity } = useElectricAgents()
   const { activeServer } = useServerConnection()
-  const { projects, activeProjectId, setActiveProjectId, createProject } =
-    useProjects()
   const [selected, setSelected] = useState(null)
   const [error, setError] = useState(null)
 
@@ -86,10 +83,7 @@ export function NewSessionPage(): React.ReactElement {
       if (!spawnEntity) return
       setError(null)
       const name = nanoid(10)
-      const tags: Record | undefined = activeProjectId
-        ? { project: activeProjectId }
-        : undefined
-      const tx = spawnEntity({ type: typeName, name, args, tags })
+      const tx = spawnEntity({ type: typeName, name, args })
       navigate({
         to: `/entity/$`,
         params: { _splat: `${typeName}/${name}` },
@@ -116,7 +110,7 @@ export function NewSessionPage(): React.ReactElement {
         )
       }
     },
-    [navigate, spawnEntity, baseUrl, activeProjectId]
+    [navigate, spawnEntity, baseUrl]
   )
 
   const handleSelectType = useCallback(
@@ -158,10 +152,6 @@ export function NewSessionPage(): React.ReactElement {
               onStartDefault={handleStartDefault}
               spawnReady={Boolean(spawnEntity)}
               error={error}
-              projects={projects}
-              activeProjectId={activeProjectId}
-              onChangeProject={setActiveProjectId}
-              onCreateProject={createProject}
             />
           )}
         
@@ -177,10 +167,6 @@ function Picker({ onStartDefault, spawnReady, error, - projects, - activeProjectId, - onChangeProject, - onCreateProject, }: { defaultAgent: ElectricEntityType | null otherAgents: Array @@ -188,10 +174,6 @@ function Picker({ onStartDefault: (text: string, args: Record) => void spawnReady: boolean error: string | null - projects: Array<{ id: string; name: string }> - activeProjectId: string | null - onChangeProject: (id: string | null) => void - onCreateProject: (name: string) => { id: string } }): React.ReactElement { const hasAnyAgent = defaultAgent !== null || otherAgents.length > 0 @@ -208,13 +190,6 @@ function Picker({
- - {error &&
{error}
} {defaultAgent && ( @@ -531,92 +506,3 @@ function PillToggle({ ) } - -function ProjectPicker({ - projects, - activeProjectId, - onChangeProject, - onCreateProject, -}: { - projects: Array<{ id: string; name: string }> - activeProjectId: string | null - onChangeProject: (id: string | null) => void - onCreateProject: (name: string) => { id: string } -}): React.ReactElement { - const [creating, setCreating] = useState(false) - const [newName, setNewName] = useState(``) - const inputRef = useRef(null) - - const handleCreate = useCallback(() => { - const trimmed = newName.trim() - if (!trimmed) return - const project = onCreateProject(trimmed) - onChangeProject(project.id) - setNewName(``) - setCreating(false) - }, [newName, onCreateProject, onChangeProject]) - - return ( -
- - Project - -
- - value={activeProjectId ?? `__none__`} - onValueChange={(v) => { - if (v === `__new__`) { - setCreating(true) - setTimeout(() => inputRef.current?.focus(), 0) - } else { - onChangeProject(v === `__none__` ? null : v) - } - }} - > - - - No project - {projects.map((p) => ( - - {p.name} - - ))} - + New project… - - - - {creating && ( -
{ - e.preventDefault() - handleCreate() - }} - > - setNewName(e.target.value)} - placeholder="Project name" - className={styles.projectCreateInput} - onKeyDown={(e) => { - if (e.key === `Escape`) { - setCreating(false) - setNewName(``) - } - }} - /> - -
- )} -
-
- ) -} diff --git a/packages/agents-server-ui/src/components/SearchPalette.module.css b/packages/agents-server-ui/src/components/SearchPalette.module.css index 13866aa072..a98f05050e 100644 --- a/packages/agents-server-ui/src/components/SearchPalette.module.css +++ b/packages/agents-server-ui/src/components/SearchPalette.module.css @@ -98,7 +98,7 @@ } .row[data-active='true'], .row:hover { - background: var(--ds-gray-a3); + background: var(--ds-bg-hover); } .rowTitle { diff --git a/packages/agents-server-ui/src/components/ServerPicker.module.css b/packages/agents-server-ui/src/components/ServerPicker.module.css index bd8f141922..f1a069d625 100644 --- a/packages/agents-server-ui/src/components/ServerPicker.module.css +++ b/packages/agents-server-ui/src/components/ServerPicker.module.css @@ -13,7 +13,7 @@ transition: background 0.08s ease; } .tile:hover { - background: var(--ds-gray-a3); + background: var(--ds-bg-hover); } .tileLabel { diff --git a/packages/agents-server-ui/src/components/Sidebar.module.css b/packages/agents-server-ui/src/components/Sidebar.module.css index 79790d8e8c..4770713840 100644 --- a/packages/agents-server-ui/src/components/Sidebar.module.css +++ b/packages/agents-server-ui/src/components/Sidebar.module.css @@ -55,7 +55,7 @@ transition: background 0.08s ease; } .newSessionRow:hover { - background: var(--ds-gray-a3); + background: var(--ds-bg-hover); } /* Same 22px slot, same 16px glyph and the SAME `--ds-text-1` colour as the sidebar/search IconButtons in SidebarHeader so the three @@ -123,41 +123,6 @@ padding-top: 20px; } -.projectHeader { - all: unset; - display: flex; - align-items: center; - gap: 5px; - width: 100%; - height: var(--ds-row-height-md); - padding: 14px 4px 4px 8px; - cursor: pointer; - color: var(--ds-text-2); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.06em; - transition: color 0.1s ease; -} -.projectHeader:hover { - color: var(--ds-text-1); -} -.projectHeaderIcon { - flex-shrink: 0; - opacity: 0.7; -} -.projectHeaderLabel { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.projectHeaderCount { - flex-shrink: 0; - opacity: 0.5; - font-variant-numeric: tabular-nums; -} - /* Section label ("Pinned", "Today", "Last 7 days", …) — text aligns with the icon column above so the column reads cleanly from top to bottom. */ diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 6aae47ff30..3f23af6f4c 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -1,9 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { ChevronDown, ChevronRight, FolderOpen, SquarePen } from 'lucide-react' +import { SquarePen } from 'lucide-react' import { useLiveQuery } from '@tanstack/react-db' import { useNavigate } from '@tanstack/react-router' import { useElectricAgents } from '../lib/ElectricAgentsProvider' -import { useProjects } from '../hooks/useProjects' import { bucketEntities } from '../lib/sessionGroups' import { HoverCard, ScrollArea, Stack, Text } from '../ui' import { NewSessionKey } from '../lib/keyLabels' @@ -15,7 +14,6 @@ import { SidebarTree } from './SidebarTree' import { SidebarFooter } from './SidebarFooter' import styles from './Sidebar.module.css' import type { ElectricEntity } from '../lib/ElectricAgentsProvider' -import type { Project } from '../hooks/useProjects' const SIDEBAR_WIDTH_KEY = `electric-agents-ui.sidebar.width` const SIDEBAR_DEFAULT_WIDTH = 240 @@ -55,14 +53,10 @@ export function Sidebar({ onTogglePin: (url: string) => void }): React.ReactElement { const { entitiesCollection } = useElectricAgents() - const { projects } = useProjects() const navigate = useNavigate() const [width, setWidth] = useSidebarWidth() const [resizeHandleHover, setResizeHandleHover] = useState(false) const [resizing, setResizing] = useState(false) - const [collapsedProjects, setCollapsedProjects] = useState>( - () => new Set() - ) const hoverHandle = HoverCard.useHandle() @@ -116,25 +110,11 @@ export function Sidebar({ [roots, pinnedSet] ) - const { projectSections, ungrouped } = useMemo( - () => groupByProject(unpinnedRoots, projects), - [unpinnedRoots, projects] + const ungroupedBuckets = useMemo( + () => bucketEntities(unpinnedRoots), + [unpinnedRoots] ) - const ungroupedBuckets = useMemo(() => bucketEntities(ungrouped), [ungrouped]) - - const toggleProjectCollapsed = useCallback((projectId: string) => { - setCollapsedProjects((prev) => { - const next = new Set(prev) - if (next.has(projectId)) { - next.delete(projectId) - } else { - next.add(projectId) - } - return next - }) - }, []) - const handleNewSession = useCallback(() => { navigate({ to: `/` }) }, [navigate]) @@ -196,36 +176,6 @@ export function Sidebar({ )} - {projectSections.map((section) => { - const collapsed = collapsedProjects.has(section.id) - return ( -
- - {!collapsed && - section.items.map((root) => ( - - ))} -
- ) - })} - {ungroupedBuckets.map((group) => (
{group.label} @@ -267,49 +217,6 @@ export function Sidebar({ ) } -interface ProjectSection { - id: string - name: string - items: Array -} - -function groupByProject( - roots: ReadonlyArray, - projects: ReadonlyArray -): { - projectSections: Array - ungrouped: Array -} { - const projectMap = new Map(projects.map((p) => [p.id, p])) - const byProject = new Map>() - const ungrouped: Array = [] - - for (const entity of roots) { - const projectId = entity.tags?.project - if (projectId && projectMap.has(projectId)) { - const list = byProject.get(projectId) ?? [] - list.push(entity) - byProject.set(projectId, list) - } else { - ungrouped.push(entity) - } - } - - const projectSections: Array = [] - for (const [id, items] of byProject) { - const project = projectMap.get(id)! - projectSections.push({ id, name: project.name, items }) - } - - projectSections.sort((a, b) => { - const aMax = Math.max(...a.items.map((e) => e.updated_at)) - const bMax = Math.max(...b.items.map((e) => e.updated_at)) - return bMax - aMax - }) - - return { projectSections, ungrouped } -} - function buildEntityTree(entities: ReadonlyArray): { roots: Array childrenByParent: Map> diff --git a/packages/agents-server-ui/src/components/SidebarRow.module.css b/packages/agents-server-ui/src/components/SidebarRow.module.css index 163cb67ca8..8487000ae0 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.module.css +++ b/packages/agents-server-ui/src/components/SidebarRow.module.css @@ -33,7 +33,7 @@ transition: background 0.08s ease; } .row:hover { - background: var(--ds-gray-a3); + background: var(--ds-bg-hover); } .selected { background: var(--ds-accent-a3); @@ -105,7 +105,11 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: 1; + /* Tall enough to show descenders (g, p, y) — `line-height: 1` + clips them inside the title's `overflow: hidden` box. The row + stays at `--ds-row-height-md` via `align-items: center`, so + bumping line-height here doesn't grow the row. */ + line-height: 1.3; } /* Type label (e.g. "horton") — same size as title, just muted. @@ -123,10 +127,19 @@ .type { flex-shrink: 0; padding-right: 5px; - font-size: var(--ds-text-sm); + /* Sized so the type label's cap-height optically matches the + x-height of the title text next to it — 10px caps ≈ 7px tall, + same height as the lowercase letters of the 13px title. */ + font-size: 10px; color: var(--ds-text-3); text-transform: lowercase; line-height: 1; + /* Drop 1px so the type label optically shares a baseline with the + larger title text. `align-items: center` on the row centres both + glyphs in their own bounding boxes, but the smaller cap-height + of the 10px label leaves it sitting visually slightly above the + title baseline; this nudge restores the line. */ + transform: translateY(1px); /* Animate the mask in/out as the row is hovered. */ transition: color 0.1s ease, diff --git a/packages/agents-server-ui/src/components/ToolCallView.tsx b/packages/agents-server-ui/src/components/ToolCallView.tsx index ecad059f25..d19b5b1e80 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.tsx +++ b/packages/agents-server-ui/src/components/ToolCallView.tsx @@ -103,16 +103,12 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { const timedOut = r.details.timedOut as boolean | undefined return ( - - Command - + Command
{args.command as string}
{r.text && ( <> - - Output - + Output {exitCode !== undefined && exitCode !== 0 && ( exit {exitCode} @@ -134,9 +130,7 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { case `read`: return ( - - Content - + Content
             {r.text ? truncate(r.text, 2000) : `(empty)`}
           
@@ -183,9 +177,7 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { {typeof args.content === `string` && ( <> - - Content - + Content
                 {truncate(args.content, 1000)}
               
@@ -202,17 +194,13 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { default: return ( - - Input - + Input
             {JSON.stringify(args, null, 2)}
           
{r.text && ( <> - - Output - + Output
{r.text}
)} diff --git a/packages/agents-server-ui/src/components/UserMessage.module.css b/packages/agents-server-ui/src/components/UserMessage.module.css index 198a5bb9a5..4a9e86f7ac 100644 --- a/packages/agents-server-ui/src/components/UserMessage.module.css +++ b/packages/agents-server-ui/src/components/UserMessage.module.css @@ -14,7 +14,7 @@ user's "voice" turn. */ .bubble { background: var(--ds-input-bg); - border: 1px solid var(--ds-gray-a4); + border: 1px solid var(--ds-gray-a3); border-radius: 12px; } diff --git a/packages/agents-server-ui/src/components/stateExplorer/TypeList.module.css b/packages/agents-server-ui/src/components/stateExplorer/TypeList.module.css index 081faf00ed..7a5afad2f8 100644 --- a/packages/agents-server-ui/src/components/stateExplorer/TypeList.module.css +++ b/packages/agents-server-ui/src/components/stateExplorer/TypeList.module.css @@ -24,7 +24,7 @@ color: var(--ds-text-2); } .item:hover { - background: var(--ds-gray-a3); + background: var(--ds-bg-hover); } .itemSelected { background: var(--ds-accent-a3); diff --git a/packages/agents-server-ui/src/components/toolBlock.module.css b/packages/agents-server-ui/src/components/toolBlock.module.css index 1bd85679e4..6f5a76b2fd 100644 --- a/packages/agents-server-ui/src/components/toolBlock.module.css +++ b/packages/agents-server-ui/src/components/toolBlock.module.css @@ -19,9 +19,17 @@ .card { border: 1px solid var(--ds-gray-a3); - border-radius: var(--ds-radius-3); + border-radius: var(--ds-radius-4); overflow: hidden; - background: var(--ds-gray-a1); + /* Solid surface (one elevation step above the page bg) so the + card reads as a clear panel rather than a near-invisible + translucent slab over the dark page bg. Matches the way the + marketing site uses `--vp-c-bg-elv` for cards/panels. */ + background: var(--ds-surface); + /* Hairline shadow — same lift the composer uses, just enough to + separate the card from the chat surface without competing with + prose. */ + box-shadow: 0 1px 2px rgba(15, 15, 30, 0.04); } /* The header is metadata about the tool call, not part of the @@ -34,11 +42,18 @@ display: flex; align-items: center; gap: 8px; - padding: 6px 10px; + padding: 7px 10px; font-size: 12px; line-height: 1.45; - font-family: var(--ds-font-body); + /* Mono font for the header strip — the row is tool metadata + (function name + arg summary), so reading it as code matches + its semantic. The `.summary` child opts back out to body font + so the human-readable summary stays legible at small sizes. */ + font-family: var(--ds-font-mono); color: var(--ds-text-1); + /* Faint tinted strip — distinguishes the header from the body + panel below without raising it to a full chrome bar. */ + background: var(--ds-gray-a1); } /* Strip ` - diff --git a/packages/agents-server-ui/src/components/MainHeader.module.css b/packages/agents-server-ui/src/components/MainHeader.module.css index af5a7095cb..fa29e1f75d 100644 --- a/packages/agents-server-ui/src/components/MainHeader.module.css +++ b/packages/agents-server-ui/src/components/MainHeader.module.css @@ -21,11 +21,6 @@ height: 44px; padding: 0 10px; background: var(--ds-bg); - -webkit-app-region: drag; -} - -.header > * { - -webkit-app-region: no-drag; } .chrome { @@ -35,6 +30,32 @@ flex-shrink: 0; } +/* Electron desktop: every tile's MainHeader strip becomes a + window-drag region so the user can grab anywhere along the top row + of the tiled area to move the window. Height stays at the web + default (44px) so any chrome icons (sidebar toggle / search) that + render here when the sidebar is collapsed flex-center on the same + y as the macOS traffic-light centers. */ +:global(html[data-electric-desktop='true']) .header { + -webkit-app-region: drag; +} + +/* When the chrome cluster (sidebar toggle / search) is rendered in + the leftmost tile because the sidebar is collapsed, push it past + the macOS traffic lights. Other tiles in the row keep the standard + gutter — they sit to the right of the lights anyway. */ +:global(html[data-electric-desktop='true']) .header:has(.chrome) { + padding-left: 84px; +} + +:global(html[data-electric-desktop='true']) .header button, +:global(html[data-electric-desktop='true']) .header a, +:global(html[data-electric-desktop='true']) .header input, +:global(html[data-electric-desktop='true']) .header [role='button'], +:global(html[data-electric-desktop='true']) .header [data-no-drag] { + -webkit-app-region: no-drag; +} + .title { display: inline-flex; align-items: baseline; diff --git a/packages/agents-server-ui/src/components/SettingsMenu.tsx b/packages/agents-server-ui/src/components/SettingsMenu.tsx index ecbaf9573e..2e4354b447 100644 --- a/packages/agents-server-ui/src/components/SettingsMenu.tsx +++ b/packages/agents-server-ui/src/components/SettingsMenu.tsx @@ -1,6 +1,20 @@ -import { Check, Monitor, Moon, Settings, Sun } from 'lucide-react' +import { useEffect, useState } from 'react' +import { + Check, + Monitor, + Moon, + RefreshCw, + Settings, + Square, + Sun, +} from 'lucide-react' import { IconButton, Menu, Text } from '../ui' import { useDarkModeContext, type ThemePreference } from '../hooks/useDarkMode' +import { + loadDesktopState, + onDesktopStateChanged, + type DesktopState, +} from '../lib/server-connection' import styles from './SettingsMenu.module.css' const THEME_OPTIONS: ReadonlyArray<{ @@ -19,6 +33,17 @@ const THEME_OPTIONS: ReadonlyArray<{ */ export function SettingsMenu(): React.ReactElement { const { preference, setPreference } = useDarkModeContext() + const [desktopState, setDesktopState] = useState(null) + + useEffect(() => { + if (!window.electronAPI?.getDesktopState) return + void loadDesktopState().then(setDesktopState) + const unsubscribe = onDesktopStateChanged(setDesktopState) + return () => { + unsubscribe?.() + } + }, []) + return ( + {desktopState && ( + <> + + + Desktop runtime + + Status: {desktopState.runtimeStatus} + + {desktopState.runtimeUrl && ( + + {desktopState.runtimeUrl} + + )} + {desktopState.error && ( + + {desktopState.error} + + )} + void window.electronAPI?.restartRuntime?.()} + > + + Restart runtime + + void window.electronAPI?.stopRuntime?.()} + > + + Stop runtime + + + + )} ) diff --git a/packages/agents-server-ui/src/components/SidebarHeader.module.css b/packages/agents-server-ui/src/components/SidebarHeader.module.css index bd080c4e9f..a7d74f8da1 100644 --- a/packages/agents-server-ui/src/components/SidebarHeader.module.css +++ b/packages/agents-server-ui/src/components/SidebarHeader.module.css @@ -19,3 +19,26 @@ height: 44px; padding: 0 10px; } + +/* Electron desktop: extend the sidebar header up into the macOS + "hiddenInset" titlebar area so the toolbar buttons sit on the same + row as the OS traffic lights. Padding-left clears the three lights + (positioned at x:16, y:16 in main.ts → ~74px wide region) with a + small gap so the icons start to their right. + Height stays at the standard 44px so the 24px IconButton glyphs + flex-center on the same y as the traffic-light centers. + The whole bar is a window-drag region; interactive descendants + (buttons, tooltips, links, inputs) opt back out so they stay + clickable. */ +:global(html[data-electric-desktop='true']) .header { + padding-left: 84px; + -webkit-app-region: drag; +} + +:global(html[data-electric-desktop='true']) .header button, +:global(html[data-electric-desktop='true']) .header a, +:global(html[data-electric-desktop='true']) .header input, +:global(html[data-electric-desktop='true']) .header [role='button'], +:global(html[data-electric-desktop='true']) .header [data-no-drag] { + -webkit-app-region: no-drag; +} diff --git a/packages/agents-server-ui/src/hooks/useServerConnection.tsx b/packages/agents-server-ui/src/hooks/useServerConnection.tsx index 684b2a4c8b..ae013a7fe0 100644 --- a/packages/agents-server-ui/src/hooks/useServerConnection.tsx +++ b/packages/agents-server-ui/src/hooks/useServerConnection.tsx @@ -5,7 +5,13 @@ import { useEffect, useState, } from 'react' -import { loadServers, saveServers } from '../lib/server-connection' +import { + loadDesktopState, + loadServers, + onDesktopStateChanged, + saveActiveServer, + saveServers, +} from '../lib/server-connection' import type { ReactNode } from 'react' import type { ServerConfig } from '../lib/types' @@ -21,7 +27,7 @@ interface ServerConnectionState { servers: Array activeServer: ServerConfig | null connected: boolean - setActiveServer: (server: ServerConfig) => void + setActiveServer: (server: ServerConfig | null) => void addServer: (server: ServerConfig) => void removeServer: (url: string) => void } @@ -42,23 +48,45 @@ export function ServerConnectionProvider({ const [connected, setConnected] = useState(false) useEffect(() => { - loadServers() - .then((loaded) => { - const next = loaded.length > 0 ? loaded : [currentServer()] + Promise.all([loadServers(), loadDesktopState()]) + .then(([loaded, desktopState]) => { + const next = + loaded.length > 0 + ? loaded + : window.electronAPI + ? [] + : [currentServer()] + const active = + desktopState?.activeServer && + next.some((server) => server.url === desktopState.activeServer?.url) + ? desktopState.activeServer + : (next[0] ?? null) setServers(next) - setActiveServerState(next[0] ?? null) + setActiveServerState(active) if (loaded.length === 0) { void saveServers(next) } + if (active) { + void saveActiveServer(active) + } }) .catch((err) => { console.error(`Failed to load saved servers:`, err) - const next = [currentServer()] + const next = window.electronAPI ? [] : [currentServer()] setServers(next) setActiveServerState(next[0] ?? null) }) }, []) + useEffect(() => { + const unsubscribe = onDesktopStateChanged((state) => { + setActiveServerState(state.activeServer) + }) + return () => { + unsubscribe?.() + } + }, []) + useEffect(() => { if (!activeServer) { setConnected(false) @@ -86,13 +114,18 @@ export function ServerConnectionProvider({ } }, [activeServer]) + const setActiveServer = useCallback((server: ServerConfig | null) => { + setActiveServerState(server) + void saveActiveServer(server) + }, []) + const addServer = useCallback( (server: ServerConfig) => { if (servers.some((s) => s.url === server.url)) return const next = [...servers, server] setServers(next) - saveServers(next) setActiveServerState(server) + void saveServers(next).then(() => saveActiveServer(server)) }, [servers] ) @@ -101,12 +134,12 @@ export function ServerConnectionProvider({ (url: string) => { const next = servers.filter((s) => s.url !== url) setServers(next) - saveServers(next) + void saveServers(next) if (activeServer?.url === url) { - setActiveServerState(next[0] ?? null) + setActiveServer(next[0] ?? null) } }, - [servers, activeServer] + [servers, activeServer, setActiveServer] ) return ( @@ -115,7 +148,7 @@ export function ServerConnectionProvider({ servers, activeServer, connected, - setActiveServer: setActiveServerState, + setActiveServer, addServer, removeServer, }} diff --git a/packages/agents-server-ui/src/lib/server-connection.ts b/packages/agents-server-ui/src/lib/server-connection.ts index 9cc722aaa5..fa97cc7ea0 100644 --- a/packages/agents-server-ui/src/lib/server-connection.ts +++ b/packages/agents-server-ui/src/lib/server-connection.ts @@ -1,10 +1,29 @@ import type { ServerConfig } from './types' +export type DesktopRuntimeStatus = `stopped` | `starting` | `running` | `error` + +export interface DesktopState { + runtimeStatus: DesktopRuntimeStatus + runtimeUrl: string | null + activeServer: ServerConfig | null + workingDirectory: string | null + error: string | null +} + declare global { interface Window { electronAPI?: { getServers: () => Promise> saveServers: (servers: Array) => Promise + getDesktopState?: () => Promise + setActiveServer?: (server: ServerConfig | null) => Promise + restartRuntime?: () => Promise + stopRuntime?: () => Promise + getWorkingDirectory?: () => Promise + chooseWorkingDirectory?: () => Promise + onDesktopStateChanged?: ( + callback: (state: DesktopState) => void + ) => () => void } } } @@ -33,3 +52,19 @@ export async function saveServers(servers: Array): Promise { } localStorage.setItem(STORAGE_KEY, JSON.stringify(servers)) } + +export async function loadDesktopState(): Promise { + return (await window.electronAPI?.getDesktopState?.()) ?? null +} + +export async function saveActiveServer( + server: ServerConfig | null +): Promise { + await window.electronAPI?.setActiveServer?.(server) +} + +export function onDesktopStateChanged( + callback: (state: DesktopState) => void +): (() => void) | null { + return window.electronAPI?.onDesktopStateChanged?.(callback) ?? null +} diff --git a/packages/agents-server-ui/vite.config.ts b/packages/agents-server-ui/vite.config.ts index b7f2390ea4..cf811b93f8 100644 --- a/packages/agents-server-ui/vite.config.ts +++ b/packages/agents-server-ui/vite.config.ts @@ -1,11 +1,38 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' -export default defineConfig({ - base: `/__agent_ui/`, - plugins: [react()], - build: { - outDir: `dist`, - emptyOutDir: true, - }, +/** + * Tags the built `` element with `data-electric-desktop="true"` + * for the Electron desktop build so module-CSS rules like + * `:global(html[data-electric-desktop='true']) .header` match from the + * first paint — earlier than either preload (isolated world) or the + * renderer entry (runs after CSS is loaded) can reliably set the + * attribute. + */ +function desktopHtmlMarker(): Plugin { + return { + name: `electric-desktop-html-marker`, + transformIndexHtml: { + order: `pre`, + handler(html) { + return html.replace( + ``, + `` + ) + }, + }, + } +} + +export default defineConfig(({ mode }) => { + const desktop = mode === `desktop` + + return { + base: desktop ? `./` : `/__agent_ui/`, + plugins: [react(), ...(desktop ? [desktopHtmlMarker()] : [])], + build: { + outDir: desktop ? `dist-desktop` : `dist`, + emptyOutDir: true, + }, + } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82547243ae..90ec442a69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,10 +25,10 @@ importers: version: 0.4.3 '@typescript-eslint/eslint-plugin': specifier: ^8.46.0 - version: 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + version: 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/parser': specifier: ^8.46.0 - version: 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + version: 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3) eslint: specifier: ^9.37.0 version: 9.37.0(jiti@2.6.1) @@ -918,6 +918,34 @@ importers: specifier: ^4.18.1 version: 4.24.4 + examples/replay-loop-repro: + dependencies: + '@electric-sql/client': + specifier: workspace:* + version: link:../../packages/typescript-client + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.3 + version: 18.3.12 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.1 + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.3.3(vite@5.4.10(@types/node@25.6.0)(lightningcss@1.30.1)(terser@5.46.2)) + typescript: + specifier: ^5.5.3 + version: 5.8.3 + vite: + specifier: ^5.3.4 + version: 5.4.10(@types/node@25.6.0)(lightningcss@1.30.1)(terser@5.46.2) + examples/tanstack: dependencies: '@electric-sql/client': @@ -1567,6 +1595,28 @@ importers: specifier: ^4.1.0 version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.2)(tsx@4.20.3)(yaml@2.8.1)) + packages/agents-desktop: + dependencies: + '@electric-ax/agents': + specifier: workspace:* + version: link:../agents + '@electric-ax/agents-server-ui': + specifier: workspace:* + version: link:../agents-server-ui + devDependencies: + '@types/node': + specifier: ^22.19.17 + version: 22.19.17 + electron: + specifier: ^41.5.0 + version: 41.5.0 + tsdown: + specifier: ^0.9.9 + version: 0.9.9(typescript@5.8.3) + typescript: + specifier: ^5.8.3 + version: 5.8.3 + packages/agents-runtime: dependencies: '@anthropic-ai/sdk': @@ -4161,6 +4211,10 @@ packages: '@electric-sql/pglite@0.4.5': resolution: {integrity: sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==} + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -8283,6 +8337,10 @@ packages: '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -8643,6 +8701,10 @@ packages: '@swc/types@0.1.14': resolution: {integrity: sha512-PbSmTiYCN+GMrvfjrMo9bdY+f2COnwbdnoMw7rqU/PI5jXpKjxOGZ0qqZCImxnT81NkNsKnmEpvu+hRXLBeCJg==} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tailwindcss/forms@0.5.9': resolution: {integrity: sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==} peerDependencies: @@ -9280,6 +9342,9 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -9418,6 +9483,9 @@ packages: '@types/hoist-non-react-statics@3.3.5': resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -9445,6 +9513,9 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/linkify-it@3.0.5': resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} @@ -9490,6 +9561,9 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -9540,6 +9614,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -9597,6 +9674,9 @@ packages: '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@6.21.0': resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -10675,6 +10755,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -10716,6 +10800,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -10756,6 +10843,14 @@ packages: cache-control-parser@2.0.6: resolution: {integrity: sha512-N4rxCk7V8NLfUVONXG0d7S4IyTQh3KEDW5k2I4CAcEUcMQCmVkfAMn37JSWfUQudiR883vDBy5XM5+TS2Xo7uQ==} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -10976,6 +11071,9 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -11618,6 +11716,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -11680,6 +11782,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -11992,6 +12097,11 @@ packages: electron-to-chromium@1.5.52: resolution: {integrity: sha512-xtoijJTZ+qeucLBDNztDOuQBE1ksqjvNjvqFoST3nGC7fSpqJ+X6BdTBaY5BHG+IhWWmpc6b/KfpeuEDupEPOQ==} + electron@41.5.0: + resolution: {integrity: sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg==} + engines: {node: '>= 12.20.55'} + hasBin: true + emoji-regex@10.5.0: resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==} @@ -12047,6 +12157,10 @@ packages: resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==} engines: {node: '>=8'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -12125,6 +12239,9 @@ packages: es-toolkit@1.46.0: resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} @@ -12709,6 +12826,11 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-base64-decode@1.0.0: resolution: {integrity: sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==} @@ -12770,6 +12892,9 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -13015,6 +13140,10 @@ packages: resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==} engines: {node: '>=12'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -13057,6 +13186,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -13104,6 +13237,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -13302,6 +13439,9 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -13317,6 +13457,10 @@ packages: http2-client@1.3.5: resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.5: resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} engines: {node: '>= 14'} @@ -13996,6 +14140,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -14357,6 +14504,10 @@ packages: lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -14453,6 +14604,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -14755,6 +14910,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -15037,6 +15196,10 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + npm-normalize-package-bin@4.0.0: resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} engines: {node: ^18.17.0 || >=20.5.0} @@ -15281,6 +15444,10 @@ packages: resolution: {integrity: sha512-dQPNIF+gHpSkmC0+Vg9IktNyhcn28Y8R3eTLyzn52UNymkasLicl3sFAtz7oEVuFmCpgGjaUTKkwk+jW2cHpDQ==} engines: {node: ^20.19.0 || >=22.12.0} + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -15435,6 +15602,9 @@ packages: resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==} engines: {node: '>=14.16'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -15980,6 +16150,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -16492,6 +16666,9 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@3.0.0: resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} engines: {node: '>=4'} @@ -16535,6 +16712,9 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} @@ -16575,6 +16755,10 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -16726,6 +16910,9 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -16757,6 +16944,10 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -17042,6 +17233,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sqlite-vec-darwin-arm64@0.1.9: resolution: {integrity: sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==} cpu: [arm64] @@ -17388,6 +17582,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + supabase@1.226.4: resolution: {integrity: sha512-qEzoagrqZs5T7sAlfZzehX3PJ13cSBrJVs2vrh6xC+B0VI0wgOBw2gCNRcsOMJMpSr0V1l0XueCiFBWPm2U03w==} engines: {npm: '>=8'} @@ -17785,6 +17983,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} @@ -17861,8 +18063,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -17903,6 +18105,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} @@ -19011,6 +19216,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yjs@13.6.26: resolution: {integrity: sha512-wiARO3wixu7mtoRP5f7LqpUtsURP9SmNgXUt3RlnZg4qDuF7dUjthwIvwxIDmK55dPw4Wl4QdW5A3ag0atwu7g==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -22028,6 +22236,20 @@ snapshots: '@electric-sql/pglite@0.4.5': {} + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -26069,6 +26291,8 @@ snapshots: '@sinclair/typebox@0.34.49': {} + '@sindresorhus/is@4.6.0': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -26581,6 +26805,10 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tailwindcss/forms@0.5.9(tailwindcss@3.4.14)': dependencies: mini-svg-data-uri: 1.4.4 @@ -27400,7 +27628,14 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 20.17.6 + '@types/node': 22.19.17 + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.19.17 + '@types/responselike': 1.0.3 '@types/chai@5.2.2': dependencies: @@ -27412,7 +27647,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.19.17 '@types/d3-array@3.2.2': {} @@ -27575,6 +27810,8 @@ snapshots: '@types/react': 19.2.14 hoist-non-react-statics: 3.3.2 + '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -27609,6 +27846,10 @@ snapshots: '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.19.17 + '@types/linkify-it@3.0.5': {} '@types/linkify-it@5.0.0': {} @@ -27655,6 +27896,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + '@types/node@25.6.0': dependencies: undici-types: 7.19.2 @@ -27664,13 +27909,13 @@ snapshots: '@types/pg@8.11.10': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.19.17 pg-protocol: 1.7.0 pg-types: 4.0.2 '@types/pg@8.15.4': dependencies: - '@types/node': 22.19.1 + '@types/node': 22.19.17 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -27706,7 +27951,7 @@ snapshots: '@types/react@18.3.12': dependencies: '@types/prop-types': 15.7.13 - csstype: 3.1.3 + csstype: 3.2.3 '@types/react@19.1.17': dependencies: @@ -27718,6 +27963,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.19.17 + '@types/retry@0.12.0': {} '@types/semver@7.5.8': {} @@ -27759,7 +28008,7 @@ snapshots: '@types/vite-plugin-react-svg@0.2.5': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.19.17 '@types/react': 19.2.14 '@types/svgo': 2.6.4 vite: 2.9.18 @@ -27776,6 +28025,11 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.19.17 + optional: true + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -27866,20 +28120,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.46.0 eslint: 9.37.0(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -27946,15 +28200,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.46.0 '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.46.0 debug: 4.4.3 eslint: 9.37.0(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -27985,12 +28239,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.46.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@6.0.3) '@typescript-eslint/types': 8.46.0 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -28031,9 +28285,9 @@ snapshots: dependencies: typescript: 5.7.2 - '@typescript-eslint/tsconfig-utils@8.46.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.46.0(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.6.3)': dependencies: @@ -28106,15 +28360,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3 eslint: 9.37.0(jiti@2.6.1) - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -28235,10 +28489,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.46.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.46.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.46.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3) + '@typescript-eslint/project-service': 8.46.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@6.0.3) '@typescript-eslint/types': 8.46.0 '@typescript-eslint/visitor-keys': 8.46.0 debug: 4.4.3 @@ -28246,8 +28500,8 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -28320,14 +28574,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.46.0 '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.0(typescript@6.0.3) eslint: 9.37.0(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -29575,6 +29829,9 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: + optional: true + bowser@2.14.1: {} bplist-creator@0.1.0: @@ -29625,6 +29882,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -29664,6 +29923,18 @@ snapshots: cache-control-parser@2.0.6: {} + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -29903,6 +30174,10 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + clone@1.0.4: {} clsx@1.2.1: {} @@ -30566,6 +30841,8 @@ snapshots: dependencies: clone: 1.0.4 + defer-to-connect@2.0.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.0 @@ -30612,6 +30889,9 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -30774,6 +31054,14 @@ snapshots: electron-to-chromium@1.5.52: {} + electron@41.5.0: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 24.12.2 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + emoji-regex@10.5.0: {} emoji-regex@8.0.0: {} @@ -30815,6 +31103,8 @@ snapshots: env-editor@0.4.2: {} + env-paths@2.2.1: {} + env-paths@3.0.0: {} environment@1.1.0: {} @@ -31043,6 +31333,9 @@ snapshots: es-toolkit@1.46.0: {} + es6-error@4.1.1: + optional: true + es6-promise@3.3.1: {} esbuild-android-64@0.14.54: @@ -32034,6 +32327,16 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-base64-decode@1.0.0: {} fast-check@4.6.0: @@ -32109,10 +32412,18 @@ snapshots: transitivePeerDependencies: - encoding + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -32383,6 +32694,10 @@ snapshots: get-stdin@9.0.0: {} + get-stream@5.2.0: + dependencies: + pump: 3.0.2 + get-stream@6.0.1: {} get-symbol-description@1.1.0: @@ -32437,6 +32752,16 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + globals@11.12.0: {} globals@13.24.0: @@ -32486,6 +32811,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -32765,6 +33104,8 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + http-cache-semantics@4.2.0: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -32791,6 +33132,11 @@ snapshots: http2-client@1.3.5: {} + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.5: dependencies: agent-base: 7.1.1 @@ -33571,6 +33917,9 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: + optional: true + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -33769,7 +34118,7 @@ snapshots: lightningcss@1.30.1: dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.2 optionalDependencies: lightningcss-darwin-arm64: 1.30.1 lightningcss-darwin-x64: 1.30.1 @@ -33917,6 +34266,8 @@ snapshots: dependencies: tslib: 2.8.1 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.3.5: {} @@ -33973,7 +34324,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 makeerror@1.0.12: dependencies: @@ -34004,6 +34355,11 @@ snapshots: marky@1.3.0: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -34650,6 +35006,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@1.0.1: {} + mimic-response@3.1.0: {} mini-svg-data-uri@1.4.4: {} @@ -34927,6 +35285,8 @@ snapshots: normalize-range@0.1.2: {} + normalize-url@6.1.0: {} + npm-normalize-package-bin@4.0.0: {} npm-package-arg@11.0.3: @@ -35266,6 +35626,8 @@ snapshots: '@oxc-transform/binding-win32-arm64-msvc': 0.96.0 '@oxc-transform/binding-win32-x64-msvc': 0.96.0 + p-cancelable@2.1.1: {} + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -35416,6 +35778,8 @@ snapshots: peek-readable@5.4.2: {} + pend@1.2.0: {} + perfect-debounce@1.0.0: {} perfect-scrollbar@1.5.6: {} @@ -35984,6 +36348,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@radix-ui/primitive': 1.1.3 @@ -36718,6 +37084,8 @@ snapshots: reselect@5.1.1: {} + resolve-alpn@1.2.1: {} + resolve-from@3.0.0: {} resolve-from@4.0.0: {} @@ -36759,6 +37127,10 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1 @@ -36795,12 +37167,22 @@ snapshots: dependencies: glob: 10.4.5 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + robust-predicates@3.0.3: {} rolldown-plugin-dts@0.9.11(rolldown@1.0.0-beta.8-commit.151352b(typescript@5.8.3))(typescript@5.8.3): dependencies: - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 ast-kit: 1.4.3 debug: 4.4.3 @@ -37010,6 +37392,9 @@ snapshots: secure-json-parse@4.1.0: {} + semver-compare@1.0.0: + optional: true + semver@6.3.1: {} semver@7.6.3: {} @@ -37054,6 +37439,11 @@ snapshots: serialize-error@2.1.0: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -37402,6 +37792,9 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: + optional: true + sqlite-vec-darwin-arm64@0.1.9: optional: true @@ -37757,6 +38150,12 @@ snapshots: pirates: 4.0.6 ts-interface-checker: 0.1.13 + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + supabase@1.226.4: dependencies: bin-links: 5.0.0 @@ -37981,8 +38380,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinypool@1.1.1: {} @@ -38093,9 +38492,9 @@ snapshots: dependencies: typescript: 5.7.2 - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.1.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 ts-declaration-location@1.0.5(typescript@5.6.3): dependencies: @@ -38125,7 +38524,7 @@ snapshots: lightningcss: 1.30.1 rolldown: 1.0.0-beta.8-commit.151352b(typescript@5.8.3) rolldown-plugin-dts: 0.9.11(rolldown@1.0.0-beta.8-commit.151352b(typescript@5.8.3))(typescript@5.8.3) - tinyexec: 1.0.2 + tinyexec: 1.1.2 tinyglobby: 0.2.15 unconfig: 7.5.0 unplugin-lightningcss: 0.3.3 @@ -38252,6 +38651,9 @@ snapshots: type-detect@4.0.8: {} + type-fest@0.13.1: + optional: true + type-fest@0.16.0: {} type-fest@0.20.2: {} @@ -38328,7 +38730,7 @@ snapshots: typescript@5.8.3: {} - typescript@5.9.3: {} + typescript@6.0.3: {} ua-parser-js@1.0.40: {} @@ -38367,6 +38769,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + undici-types@7.19.2: optional: true @@ -39820,6 +40224,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yjs@13.6.26: dependencies: lib0: 0.2.99 From 7130608c3387fdea0399c6d3edf1ce9269f8af53 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 15:02:17 +0100 Subject: [PATCH 12/57] feat(agents-desktop): native menus, API key prompt, localhost discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds out the Electron shell on top of the bundled-runtime base: - Application menu (File/Edit/View/Window/Help) and tray menu wired to a `desktop:command` IPC channel so menu items, on-screen buttons and hotkeys all go through the same renderer actions. Window submenu rebuilds on focus/blur and lists open windows by their session document title (driven by a new `useDocumentTitle` hook). - Custom branded About dialog (a small frameless `BrowserWindow`) so the app icon and copy are consistent across platforms — the native macOS panel ignores `iconPath`. App icon, tray template icon (1x/2x black-on-transparent), and `app.dock.setIcon` round out the branding. - First-launch API keys dialog: on startup the renderer asks main for the saved/suggested key set; if no Anthropic or OpenAI key is configured it pops a modal pre-filled from `process.env.*` (snapshot taken at launch). Saved values are persisted in `settings.json`, mirrored back into `process.env` for Horton's `createBuiltinAgentHandler`, and the runtime is restarted so the next request picks them up. Optional `BRAVE_SEARCH_API_KEY` is captured in the same flow. - Localhost server discovery: main probes a focused port set (4437/4438/4439/3000/4000/8080) for `GET /_electric/health` on a 30 s background loop and broadcasts the set via `desktop:state- changed`. The renderer's `ServerPicker` polls every 5 s while its menu is open and surfaces matching servers as one-click "add" rows under the saved-server list (no header, consistent row height with the trash-button rows). - Bug fix: `Add server` dialog cancel was disabled whenever no servers were saved — a holdover from the web build's auto-seeded `This Server` fallback that doesn't exist in desktop. Cancel / Esc / backdrop click now always dismiss. Co-authored-by: Cursor --- packages/agents-desktop/assets/icon.png | Bin 0 -> 19664 bytes .../agents-desktop/assets/trayTemplate.png | Bin 0 -> 382 bytes .../agents-desktop/assets/trayTemplate@2x.png | Bin 0 -> 457 bytes packages/agents-desktop/package.json | 1 + packages/agents-desktop/src/main.ts | 749 ++++++++++++++++-- packages/agents-desktop/src/preload.ts | 47 ++ .../src/components/ApiKeysModal.module.css | 24 + .../src/components/ApiKeysModal.tsx | 205 +++++ .../src/components/ServerPicker.module.css | 9 + .../src/components/ServerPicker.tsx | 128 ++- .../src/hooks/useDocumentTitle.ts | 49 ++ .../src/lib/server-connection.ts | 89 +++ packages/agents-server-ui/src/router.tsx | 80 +- 13 files changed, 1285 insertions(+), 96 deletions(-) create mode 100644 packages/agents-desktop/assets/icon.png create mode 100644 packages/agents-desktop/assets/trayTemplate.png create mode 100644 packages/agents-desktop/assets/trayTemplate@2x.png create mode 100644 packages/agents-server-ui/src/components/ApiKeysModal.module.css create mode 100644 packages/agents-server-ui/src/components/ApiKeysModal.tsx create mode 100644 packages/agents-server-ui/src/hooks/useDocumentTitle.ts diff --git a/packages/agents-desktop/assets/icon.png b/packages/agents-desktop/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a09e39c973f2fe0f848fa1aa0a3fc6024ca857e GIT binary patch literal 19664 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 CXK)k% literal 0 HcmV?d00001 diff --git a/packages/agents-desktop/assets/trayTemplate@2x.png b/packages/agents-desktop/assets/trayTemplate@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ecf66f6e1609d226e0c1541e3b52e8370ebe81f1 GIT binary patch literal 457 zcmeAS@N?(olHy`uVBq!ia0vp^3P7yG!2%@LZ7c#&3dtTpz6=aiY77hwEes65fIo>z%ybP*fhfe#$ja2n%Gg5Nz`)ADfM1_i0!2e^eoAIq zC2kFMMO&8uHE6(XD9OxCEiOsSEx@fuGkr=mP|sRV7sn8f<8RMt^D!BUxIJuEsF;<& z#>mH$ohWIuy)oHThn-P^Bawf52j|19*0nxtj=#dq?q@2#SD#&Q$m;w&rI!1%9{oM` zb4K?eejcOsw>+L6Kl6Xq{7}0upT&Z!&PgX8pK2CuwMxuvAA8Ry>u%Q>KkGBJe%v|# z)j{Dp(;=mv-OPe_L^~ecZ}j*lDKM?@kL`p5@ywooBn84W{|I_KOLd4?US#mqqU80* cJqzwK2K|W-m8nm>26Q-sr>mdKI;Vst0N{L-)&Kwi literal 0 HcmV?d00001 diff --git a/packages/agents-desktop/package.json b/packages/agents-desktop/package.json index 8db65f12da..3abee03c34 100644 --- a/packages/agents-desktop/package.json +++ b/packages/agents-desktop/package.json @@ -1,5 +1,6 @@ { "name": "@electric-ax/agents-desktop", + "productName": "Electric Agents", "private": true, "version": "0.0.0", "type": "module", diff --git a/packages/agents-desktop/src/main.ts b/packages/agents-desktop/src/main.ts index d039451f7b..e3d8dbf925 100644 --- a/packages/agents-desktop/src/main.ts +++ b/packages/agents-desktop/src/main.ts @@ -7,8 +7,10 @@ import { dialog, ipcMain, nativeImage, + shell, } from 'electron' import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -19,18 +21,65 @@ type ServerConfig = { type DesktopRuntimeStatus = `stopped` | `starting` | `running` | `error` +type DiscoveredServer = { + url: string + port: number + /** Epoch ms — when we last saw a healthy `/_electric/health` response. */ + lastSeen: number +} + type DesktopState = { runtimeStatus: DesktopRuntimeStatus runtimeUrl: string | null activeServer: ServerConfig | null workingDirectory: string | null error: string | null + /** + * Agents-server instances detected on this machine via the periodic + * localhost scan in `runDiscovery()`. Renderers (e.g. `ServerPicker`) + * surface these as one-click "add" suggestions. + */ + discoveredServers: Array +} + +type ApiKeys = { + anthropic: string | null + openai: string | null + /** + * Optional. Mirrored to `BRAVE_SEARCH_API_KEY` so Horton's + * `brave_search` tool can call the Brave API directly. When unset + * Horton falls back to Anthropic's built-in web search (which uses + * the Anthropic key). Because it's optional, missing brave never + * triggers the first-launch dialog on its own. + */ + brave: string | null } type DesktopSettings = { servers: Array activeServer: ServerConfig | null workingDirectory: string | null + /** + * LLM provider API keys persisted in `settings.json` and applied to + * `process.env` so the bundled `BuiltinAgentsServer` (Horton) picks + * them up. Read by `applyApiKeys()` and surfaced to the renderer + * via `desktop:get-api-keys-status` for the first-launch prompt. + */ + apiKeys: ApiKeys +} + +/** + * Payload returned by `desktop:get-api-keys-status`. The renderer + * uses `saved` to seed the first-launch dialog (or skip showing it + * when keys already exist) and `suggested` to pre-fill empty fields + * with whatever was found in `process.env` at startup — making it a + * one-click confirmation flow for users who already export their + * keys from a shell rc file. + */ +type ApiKeysStatus = { + hasAnyKey: boolean + saved: ApiKeys + suggested: ApiKeys } const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)) @@ -40,11 +89,55 @@ const RENDERER_INDEX = path.resolve( `../agents-server-ui/dist-desktop/index.html` ) const PRELOAD_PATH = path.resolve(MODULE_DIR, `preload.cjs`) +const TRAY_ICON_PATH = path.resolve(PACKAGE_DIR, `assets/trayTemplate.png`) +const TRAY_ICON_2X_PATH = path.resolve( + PACKAGE_DIR, + `assets/trayTemplate@2x.png` +) +const APP_ICON_PATH = path.resolve(PACKAGE_DIR, `assets/icon.png`) +const APP_DISPLAY_NAME = `Electric Agents` + +/** + * Commands sent from the menu / tray (main process) to the focused + * renderer over the `desktop:command` IPC channel. The renderer + * subscribes via `window.electronAPI.onDesktopCommand` and dispatches + * to the matching app action (sidebar toggle, search palette, new + * chat, close active tile…). Keeping the menu definitions in main and + * the action implementations in the renderer means the same actions + * stay reachable from in-app buttons / hotkeys when running in a + * regular browser. + */ +type DesktopCommand = + | `new-chat` + | `close-tile` + | `toggle-sidebar` + | `open-search` + | `split-right` + | `split-down` + | `cycle-tile` const DEFAULT_SETTINGS: DesktopSettings = { servers: [], activeServer: null, workingDirectory: null, + apiKeys: { anthropic: null, openai: null, brave: null }, +} + +/** + * Snapshot of provider API keys as they were when the app launched. + * Captured before `applyApiKeys()` overwrites the live env so + * `desktop:get-api-keys-status` can offer them as one-click + * suggestions when the user hasn't saved any keys yet. + * + * Recapturing on every status query would defeat the purpose: by + * then `applyApiKeys()` has already mirrored the saved values into + * `process.env`, which would loop back as a "suggestion" identical + * to the saved value. + */ +const ENV_API_KEYS_SNAPSHOT: ApiKeys = { + anthropic: process.env.ANTHROPIC_API_KEY?.trim() || null, + openai: process.env.OPENAI_API_KEY?.trim() || null, + brave: process.env.BRAVE_SEARCH_API_KEY?.trim() || null, } let settings: DesktopSettings = { ...DEFAULT_SETTINGS } @@ -54,10 +147,12 @@ let state: DesktopState = { activeServer: null, workingDirectory: null, error: null, + discoveredServers: [], } let runtime: BuiltinAgentsServer | null = null let runtimeGeneration = 0 let tray: Tray | null = null +let aboutWindow: BrowserWindow | null = null let isQuitting = false const windows = new Set() @@ -99,6 +194,62 @@ function serverInList( return Boolean(server && servers.some((entry) => entry.url === server.url)) } +function normalizeApiKeys(value: unknown): ApiKeys { + if (!value || typeof value !== `object`) { + return { anthropic: null, openai: null, brave: null } + } + const maybe = value as Partial> + const pick = (raw: unknown): string | null => { + if (typeof raw !== `string`) return null + const trimmed = raw.trim() + return trimmed.length > 0 ? trimmed : null + } + return { + anthropic: pick(maybe.anthropic), + openai: pick(maybe.openai), + brave: pick(maybe.brave), + } +} + +/** + * Mirror persisted API keys into `process.env` so the bundled + * `BuiltinAgentsServer` (Horton) — which reads them via + * `process.env.ANTHROPIC_API_KEY` / `OPENAI_API_KEY` directly inside + * `createBuiltinAgentHandler` — sees them on its next start. Saved + * values take precedence; for slots the user hasn't saved yet we fall + * back to whatever was in the launch environment so external `.env` / + * shell setups keep working until the user opts in via the dialog. + */ +function applyApiKeys(): void { + const resolveSlot = ( + saved: string | null, + env: string | null, + name: `ANTHROPIC_API_KEY` | `OPENAI_API_KEY` | `BRAVE_SEARCH_API_KEY` + ): void => { + const value = saved ?? env + if (value) { + process.env[name] = value + } else { + delete process.env[name] + } + } + resolveSlot( + settings.apiKeys.anthropic, + ENV_API_KEYS_SNAPSHOT.anthropic, + `ANTHROPIC_API_KEY` + ) + resolveSlot( + settings.apiKeys.openai, + ENV_API_KEYS_SNAPSHOT.openai, + `OPENAI_API_KEY` + ) + resolveSlot( + settings.apiKeys.brave, + ENV_API_KEYS_SNAPSHOT.brave, + `BRAVE_SEARCH_API_KEY` + ) +} + async function loadSettings(): Promise { try { const raw = await readFile(settingsPath(), `utf8`) @@ -112,6 +263,7 @@ async function loadSettings(): Promise { typeof parsed.workingDirectory === `string` ? parsed.workingDirectory : null, + apiKeys: normalizeApiKeys(parsed.apiKeys), } } catch { settings = { ...DEFAULT_SETTINGS } @@ -122,6 +274,8 @@ async function loadSettings(): Promise { activeServer: settings.activeServer, workingDirectory: settings.workingDirectory, } + + applyApiKeys() } async function saveSettings(): Promise { @@ -142,10 +296,27 @@ function statusLabel(status: DesktopRuntimeStatus): string { } } +function sendCommand(command: DesktopCommand): void { + const focused = BrowserWindow.getFocusedWindow() + const target = + focused ?? [...windows].find((win) => !win.isDestroyed()) ?? null + target?.webContents.send(`desktop:command`, command) +} + function createTrayIcon(): Electron.NativeImage { - const icon = nativeImage.createFromDataURL( - `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=` - ) + // Electric brand mark rasterised to 26×22 (1×) and 51×44 (2×) + // black-on-transparent PNGs in `assets/`. We add the @2x variant as + // a representation so retina menu bars stay crisp; macOS template + // mode then auto-recolors for light/dark menu bars. + const icon = nativeImage.createFromPath(TRAY_ICON_PATH) + try { + icon.addRepresentation({ + scaleFactor: 2, + buffer: nativeImage.createFromPath(TRAY_ICON_2X_PATH).toPNG(), + }) + } catch { + // @2x asset missing — fall back to single-resolution. + } if (process.platform === `darwin`) { icon.setTemplateImage(true) } @@ -248,9 +419,21 @@ function createWindow(): BrowserWindow { windows.add(win) win.on(`closed`, () => { windows.delete(win) + buildApplicationMenu() + }) + // The renderer keeps `document.title` in sync with the active tile's + // entity (see `useDocumentTitle.ts`). Forwarding `page-title-updated` + // into a menu rebuild lets the Window submenu show one entry per + // open window labelled with that window's active session. + win.webContents.on(`page-title-updated`, () => { + buildApplicationMenu() + }) + win.on(`focus`, () => { + buildApplicationMenu() }) win.webContents.setWindowOpenHandler(() => ({ action: `deny` })) void win.loadFile(RENDERER_INDEX) + buildApplicationMenu() return win } @@ -318,6 +501,33 @@ async function stopRuntime(): Promise { setState({ runtimeStatus: `stopped`, runtimeUrl: null, error: null }) } +function getApiKeysStatus(): ApiKeysStatus { + const saved = settings.apiKeys + // Brave is optional (falls back to Anthropic built-in search), so + // it doesn't count toward "the app is configured" — the dialog + // only auto-opens when the user has no LLM provider key at all. + const hasAnyKey = Boolean(saved.anthropic || saved.openai) + // Only suggest env values for slots the user hasn't already saved + // — once they've persisted a key for a provider, the dialog should + // show their saved value rather than the (potentially different) + // environment value. + const suggested: ApiKeys = { + anthropic: saved.anthropic ? null : ENV_API_KEYS_SNAPSHOT.anthropic, + openai: saved.openai ? null : ENV_API_KEYS_SNAPSHOT.openai, + brave: saved.brave ? null : ENV_API_KEYS_SNAPSHOT.brave, + } + return { hasAnyKey, saved, suggested } +} + +async function setApiKeys(next: ApiKeys): Promise { + settings.apiKeys = normalizeApiKeys(next) + applyApiKeys() + await saveSettings() + if (settings.activeServer) { + await restartRuntime() + } +} + async function setActiveServer(server: ServerConfig | null): Promise { const normalized = normalizeServer(server) settings.activeServer = @@ -330,10 +540,104 @@ async function setActiveServer(server: ServerConfig | null): Promise { async function quitApp(): Promise { if (isQuitting) return isQuitting = true + stopDiscoveryLoop() await stopExistingRuntime().catch(() => {}) app.quit() } +/** + * Localhost ports we probe for running `agents-server` instances. + * + * - 4437: `packages/agents-server` `DEFAULT_PORT`. + * - 4438/4439: common offsets when running multiple servers side-by-side. + * - 3000/4000/8080: common Node/dev defaults users sometimes pick. + * + * Identification is via `GET /_electric/health` returning + * `{ status: "ok" }` (see `ElectricAgentsServer.handleRequestInner`), + * so collisions with unrelated services on these ports are filtered out. + */ +const DISCOVERY_PORTS: ReadonlyArray = [ + 4437, 4438, 4439, 3000, 4000, 8080, +] +const DISCOVERY_TIMEOUT_MS = 1500 +const DISCOVERY_INTERVAL_MS = 30_000 + +let discoveryTimer: NodeJS.Timeout | null = null +let discoveryInFlight: Promise | null = null + +async function probeAgentsServer(url: string): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS) + try { + const res = await fetch(`${url}/_electric/health`, { + signal: controller.signal, + headers: { accept: `application/json` }, + }) + if (!res.ok) return false + const json = (await res.json()) as { status?: unknown } + return json?.status === `ok` + } catch { + return false + } finally { + clearTimeout(timer) + } +} + +async function runDiscovery(): Promise { + if (discoveryInFlight) { + await discoveryInFlight + return + } + discoveryInFlight = (async () => { + // Don't probe the bundled runtime URL — that's our own Horton + // process and isn't a separate agents-server. + const skip = state.runtimeUrl ? new URL(state.runtimeUrl).port : null + const results = await Promise.all( + DISCOVERY_PORTS.map(async (port) => { + if (skip && String(port) === skip) return null + const url = `http://127.0.0.1:${port}` + const ok = await probeAgentsServer(url) + return ok ? { url, port, lastSeen: Date.now() } : null + }) + ) + const found = results.filter( + (entry): entry is DiscoveredServer => entry !== null + ) + found.sort((a, b) => a.port - b.port) + + const prev = state.discoveredServers + const same = + prev.length === found.length && + prev.every((entry, i) => entry.url === found[i]?.url) + if (same) { + // Same set of URLs — keep prior `lastSeen` to avoid noisy + // broadcasts to renderers every tick. + return + } + setState({ discoveredServers: found }) + })() + try { + await discoveryInFlight + } finally { + discoveryInFlight = null + } +} + +function startDiscoveryLoop(): void { + if (discoveryTimer) return + void runDiscovery() + discoveryTimer = setInterval(() => { + void runDiscovery() + }, DISCOVERY_INTERVAL_MS) +} + +function stopDiscoveryLoop(): void { + if (discoveryTimer) { + clearInterval(discoveryTimer) + discoveryTimer = null + } +} + function registerIpcHandlers(): void { ipcMain.handle(`desktop:get-servers`, () => settings.servers) ipcMain.handle( @@ -361,6 +665,14 @@ function registerIpcHandlers(): void { ipcMain.handle(`desktop:stop-runtime`, async () => { await stopRuntime() }) + ipcMain.handle(`desktop:rescan-servers`, async () => { + await runDiscovery() + return state.discoveredServers + }) + ipcMain.handle(`desktop:get-api-keys-status`, () => getApiKeysStatus()) + ipcMain.handle(`desktop:save-api-keys`, async (_event, keys: ApiKeys) => { + await setApiKeys(keys) + }) ipcMain.handle( `desktop:get-working-directory`, () => settings.workingDirectory @@ -380,7 +692,343 @@ function registerIpcHandlers(): void { }) } +function windowDisplayLabel(win: BrowserWindow): string { + const raw = win.getTitle() + if (!raw) return APP_DISPLAY_NAME + // The renderer formats titles as `${session} — Electric Agents`. + // Strip the suffix so the Window submenu reads cleanly as just the + // session name (the menu already lives under "Electric Agents"). + const suffix = ` — ${APP_DISPLAY_NAME}` + if (raw.endsWith(suffix)) { + return raw.slice(0, -suffix.length) || APP_DISPLAY_NAME + } + return raw +} + +/** + * Custom About panel rendered as a small frameless `BrowserWindow`. + * + * The macOS native About panel only honours `iconPath` on Linux / + * Windows — on darwin it always shows the bundle icon, which during + * `electron .` dev mode is Electron's default atom. A standalone + * window lets us show the real Electric mark and consistent + * brand copy on every platform. + */ +function showAboutDialog(): void { + if (aboutWindow && !aboutWindow.isDestroyed()) { + aboutWindow.focus() + return + } + + const iconBase64 = (() => { + try { + return readFileSync(APP_ICON_PATH).toString(`base64`) + } catch { + return `` + } + })() + const iconSrc = iconBase64 ? `data:image/png;base64,${iconBase64}` : `` + + const html = ` + + + + +About ${APP_DISPLAY_NAME} + + + +
+ ${iconSrc ? `${APP_DISPLAY_NAME}` : ``} +

${APP_DISPLAY_NAME}

+

Version ${app.getVersion() || `dev`}

+

The durable runtime for long-lived agents.

+

+ Built on Electric Streams, every agent sleeps when idle, wakes on + demand and survives restarts — bringing durable, composable, + serverless agents to the infrastructure you already run. +

+
+ electric.ax/agents + © ${new Date().getFullYear()} Electric DB Limited +
+
+ +` + + const win = new BrowserWindow({ + width: 380, + height: 460, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + title: `About ${APP_DISPLAY_NAME}`, + titleBarStyle: process.platform === `darwin` ? `hiddenInset` : `default`, + trafficLightPosition: + process.platform === `darwin` ? { x: 12, y: 12 } : undefined, + backgroundColor: `#f7f8fa`, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + aboutWindow = win + win.setMenuBarVisibility(false) + win.on(`closed`, () => { + if (aboutWindow === win) aboutWindow = null + }) + win.once(`ready-to-show`, () => win.show()) + // Open external links (electric.ax/agents) in the user's browser + // instead of inside this little About window. + win.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url) + return { action: `deny` } + }) + win.webContents.on(`will-navigate`, (event, url) => { + if (url === win.webContents.getURL()) return + event.preventDefault() + void shell.openExternal(url) + }) + void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) +} + +function buildApplicationMenu(): void { + const isMac = process.platform === `darwin` + const focused = BrowserWindow.getFocusedWindow() + const liveWindows = [...windows].filter((win) => !win.isDestroyed()) + + // Sub-menu shared between File on Win/Linux and the application menu + // on macOS. Each item maps to a renderer command implemented in the + // shared `agents-server-ui` (see hooks under `src/hooks/`) so the + // behaviour stays identical to the in-app buttons / hotkeys. + const fileSubmenu: Array = [ + { + label: `New Chat`, + accelerator: `CommandOrControl+N`, + click: () => sendCommand(`new-chat`), + }, + { + label: `New Window`, + accelerator: `Shift+CommandOrControl+N`, + click: () => createWindow(), + }, + { type: `separator` }, + { + label: `Close Tile`, + accelerator: `CommandOrControl+W`, + click: () => sendCommand(`close-tile`), + }, + { + label: `Close Window`, + accelerator: `Shift+CommandOrControl+W`, + role: `close`, + }, + ] + + const template: Array = [ + ...(isMac + ? [ + { + label: APP_DISPLAY_NAME, + submenu: [ + { role: `about` as const }, + { type: `separator` as const }, + { role: `services` as const }, + { type: `separator` as const }, + { role: `hide` as const }, + { role: `hideOthers` as const }, + { role: `unhide` as const }, + { type: `separator` as const }, + { + label: `Quit ${APP_DISPLAY_NAME}`, + accelerator: `CommandOrControl+Q`, + click: () => void quitApp(), + }, + ], + }, + ] + : []), + { + label: `File`, + submenu: fileSubmenu, + }, + { + label: `Edit`, + submenu: [ + { role: `undo` }, + { role: `redo` }, + { type: `separator` }, + { role: `cut` }, + { role: `copy` }, + { role: `paste` }, + ...(isMac + ? [ + { role: `pasteAndMatchStyle` as const }, + { role: `delete` as const }, + ] + : [{ role: `delete` as const }]), + { role: `selectAll` }, + ], + }, + { + label: `View`, + submenu: [ + { + label: `Toggle Sidebar`, + accelerator: `CommandOrControl+B`, + click: () => sendCommand(`toggle-sidebar`), + }, + { + label: `Search Sessions…`, + accelerator: `CommandOrControl+K`, + click: () => sendCommand(`open-search`), + }, + { type: `separator` }, + { + label: `Split Right`, + accelerator: `CommandOrControl+D`, + click: () => sendCommand(`split-right`), + }, + { + label: `Split Down`, + accelerator: `Shift+CommandOrControl+D`, + click: () => sendCommand(`split-down`), + }, + { + label: `Cycle Tile`, + accelerator: `CommandOrControl+\\`, + click: () => sendCommand(`cycle-tile`), + }, + { type: `separator` }, + { role: `togglefullscreen` }, + { role: `resetZoom` }, + { role: `zoomIn` }, + { role: `zoomOut` }, + { type: `separator` }, + { role: `reload` }, + { role: `forceReload` }, + { role: `toggleDevTools` }, + ], + }, + { + label: `Window`, + submenu: [ + { role: `minimize` }, + { role: `zoom` }, + ...(isMac + ? [{ type: `separator` as const }, { role: `front` as const }] + : [{ role: `close` as const }]), + ...(liveWindows.length > 0 + ? ([ + { type: `separator` }, + ...liveWindows.map( + (win): Electron.MenuItemConstructorOptions => ({ + label: windowDisplayLabel(win), + type: `checkbox`, + checked: win === focused, + click: () => { + if (win.isDestroyed()) return + if (win.isMinimized()) win.restore() + win.show() + win.focus() + }, + }) + ), + ] as Array) + : []), + ], + }, + { + label: `Help`, + submenu: [ + { + label: `About ${APP_DISPLAY_NAME}`, + click: () => showAboutDialog(), + }, + { type: `separator` }, + { + label: `Electric Documentation`, + click: () => { + void shell.openExternal(`https://electric-sql.com/docs/agents`) + }, + }, + { + label: `Electric on GitHub`, + click: () => { + void shell.openExternal(`https://github.com/electric-sql/electric`) + }, + }, + { type: `separator` }, + { + label: `Report an Issue`, + click: () => { + void shell.openExternal( + `https://github.com/electric-sql/electric/issues/new` + ) + }, + }, + ], + }, + ] + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} + async function main(): Promise { + // Make sure macOS shows the product name everywhere (about menu, + // dock tooltip, default window title) instead of the npm package id. + app.setName(APP_DISPLAY_NAME) + if (!app.requestSingleInstanceLock()) { app.quit() return @@ -398,6 +1046,11 @@ async function main(): Promise { showOrCreateWindow() }) + // Re-render the menu when focus changes so the Window submenu's + // checkmark moves to the now-focused window. + app.on(`browser-window-focus`, () => buildApplicationMenu()) + app.on(`browser-window-blur`, () => buildApplicationMenu()) + app.on(`before-quit`, (event) => { if (isQuitting) return event.preventDefault() @@ -408,67 +1061,51 @@ async function main(): Promise { await loadSettings() registerIpcHandlers() - tray = new Tray(createTrayIcon()) + app.setAboutPanelOptions({ + applicationName: APP_DISPLAY_NAME, + applicationVersion: app.getVersion() || `dev`, + version: app.getVersion() || `dev`, + copyright: `© ${new Date().getFullYear()} Electric DB Limited`, + website: `https://electric.ax/agents`, + // `iconPath` only affects Linux/Windows. macOS shows the app + // bundle icon, which during dev is the Electron atom — we surface + // the proper Electric mark via the custom About window instead. + iconPath: APP_ICON_PATH, + credits: `The durable runtime for long-lived agents.`, + }) + + // Dock icon on macOS — replaces the default Electron icon during + // `electron .` dev. (Linux/Windows package icons are wired via the + // builder config when we add packaging.) + if (process.platform === `darwin` && app.dock) { + try { + const dockIcon = nativeImage.createFromPath(APP_ICON_PATH) + if (!dockIcon.isEmpty()) { + app.dock.setIcon(dockIcon) + } + } catch { + // Non-fatal — dev still works with the default Electron icon. + } + } + + const trayIcon = createTrayIcon() + if (trayIcon.isEmpty()) { + console.error( + `[agents-desktop] Tray icon failed to load from ${TRAY_ICON_PATH}; ` + + `the menu bar item may be invisible.` + ) + } + tray = new Tray(trayIcon) tray.on(`click`, () => showOrCreateWindow()) updateTray() - Menu.setApplicationMenu( - Menu.buildFromTemplate([ - { - label: `Electric Agents`, - submenu: [ - { role: `about` }, - { type: `separator` }, - { - label: `New Window`, - accelerator: `CommandOrControl+N`, - click: () => createWindow(), - }, - { type: `separator` }, - { role: `hide` }, - { role: `hideOthers` }, - { role: `unhide` }, - { type: `separator` }, - { - label: `Quit`, - accelerator: `CommandOrControl+Q`, - click: () => void quitApp(), - }, - ], - }, - { - label: `Edit`, - submenu: [ - { role: `undo` }, - { role: `redo` }, - { type: `separator` }, - { role: `cut` }, - { role: `copy` }, - { role: `paste` }, - { role: `selectAll` }, - ], - }, - { - label: `View`, - submenu: [ - { role: `reload` }, - { role: `forceReload` }, - { role: `toggleDevTools` }, - { type: `separator` }, - { role: `resetZoom` }, - { role: `zoomIn` }, - { role: `zoomOut` }, - { type: `separator` }, - { role: `togglefullscreen` }, - ], - }, - ]) - ) + buildApplicationMenu() createWindow() if (settings.activeServer) { void restartRuntime() } + startDiscoveryLoop() } void main() diff --git a/packages/agents-desktop/src/preload.ts b/packages/agents-desktop/src/preload.ts index 3b6a192a2f..0db1ea3b15 100644 --- a/packages/agents-desktop/src/preload.ts +++ b/packages/agents-desktop/src/preload.ts @@ -25,14 +25,45 @@ type ServerConfig = { type DesktopRuntimeStatus = `stopped` | `starting` | `running` | `error` +type DiscoveredServer = { + url: string + port: number + lastSeen: number +} + type DesktopState = { runtimeStatus: DesktopRuntimeStatus runtimeUrl: string | null activeServer: ServerConfig | null workingDirectory: string | null error: string | null + discoveredServers: Array +} + +type ApiKeys = { + anthropic: string | null + openai: string | null + brave: string | null } +type ApiKeysStatus = { + hasAnyKey: boolean + saved: ApiKeys + suggested: ApiKeys +} + +// Mirror of `DesktopCommand` in main.ts. Kept as a string union here so +// the preload bundle has zero runtime cost; main is the source of +// truth for which commands actually fire. +type DesktopCommand = + | `new-chat` + | `close-tile` + | `toggle-sidebar` + | `open-search` + | `split-right` + | `split-down` + | `cycle-tile` + const api = { getServers: (): Promise> => ipcRenderer.invoke(`desktop:get-servers`), @@ -45,6 +76,12 @@ const api = { restartRuntime: (): Promise => ipcRenderer.invoke(`desktop:restart-runtime`), stopRuntime: (): Promise => ipcRenderer.invoke(`desktop:stop-runtime`), + rescanServers: (): Promise> => + ipcRenderer.invoke(`desktop:rescan-servers`), + getApiKeysStatus: (): Promise => + ipcRenderer.invoke(`desktop:get-api-keys-status`), + saveApiKeys: (keys: ApiKeys): Promise => + ipcRenderer.invoke(`desktop:save-api-keys`, keys), getWorkingDirectory: (): Promise => ipcRenderer.invoke(`desktop:get-working-directory`), chooseWorkingDirectory: (): Promise => @@ -57,6 +94,16 @@ const api = { ipcRenderer.on(`desktop:state-changed`, listener) return () => ipcRenderer.removeListener(`desktop:state-changed`, listener) }, + onDesktopCommand: ( + callback: (command: DesktopCommand) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + command: DesktopCommand + ) => callback(command) + ipcRenderer.on(`desktop:command`, listener) + return () => ipcRenderer.removeListener(`desktop:command`, listener) + }, } contextBridge.exposeInMainWorld(`electronAPI`, api) diff --git a/packages/agents-server-ui/src/components/ApiKeysModal.module.css b/packages/agents-server-ui/src/components/ApiKeysModal.module.css new file mode 100644 index 0000000000..be3f6bd192 --- /dev/null +++ b/packages/agents-server-ui/src/components/ApiKeysModal.module.css @@ -0,0 +1,24 @@ +/* + * Mirrors the spacing of the "Add server" form in `ServerPicker.module.css` + * so both first-run dialogs feel like part of the same setup flow. + */ +.form { + display: flex; + flex-direction: column; + gap: var(--ds-space-4); +} + +.hint { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: var(--ds-radius-2); + background: var(--ds-bg-subtle); + border: 1px solid var(--ds-border-1); + color: var(--ds-text-2); +} + +.actions { + margin-top: var(--ds-space-2); +} diff --git a/packages/agents-server-ui/src/components/ApiKeysModal.tsx b/packages/agents-server-ui/src/components/ApiKeysModal.tsx new file mode 100644 index 0000000000..91bbd9e8f5 --- /dev/null +++ b/packages/agents-server-ui/src/components/ApiKeysModal.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useState } from 'react' +import { Sparkles } from 'lucide-react' +import { + loadApiKeysStatus, + saveApiKeys as persistApiKeys, + type ApiKeysStatus, +} from '../lib/server-connection' +import { Button, Dialog, Field, Input, Stack, Text } from '../ui' +import styles from './ApiKeysModal.module.css' + +/** + * First-launch dialog that captures provider API keys for the + * bundled local Horton runtime in the Electron desktop app. + * + * Behavior: + * - Web build: noop (returns `null`, never queries IPC). + * - Desktop build: on mount, asks main for `ApiKeysStatus`. If no + * keys are saved yet, opens a modal with the two provider inputs + * pre-filled from `process.env.ANTHROPIC_API_KEY` / + * `OPENAI_API_KEY` (captured by main at launch — see + * `ENV_API_KEYS_SNAPSHOT` in `packages/agents-desktop/src/main.ts`). + * - Save persists via `desktop:save-api-keys`, which writes + * `settings.json`, mirrors the values into `process.env`, and + * restarts the runtime so Horton picks them up on its next start. + * - Skip just closes the dialog — nothing is persisted, so the + * prompt reappears on next launch (intentional until a settings + * UI exists for editing keys later). + */ +export function ApiKeysModal(): React.ReactElement | null { + const isDesktop = typeof window !== `undefined` && Boolean(window.electronAPI) + const [status, setStatus] = useState(null) + const [open, setOpen] = useState(false) + + useEffect(() => { + if (!isDesktop) return + let cancelled = false + void loadApiKeysStatus().then((result) => { + if (cancelled || !result) return + setStatus(result) + if (!result.hasAnyKey) setOpen(true) + }) + return () => { + cancelled = true + } + }, [isDesktop]) + + if (!status) return null + + return ( + { + // Skipping is allowed (we re-prompt next launch); use the + // controlled `open` state so the close-on-Escape / + // backdrop-click paths feed back into our own setter rather + // than orphaning the dialog. + setOpen(next) + }} + > + + Set up your API keys + + Electric Agents bundles a local runtime that calls the LLM provider of + your choice. Provide an Anthropic and/or OpenAI API key — they're + stored on this machine only and used by the local Horton runtime. + Brave Search is optional and powers the web-search tool. + + { + await persistApiKeys({ + anthropic: anthropic.trim() || null, + openai: openai.trim() || null, + brave: brave.trim() || null, + }) + setOpen(false) + // Refresh local status so subsequent sessions in this + // window won't re-prompt even though the dialog is + // already closed. + const next = await loadApiKeysStatus() + if (next) setStatus(next) + }} + onSkip={() => setOpen(false)} + /> + + + ) +} + +type FormValues = { anthropic: string; openai: string; brave: string } + +function ApiKeysForm({ + initial, + showSuggestionHint, + onSave, + onSkip, +}: { + initial: FormValues + showSuggestionHint: boolean + onSave: (keys: FormValues) => Promise + onSkip: () => void +}): React.ReactElement { + const [anthropic, setAnthropic] = useState(initial.anthropic) + const [openai, setOpenai] = useState(initial.openai) + const [brave, setBrave] = useState(initial.brave) + const [saving, setSaving] = useState(false) + // Save is enabled as long as the user has typed something — Brave + // alone is allowed (e.g. they already have an LLM key in `.env` + // and just want to add web-search support), but typing nothing + // would be a no-op so we keep the button disabled. + const canSave = + anthropic.trim().length > 0 || + openai.trim().length > 0 || + brave.trim().length > 0 + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!canSave || saving) return + setSaving(true) + try { + await onSave({ anthropic, openai, brave }) + } finally { + setSaving(false) + } + }, + [anthropic, openai, brave, canSave, saving, onSave] + ) + + return ( +
+ {showSuggestionHint && ( +
+ + + Pre-filled from your environment. Click save to persist them. + +
+ )} + + + setAnthropic(e.target.value)} + size={2} + autoFocus + /> + + + setOpenai(e.target.value)} + size={2} + /> + + + setBrave(e.target.value)} + size={2} + /> + + + + + + +
+ ) +} diff --git a/packages/agents-server-ui/src/components/ServerPicker.module.css b/packages/agents-server-ui/src/components/ServerPicker.module.css index f1a069d625..459e2484fc 100644 --- a/packages/agents-server-ui/src/components/ServerPicker.module.css +++ b/packages/agents-server-ui/src/components/ServerPicker.module.css @@ -53,6 +53,15 @@ align-items: center; gap: 8px; flex: 1; + /* + * Match the saved-server row's IconButton (size=1, 24px tall) so + * rows without a trailing button — discovered-localhost entries — + * still render at the same height. Without this min-height the + * trash button is the only thing forcing the saved row up to + * 24px and the menu visibly jumps as the cursor moves between + * the two groups. + */ + min-height: 24px; } .menuRowName { diff --git a/packages/agents-server-ui/src/components/ServerPicker.tsx b/packages/agents-server-ui/src/components/ServerPicker.tsx index 47b3aff70f..4a3482d27d 100644 --- a/packages/agents-server-ui/src/components/ServerPicker.tsx +++ b/packages/agents-server-ui/src/components/ServerPicker.tsx @@ -1,6 +1,12 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ChevronsUpDown, Plus, Trash2 } from 'lucide-react' import { useServerConnection } from '../hooks/useServerConnection' +import { + loadDesktopState, + onDesktopStateChanged, + rescanDiscoveredServers, + type DiscoveredServer, +} from '../lib/server-connection' import { Button, Dialog, @@ -14,6 +20,9 @@ import { } from '../ui' import styles from './ServerPicker.module.css' +/** How often to re-probe localhost while the picker menu is open. */ +const DISCOVERY_REFRESH_MS = 5000 + type ServerStatus = `ok` | `down` | `unset` /** @@ -36,6 +45,70 @@ export function ServerPicker(): React.ReactElement { removeServer, } = useServerConnection() const [adding, setAdding] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [discovered, setDiscovered] = useState>([]) + const isDesktop = typeof window !== `undefined` && Boolean(window.electronAPI) + + // Mirror the main process's discovered-server set into local state + // via the same desktop-state broadcast channel the rest of the + // desktop UI listens on. Web mode never receives a payload here. + useEffect(() => { + if (!isDesktop) return + void loadDesktopState().then((s) => { + if (s?.discoveredServers) setDiscovered(s.discoveredServers) + }) + const unsubscribe = onDesktopStateChanged((s) => + setDiscovered(s.discoveredServers ?? []) + ) + return () => { + unsubscribe?.() + } + }, [isDesktop]) + + // Hide URLs the user has already saved — the saved-servers list + // covers them. Sort by port for a stable display order. + const savedUrls = useMemo(() => new Set(servers.map((s) => s.url)), [servers]) + const newDiscovered = useMemo( + () => + discovered + .filter((entry) => !savedUrls.has(entry.url)) + .sort((a, b) => a.port - b.port), + [discovered, savedUrls] + ) + + const handleAddDiscovered = useCallback( + (entry: DiscoveredServer) => { + addServer({ name: `localhost:${entry.port}`, url: entry.url }) + }, + [addServer] + ) + + // While the menu is open, re-probe localhost on a 5-second cadence + // so newly-started agents servers appear (and stopped ones drop) + // without a manual refresh button. Background discovery in the + // main process still runs every 30s when the menu is closed — + // this loop just tightens the cadence for the moment the user + // is actively looking at the list. Probe results are broadcast + // via `desktop:state-changed`, so our existing subscription + // updates `discovered` automatically; we don't need the return + // value here. + useEffect(() => { + if (!isDesktop || !menuOpen) return + let cancelled = false + const tick = () => { + void rescanDiscoveredServers().catch(() => { + // Swallow — main will report errors via state if it cares. + }) + } + tick() + const interval = setInterval(() => { + if (!cancelled) tick() + }, DISCOVERY_REFRESH_MS) + return () => { + cancelled = true + clearInterval(interval) + } + }, [isDesktop, menuOpen]) const status: ServerStatus = !activeServer ? `unset` @@ -43,24 +116,14 @@ export function ServerPicker(): React.ReactElement { ? `ok` : `down` - // Dismissing the dialog when there is no configured server would leave - // the app in an unusable state — block it until at least one entry has - // been added. (`useServerConnection` seeds a fallback "This Server" - // entry on first load, so this is a defensive guard rather than a - // common path.) - const canDismissAdd = servers.length > 0 - - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open && !canDismissAdd) return - setAdding(open) - }, - [canDismissAdd] - ) + // Dialog is always dismissible. The picker tile already shows + // "No server" as a valid empty state, and the user can re-open + // the form any time from the Add server menu item — there's no + // need to trap them in the modal on first launch. return ( <> - + ) })} - {servers.length > 0 && } + {isDesktop && newDiscovered.length > 0 && ( + <> + {servers.length > 0 && } + {newDiscovered.map((entry) => ( + handleAddDiscovered(entry)} + > + + + + localhost:{entry.port} + + + + ))} + + )} + setAdding(true)}> Add server @@ -119,7 +200,7 @@ export function ServerPicker(): React.ReactElement { - + Add server @@ -131,7 +212,6 @@ export function ServerPicker(): React.ReactElement { addServer({ name, url }) setAdding(false) }} - canCancel={canDismissAdd} onCancel={() => setAdding(false)} /> @@ -143,11 +223,9 @@ export function ServerPicker(): React.ReactElement { function AddServerForm({ onAdd, onCancel, - canCancel, }: { onAdd: (name: string, url: string) => void onCancel: () => void - canCancel: boolean }): React.ReactElement { const [name, setName] = useState(``) const [url, setUrl] = useState(``) @@ -184,13 +262,7 @@ function AddServerForm({
-
) } From 4acdab874d23939b583e2accb2c7525747e0bd98 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 15:06:21 +0100 Subject: [PATCH 13/57] fix(agents-ui): use eq() and drop redundant limit in useDocumentTitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-entity lookup was using a raw `===` comparison inside `.where()`, which TanStack DB rejects (the predicate evaluates to a boolean at build time instead of producing a query expression). The trailing `.limit(1)` then tripped the "LIMIT/OFFSET require ORDER BY" guard once the predicate was fixed. Switch to `eq(e.url, activeEntityUrl)` and drop the limit — `url` is the primary key, so the predicate already constrains the result to at most one row, the same pattern the other entity-by-url queries (Workspace, TileContainer) already use. Co-authored-by: Cursor --- packages/agents-server-ui/src/hooks/useDocumentTitle.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/agents-server-ui/src/hooks/useDocumentTitle.ts b/packages/agents-server-ui/src/hooks/useDocumentTitle.ts index a8033da7dd..5f18606a6a 100644 --- a/packages/agents-server-ui/src/hooks/useDocumentTitle.ts +++ b/packages/agents-server-ui/src/hooks/useDocumentTitle.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useLiveQuery } from '@tanstack/react-db' +import { eq, useLiveQuery } from '@tanstack/react-db' import { useElectricAgents } from '../lib/ElectricAgentsProvider' import { getEntityDisplayTitle } from '../lib/entityDisplay' import { useWorkspace } from './useWorkspace' @@ -28,8 +28,7 @@ export function useDocumentTitle(): void { if (!entitiesCollection || !activeEntityUrl) return undefined return q .from({ e: entitiesCollection }) - .where(({ e }) => e.url === activeEntityUrl) - .limit(1) + .where(({ e }) => eq(e.url, activeEntityUrl)) }, [entitiesCollection, activeEntityUrl] ) From 0edd54dba2a643df1549bd44247cc1575722fd4b Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 15:23:46 +0100 Subject: [PATCH 14/57] feat(agents-ui): full Settings screen + cog submenus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the settings cog dropdown into a launcher with cascading submenus (Theme, Local Runtime) plus a "Settings…" link that opens a full settings screen at /settings/. The screen mirrors the macOS System Settings layout: a categories sidebar on the left and bordered section cards on the right. - Categories: General (provider API keys), Appearance (theme tile picker), Local Runtime (status badge + start/restart/stop, desktop only). - Extract ApiKeysForm into a shared component reused by the first-launch modal and the General page. - RootShell swaps the workspace sidebar for the settings sidebar while on /settings/* so the experience reads as part of the same shell. - useDocumentTitle now recognises the settings route so the Electron Window menu reflects the active settings page rather than the previously-active session. Co-authored-by: Cursor --- ...odal.module.css => ApiKeysForm.module.css} | 0 .../src/components/ApiKeysForm.tsx | 147 +++++++++++++++ .../src/components/ApiKeysModal.tsx | 132 ++----------- .../src/components/SettingsMenu.module.css | 30 +++ .../src/components/SettingsMenu.tsx | 174 +++++++++++++----- .../settings/SettingsScreen.module.css | 140 ++++++++++++++ .../components/settings/SettingsScreen.tsx | 98 ++++++++++ .../settings/SettingsSidebar.module.css | 114 ++++++++++++ .../components/settings/SettingsSidebar.tsx | 114 ++++++++++++ .../settings/pages/AppearancePage.module.css | 84 +++++++++ .../settings/pages/AppearancePage.tsx | 81 ++++++++ .../components/settings/pages/GeneralPage.tsx | 97 ++++++++++ .../settings/pages/LocalRuntimePage.tsx | 142 ++++++++++++++ .../src/hooks/useDocumentTitle.ts | 28 ++- packages/agents-server-ui/src/router.tsx | 108 ++++++++++- 15 files changed, 1310 insertions(+), 179 deletions(-) rename packages/agents-server-ui/src/components/{ApiKeysModal.module.css => ApiKeysForm.module.css} (100%) create mode 100644 packages/agents-server-ui/src/components/ApiKeysForm.tsx create mode 100644 packages/agents-server-ui/src/components/settings/SettingsScreen.module.css create mode 100644 packages/agents-server-ui/src/components/settings/SettingsScreen.tsx create mode 100644 packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css create mode 100644 packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx create mode 100644 packages/agents-server-ui/src/components/settings/pages/AppearancePage.module.css create mode 100644 packages/agents-server-ui/src/components/settings/pages/AppearancePage.tsx create mode 100644 packages/agents-server-ui/src/components/settings/pages/GeneralPage.tsx create mode 100644 packages/agents-server-ui/src/components/settings/pages/LocalRuntimePage.tsx diff --git a/packages/agents-server-ui/src/components/ApiKeysModal.module.css b/packages/agents-server-ui/src/components/ApiKeysForm.module.css similarity index 100% rename from packages/agents-server-ui/src/components/ApiKeysModal.module.css rename to packages/agents-server-ui/src/components/ApiKeysForm.module.css diff --git a/packages/agents-server-ui/src/components/ApiKeysForm.tsx b/packages/agents-server-ui/src/components/ApiKeysForm.tsx new file mode 100644 index 0000000000..b357de3b75 --- /dev/null +++ b/packages/agents-server-ui/src/components/ApiKeysForm.tsx @@ -0,0 +1,147 @@ +import { useCallback, useState } from 'react' +import { Sparkles } from 'lucide-react' +import { Button, Field, Input, Stack, Text } from '../ui' +import styles from './ApiKeysForm.module.css' + +export type ApiKeysFormValues = { + anthropic: string + openai: string + brave: string +} + +interface ApiKeysFormProps { + initial: ApiKeysFormValues + /** When true, render the "pre-filled from your environment" callout. */ + showSuggestionHint?: boolean + /** Submit handler — should persist + return when the round-trip is done. */ + onSave: (keys: ApiKeysFormValues) => Promise + /** + * Optional secondary action label/handler. The first-launch modal + * uses "Skip for now"; the settings page omits it entirely so the + * user just clicks Save to persist (or navigates away to discard). + */ + onSecondary?: () => void + secondaryLabel?: string + /** Override the primary button label. Defaults to "Save". */ + saveLabel?: string + /** Override the in-flight primary button label. Defaults to "Saving…". */ + savingLabel?: string + /** Auto-focus the Anthropic field on mount. Defaults to `false`. */ + autoFocus?: boolean +} + +/** + * Shared API-keys form for the local Horton runtime. Used by: + * + * - `ApiKeysModal` — the first-launch dialog that fires when no + * keys are saved yet. + * - `GeneralPage` (Settings → General) — the always-on editor for + * revising keys after initial setup. + * + * Save is enabled as soon as any field has content. The Brave field + * is optional in both contexts — typing only Brave is allowed (e.g. + * the user already has an LLM key in `.env` and just wants to add + * web-search support). Empty submit is disabled because it would + * be a no-op. + */ +export function ApiKeysForm({ + initial, + showSuggestionHint = false, + onSave, + onSecondary, + secondaryLabel, + saveLabel = `Save`, + savingLabel = `Saving…`, + autoFocus = false, +}: ApiKeysFormProps): React.ReactElement { + const [anthropic, setAnthropic] = useState(initial.anthropic) + const [openai, setOpenai] = useState(initial.openai) + const [brave, setBrave] = useState(initial.brave) + const [saving, setSaving] = useState(false) + const canSave = + anthropic.trim().length > 0 || + openai.trim().length > 0 || + brave.trim().length > 0 + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!canSave || saving) return + setSaving(true) + try { + await onSave({ anthropic, openai, brave }) + } finally { + setSaving(false) + } + }, + [anthropic, openai, brave, canSave, saving, onSave] + ) + + return ( +
+ {showSuggestionHint && ( +
+ + + Pre-filled from your environment. Click save to persist them. + +
+ )} + + + setAnthropic(e.target.value)} + size={2} + autoFocus={autoFocus} + /> + + + setOpenai(e.target.value)} + size={2} + /> + + + setBrave(e.target.value)} + size={2} + /> + + + + {onSecondary && secondaryLabel && ( + + )} + + +
+ ) +} diff --git a/packages/agents-server-ui/src/components/ApiKeysModal.tsx b/packages/agents-server-ui/src/components/ApiKeysModal.tsx index 91bbd9e8f5..6faf9ac9f8 100644 --- a/packages/agents-server-ui/src/components/ApiKeysModal.tsx +++ b/packages/agents-server-ui/src/components/ApiKeysModal.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useState } from 'react' -import { Sparkles } from 'lucide-react' +import { useEffect, useState } from 'react' import { loadApiKeysStatus, saveApiKeys as persistApiKeys, type ApiKeysStatus, } from '../lib/server-connection' -import { Button, Dialog, Field, Input, Stack, Text } from '../ui' -import styles from './ApiKeysModal.module.css' +import { Dialog } from '../ui' +import { ApiKeysForm } from './ApiKeysForm' /** * First-launch dialog that captures provider API keys for the @@ -23,8 +22,11 @@ import styles from './ApiKeysModal.module.css' * `settings.json`, mirrors the values into `process.env`, and * restarts the runtime so Horton picks them up on its next start. * - Skip just closes the dialog — nothing is persisted, so the - * prompt reappears on next launch (intentional until a settings - * UI exists for editing keys later). + * prompt reappears on next launch (the user can also revisit + * Settings → General to set keys at any time). + * + * The form itself lives in `ApiKeysForm` so the same component is + * reused by Settings → General with a different secondary action. */ export function ApiKeysModal(): React.ReactElement | null { const isDesktop = typeof window !== `undefined` && Boolean(window.electronAPI) @@ -50,8 +52,8 @@ export function ApiKeysModal(): React.ReactElement | null { { - // Skipping is allowed (we re-prompt next launch); use the - // controlled `open` state so the close-on-Escape / + // Skipping is allowed (Settings → General can fix keys later); + // use the controlled `open` state so close-on-Escape / // backdrop-click paths feed back into our own setter rather // than orphaning the dialog. setOpen(next) @@ -77,6 +79,7 @@ export function ApiKeysModal(): React.ReactElement | null { status.suggested.openai || status.suggested.brave )} + autoFocus onSave={async ({ anthropic, openai, brave }) => { await persistApiKeys({ anthropic: anthropic.trim() || null, @@ -84,122 +87,13 @@ export function ApiKeysModal(): React.ReactElement | null { brave: brave.trim() || null, }) setOpen(false) - // Refresh local status so subsequent sessions in this - // window won't re-prompt even though the dialog is - // already closed. const next = await loadApiKeysStatus() if (next) setStatus(next) }} - onSkip={() => setOpen(false)} + onSecondary={() => setOpen(false)} + secondaryLabel="Skip for now" /> ) } - -type FormValues = { anthropic: string; openai: string; brave: string } - -function ApiKeysForm({ - initial, - showSuggestionHint, - onSave, - onSkip, -}: { - initial: FormValues - showSuggestionHint: boolean - onSave: (keys: FormValues) => Promise - onSkip: () => void -}): React.ReactElement { - const [anthropic, setAnthropic] = useState(initial.anthropic) - const [openai, setOpenai] = useState(initial.openai) - const [brave, setBrave] = useState(initial.brave) - const [saving, setSaving] = useState(false) - // Save is enabled as long as the user has typed something — Brave - // alone is allowed (e.g. they already have an LLM key in `.env` - // and just want to add web-search support), but typing nothing - // would be a no-op so we keep the button disabled. - const canSave = - anthropic.trim().length > 0 || - openai.trim().length > 0 || - brave.trim().length > 0 - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault() - if (!canSave || saving) return - setSaving(true) - try { - await onSave({ anthropic, openai, brave }) - } finally { - setSaving(false) - } - }, - [anthropic, openai, brave, canSave, saving, onSave] - ) - - return ( -
- {showSuggestionHint && ( -
- - - Pre-filled from your environment. Click save to persist them. - -
- )} - - - setAnthropic(e.target.value)} - size={2} - autoFocus - /> - - - setOpenai(e.target.value)} - size={2} - /> - - - setBrave(e.target.value)} - size={2} - /> - - - - - - -
- ) -} diff --git a/packages/agents-server-ui/src/components/SettingsMenu.module.css b/packages/agents-server-ui/src/components/SettingsMenu.module.css index ab41e04cf9..cc5cb5ccf9 100644 --- a/packages/agents-server-ui/src/components/SettingsMenu.module.css +++ b/packages/agents-server-ui/src/components/SettingsMenu.module.css @@ -5,3 +5,33 @@ margin-left: auto; color: var(--ds-text-2); } + +/* Submenu trigger rows match `Menu.Item` geometry but the trigger + itself is rendered by Base UI's `MenuSubmenuTrigger` rather than + `MenuItem`, so we don't pick up the shared `styles.item` class + from the Menu primitive. Recreate the relevant bits inline so the + row looks identical to its siblings, and float the chevron right. */ +.submenuTrigger { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 3px 3px 8px; + border-radius: 7px; + font-size: var(--ds-text-sm); + line-height: 1.3; + font-family: var(--ds-font-body); + color: var(--ds-text-1); + cursor: pointer; + outline: none; + user-select: none; + text-align: start; + width: 100%; + box-sizing: border-box; +} +.submenuTrigger[data-highlighted] { + background: var(--ds-bg-hover); +} +.submenuChevron { + margin-left: auto; + color: var(--ds-text-3); +} diff --git a/packages/agents-server-ui/src/components/SettingsMenu.tsx b/packages/agents-server-ui/src/components/SettingsMenu.tsx index 2e4354b447..40dc72c8e6 100644 --- a/packages/agents-server-ui/src/components/SettingsMenu.tsx +++ b/packages/agents-server-ui/src/components/SettingsMenu.tsx @@ -1,10 +1,15 @@ import { useEffect, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' import { Check, + ChevronRight, + Cpu, Monitor, Moon, + Palette, + Play, RefreshCw, - Settings, + Settings as SettingsIcon, Square, Sun, } from 'lucide-react' @@ -27,13 +32,32 @@ const THEME_OPTIONS: ReadonlyArray<{ { value: `system`, label: `System`, icon: }, ] +const RUNTIME_STATUS_LABELS: Record = { + running: `Running`, + starting: `Starting`, + stopped: `Stopped`, + error: `Error`, +} + /** - * Settings cog dropdown — currently exposes the theme switcher - * (Light / Dark / System) and reserves space for future preferences. + * Settings cog dropdown. + * + * The top-level menu is a tight three-row launcher: + * + * - Theme → submenu (Light / Dark / System) + * - Local Runtime → submenu (status + start/restart/stop) + * - Settings… → opens the full Settings screen at /settings + * + * "Local Runtime" only renders on the desktop build (it's the only + * place where the bundled Horton runtime exists). The Settings link + * is always shown — Settings → General is useful in the web build + * too once additional preferences land there. */ export function SettingsMenu(): React.ReactElement { const { preference, setPreference } = useDarkModeContext() + const navigate = useNavigate() const [desktopState, setDesktopState] = useState(null) + const isDesktop = typeof window !== `undefined` && Boolean(window.electronAPI) useEffect(() => { if (!window.electronAPI?.getDesktopState) return @@ -44,6 +68,12 @@ export function SettingsMenu(): React.ReactElement { } }, []) + const runtimeStatus = desktopState?.runtimeStatus ?? `stopped` + const runtimeUrl = desktopState?.runtimeUrl ?? null + const runtimeError = desktopState?.error ?? null + const runtimeIsRunning = runtimeStatus === `running` + const runtimeIsStarting = runtimeStatus === `starting` + return ( - + } /> - - Theme - {THEME_OPTIONS.map((opt) => { - const active = preference === opt.value - return ( - setPreference(opt.value)} - > - {opt.icon} - {opt.label} - {active && } - - ) - })} - - {desktopState && ( - <> - - - Desktop runtime - - Status: {desktopState.runtimeStatus} - - {desktopState.runtimeUrl && ( - - {desktopState.runtimeUrl} + + + + Theme + + + + {THEME_OPTIONS.map((opt) => { + const active = preference === opt.value + return ( + setPreference(opt.value)} + > + {opt.icon} + {opt.label} + {active && } - )} - {desktopState.error && ( + ) + })} + + + + {isDesktop && ( + + + + Local Runtime + + + + + Status - {desktopState.error} + {RUNTIME_STATUS_LABELS[runtimeStatus]} + + {runtimeUrl && ( + + + {runtimeUrl} + + + )} + {runtimeError && ( + + + {runtimeError} + + + )} + + + + {!runtimeIsRunning && !runtimeIsStarting ? ( + void window.electronAPI?.restartRuntime?.()} + > + + Start runtime + + ) : ( + void window.electronAPI?.restartRuntime?.()} + > + + Restart runtime + + )} + void window.electronAPI?.stopRuntime?.()} + > + + Stop runtime - )} - void window.electronAPI?.restartRuntime?.()} - > - - Restart runtime - - void window.electronAPI?.stopRuntime?.()} - > - - Stop runtime - - - + + + )} + + + + + navigate({ + to: `/settings/$category`, + params: { category: `general` }, + }) + } + > + + Settings… + ) diff --git a/packages/agents-server-ui/src/components/settings/SettingsScreen.module.css b/packages/agents-server-ui/src/components/settings/SettingsScreen.module.css new file mode 100644 index 0000000000..9973f7a1df --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/SettingsScreen.module.css @@ -0,0 +1,140 @@ +/* Right-hand pane of the settings screen — fills the remaining width + next to the settings sidebar and owns its own scroll container so + long pages don't push the column header off-screen. */ +.root { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--ds-bg); +} + +/* Top strip mirrors `MainHeader` (44px tall, draggable on desktop) + so the settings shell carries the same titlebar height as the + workspace and the macOS hiddenInset traffic lights stay aligned + with the sidebar's "Back to app" row. */ +.header { + flex-shrink: 0; + display: flex; + align-items: center; + height: 44px; + padding: 0 16px; + background: var(--ds-bg); +} + +:global(html[data-electric-desktop='true']) .header { + -webkit-app-region: drag; +} + +:global(html[data-electric-desktop='true']) .header button, +:global(html[data-electric-desktop='true']) .header a, +:global(html[data-electric-desktop='true']) .header input, +:global(html[data-electric-desktop='true']) .header [data-no-drag] { + -webkit-app-region: no-drag; +} + +.headerTitle { + color: var(--ds-text-2); +} + +.scroll { + flex: 1; + min-height: 0; +} + +/* Centered content column — capped width so the longest sections + don't sprawl across the screen on a wide desktop window. Matches + the reading column conventions of the rest of the app. */ +.body { + max-width: 760px; + margin: 0 auto; + padding: 8px 32px 64px; +} + +.pageTitle { + margin: 16px 0 24px; + font-size: var(--ds-text-2xl); + line-height: 1.2; + font-weight: 600; + color: var(--ds-text-1); +} + +.sections { + width: 100%; +} + +/* Each settings group ("API keys", "Theme", …) renders as a card + so the page reads as a list of bordered groupings, matching the + reference screenshot. */ +.section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sectionHeader { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sectionTitle { + margin: 0; + font-size: var(--ds-text-base); + font-weight: 500; + color: var(--ds-text-1); +} + +.sectionDescription { + margin: 0; + font-size: var(--ds-text-sm); + color: var(--ds-text-3); + line-height: 1.45; +} + +.sectionBody { + border: 1px solid var(--ds-border-1); + border-radius: var(--ds-radius-3); + background: var(--ds-bg); + overflow: hidden; +} + +/* Stacked rows inside a section — labelled left, control on the + right. Border separators between rows but not above the first or + below the last so the border-radius of the wrapper isn't broken. */ +.row { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 16px; + border-top: 1px solid var(--ds-border-1); +} +.row:first-child { + border-top: 0; +} + +.rowText { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.rowLabel { + font-size: var(--ds-text-sm); + color: var(--ds-text-1); +} + +.rowDescription { + font-size: var(--ds-text-xs); + color: var(--ds-text-3); + line-height: 1.45; +} + +.rowControl { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; +} diff --git a/packages/agents-server-ui/src/components/settings/SettingsScreen.tsx b/packages/agents-server-ui/src/components/settings/SettingsScreen.tsx new file mode 100644 index 0000000000..b81cfbd80e --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/SettingsScreen.tsx @@ -0,0 +1,98 @@ +import type { ReactNode } from 'react' +import { ScrollArea, Stack, Text } from '../../ui' +import styles from './SettingsScreen.module.css' + +/** + * Right-hand pane of the settings screen. Holds the title bar (with + * the desktop drag region) and the scrollable category content area. + * + * + * + * … + * + * + * + * The shell is intentionally thin — every category page composes its + * own sections inside ``, mirroring the pattern of + * the macOS System Settings layout shown in the user's reference + * screenshot. + */ +export function SettingsScreen({ + title, + children, +}: { + title: string + children: ReactNode +}): React.ReactElement { + return ( +
+
+ + {title} + +
+ +
+

{title}

+ + {children} + +
+
+
+ ) +} + +/** + * Logical group inside a settings page. Each section gets a heading + * + optional description and renders its content in a card-like + * surface so the page reads as a list of bordered groupings (matching + * the reference screenshot). + */ +export function SettingsSection({ + title, + description, + children, +}: { + title: string + description?: ReactNode + children: ReactNode +}): React.ReactElement { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+
{children}
+
+ ) +} + +/** + * Single labelled row inside a section card. Pattern matches the + * macOS Settings layout: label on the left, control on the right. + */ +export function SettingsRow({ + label, + description, + control, +}: { + label: ReactNode + description?: ReactNode + control: ReactNode +}): React.ReactElement { + return ( +
+
+ {label} + {description && ( + {description} + )} +
+
{control}
+
+ ) +} diff --git a/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css b/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css new file mode 100644 index 0000000000..41a0047e68 --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css @@ -0,0 +1,114 @@ +/* Settings sidebar — same surface treatment as `` so the + transition between "app sidebar" and "settings sidebar" feels like + the contents of the column changed, not the column itself. */ +.root { + flex-shrink: 0; + width: 240px; + min-width: 240px; + background: var(--ds-bg-subtle); + border-right: 1px solid var(--ds-border-1); + position: relative; +} + +/* Top header row mirrors the geometry of `SidebarHeader` (44px tall, + 10px gutter) so the in-app titlebar height stays consistent across + the two sidebars. The button itself opts out of the drag region via + `data-no-drag` so it remains clickable on Electron. */ +.header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + height: 44px; + padding: 0 10px; +} + +:global(html[data-electric-desktop='true']) .header { + padding-left: 84px; + -webkit-app-region: drag; +} + +:global(html[data-electric-desktop='true']) .header [data-no-drag] { + -webkit-app-region: no-drag; +} + +.backButton { + all: unset; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--ds-radius-2); + cursor: pointer; + color: var(--ds-text-2); + transition: + background 0.08s ease, + color 0.08s ease; +} +.backButton:hover { + background: var(--ds-bg-hover); + color: var(--ds-text-1); +} +.backButton:focus-visible { + outline: 2px solid var(--ds-accent-a8); + outline-offset: 1px; +} + +.scrollFlex { + flex: 1; + min-height: 0; +} + +.list { + padding: 4px 8px 8px; + gap: 1px; +} + +/* Category rows — match the geometry of `` so the column + reads as one consistent stack of selectable items. The active state + uses the standard accent surface, similar to the "selected entity" + row in the main sidebar. */ +.row { + all: unset; + display: flex; + align-items: center; + gap: 8px; + height: var(--ds-row-height-md); + padding: 0 8px; + border-radius: 7px; + cursor: pointer; + color: var(--ds-text-1); + transition: background 0.08s ease; +} +.row:hover { + background: var(--ds-bg-hover); +} +.row:focus-visible { + outline: 2px solid var(--ds-accent-a8); + outline-offset: -2px; +} +.rowActive, +.rowActive:hover { + background: var(--ds-accent-a3); + color: var(--ds-text-1); +} + +.iconSlot { + width: 18px; + height: 18px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ds-text-2); +} +.rowActive .iconSlot { + color: var(--ds-text-1); +} + +.label { + font-size: var(--ds-text-sm); + line-height: 1; + flex: 1; + min-width: 0; +} diff --git a/packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx b/packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx new file mode 100644 index 0000000000..28046ac4a1 --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { ArrowLeft, Cpu, Palette, Settings as SettingsIcon } from 'lucide-react' +import { ScrollArea, Stack, Text } from '../../ui' +import { + loadDesktopState, + onDesktopStateChanged, + type DesktopState, +} from '../../lib/server-connection' +import styles from './SettingsSidebar.module.css' + +export type SettingsCategoryId = `general` | `appearance` | `local-runtime` + +interface CategoryDef { + id: SettingsCategoryId + label: string + icon: React.ReactElement + /** When false, hide the row entirely (e.g. desktop-only rows). */ + visible: boolean +} + +/** + * Settings sidebar — replaces the regular `` while the user + * is on a `/settings/*` route. Mirrors the visual chrome of the main + * sidebar (same background, same width as the standard sidebar header + * gutter) so the settings experience reads as part of the same shell + * rather than a modal overlay. + * + * The header row sits in the macOS draggable region (see + * `:global(html[data-electric-desktop='true'])` rules in the + * stylesheet); the "Back to app" affordance opts back out via + * `data-no-drag` so it stays clickable. + */ +export function SettingsSidebar({ + activeCategory, +}: { + activeCategory: SettingsCategoryId +}): React.ReactElement { + const navigate = useNavigate() + const [desktopState, setDesktopState] = useState(null) + const isDesktop = typeof window !== `undefined` && Boolean(window.electronAPI) + + useEffect(() => { + if (!window.electronAPI?.getDesktopState) return + void loadDesktopState().then(setDesktopState) + const unsubscribe = onDesktopStateChanged(setDesktopState) + return () => { + unsubscribe?.() + } + }, []) + + const categories: ReadonlyArray = [ + { + id: `general`, + label: `General`, + icon: , + visible: true, + }, + { + id: `appearance`, + label: `Appearance`, + icon: , + visible: true, + }, + { + id: `local-runtime`, + label: `Local Runtime`, + icon: , + visible: isDesktop || Boolean(desktopState), + }, + ] + + return ( + +
+ +
+ + + + {categories + .filter((c) => c.visible) + .map((c) => ( + + ))} + + +
+ ) +} diff --git a/packages/agents-server-ui/src/components/settings/pages/AppearancePage.module.css b/packages/agents-server-ui/src/components/settings/pages/AppearancePage.module.css new file mode 100644 index 0000000000..3b199424d9 --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/pages/AppearancePage.module.css @@ -0,0 +1,84 @@ +/* Three-up theme selector — visual analogue of the macOS System + Settings appearance picker. Each tile shows an icon + label/hint + and surfaces its selection state with an accent border + check + mark in the corner. */ +.themeGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + padding: 16px; +} + +.tile { + all: unset; + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + border-radius: var(--ds-radius-3); + border: 1px solid var(--ds-border-1); + background: var(--ds-bg); + cursor: pointer; + transition: + border-color 0.1s ease, + background 0.1s ease; +} +.tile:hover { + background: var(--ds-bg-subtle); +} +.tile:focus-visible { + outline: 2px solid var(--ds-accent-a8); + outline-offset: 1px; +} + +.tileActive { + border-color: var(--ds-accent-a8); + background: var(--ds-accent-a3); +} +.tileActive:hover { + background: var(--ds-accent-a3); +} + +.tileIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 999px; + background: var(--ds-bg-subtle); + color: var(--ds-text-1); + flex-shrink: 0; +} +.tileActive .tileIcon { + background: var(--ds-accent-a4); + color: var(--ds-text-1); +} + +.tileBody { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.tileMark { + position: absolute; + top: 8px; + right: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--ds-accent-a8); + color: var(--ds-bg); +} + +@media (max-width: 600px) { + .themeGrid { + grid-template-columns: 1fr; + } +} diff --git a/packages/agents-server-ui/src/components/settings/pages/AppearancePage.tsx b/packages/agents-server-ui/src/components/settings/pages/AppearancePage.tsx new file mode 100644 index 0000000000..188f895389 --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/pages/AppearancePage.tsx @@ -0,0 +1,81 @@ +import { Check, Monitor, Moon, Sun } from 'lucide-react' +import { + useDarkModeContext, + type ThemePreference, +} from '../../../hooks/useDarkMode' +import { Text } from '../../../ui' +import { SettingsScreen, SettingsSection } from '../SettingsScreen' +import styles from './AppearancePage.module.css' + +const THEME_OPTIONS: ReadonlyArray<{ + value: ThemePreference + label: string + icon: React.ReactElement + hint: string +}> = [ + { + value: `light`, + label: `Light`, + icon: , + hint: `Always use the light palette.`, + }, + { + value: `dark`, + label: `Dark`, + icon: , + hint: `Always use the dark palette.`, + }, + { + value: `system`, + label: `System`, + icon: , + hint: `Follow your OS setting.`, + }, +] + +/** + * Settings → Appearance. Currently exposes the theme switcher only; + * future appearance preferences (font scale, density, …) land here. + */ +export function AppearancePage(): React.ReactElement { + const { preference, setPreference } = useDarkModeContext() + + return ( + + +
+ {THEME_OPTIONS.map((opt) => { + const active = preference === opt.value + return ( + + ) + })} +
+
+
+ ) +} diff --git a/packages/agents-server-ui/src/components/settings/pages/GeneralPage.tsx b/packages/agents-server-ui/src/components/settings/pages/GeneralPage.tsx new file mode 100644 index 0000000000..81eef8206a --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/pages/GeneralPage.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react' +import { ApiKeysForm } from '../../ApiKeysForm' +import { + loadApiKeysStatus, + saveApiKeys as persistApiKeys, + type ApiKeysStatus, +} from '../../../lib/server-connection' +import { Text } from '../../../ui' +import { SettingsScreen, SettingsSection } from '../SettingsScreen' + +/** + * Settings → General. Currently surfaces the provider API keys for + * the bundled local Horton runtime; future general preferences land + * here too. + * + * On the desktop build the form persists keys via `desktop:save-api-keys`, + * which writes `settings.json`, mirrors the values into `process.env`, + * and restarts the runtime so Horton picks up the new keys on its + * next start. On the web build the IPC bridge is absent and we render + * an explanatory message instead. + */ +export function GeneralPage(): React.ReactElement { + const isDesktop = typeof window !== `undefined` && Boolean(window.electronAPI) + const [status, setStatus] = useState(null) + const [savedAt, setSavedAt] = useState(null) + + useEffect(() => { + if (!isDesktop) return + let cancelled = false + void loadApiKeysStatus().then((result) => { + if (cancelled) return + setStatus(result) + }) + return () => { + cancelled = true + } + }, [isDesktop]) + + return ( + + + {!isDesktop ? ( +
+ + No editable provider keys in the web build. + +
+ ) : !status ? ( +
+ + Loading… + +
+ ) : ( +
+ { + await persistApiKeys({ + anthropic: anthropic.trim() || null, + openai: openai.trim() || null, + brave: brave.trim() || null, + }) + const next = await loadApiKeysStatus() + if (next) setStatus(next) + setSavedAt(Date.now()) + }} + saveLabel="Save changes" + savingLabel="Saving…" + /> +
+ )} +
+
+ ) +} diff --git a/packages/agents-server-ui/src/components/settings/pages/LocalRuntimePage.tsx b/packages/agents-server-ui/src/components/settings/pages/LocalRuntimePage.tsx new file mode 100644 index 0000000000..0ba0ab64a7 --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/pages/LocalRuntimePage.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react' +import { Play, RefreshCw, Square } from 'lucide-react' +import { + loadDesktopState, + onDesktopStateChanged, + type DesktopState, +} from '../../../lib/server-connection' +import { Badge, Button, Stack, Text } from '../../../ui' +import { SettingsRow, SettingsScreen, SettingsSection } from '../SettingsScreen' + +const STATUS_TONES: Record< + DesktopState[`runtimeStatus`], + { label: string; tone: `success` | `warning` | `danger` | `info` } +> = { + running: { label: `Running`, tone: `success` }, + starting: { label: `Starting`, tone: `info` }, + stopped: { label: `Stopped`, tone: `warning` }, + error: { label: `Error`, tone: `danger` }, +} + +/** + * Settings → Local Runtime. Shows the lifecycle state of the bundled + * Horton runtime managed by the Electron main process and exposes + * start / restart / stop controls. + * + * The runtime is desktop-only; on the web build (no `electronAPI` + * bridge) we render an explanatory message instead so the page + * remains discoverable / informative even though there's nothing + * to control. + */ +export function LocalRuntimePage(): React.ReactElement { + const isDesktop = typeof window !== `undefined` && Boolean(window.electronAPI) + const [state, setState] = useState(null) + + useEffect(() => { + if (!isDesktop) return + let cancelled = false + void loadDesktopState().then((s) => { + if (cancelled) return + setState(s) + }) + const off = onDesktopStateChanged(setState) + return () => { + cancelled = true + off?.() + } + }, [isDesktop]) + + if (!isDesktop) { + return ( + + +
+ + Run Electric Agents on your machine to manage the bundled local + Horton runtime here. + +
+
+
+ ) + } + + const status = state?.runtimeStatus ?? `stopped` + const statusInfo = STATUS_TONES[status] + const isRunning = status === `running` + const isStarting = status === `starting` + const canStart = !isRunning && !isStarting + + return ( + + + {statusInfo.label}} + /> + + {state?.runtimeUrl ?? `—`} + + } + /> + {state?.error && ( + + {state.error} + + } + /> + )} + + + +
+ + {canStart ? ( + + ) : ( + + )} + + +
+
+
+ ) +} diff --git a/packages/agents-server-ui/src/hooks/useDocumentTitle.ts b/packages/agents-server-ui/src/hooks/useDocumentTitle.ts index 5f18606a6a..06a67253d8 100644 --- a/packages/agents-server-ui/src/hooks/useDocumentTitle.ts +++ b/packages/agents-server-ui/src/hooks/useDocumentTitle.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { useLocation } from '@tanstack/react-router' import { eq, useLiveQuery } from '@tanstack/react-db' import { useElectricAgents } from '../lib/ElectricAgentsProvider' import { getEntityDisplayTitle } from '../lib/entityDisplay' @@ -6,6 +7,12 @@ import { useWorkspace } from './useWorkspace' const APP_NAME = `Electric Agents` +const SETTINGS_CATEGORY_LABELS: Record = { + general: `General`, + appearance: `Appearance`, + 'local-runtime': `Local Runtime`, +} + /** * Keeps `document.title` in sync with the active tile's entity so the * browser tab / Electron window title reads as e.g. "Build the report @@ -22,6 +29,8 @@ export function useDocumentTitle(): void { const { helpers } = useWorkspace() const activeEntityUrl = helpers.activeTile?.entityUrl ?? null const { entitiesCollection } = useElectricAgents() + const location = useLocation() + const settingsLabel = parseSettingsLabel(location.pathname) const { data: matches = [] } = useLiveQuery( (q) => { @@ -36,6 +45,10 @@ export function useDocumentTitle(): void { useEffect(() => { if (typeof document === `undefined`) return + if (settingsLabel) { + document.title = `${settingsLabel} — Settings — ${APP_NAME}` + return + } if (!activeEntityUrl) { document.title = APP_NAME return @@ -44,5 +57,18 @@ export function useDocumentTitle(): void { ? getEntityDisplayTitle(entity).title : activeEntityUrl.replace(/^\//, ``) document.title = `${sessionLabel} — ${APP_NAME}` - }, [activeEntityUrl, entity]) + }, [activeEntityUrl, entity, settingsLabel]) +} + +/** + * Reads `/settings/` off the URL and returns a human label + * for the chrome (`General`, `Appearance`, `Local Runtime`). Returns + * `null` for any non-settings route so the entity-based title kicks + * in instead. + */ +function parseSettingsLabel(pathname: string): string | null { + const match = pathname.match(/^\/settings(?:\/([^/?]+))?/) + if (!match) return null + const category = match[1] ?? `general` + return SETTINGS_CATEGORY_LABELS[category] ?? `Settings` } diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index 52c5aaf901..e7780d2c67 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -5,6 +5,8 @@ import { createRootRoute, createRoute, createRouter, + redirect, + useLocation, useNavigate, useParams, } from '@tanstack/react-router' @@ -31,8 +33,21 @@ import { Sidebar } from './components/Sidebar' import { SearchPalette } from './components/SearchPalette' import { Workspace } from './components/workspace/Workspace' import { ApiKeysModal } from './components/ApiKeysModal' +import { + SettingsSidebar, + type SettingsCategoryId, +} from './components/settings/SettingsSidebar' +import { GeneralPage } from './components/settings/pages/GeneralPage' +import { AppearancePage } from './components/settings/pages/AppearancePage' +import { LocalRuntimePage } from './components/settings/pages/LocalRuntimePage' import styles from './router.module.css' +const SETTINGS_CATEGORY_IDS: ReadonlyArray = [ + `general`, + `appearance`, + `local-runtime`, +] + function RootLayout(): React.ReactElement { return ( @@ -163,16 +178,31 @@ function RootShell(): React.ReactElement { const splat = (params as Record)._splat const selectedEntityUrl = splat ? `/${splat}` : null + // Settings is its own sidebar — when the user navigates into + // `/settings/*` we swap the workspace sidebar (sessions list) for + // a settings-categories sidebar so the settings experience reads + // as part of the same shell rather than a modal overlay. The + // `` component rendered to the right comes from whichever + // route matched, so the right column behaves the same way for + // both the workspace and settings routes. + const location = useLocation() + const settingsCategory = parseSettingsCategory(location.pathname) + const inSettings = settingsCategory !== null + return (
- {!collapsed && ( - + {inSettings ? ( + + ) : ( + !collapsed && ( + + ) )} @@ -181,6 +211,21 @@ function RootShell(): React.ReactElement { ) } +/** + * Read the active settings category off the URL. + * + * Returns the category id when the user is on `/settings/`, + * `null` otherwise. We hand-parse instead of using `useParams` because + * `RootShell` lives above the routes and doesn't have a strict route + * context to type-narrow against. + */ +function parseSettingsCategory(pathname: string): SettingsCategoryId | null { + const match = pathname.match(/^\/settings\/([^/?]+)/) + if (!match) return null + const id = match[1] as SettingsCategoryId + return SETTINGS_CATEGORY_IDS.includes(id) ? id : null +} + /** * Search-param schema for the workspace routes. * @@ -230,7 +275,52 @@ const entityRoute = createRoute({ validateSearch: workspaceSearchSchema, }) -const routeTree = rootRoute.addChildren([indexRoute, entityRoute]) +/** + * Settings shell — `/settings` redirects to the default category so + * the user always lands inside a populated panel rather than an empty + * shell. Each child route renders one category's screen on the right + * while `RootShell` swaps in the settings sidebar on the left. + */ +const settingsIndexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: `/settings`, + beforeLoad: () => { + throw redirect({ + to: `/settings/$category`, + params: { category: `general` }, + }) + }, + component: () => null, +}) + +const settingsCategoryRoute = createRoute({ + getParentRoute: () => rootRoute, + path: `/settings/$category`, + component: SettingsCategoryPage, +}) + +function SettingsCategoryPage(): React.ReactElement { + const params = useParams({ strict: false }) as Record< + string, + string | undefined + > + switch (params.category as SettingsCategoryId | undefined) { + case `appearance`: + return + case `local-runtime`: + return + case `general`: + default: + return + } +} + +const routeTree = rootRoute.addChildren([ + indexRoute, + entityRoute, + settingsIndexRoute, + settingsCategoryRoute, +]) export const router = createRouter({ routeTree, From 89617e852d8b125161f00b9a82ea3f82d863e6b8 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 16:09:08 +0100 Subject: [PATCH 15/57] feat(agents-desktop): proper HMR via vite-plugin-electron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled tsdown + electronmon + wait-on chain with vite-plugin-electron, which builds main + preload in watch mode and manages the Electron child process with proper debouncing — no more restart loop. - agents-server-ui's Vite dev server runs in `--mode desktop` on a pinned port (5183) so the desktop main process can wait on it deterministically and load the renderer with full React Refresh / CSS HMR. The desktopHtmlMarker plugin runs in dev too, so `data-electric-desktop="true"` is on `` from the first byte. - Main / preload now build via Vite. All bare imports are externalised so Node resolves `node_modules` at runtime — fixes the jsdom→canvas build error and keeps `dist/main.js` at ~94 kB. - `setActiveServer` no longer calls `restartRuntime()` when the active server is unchanged, so the renderer mount (which always fires `saveActiveServer(active)`, doubled by React 19 StrictMode in dev) doesn't trigger a triple Horton bootstrap on every window open. - DevTools no longer auto-open on each window — multi-window setups get noisy fast. The standard View → Toggle Developer Tools menu item (Cmd+Opt+I / Ctrl+Shift+I) still works in every window. Drop tsdown / electronmon / cross-env devDeps; add vite + vite-plugin-electron. Concurrently + wait-on are kept for orchestrating the parallel UI / desktop dev servers. Co-authored-by: Cursor --- packages/agents-desktop/package.json | 13 +- packages/agents-desktop/src/main.ts | 41 +++- packages/agents-desktop/tsconfig.json | 2 +- packages/agents-desktop/tsdown.config.ts | 21 -- packages/agents-desktop/vite.config.ts | 123 ++++++++++++ packages/agents-server-ui/package.json | 1 + packages/agents-server-ui/vite.config.ts | 9 +- pnpm-lock.yaml | 245 +++++++++++++++-------- 8 files changed, 345 insertions(+), 110 deletions(-) delete mode 100644 packages/agents-desktop/tsdown.config.ts create mode 100644 packages/agents-desktop/vite.config.ts diff --git a/packages/agents-desktop/package.json b/packages/agents-desktop/package.json index 3abee03c34..c1d363afe9 100644 --- a/packages/agents-desktop/package.json +++ b/packages/agents-desktop/package.json @@ -6,8 +6,10 @@ "type": "module", "main": "./dist/main.js", "scripts": { - "build": "pnpm --filter @electric-ax/agents build && pnpm --filter @electric-ax/agents-server-ui build:desktop && tsdown", - "dev": "pnpm --filter @electric-ax/agents build && pnpm --filter @electric-ax/agents-server-ui build:desktop && tsdown && pnpm run ensure:electron && electron .", + "build": "pnpm --filter @electric-ax/agents build && pnpm --filter @electric-ax/agents-server-ui build:desktop && vite build", + "dev": "pnpm --filter @electric-ax/agents build && pnpm run ensure:electron && concurrently -k -n ui,desktop -c cyan,green \"pnpm run dev:ui\" \"pnpm run dev:desktop\"", + "dev:ui": "pnpm --filter @electric-ax/agents-server-ui dev:desktop", + "dev:desktop": "wait-on -d 200 http-get://localhost:5183 && vite", "ensure:electron": "node ./node_modules/electron/install.js", "start": "pnpm run ensure:electron && electron .", "typecheck": "tsc --noEmit" @@ -18,8 +20,11 @@ }, "devDependencies": { "@types/node": "^22.19.17", + "concurrently": "^8.2.2", "electron": "^41.5.0", - "tsdown": "^0.9.9", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vite": "^7.1.7", + "vite-plugin-electron": "^0.29.1", + "wait-on": "^9.0.1" } } diff --git a/packages/agents-desktop/src/main.ts b/packages/agents-desktop/src/main.ts index e3d8dbf925..b71e61a704 100644 --- a/packages/agents-desktop/src/main.ts +++ b/packages/agents-desktop/src/main.ts @@ -97,6 +97,16 @@ const TRAY_ICON_2X_PATH = path.resolve( const APP_ICON_PATH = path.resolve(PACKAGE_DIR, `assets/icon.png`) const APP_DISPLAY_NAME = `Electric Agents` +/** + * When set, the renderer is loaded from this dev-server URL instead + * of the prebuilt `dist-desktop/index.html` file. Wired up by the + * `dev` script in `package.json`, which boots Vite on port 5174 and + * exports `ELECTRIC_DESKTOP_DEV_SERVER_URL=http://localhost:5174` + * so the renderer gets full HMR. Unset in `start` / packaged builds, + * so production keeps loading the static bundle from disk. + */ +const DEV_SERVER_URL = process.env.ELECTRIC_DESKTOP_DEV_SERVER_URL ?? null + /** * Commands sent from the menu / tray (main process) to the focused * renderer over the `desktop:command` IPC channel. The renderer @@ -432,7 +442,18 @@ function createWindow(): BrowserWindow { buildApplicationMenu() }) win.webContents.setWindowOpenHandler(() => ({ action: `deny` })) - void win.loadFile(RENDERER_INDEX) + // Dev: load from the running Vite dev server so the renderer gets + // HMR (CSS / React Refresh / module replacement). Production: load + // the prebuilt `dist-desktop/index.html` from disk via file://. + // DevTools are not auto-opened — multi-window setups would spawn + // a detached DevTools per window, which gets noisy fast. The + // standard `View → Toggle Developer Tools` menu item (Cmd+Opt+I / + // Ctrl+Shift+I) works in every window when you actually need it. + if (DEV_SERVER_URL) { + void win.loadURL(DEV_SERVER_URL) + } else { + void win.loadFile(RENDERER_INDEX) + } buildApplicationMenu() return win @@ -530,11 +551,25 @@ async function setApiKeys(next: ApiKeys): Promise { async function setActiveServer(server: ServerConfig | null): Promise { const normalized = normalizeServer(server) - settings.activeServer = + const next = normalized && serverInList(normalized, settings.servers) ? normalized : null + // Renderer mount fires `saveActiveServer(active)` even when the + // value didn't actually change (React 19 StrictMode also double- + // fires the effect in dev). Bail early when the active server is + // identical to what we already had so we don't tear down and + // restart Horton on every window open. + const same = + (next === null && settings.activeServer === null) || + (next !== null && + settings.activeServer !== null && + next.url === settings.activeServer.url && + next.name === settings.activeServer.name) + settings.activeServer = next setState({ activeServer: settings.activeServer }) await saveSettings() - await restartRuntime() + if (!same) { + await restartRuntime() + } } async function quitApp(): Promise { diff --git a/packages/agents-desktop/tsconfig.json b/packages/agents-desktop/tsconfig.json index 1eb29bc8a8..5886ab83be 100644 --- a/packages/agents-desktop/tsconfig.json +++ b/packages/agents-desktop/tsconfig.json @@ -13,6 +13,6 @@ "esModuleInterop": true, "outDir": "./dist" }, - "include": ["src/**/*", "tsdown.config.ts"], + "include": ["src/**/*", "vite.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/agents-desktop/tsdown.config.ts b/packages/agents-desktop/tsdown.config.ts deleted file mode 100644 index 922722228b..0000000000 --- a/packages/agents-desktop/tsdown.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'tsdown' - -export default defineConfig([ - { - entry: [`src/main.ts`], - format: [`esm`], - platform: `node`, - external: [`electron`], - dts: false, - clean: true, - }, - { - entry: { preload: `src/preload.ts` }, - format: [`cjs`], - platform: `node`, - external: [`electron`], - outExtensions: () => ({ js: `.cjs` }), - dts: false, - clean: false, - }, -]) diff --git a/packages/agents-desktop/vite.config.ts b/packages/agents-desktop/vite.config.ts new file mode 100644 index 0000000000..81081eb476 --- /dev/null +++ b/packages/agents-desktop/vite.config.ts @@ -0,0 +1,123 @@ +import { defineConfig, type PluginOption } from 'vite' +import electron from 'vite-plugin-electron/simple' + +const RENDERER_DEV_SERVER_URL = `http://localhost:5183` + +/** + * Treat any bare module specifier as external — i.e. let Node + * resolve it from `node_modules` at runtime. This is the standard + * pattern for Electron main / preload bundles: + * + * - Avoids dragging optional native deps (jsdom → canvas, sharp, + * keytar, …) into the bundle and failing the build when they're + * not actually installed. + * - Keeps the bundled `main.js` small (just our own source) so any + * rebuild during dev stays sub-second. + * - Works in dev (workspace `node_modules` is symlinked) and in + * production (electron-builder ships the package's `node_modules` + * alongside the bundled main). + * + * Entry modules and any path-like import (relative, absolute) stay + * internal so they actually get bundled. + */ +function externalizeBareImports( + id: string, + parent: string | undefined +): boolean { + if (parent === undefined) return false + if (id.startsWith(`.`)) return false + if (id.startsWith(`/`) || /^[A-Za-z]:[\\/]/.test(id)) return false + return true +} + +// vite-plugin-electron ships its own bundled `Plugin` type derived +// from a different vite/@types/node peer combination than the one +// pnpm hoists for us, so the literal return type fails structural +// equality with our vite. The plugin works correctly at runtime — +// cast through `unknown` to silence the dual-instance noise. +const electronPlugin = electron as unknown as ( + options: Parameters[0] +) => PluginOption + +/** + * Vite config for the Electron app's main + preload bundles. + * + * The renderer is its own Vite project (`agents-server-ui`) — this + * config is only responsible for compiling the Node-side `main.ts` + * and `preload.ts`, and for managing the Electron child process in + * dev mode. + * + * Dev (`vite`): + * - `vite-plugin-electron/simple` builds main + preload in watch + * mode and spawns Electron once the initial build completes. + * - On any subsequent rebuild it restarts the Electron child with + * proper debouncing — no manual `electronmon` loop. + * - The renderer is loaded from `RENDERER_DEV_SERVER_URL` (the + * parallel `agents-server-ui` dev server, started by the `dev` + * script via `concurrently`). We export the URL into the env so + * the spawned Electron process picks it up in `main.ts`. + * - The host Vite dev server itself is unused — the renderer lives + * in another package — so we bind it to a random port and + * suppress the auto-open behaviour. + * + * Build (`vite build`): + * - Builds main + preload to `dist/`. The renderer is built + * separately by `agents-server-ui`'s `build:desktop` script. + */ +export default defineConfig({ + server: { + port: 0, + strictPort: false, + open: false, + }, + plugins: [ + electronPlugin({ + main: { + entry: `src/main.ts`, + onstart({ startup }) { + // Inherits the parent process env, so setting it here lets + // `main.ts` read `process.env.ELECTRIC_DESKTOP_DEV_SERVER_URL` + // and load the renderer from the Vite dev server instead of + // the prebuilt `dist-desktop/index.html`. + process.env.ELECTRIC_DESKTOP_DEV_SERVER_URL = RENDERER_DEV_SERVER_URL + void startup() + }, + vite: { + build: { + outDir: `dist`, + emptyOutDir: false, + sourcemap: `inline`, + minify: false, + rollupOptions: { + external: externalizeBareImports, + output: { + entryFileNames: `main.js`, + format: `es`, + inlineDynamicImports: true, + }, + }, + }, + }, + }, + preload: { + input: `src/preload.ts`, + vite: { + build: { + outDir: `dist`, + emptyOutDir: false, + sourcemap: `inline`, + minify: false, + rollupOptions: { + external: externalizeBareImports, + output: { + entryFileNames: `preload.cjs`, + format: `cjs`, + inlineDynamicImports: true, + }, + }, + }, + }, + }, + }), + ], +}) diff --git a/packages/agents-server-ui/package.json b/packages/agents-server-ui/package.json index 52652110d0..78fe4d7a2e 100644 --- a/packages/agents-server-ui/package.json +++ b/packages/agents-server-ui/package.json @@ -7,6 +7,7 @@ "build": "vite build", "build:desktop": "vite build --mode desktop", "dev": "vite dev", + "dev:desktop": "vite dev --mode desktop --port 5183 --strictPort", "preview": "vite preview", "test": "vitest run --passWithNoTests", "coverage": "pnpm exec vitest run --coverage --passWithNoTests", diff --git a/packages/agents-server-ui/vite.config.ts b/packages/agents-server-ui/vite.config.ts index cf811b93f8..8cdbb94a3f 100644 --- a/packages/agents-server-ui/vite.config.ts +++ b/packages/agents-server-ui/vite.config.ts @@ -24,11 +24,16 @@ function desktopHtmlMarker(): Plugin { } } -export default defineConfig(({ mode }) => { +export default defineConfig(({ command, mode }) => { const desktop = mode === `desktop` + // Desktop *build* serves the bundle via file:// from the Electron + // app, so assets must be referenced with relative URLs (`./`). The + // dev server, on the other hand, serves over http and needs an + // absolute base (`/`) for HMR and dynamic imports to resolve. + const desktopServe = desktop && command === `serve` return { - base: desktop ? `./` : `/__agent_ui/`, + base: desktop ? (desktopServe ? `/` : `./`) : `/__agent_ui/`, plugins: [react(), ...(desktop ? [desktopHtmlMarker()] : [])], build: { outDir: desktop ? `dist-desktop` : `dist`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90ec442a69..ff5d49c1db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1607,15 +1607,24 @@ importers: '@types/node': specifier: ^22.19.17 version: 22.19.17 + concurrently: + specifier: ^8.2.2 + version: 8.2.2 electron: specifier: ^41.5.0 version: 41.5.0 - tsdown: - specifier: ^0.9.9 - version: 0.9.9(typescript@5.8.3) typescript: specifier: ^5.8.3 version: 5.8.3 + vite: + specifier: ^7.1.7 + version: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.2)(tsx@4.20.3)(yaml@2.8.1) + vite-plugin-electron: + specifier: ^0.29.1 + version: 0.29.1 + wait-on: + specifier: ^9.0.1 + version: 9.0.5 packages/agents-runtime: dependencies: @@ -2349,13 +2358,13 @@ importers: version: 5.8.3 vitepress: specifier: ^1.3.1 - version: 1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3) + version: 1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(axios@1.16.0)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3) vitepress-plugin-llms: specifier: ^1.7.5 version: 1.7.5 vitepress-plugin-tabs: specifier: ^0.5.0 - version: 0.5.0(vitepress@1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3))(vue@3.5.12(typescript@5.8.3)) + version: 0.5.0(vitepress@1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(axios@1.16.0)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3))(vue@3.5.12(typescript@5.8.3)) vitest: specifier: ^4.0.15 version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@29.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.1)(terser@5.46.2)(tsx@4.20.3)(yaml@2.6.0) @@ -5518,6 +5527,26 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@hapi/address@5.1.1': + resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} + engines: {node: '>=14.0.0'} + + '@hapi/formula@3.0.2': + resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/tlds@1.1.6': + resolution: {integrity: sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==} + engines: {node: '>=14.0.0'} + + '@hapi/topo@6.0.2': + resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} @@ -10562,6 +10591,9 @@ packages: aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + babel-dead-code-elimination@1.0.10: resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} @@ -12192,10 +12224,6 @@ packages: es-array-method-boxes-properly@1.0.0: resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==} - es-define-property@1.0.0: - resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} - engines: {node: '>= 0.4'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -12977,6 +13005,15 @@ packages: focus-trap@7.6.0: resolution: {integrity: sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fontfaceobserver@2.3.0: resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} @@ -12998,6 +13035,10 @@ packages: resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} engines: {node: '>= 6'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -13110,10 +13151,6 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} - get-intrinsic@1.2.4: - resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} - engines: {node: '>= 0.4'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -13230,9 +13267,6 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} - gopd@1.0.1: - resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -13291,18 +13325,10 @@ packages: has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-proto@1.0.3: - resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} - engines: {node: '>= 0.4'} - has-proto@1.2.0: resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} engines: {node: '>= 0.4'} - has-symbols@1.0.3: - resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -14010,6 +14036,10 @@ packages: resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} engines: {node: '>= 0.6.0'} + joi@18.2.1: + resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==} + engines: {node: '>= 20'} + jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} @@ -14877,10 +14907,6 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - mime-db@1.53.0: - resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} @@ -16090,6 +16116,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + psql-describe@0.1.6: resolution: {integrity: sha512-cZqmsO1FOTmKZFnwbZxViPzEkH/Kyof/t1O2QI25oN5TEexXl6AXVFNIYpoIVBGm2Ic+ImJDR760zUgBMBv+KQ==} @@ -16848,6 +16878,9 @@ packages: rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -18451,6 +18484,14 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-plugin-electron@0.29.1: + resolution: {integrity: sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==} + peerDependencies: + vite-plugin-electron-renderer: '*' + peerDependenciesMeta: + vite-plugin-electron-renderer: + optional: true + vite-plugin-pwa@0.21.0: resolution: {integrity: sha512-gnDE5sN2hdxA4vTl0pe6PCTPXqChk175jH8dZVVTBjFhWarZZoXaAdoTIKCIa8Zbx94sC0CnCOyERBWpxvry+g==} engines: {node: '>=16.0.0'} @@ -18828,6 +18869,11 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + wait-on@9.0.5: + resolution: {integrity: sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==} + engines: {node: '>=20.0.0'} + hasBin: true + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -19285,7 +19331,7 @@ snapshots: '@alcalzone/ansi-tokenize@0.2.5': dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 '@algolia/autocomplete-core@1.17.6(@algolia/client-search@5.13.0)(algoliasearch@5.13.0)(search-insights@2.17.2)': @@ -23333,6 +23379,22 @@ snapshots: - supports-color - utf-8-validate + '@hapi/address@5.1.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/formula@3.0.2': {} + + '@hapi/hoek@11.0.7': {} + + '@hapi/pinpoint@2.0.1': {} + + '@hapi/tlds@1.1.6': {} + + '@hapi/topo@6.0.2': + dependencies: + '@hapi/hoek': 11.0.7 + '@harperfast/extended-iterable@1.0.3': {} '@headlessui/react@1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -29204,12 +29266,13 @@ snapshots: - '@vue/composition-api' - vue - '@vueuse/integrations@11.2.0(focus-trap@7.6.0)(jwt-decode@4.0.0)(vue@3.5.12(typescript@5.8.3))': + '@vueuse/integrations@11.2.0(axios@1.16.0)(focus-trap@7.6.0)(jwt-decode@4.0.0)(vue@3.5.12(typescript@5.8.3))': dependencies: '@vueuse/core': 11.2.0(vue@3.5.12(typescript@5.8.3)) '@vueuse/shared': 11.2.0(vue@3.5.12(typescript@5.8.3)) vue-demi: 0.14.10(vue@3.5.12(typescript@5.8.3)) optionalDependencies: + axios: 1.16.0 focus-trap: 7.6.0 jwt-decode: 4.0.0 transitivePeerDependencies: @@ -29538,6 +29601,14 @@ snapshots: aws4fetch@1.0.20: {} + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + babel-dead-code-elimination@1.0.10: dependencies: '@babel/core': 7.29.0 @@ -29942,10 +30013,10 @@ snapshots: call-bind@1.0.7: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 set-function-length: 1.2.2 call-bind@1.0.8: @@ -30272,7 +30343,7 @@ snapshots: compressible@2.0.18: dependencies: - mime-db: 1.53.0 + mime-db: 1.54.0 compression@1.7.5: dependencies: @@ -30808,7 +30879,7 @@ snapshots: array-buffer-byte-length: 1.0.1 call-bind: 1.0.7 es-get-iterator: 1.1.3 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-arguments: 1.1.1 is-array-buffer: 3.0.4 is-date-object: 1.0.5 @@ -30845,9 +30916,9 @@ snapshots: define-data-property@1.1.4: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 - gopd: 1.0.1 + gopd: 1.2.0 define-lazy-prop@2.0.0: {} @@ -31111,7 +31182,7 @@ snapshots: enzyme-shallow-equal@1.0.7: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 object-is: 1.1.6 enzyme@3.11.0: @@ -31171,7 +31242,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -31263,10 +31334,6 @@ snapshots: es-array-method-boxes-properly@1.0.0: {} - es-define-property@1.0.0: - dependencies: - get-intrinsic: 1.2.4 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -31315,11 +31382,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 es-shim-unscopables@1.0.2: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 es-shim-unscopables@1.1.0: dependencies: @@ -32523,6 +32590,8 @@ snapshots: dependencies: tabbable: 6.2.0 + follow-redirects@1.16.0: {} + fontfaceobserver@2.3.0: {} for-each@0.3.3: @@ -32546,6 +32615,14 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + format@0.2.2: {} formdata-polyfill@4.0.10: @@ -32614,7 +32691,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.3 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -32658,14 +32735,6 @@ snapshots: get-east-asian-width@1.5.0: {} - get-intrinsic@1.2.4: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - has-proto: 1.0.3 - has-symbols: 1.0.3 - hasown: 2.0.2 - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -32676,7 +32745,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -32805,10 +32874,6 @@ snapshots: google-logging-utils@1.1.3: {} - gopd@1.0.1: - dependencies: - get-intrinsic: 1.2.4 - gopd@1.2.0: {} got@11.8.6: @@ -32871,16 +32936,12 @@ snapshots: has-property-descriptors@1.0.2: dependencies: - es-define-property: 1.0.0 - - has-proto@1.0.3: {} + es-define-property: 1.0.1 has-proto@1.2.0: dependencies: dunder-proto: 1.0.1 - has-symbols@1.0.3: {} - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -33274,13 +33335,13 @@ snapshots: internal-slot@1.0.7: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.3 side-channel: 1.1.0 internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.3 side-channel: 1.1.0 internmap@1.0.1: {} @@ -33362,11 +33423,11 @@ snapshots: is-core-module@2.15.1: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 is-core-module@2.16.1: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 is-data-view@1.0.2: dependencies: @@ -33472,7 +33533,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 is-regexp@1.0.0: {} @@ -33716,6 +33777,16 @@ snapshots: jmespath@0.16.0: {} + joi@18.2.1: + dependencies: + '@hapi/address': 5.1.1 + '@hapi/formula': 3.0.2 + '@hapi/hoek': 11.0.7 + '@hapi/pinpoint': 2.0.1 + '@hapi/tlds': 1.1.6 + '@hapi/topo': 6.0.2 + '@standard-schema/spec': 1.1.0 + jose@4.15.9: {} jose@5.2.3: {} @@ -34986,8 +35057,6 @@ snapshots: mime-db@1.52.0: {} - mime-db@1.53.0: {} - mime-db@1.54.0: {} mime-types@2.1.35: @@ -36297,6 +36366,8 @@ snapshots: proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + psql-describe@0.1.6: {} pstree.remy@1.1.8: {} @@ -37325,6 +37396,10 @@ snapshots: dependencies: tslib: 2.8.1 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -37493,8 +37568,8 @@ snapshots: define-data-property: 1.1.4 es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.2.4 - gopd: 1.0.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 has-property-descriptors: 1.0.2 set-function-name@2.0.2: @@ -37651,7 +37726,7 @@ snapshots: dependencies: call-bind: 1.0.7 es-errors: 1.3.0 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 object-inspect: 1.13.2 side-channel@1.1.0: @@ -39047,6 +39122,8 @@ snapshots: - supports-color - terser + vite-plugin-electron@0.29.1: {} + vite-plugin-pwa@0.21.0(vite@5.4.10(@types/node@25.6.0)(lightningcss@1.30.1)(terser@5.46.2))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0): dependencies: debug: 4.3.7(supports-color@5.5.0) @@ -39285,12 +39362,12 @@ snapshots: transitivePeerDependencies: - supports-color - vitepress-plugin-tabs@0.5.0(vitepress@1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3))(vue@3.5.12(typescript@5.8.3)): + vitepress-plugin-tabs@0.5.0(vitepress@1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(axios@1.16.0)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3))(vue@3.5.12(typescript@5.8.3)): dependencies: - vitepress: 1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3) + vitepress: 1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(axios@1.16.0)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3) vue: 3.5.12(typescript@5.8.3) - vitepress@1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3): + vitepress@1.5.0(@algolia/client-search@5.13.0)(@types/node@25.6.0)(@types/react@18.3.12)(axios@1.16.0)(jwt-decode@4.0.0)(lightningcss@1.30.1)(postcss@8.5.6)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2)(terser@5.46.2)(typescript@5.8.3): dependencies: '@docsearch/css': 3.7.0 '@docsearch/js': 3.7.0(@algolia/client-search@5.13.0)(@types/react@18.3.12)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(search-insights@2.17.2) @@ -39303,7 +39380,7 @@ snapshots: '@vue/devtools-api': 7.6.3 '@vue/shared': 3.5.12 '@vueuse/core': 11.2.0(vue@3.5.12(typescript@5.8.3)) - '@vueuse/integrations': 11.2.0(focus-trap@7.6.0)(jwt-decode@4.0.0)(vue@3.5.12(typescript@5.8.3)) + '@vueuse/integrations': 11.2.0(axios@1.16.0)(focus-trap@7.6.0)(jwt-decode@4.0.0)(vue@3.5.12(typescript@5.8.3)) focus-trap: 7.6.0 mark.js: 8.11.1 minisearch: 7.1.0 @@ -39796,6 +39873,16 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + wait-on@9.0.5: + dependencies: + axios: 1.16.0 + joi: 18.2.1 + lodash: 4.18.1 + minimist: 1.2.8 + rxjs: 7.8.2 + transitivePeerDependencies: + - debug + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -40097,13 +40184,13 @@ snapshots: wrap-ansi@8.1.0: dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 string-width: 5.1.2 strip-ansi: 7.2.0 wrap-ansi@9.0.2: dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 From 9ed2294352a2d6c53fa9af43099b9f741e90bbca Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 16:19:27 +0100 Subject: [PATCH 16/57] feat(agents-ui): sidebar filter & view menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ≡ dropdown between the server picker and settings cog. Group the session list by Date / Type / Status, hide noisy types or statuses via Show submenus, and Expand/Collapse all in one click. Prefs persist to localStorage. Co-authored-by: Cursor --- .../src/components/Sidebar.tsx | 55 ++++- .../src/components/SidebarFooter.tsx | 13 +- .../src/components/SidebarViewMenu.module.css | 32 +++ .../src/components/SidebarViewMenu.tsx | 203 ++++++++++++++++++ .../src/hooks/useExpandedTreeNodes.ts | 41 ++++ .../src/hooks/useSidebarView.ts | 163 ++++++++++++++ .../agents-server-ui/src/lib/sessionGroups.ts | 67 ++++++ 7 files changed, 563 insertions(+), 11 deletions(-) create mode 100644 packages/agents-server-ui/src/components/SidebarViewMenu.module.css create mode 100644 packages/agents-server-ui/src/components/SidebarViewMenu.tsx create mode 100644 packages/agents-server-ui/src/hooks/useSidebarView.ts diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 7ddeab6bf5..61dbc5d5be 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -3,7 +3,12 @@ import { SquarePen } from 'lucide-react' import { useLiveQuery } from '@tanstack/react-db' import { useNavigate } from '@tanstack/react-router' import { useElectricAgents } from '../lib/ElectricAgentsProvider' -import { bucketEntities } from '../lib/sessionGroups' +import { + bucketEntities, + groupByStatus, + groupByType, +} from '../lib/sessionGroups' +import { useSidebarView } from '../hooks/useSidebarView' import { HoverCard, ScrollArea, Stack, Text } from '../ui' import { NewSessionKey } from '../lib/keyLabels' import { setDragPayload } from '../lib/workspace/dragPayload' @@ -105,12 +110,29 @@ export function Sidebar({ }, [entitiesCollection] ) + + const view = useSidebarView() + + // Apply Show > Type / Show > Status filters before building the + // tree so a hidden parent doesn't accidentally hide its (visible) + // children — instead, the children are reparented to the root level + // in the filtered view, which is the conventional behaviour for + // tree filtering. + const visibleEntities = useMemo(() => { + if (view.hiddenTypes.size === 0 && view.hiddenStatuses.size === 0) { + return entities + } + return entities.filter( + (e) => !view.hiddenTypes.has(e.type) && !view.hiddenStatuses.has(e.status) + ) + }, [entities, view.hiddenTypes, view.hiddenStatuses]) + const pinnedSet = useMemo(() => new Set(pinnedUrls), [pinnedUrls]) - const pinnedEntities = entities.filter((e) => pinnedSet.has(e.url)) + const pinnedEntities = visibleEntities.filter((e) => pinnedSet.has(e.url)) const { roots, childrenByParent } = useMemo( - () => buildEntityTree(entities), - [entities] + () => buildEntityTree(visibleEntities), + [visibleEntities] ) const unpinnedRoots = useMemo( @@ -118,10 +140,17 @@ export function Sidebar({ [roots, pinnedSet] ) - const ungroupedBuckets = useMemo( - () => bucketEntities(unpinnedRoots), - [unpinnedRoots] - ) + const ungroupedBuckets = useMemo(() => { + switch (view.groupBy) { + case `type`: + return groupByType(unpinnedRoots) + case `status`: + return groupByStatus(unpinnedRoots) + case `date`: + default: + return bucketEntities(unpinnedRoots) + } + }, [unpinnedRoots, view.groupBy]) const handleNewSession = useCallback(() => { navigate({ to: `/` }) @@ -213,6 +242,16 @@ export function Sidebar({ No sessions )} + {entities.length > 0 && visibleEntities.length === 0 && ( + + No sessions match the current filters + + )} diff --git a/packages/agents-server-ui/src/components/SidebarFooter.tsx b/packages/agents-server-ui/src/components/SidebarFooter.tsx index c40ed06465..6077b353d1 100644 --- a/packages/agents-server-ui/src/components/SidebarFooter.tsx +++ b/packages/agents-server-ui/src/components/SidebarFooter.tsx @@ -1,18 +1,25 @@ import { ServerPicker } from './ServerPicker' import { SettingsMenu } from './SettingsMenu' +import { SidebarViewMenu } from './SidebarViewMenu' import styles from './SidebarFooter.module.css' /** * Bottom-anchored row in the sidebar. * - * Hosts the active-server picker on the left and the settings cog on - * the right. Settings dropdown currently exposes the theme toggle; - * future preferences land in the same menu. + * Layout (left → right): + * - ServerPicker: takes the leading flex slot, can grow to fill + * remaining width. + * - SidebarViewMenu: filter / grouping for the session list. + * - SettingsMenu: theme + runtime + Settings… launcher. + * + * The two trailing icon buttons sit flush to the right edge so the + * sidebar's icon column reads as a clean vertical rail. */ export function SidebarFooter(): React.ReactElement { return (
+
) diff --git a/packages/agents-server-ui/src/components/SidebarViewMenu.module.css b/packages/agents-server-ui/src/components/SidebarViewMenu.module.css new file mode 100644 index 0000000000..f274287c8f --- /dev/null +++ b/packages/agents-server-ui/src/components/SidebarViewMenu.module.css @@ -0,0 +1,32 @@ +/* Mirrors SettingsMenu.module.css — kept as a separate stylesheet + so each menu can grow its own tweaks without spooky cross-talk. */ + +.activeMark { + margin-left: auto; + color: var(--ds-text-2); +} + +.submenuTrigger { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 3px 3px 8px; + border-radius: 7px; + font-size: var(--ds-text-sm); + line-height: 1.3; + font-family: var(--ds-font-body); + color: var(--ds-text-1); + cursor: pointer; + outline: none; + user-select: none; + text-align: start; + width: 100%; + box-sizing: border-box; +} +.submenuTrigger[data-highlighted] { + background: var(--ds-bg-hover); +} +.submenuChevron { + margin-left: auto; + color: var(--ds-text-3); +} diff --git a/packages/agents-server-ui/src/components/SidebarViewMenu.tsx b/packages/agents-server-ui/src/components/SidebarViewMenu.tsx new file mode 100644 index 0000000000..06f64f530a --- /dev/null +++ b/packages/agents-server-ui/src/components/SidebarViewMenu.tsx @@ -0,0 +1,203 @@ +import { useMemo } from 'react' +import { + Activity, + CalendarClock, + Check, + ChevronRight, + ChevronsDownUp, + ChevronsUpDown, + Eye, + EyeOff, + ListFilter, + Tag, +} from 'lucide-react' +import { useLiveQuery } from '@tanstack/react-db' +import { IconButton, Menu, Text } from '../ui' +import { useElectricAgents } from '../lib/ElectricAgentsProvider' +import { + SIDEBAR_GROUP_BY_LABELS, + SIDEBAR_GROUP_BY_OPTIONS, + setSidebarGroupBy, + toggleSidebarStatusVisibility, + toggleSidebarTypeVisibility, + useSidebarView, + type SidebarGroupBy, +} from '../hooks/useSidebarView' +import { + collapseAllExpanded, + expandAllUrls, +} from '../hooks/useExpandedTreeNodes' +import styles from './SidebarViewMenu.module.css' + +/** Hardcoded enum from `ElectricAgentsProvider.entitySchema`. */ +const STATUSES = [`spawning`, `running`, `idle`, `stopped`] as const + +const GROUP_BY_ICONS: Record = { + date: , + type: , + status: , +} + +/** + * Sidebar view-and-filter menu — the funnel-icon dropdown that sits + * between the server picker and the settings cog in the sidebar + * footer. + * + * Mirrors the standard "Group by" / "Show" / collapse-all pattern + * common in agent / IDE chrome. Group-by is single-select; Show + * filters are multi-select submenus per category (Type, Status), so + * a project with lots of stopped runs can hide them without + * losing access to running sessions. + * + * State lives in the module-level `useSidebarView` store so the menu + * (rendered in a portal) and the Sidebar (rendered up the tree) can + * read and write the same prefs without an enclosing context. + */ +export function SidebarViewMenu(): React.ReactElement { + const view = useSidebarView() + const { entitiesCollection } = useElectricAgents() + + // Distinct types currently present in the entities collection — + // drives the "Show > Type" submenu so newly-introduced agent kinds + // appear automatically rather than being hardcoded here. + const { data: entities = [] } = useLiveQuery( + (q) => { + if (!entitiesCollection) return undefined + return q + .from({ e: entitiesCollection }) + .orderBy(({ e }) => e.updated_at, `desc`) + }, + [entitiesCollection] + ) + const distinctTypes = useMemo(() => { + const seen = new Set() + for (const e of entities) seen.add(e.type) + return Array.from(seen).sort((a, b) => a.localeCompare(b)) + }, [entities]) + + // "Expand all" needs to know which URLs are expandable (i.e. roots + // with children). The Sidebar already builds this graph; for menu + // purposes the cheap approximation of "every entity that is the + // parent of at least one other entity" is good enough. + const expandableUrls = useMemo(() => { + const parents = new Set() + for (const e of entities) { + if (e.parent) parents.add(e.parent) + } + return Array.from(parents) + }, [entities]) + + const formatLabel = (id: string): string => + id.replace(/[-_]+/g, ` `).replace(/\b\w/g, (c) => c.toUpperCase()) + + return ( + + + + + } + /> + + + Group by + {SIDEBAR_GROUP_BY_OPTIONS.map((opt) => { + const active = view.groupBy === opt + return ( + setSidebarGroupBy(opt)}> + {GROUP_BY_ICONS[opt]} + {SIDEBAR_GROUP_BY_LABELS[opt]} + {active && } + + ) + })} + + + + + + Show + + + + + Type + + + + {distinctTypes.length === 0 ? ( + + + No types yet + + + ) : ( + distinctTypes.map((t) => { + const visible = !view.hiddenTypes.has(t) + return ( + toggleSidebarTypeVisibility(t)} + > + {visible ? : } + {formatLabel(t)} + {visible && ( + + )} + + ) + }) + )} + + + + + + + Status + + + + {STATUSES.map((s) => { + const visible = !view.hiddenStatuses.has(s) + return ( + toggleSidebarStatusVisibility(s)} + > + {visible ? : } + {formatLabel(s)} + {visible && ( + + )} + + ) + })} + + + + + + + expandAllUrls(expandableUrls)} + disabled={expandableUrls.length === 0} + > + + Expand all + + collapseAllExpanded()}> + + Collapse all + + + + ) +} diff --git a/packages/agents-server-ui/src/hooks/useExpandedTreeNodes.ts b/packages/agents-server-ui/src/hooks/useExpandedTreeNodes.ts index 7862e349b0..e5c6cdb59f 100644 --- a/packages/agents-server-ui/src/hooks/useExpandedTreeNodes.ts +++ b/packages/agents-server-ui/src/hooks/useExpandedTreeNodes.ts @@ -49,6 +49,37 @@ class ExpandedTreeNodesStore { this.notify(url) } + /** + * Collapse every currently-expanded row in one shot. Notifies each + * affected URL's listeners individually so only the rows that were + * actually expanded re-render. + */ + collapseAll = (): void => { + if (this.expanded.size === 0) return + const wasExpanded = Array.from(this.expanded) + this.expanded.clear() + this.persist() + for (const url of wasExpanded) this.notify(url) + } + + /** + * Expand every URL provided. Useful for the "Expand all" affordance + * — caller passes the set of expandable nodes (e.g. tree roots + * with children) so we don't need to know about the entity tree + * here. + */ + expandAll = (urls: ReadonlyArray): void => { + let changed = false + for (const url of urls) { + if (!this.expanded.has(url)) { + this.expanded.add(url) + this.notify(url) + changed = true + } + } + if (changed) this.persist() + } + subscribe = (url: string, listener: Listener): (() => void) => { let bucket = this.listeners.get(url) if (!bucket) { @@ -107,6 +138,16 @@ export function toggleExpanded(url: string): void { store.toggle(url) } +/** Collapse every expanded row. Bound to the SidebarViewMenu action. */ +export function collapseAllExpanded(): void { + store.collapseAll() +} + +/** Expand the supplied list of URLs (no-op for already-expanded). */ +export function expandAllUrls(urls: ReadonlyArray): void { + store.expandAll(urls) +} + /** * Synchronous read for non-component code paths (e.g. selection * effects in the entity router). Components should use diff --git a/packages/agents-server-ui/src/hooks/useSidebarView.ts b/packages/agents-server-ui/src/hooks/useSidebarView.ts new file mode 100644 index 0000000000..03b23e8b27 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useSidebarView.ts @@ -0,0 +1,163 @@ +import { useSyncExternalStore } from 'react' + +const STORAGE_KEY = `electric-agents-ui.sidebar.view` + +/** Available grouping modes for the session list. */ +export type SidebarGroupBy = `date` | `type` | `status` + +export const SIDEBAR_GROUP_BY_OPTIONS: ReadonlyArray = [ + `date`, + `type`, + `status`, +] + +export const SIDEBAR_GROUP_BY_LABELS: Record = { + date: `Date`, + type: `Type`, + status: `Status`, +} + +interface SidebarViewState { + groupBy: SidebarGroupBy + /** Entity types to *hide*. Stored as an exclusion set so newly seen + * types default to visible without an explicit allow-list update. */ + hiddenTypes: Set + /** Statuses to hide. Same exclusion-set convention as hiddenTypes. */ + hiddenStatuses: Set +} + +const DEFAULT_STATE: SidebarViewState = { + groupBy: `date`, + hiddenTypes: new Set(), + hiddenStatuses: new Set(), +} + +/** + * View preferences for the sidebar — drives the `` + * dropdown next to the settings cog. + * + * **Why an external store (vs `useState` + context)?** + * The pattern matches `useExpandedTreeNodes`: the SidebarViewMenu + * (which renders inside a popup portal) and the Sidebar (which lives + * up the tree) both need to read and write this state without + * forcing a context provider near the top of the app. A module-level + * store + `useSyncExternalStore` lets each subscribe individually + * and only re-render when their slice changes. + * + * **Hidden vs visible** — both `hiddenTypes` and `hiddenStatuses` are + * stored as *exclusion* sets. Anything not in the set is shown. This + * means a freshly-seen entity type (e.g. a new agent kind shipped in + * a server update) is visible by default rather than silently filtered + * out because it wasn't in an old allow-list. + */ +type Listener = () => void + +class SidebarViewStore { + private state: SidebarViewState = readInitial() + private listeners: Set = new Set() + + getState = (): SidebarViewState => this.state + + setGroupBy = (groupBy: SidebarGroupBy): void => { + if (this.state.groupBy === groupBy) return + this.state = { ...this.state, groupBy } + this.persist() + this.notify() + } + + toggleTypeVisibility = (type: string): void => { + const next = new Set(this.state.hiddenTypes) + if (next.has(type)) next.delete(type) + else next.add(type) + this.state = { ...this.state, hiddenTypes: next } + this.persist() + this.notify() + } + + toggleStatusVisibility = (status: string): void => { + const next = new Set(this.state.hiddenStatuses) + if (next.has(status)) next.delete(status) + else next.add(status) + this.state = { ...this.state, hiddenStatuses: next } + this.persist() + this.notify() + } + + resetVisibility = (): void => { + this.state = { + ...this.state, + hiddenTypes: new Set(), + hiddenStatuses: new Set(), + } + this.persist() + this.notify() + } + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + private notify(): void { + for (const l of this.listeners) l() + } + + private persist(): void { + if (typeof window === `undefined`) return + try { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + groupBy: this.state.groupBy, + hiddenTypes: Array.from(this.state.hiddenTypes), + hiddenStatuses: Array.from(this.state.hiddenStatuses), + }) + ) + } catch { + // Quota / private mode — silent. View prefs not making it to + // disk is recoverable on next session. + } + } +} + +function readInitial(): SidebarViewState { + if (typeof window === `undefined`) return DEFAULT_STATE + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return DEFAULT_STATE + const parsed = JSON.parse(raw) as Partial<{ + groupBy: SidebarGroupBy + hiddenTypes: Array + hiddenStatuses: Array + }> + return { + groupBy: SIDEBAR_GROUP_BY_OPTIONS.includes( + parsed.groupBy as SidebarGroupBy + ) + ? (parsed.groupBy as SidebarGroupBy) + : DEFAULT_STATE.groupBy, + hiddenTypes: new Set( + Array.isArray(parsed.hiddenTypes) ? parsed.hiddenTypes : [] + ), + hiddenStatuses: new Set( + Array.isArray(parsed.hiddenStatuses) ? parsed.hiddenStatuses : [] + ), + } + } catch { + return DEFAULT_STATE + } +} + +const store = new SidebarViewStore() + +/** Read the full sidebar-view state (re-renders whenever any slice changes). */ +export function useSidebarView(): SidebarViewState { + return useSyncExternalStore(store.subscribe, store.getState, store.getState) +} + +export const setSidebarGroupBy = store.setGroupBy +export const toggleSidebarTypeVisibility = store.toggleTypeVisibility +export const toggleSidebarStatusVisibility = store.toggleStatusVisibility +export const resetSidebarVisibility = store.resetVisibility diff --git a/packages/agents-server-ui/src/lib/sessionGroups.ts b/packages/agents-server-ui/src/lib/sessionGroups.ts index 71205baa26..56932c14de 100644 --- a/packages/agents-server-ui/src/lib/sessionGroups.ts +++ b/packages/agents-server-ui/src/lib/sessionGroups.ts @@ -121,3 +121,70 @@ class Group implements SessionGroup { public label: string ) {} } + +/** + * Group by entity `type`, sorted by group size (most populous group + * first) and then alphabetically. Inside each group entities are + * ordered by `updated_at` descending — same as the date buckets — so + * the most recently touched entity in any group is always at the top. + */ +export function groupByType( + entities: ReadonlyArray +): Array { + const buckets = new Map() + for (const entity of [...entities].sort( + (a, b) => b.updated_at - a.updated_at + )) { + const t = entity.type + let group = buckets.get(t) + if (!group) { + group = new Group(`type-${t}`, `older`, formatLabel(t)) + buckets.set(t, group) + } + group.items.push(entity) + } + return Array.from(buckets.values()).sort((a, b) => { + const dx = b.items.length - a.items.length + if (dx !== 0) return dx + return a.label.localeCompare(b.label) + }) +} + +/** + * Group by `status`, ordered by lifecycle (running → idle → spawning + * → stopped) so the user's eye lands on currently-active sessions + * first. Same in-group sort as `groupByType`. + */ +const STATUS_ORDER: Record = { + running: 0, + idle: 1, + spawning: 2, + stopped: 3, +} + +export function groupByStatus( + entities: ReadonlyArray +): Array { + const buckets = new Map() + for (const entity of [...entities].sort( + (a, b) => b.updated_at - a.updated_at + )) { + const s = entity.status + let group = buckets.get(s) + if (!group) { + group = new Group(`status-${s}`, `older`, formatLabel(s)) + buckets.set(s, group) + } + group.items.push(entity) + } + return Array.from(buckets.values()).sort((a, b) => { + const ax = STATUS_ORDER[a.id.replace(`status-`, ``)] ?? 99 + const bx = STATUS_ORDER[b.id.replace(`status-`, ``)] ?? 99 + return ax - bx + }) +} + +/** Title-case a snake_case / kebab-case identifier for use as a label. */ +function formatLabel(id: string): string { + return id.replace(/[-_]+/g, ` `).replace(/\b\w/g, (c) => c.toUpperCase()) +} From 2ebb9a916ed8f123f8edea026acf42e5cc294d8c Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:19:23 +0100 Subject: [PATCH 17/57] feat(agents-ui): per-session working directory picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a way to choose a `workingDirectory` spawn arg for each new Horton session, without touching the runtime's global cwd. • horton: accepts an optional `workingDirectory` spawn arg and routes it into the system prompt + filesystem tools, with fallback to the runtime's configured cwd. • agents-desktop: new `desktop:pick-directory` IPC for one-shot native folder picks (no persistence, no runtime restart). • new-session composer: a `WorkingDirectoryPicker` pill sits next to the model / reasoning controls, defaulting to the most-recently-used path. • sidebar filter menu: adds a "Working dir" group-by mode backed by `groupByWorkingDirectory` in `sessionGroups`. • shared UI: new `Combobox` primitive (Base UI wrapper, types mirror `Select`) used by the picker for typeahead + recents + native browse, with ServerPicker-style row geometry and a check-↔-IconButton swap on hover for removing recents. Co-authored-by: Cursor --- packages/agents-desktop/src/main.ts | 17 ++ packages/agents-desktop/src/preload.ts | 2 + .../src/components/Sidebar.tsx | 3 + .../src/components/SidebarViewMenu.tsx | 2 + .../WorkingDirectoryPicker.module.css | 139 ++++++++++ .../src/components/WorkingDirectoryPicker.tsx | 260 ++++++++++++++++++ .../src/components/views/NewSessionView.tsx | 41 ++- .../src/hooks/useRecentWorkingDirectories.ts | 92 +++++++ .../src/hooks/useSidebarView.ts | 4 +- .../src/lib/server-connection.ts | 10 + .../agents-server-ui/src/lib/sessionGroups.ts | 50 ++++ .../src/ui/Combobox.module.css | 124 +++++++++ packages/agents-server-ui/src/ui/Combobox.tsx | 254 +++++++++++++++++ packages/agents-server-ui/src/ui/index.ts | 2 + packages/agents/src/agents/horton.ts | 18 +- 15 files changed, 1013 insertions(+), 5 deletions(-) create mode 100644 packages/agents-server-ui/src/components/WorkingDirectoryPicker.module.css create mode 100644 packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx create mode 100644 packages/agents-server-ui/src/hooks/useRecentWorkingDirectories.ts create mode 100644 packages/agents-server-ui/src/ui/Combobox.module.css create mode 100644 packages/agents-server-ui/src/ui/Combobox.tsx diff --git a/packages/agents-desktop/src/main.ts b/packages/agents-desktop/src/main.ts index b71e61a704..0c89dd0258 100644 --- a/packages/agents-desktop/src/main.ts +++ b/packages/agents-desktop/src/main.ts @@ -725,6 +725,23 @@ function registerIpcHandlers(): void { } return settings.workingDirectory }) + // One-shot directory picker — does NOT mutate the runtime cwd or + // restart anything. Used by the new-session screen so each spawned + // session can carry its own `workingDirectory` spawn arg without + // disturbing the global default. Returns `null` on cancel; caller + // is responsible for treating the result as ephemeral and (if it + // wants to remember it) plumbing it into recent-dirs storage. + ipcMain.handle( + `desktop:pick-directory`, + async (_event, options?: { defaultPath?: string }) => { + const result = await dialog.showOpenDialog({ + properties: [`openDirectory`, `createDirectory`], + defaultPath: options?.defaultPath, + }) + if (result.canceled) return null + return result.filePaths[0] ?? null + } + ) } function windowDisplayLabel(win: BrowserWindow): string { diff --git a/packages/agents-desktop/src/preload.ts b/packages/agents-desktop/src/preload.ts index 0db1ea3b15..8cca814cf6 100644 --- a/packages/agents-desktop/src/preload.ts +++ b/packages/agents-desktop/src/preload.ts @@ -86,6 +86,8 @@ const api = { ipcRenderer.invoke(`desktop:get-working-directory`), chooseWorkingDirectory: (): Promise => ipcRenderer.invoke(`desktop:choose-working-directory`), + pickDirectory: (options?: { defaultPath?: string }): Promise => + ipcRenderer.invoke(`desktop:pick-directory`, options), onDesktopStateChanged: ( callback: (state: DesktopState) => void ): (() => void) => { diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 61dbc5d5be..ab8c702798 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { bucketEntities, groupByStatus, groupByType, + groupByWorkingDirectory, } from '../lib/sessionGroups' import { useSidebarView } from '../hooks/useSidebarView' import { HoverCard, ScrollArea, Stack, Text } from '../ui' @@ -146,6 +147,8 @@ export function Sidebar({ return groupByType(unpinnedRoots) case `status`: return groupByStatus(unpinnedRoots) + case `workingDir`: + return groupByWorkingDirectory(unpinnedRoots) case `date`: default: return bucketEntities(unpinnedRoots) diff --git a/packages/agents-server-ui/src/components/SidebarViewMenu.tsx b/packages/agents-server-ui/src/components/SidebarViewMenu.tsx index 06f64f530a..ad2667f238 100644 --- a/packages/agents-server-ui/src/components/SidebarViewMenu.tsx +++ b/packages/agents-server-ui/src/components/SidebarViewMenu.tsx @@ -8,6 +8,7 @@ import { ChevronsUpDown, Eye, EyeOff, + Folder, ListFilter, Tag, } from 'lucide-react' @@ -36,6 +37,7 @@ const GROUP_BY_ICONS: Record = { date: , type: , status: , + workingDir: , } /** diff --git a/packages/agents-server-ui/src/components/WorkingDirectoryPicker.module.css b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.module.css new file mode 100644 index 0000000000..0318267fdf --- /dev/null +++ b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.module.css @@ -0,0 +1,139 @@ +/* Trigger pill — matches `Select.triggerPill` geometry so it slots + into the composer toolbar next to the model / reasoning pills. + We don't reuse Select's class directly because this trigger + layout is icon + label (no chevron / Value child) and needs an + ellipsis on the label, but the visual treatment (height, padding, + bg, hover) mirrors `Select.module.css → .triggerPill` exactly. */ +.trigger { + display: inline-flex; + align-items: center; + gap: 4px; + height: 24px; + max-width: 220px; + padding: 0 8px; + border: none; + border-radius: var(--ds-radius-2); + background: var(--ds-chip-bg); + font-family: var(--ds-font-body); + font-size: var(--ds-text-xs); + line-height: 1; + color: var(--ds-text-2); + cursor: pointer; + outline: none; + user-select: none; + transition: + background 100ms ease, + color 100ms ease; +} +.trigger:hover:not(:disabled) { + background: var(--ds-bg-hover); + color: var(--ds-text-1); +} +.trigger:focus-visible { + box-shadow: 0 0 0 1px var(--ds-accent-9); +} +.trigger[data-disabled], +.trigger:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.trigger[data-empty='true'] { + /* Slightly muted so the unset state reads as a placeholder rather + than an active selection. */ + color: var(--ds-text-3); +} +.triggerIcon { + flex-shrink: 0; + color: var(--ds-text-3); +} +.triggerLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* Popup needs a touch more breathing room than the default 180px + min-width because path strings are long. Keep narrow enough to + feel like a contextual menu rather than a settings panel. */ +.popup { + min-width: 280px; + max-width: min(calc(100vw - 16px), 420px); +} + +/* Inner row wrapper — copied verbatim from + `ServerPicker.module.css → .menuRow`. Sits inside `Combobox.Item` + (which supplies the row's padding / radius / highlight via the + shared `.item` class) and carries the row-content layout so every + row — None, recents, Open folder… — shares one geometry. + + `min-height: 24px` matches an inline `IconButton size={1}` so + rows with a trailing button and rows without one stay on the + same row pitch. `gap: 8px` matches ServerPicker's row gap so + icon → label → trailing read at identical spacing across + dropdowns. */ +.menuRow { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 1; + min-height: 24px; +} + +.menuRowIcon { + flex-shrink: 0; + color: var(--ds-text-3); +} + +.menuRowLabel { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* Trailing slot — sized to `IconButton size={1}` (24×24) so the + remove button slots in cleanly and `margin-left: auto` pushes + it past the flex-1 label to the row's right edge, exactly like + `ServerPicker.module.css → .removeBtn`. The check sits stacked + in the same slot via absolute positioning so swapping check ↔ X + never reflows the row. */ +.trailing { + position: relative; + flex-shrink: 0; + width: 24px; + height: 24px; + margin-left: auto; +} + +.trailingCheck { + position: absolute; + inset: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ds-text-2); + pointer-events: none; +} + +.trailingRemove { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 0.1s ease; +} + +/* Hover / kbd-focus on the row reveals the X and hides the check + — they share the same trailing slot, so the row never reflows. + The IconButton stays interactive (its own pointer events live on + the button element, not on `.trailing`) so its `:hover` bg + tinting from `Button.module.css → .variant-ghost` still reads. */ +.recentItem:hover .trailingRemove, +.recentItem[data-highlighted] .trailingRemove { + opacity: 1; +} +.recentItem:hover .trailingCheck, +.recentItem[data-highlighted] .trailingCheck { + opacity: 0; +} diff --git a/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx new file mode 100644 index 0000000000..e6d7557673 --- /dev/null +++ b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx @@ -0,0 +1,260 @@ +import { useCallback, useMemo, useState } from 'react' +import { Check, Folder, FolderOpen, Home, X } from 'lucide-react' +import { Combobox, IconButton } from '../ui' +import { useRecentWorkingDirectories } from '../hooks/useRecentWorkingDirectories' +import styles from './WorkingDirectoryPicker.module.css' + +type WorkingDirectoryPickerProps = { + value: string | null + onChange: (path: string | null) => void + /** Optional default path the native picker should start in. */ + defaultPath?: string | null + disabled?: boolean +} + +/** + * Sentinel values for the special, non-path rows. Both are valid + * `Combobox.Item` values (the wrapped `Combobox` + * doesn't allow `null` item values, matching `Select`), and both are + * intercepted in `handleValueChange` before propagating to `onChange`. + * + * - NONE_VALUE → maps to external `null` (use server default) + * - BROWSE_VALUE → fires the native folder picker, doesn't commit + */ +const NONE_VALUE = `__none__` +const BROWSE_VALUE = `__browse__` + +/** + * Combobox for choosing the `workingDirectory` spawn arg. + * + * Built on the shared `Combobox` UI primitive so popup surface, row + * geometry, and indicator placement match every other dropdown in + * the app. Selecting "None" clears the spawn arg (the agent runs in + * the server's configured cwd); selecting "Open folder…" shells out + * to the native folder picker (Electron only). + * + * Per-row trailing affordance: + * - Resting + selected → ✓ check + * - Hovered / kbd-highlighted → ✕ remove (recents only) + * Both icons share the same trailing slot so swapping them never + * shifts the row's layout. + */ +export function WorkingDirectoryPicker({ + value, + onChange, + defaultPath, + disabled, +}: WorkingDirectoryPickerProps): React.ReactElement { + const [inputValue, setInputValue] = useState(``) + const { recents, addRecent, removeRecent } = useRecentWorkingDirectories() + + const isDesktop = + typeof window !== `undefined` && Boolean(window.electronAPI?.pickDirectory) + + const homeDir = useMemo( + () => detectHomeDir(recents, defaultPath), + [recents, defaultPath] + ) + + const triggerLabel = useMemo( + () => (value ? tildify(value, homeDir) : `None`), + [value, homeDir] + ) + + const filteredRecents = useMemo(() => { + const q = inputValue.trim().toLowerCase() + if (!q) return recents + return recents.filter((p) => p.toLowerCase().includes(q)) + }, [recents, inputValue]) + + // External `value` (string|null) → internal Combobox value (string). + // `null` maps to the NONE_VALUE sentinel so the "None" row reads as + // the active selection in the popup. + const internalValue = value ?? NONE_VALUE + + const commit = useCallback( + (path: string | null) => { + const trimmed = path?.trim() || null + onChange(trimmed) + if (trimmed) addRecent(trimmed) + }, + [onChange, addRecent] + ) + + const handleBrowse = useCallback(async () => { + if (!window.electronAPI?.pickDirectory) return + const picked = await window.electronAPI.pickDirectory({ + defaultPath: value ?? defaultPath ?? undefined, + }) + if (picked) commit(picked) + }, [commit, defaultPath, value]) + + const handleValueChange = useCallback( + (next: string | null) => { + // Routing: special sentinels run actions instead of committing. + if (next === BROWSE_VALUE) { + void handleBrowse() + return + } + if (next === NONE_VALUE || next === null) { + commit(null) + return + } + commit(next) + }, + [commit, handleBrowse] + ) + + return ( + + value={internalValue} + onValueChange={handleValueChange} + inputValue={inputValue} + onInputValueChange={setInputValue} + disabled={disabled} + > + + + {triggerLabel} + + } + /> + + + + {/* Every row uses the same `.menuRow` inner wrapper as + ServerPicker's saved-server rows. The wrapper is what + carries `min-height: 24px` (sized to match an inline + `IconButton size={1}`), so rows with a trailing control + and rows without one stay on a uniform 30px row pitch + (24px content + 6px item padding). Mirrors the trick + `ServerPicker.module.css → .menuRow` uses to stop the + menu jumping between saved and discovered groups. */} + + + + None + + {value === null && ( + + + + )} + + + + + {filteredRecents.map((path) => { + const isSelected = path === value + return ( + + + + + {tildify(path, homeDir)} + + {/* Trailing slot is a 24×24 box (matching + IconButton size={1}) with the check and the + remove IconButton stacked via absolute + positioning. Opacity-only swap on row hover / + kbd-highlight keeps the row pitch identical to + every other row in the popup. */} + + {isSelected && ( + + + + )} + { + // Stop the click from bubbling up to the + // Combobox.Item's selection handler — + // without this, removing a recent would + // also commit it. + e.stopPropagation() + e.preventDefault() + removeRecent(path) + }} + onPointerDown={(e) => e.stopPropagation()} + aria-label={`Remove ${path} from recents`} + title="Remove from recents" + > + + + + + + ) + })} + + {isDesktop && ( + <> + + + + + Open folder… + + + + )} + + + + ) +} + +/** + * Replace `$HOME` with `~` in a path for display. We don't have a + * platform `os.homedir()` in the renderer, so we sniff the longest + * common prefix among recent paths and `defaultPath` that looks like + * a home dir (`/Users/` on macOS, `/home/` on Linux, + * `C:\\Users\\` on Windows). Degrades gracefully — if we can't + * find one, paths render as-is. + */ +function tildify(path: string, homeDir: string | null): string { + if (!homeDir) return path + if (path === homeDir) return `~` + if (path.startsWith(homeDir + `/`)) return `~${path.slice(homeDir.length)}` + if (path.startsWith(homeDir + `\\`)) return `~${path.slice(homeDir.length)}` + return path +} + +function detectHomeDir( + recents: ReadonlyArray, + defaultPath?: string | null +): string | null { + const candidates = [defaultPath, ...recents].filter( + (p): p is string => typeof p === `string` && p.length > 0 + ) + for (const p of candidates) { + const m = + p.match(/^(\/Users\/[^/]+)/) || + p.match(/^(\/home\/[^/]+)/) || + p.match(/^([A-Za-z]:\\Users\\[^\\]+)/) + if (m) return m[1] ?? null + } + return null +} diff --git a/packages/agents-server-ui/src/components/views/NewSessionView.tsx b/packages/agents-server-ui/src/components/views/NewSessionView.tsx index e433c36769..8c7b5fddb6 100644 --- a/packages/agents-server-ui/src/components/views/NewSessionView.tsx +++ b/packages/agents-server-ui/src/components/views/NewSessionView.tsx @@ -6,8 +6,10 @@ import { nanoid } from 'nanoid' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' import { useServerConnection } from '../../hooks/useServerConnection' import { useWorkspace } from '../../hooks/useWorkspace' +import { useRecentWorkingDirectories } from '../../hooks/useRecentWorkingDirectories' import { Select, Stack, Text } from '../../ui' import { SchemaForm, hasSchemaProperties, isObjectSchema } from '../SchemaForm' +import { WorkingDirectoryPicker } from '../WorkingDirectoryPicker' import styles from '../NewSessionPage.module.css' import type { ElectricEntityType } from '../../lib/ElectricAgentsProvider' import type { StandaloneViewProps } from '../../lib/workspace/viewRegistry' @@ -47,6 +49,16 @@ export function NewSessionView({ const { helpers } = useWorkspace() const [selected, setSelected] = useState(null) const [error, setError] = useState(null) + const { recents: recentDirs, addRecent: addRecentDir } = + useRecentWorkingDirectories() + // Default to the most-recently-used working directory so a user + // who keeps opening sessions against the same project root doesn't + // have to re-select it each time. Initialised lazily so subsequent + // additions to `recents` don't yank the picker out from under the + // user mid-edit. + const [workingDirectory, setWorkingDirectory] = useState( + () => recentDirs[0] ?? null + ) const { data: entityTypes = [] } = useLiveQuery( (query) => { @@ -134,9 +146,17 @@ export function NewSessionView({ const handleStartDefault = useCallback( (text: string, args: Record) => { if (!defaultAgent) return - void doSpawn(defaultAgent.name, args, text) + // Inject the picker's choice into the spawn args for the + // composer flow only — non-default agents have their own + // schemas and may not understand `workingDirectory`. Also + // remember the chosen path so the next session opens with the + // same default. + const augmented = + workingDirectory !== null ? { ...args, workingDirectory } : args + if (workingDirectory !== null) addRecentDir(workingDirectory) + void doSpawn(defaultAgent.name, augmented, text) }, - [defaultAgent, doSpawn] + [defaultAgent, doSpawn, workingDirectory, addRecentDir] ) return ( @@ -157,6 +177,8 @@ export function NewSessionView({ onStartDefault={handleStartDefault} spawnReady={Boolean(spawnEntity)} error={error} + workingDirectory={workingDirectory} + onChangeWorkingDirectory={setWorkingDirectory} /> )}
@@ -171,6 +193,8 @@ function Picker({ onStartDefault, spawnReady, error, + workingDirectory, + onChangeWorkingDirectory, }: { defaultAgent: ElectricEntityType | null otherAgents: Array @@ -178,6 +202,8 @@ function Picker({ onStartDefault: (text: string, args: Record) => void spawnReady: boolean error: string | null + workingDirectory: string | null + onChangeWorkingDirectory: (path: string | null) => void }): React.ReactElement { const hasAnyAgent = defaultAgent !== null || otherAgents.length > 0 @@ -201,6 +227,8 @@ function Picker({ agent={defaultAgent} onSubmit={onStartDefault} disabled={!spawnReady} + workingDirectory={workingDirectory} + onChangeWorkingDirectory={onChangeWorkingDirectory} /> )} @@ -308,10 +336,14 @@ function DefaultAgentComposer({ agent, onSubmit, disabled, + workingDirectory, + onChangeWorkingDirectory, }: { agent: ElectricEntityType onSubmit: (text: string, args: Record) => void disabled?: boolean + workingDirectory: string | null + onChangeWorkingDirectory: (path: string | null) => void }): React.ReactElement { const [value, setValue] = useState(``) const [submitting, setSubmitting] = useState(false) @@ -404,6 +436,11 @@ function DefaultAgentComposer({ /> ) : null )} +
{submitting && Starting…} diff --git a/packages/agents-server-ui/src/hooks/useRecentWorkingDirectories.ts b/packages/agents-server-ui/src/hooks/useRecentWorkingDirectories.ts new file mode 100644 index 0000000000..7395c3fc44 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useRecentWorkingDirectories.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useState } from 'react' + +const STORAGE_KEY = `electric-agents-ui.recent-working-dirs` +const MAX_RECENTS = 10 + +/** + * Most-recently-used absolute paths chosen by the user as a Horton + * working directory. Persisted to `localStorage` so it survives reloads + * and is shared across Electron windows (same origin, so localStorage + * is shared). + * + * **Why localStorage rather than IPC + main-process settings?** The + * recents list is purely UI sugar — it doesn't affect any backend + * behaviour. Keeping it client-side means the same hook works in the + * web build where there's no Electron main process, and we avoid an + * IPC round-trip every time the picker opens. + * + * Recents are stored newest-first; calling `addRecent(path)` moves an + * existing path to the front and trims the tail at `MAX_RECENTS`. + */ +function readInitial(): Array { + if (typeof window === `undefined`) return [] + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter((v): v is string => typeof v === `string`) + } catch { + return [] + } +} + +function persist(list: Array): void { + if (typeof window === `undefined`) return + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(list)) + } catch { + // Quota / private mode — silent. Recents are pure UI sugar. + } +} + +// Module-level state + listeners so the hook stays in sync across +// every mounted instance (e.g. NewSessionView and the sidebar +// group-by reading the same recents list don't drift). +let recents: Array = readInitial() +const listeners = new Set<() => void>() + +function notify(): void { + for (const l of listeners) l() +} + +export function useRecentWorkingDirectories(): { + recents: ReadonlyArray + addRecent: (path: string) => void + removeRecent: (path: string) => void + clearRecents: () => void +} { + const [snapshot, setSnapshot] = useState>(recents) + useEffect(() => { + const listener = (): void => setSnapshot(recents) + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, []) + + const addRecent = useCallback((path: string) => { + const trimmed = path.trim() + if (!trimmed) return + recents = [trimmed, ...recents.filter((p) => p !== trimmed)].slice( + 0, + MAX_RECENTS + ) + persist(recents) + notify() + }, []) + + const removeRecent = useCallback((path: string) => { + recents = recents.filter((p) => p !== path) + persist(recents) + notify() + }, []) + + const clearRecents = useCallback(() => { + recents = [] + persist(recents) + notify() + }, []) + + return { recents: snapshot, addRecent, removeRecent, clearRecents } +} diff --git a/packages/agents-server-ui/src/hooks/useSidebarView.ts b/packages/agents-server-ui/src/hooks/useSidebarView.ts index 03b23e8b27..b3accd45a4 100644 --- a/packages/agents-server-ui/src/hooks/useSidebarView.ts +++ b/packages/agents-server-ui/src/hooks/useSidebarView.ts @@ -3,18 +3,20 @@ import { useSyncExternalStore } from 'react' const STORAGE_KEY = `electric-agents-ui.sidebar.view` /** Available grouping modes for the session list. */ -export type SidebarGroupBy = `date` | `type` | `status` +export type SidebarGroupBy = `date` | `type` | `status` | `workingDir` export const SIDEBAR_GROUP_BY_OPTIONS: ReadonlyArray = [ `date`, `type`, `status`, + `workingDir`, ] export const SIDEBAR_GROUP_BY_LABELS: Record = { date: `Date`, type: `Type`, status: `Status`, + workingDir: `Working dir`, } interface SidebarViewState { diff --git a/packages/agents-server-ui/src/lib/server-connection.ts b/packages/agents-server-ui/src/lib/server-connection.ts index 6498e52f93..b6ec1343b3 100644 --- a/packages/agents-server-ui/src/lib/server-connection.ts +++ b/packages/agents-server-ui/src/lib/server-connection.ts @@ -87,6 +87,16 @@ declare global { saveApiKeys?: (keys: ApiKeys) => Promise getWorkingDirectory?: () => Promise chooseWorkingDirectory?: () => Promise + /** + * One-shot native folder picker. Unlike `chooseWorkingDirectory`, + * this does NOT update the runtime's persistent working dir or + * restart the runtime — used by the new-session screen so each + * spawned session can carry its own ephemeral `workingDirectory` + * spawn arg. + */ + pickDirectory?: (options?: { + defaultPath?: string + }) => Promise onDesktopStateChanged?: ( callback: (state: DesktopState) => void ) => () => void diff --git a/packages/agents-server-ui/src/lib/sessionGroups.ts b/packages/agents-server-ui/src/lib/sessionGroups.ts index 56932c14de..e4f423e055 100644 --- a/packages/agents-server-ui/src/lib/sessionGroups.ts +++ b/packages/agents-server-ui/src/lib/sessionGroups.ts @@ -188,3 +188,53 @@ export function groupByStatus( function formatLabel(id: string): string { return id.replace(/[-_]+/g, ` `).replace(/\b\w/g, (c) => c.toUpperCase()) } + +/** + * Group by `spawn_args.workingDirectory`. Entities without a working + * directory fall into a single trailing "None" bucket so they're + * still visible — hiding them would silently drop sessions that were + * spawned through paths that don't carry a cwd (e.g. older sessions, + * agent types other than horton). + * + * Buckets are labelled with the basename of the path; the full path + * is stashed on `id` for tooltip / debugging purposes downstream. + * Sort order: most-populous first, alphabetical tiebreaker. + */ +export function groupByWorkingDirectory( + entities: ReadonlyArray +): Array { + const buckets = new Map() + const noDir = new Group(`cwd:none`, `older`, `None`) + + for (const entity of [...entities].sort( + (a, b) => b.updated_at - a.updated_at + )) { + const raw = entity.spawn_args?.workingDirectory + const cwd = typeof raw === `string` && raw.trim().length > 0 ? raw : null + if (cwd === null) { + noDir.items.push(entity) + continue + } + let group = buckets.get(cwd) + if (!group) { + group = new Group(`cwd:${cwd}`, `older`, basenameOrPath(cwd)) + buckets.set(cwd, group) + } + group.items.push(entity) + } + + const dirGroups = Array.from(buckets.values()).sort((a, b) => { + const dx = b.items.length - a.items.length + if (dx !== 0) return dx + return a.label.localeCompare(b.label) + }) + // "None" bucket always last so user-tagged groups dominate the + // visual top of the list. + return noDir.items.length > 0 ? [...dirGroups, noDir] : dirGroups +} + +function basenameOrPath(path: string): string { + const trimmed = path.replace(/[/\\]+$/, ``) + const idx = Math.max(trimmed.lastIndexOf(`/`), trimmed.lastIndexOf(`\\`)) + return idx === -1 ? trimmed : trimmed.slice(idx + 1) || trimmed +} diff --git a/packages/agents-server-ui/src/ui/Combobox.module.css b/packages/agents-server-ui/src/ui/Combobox.module.css new file mode 100644 index 0000000000..b99dd5932e --- /dev/null +++ b/packages/agents-server-ui/src/ui/Combobox.module.css @@ -0,0 +1,124 @@ +/* Combobox surface — composes with `popoverStyles.popup` from + Popover.module.css so border, shadow, radius, and animations + match Menu / Select / Popover dropdowns. The 3px inner padding + keeps the concentric corner geometry (border + padding + + item_radius = popup_radius = 11px) so items nestle inside the + popup with a uniform 3px halo on all four sides. */ +.popup { + padding: 3px; + min-width: 180px; + max-height: min(60vh, 360px); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Search input — a row-shaped box pinned to the top of the popup. + Borderless so it reads as a continuation of the popup surface + rather than a nested form field; the popup's own border is the + edge. + + Sized to match an item row exactly (`height: 30px` = 24px content + + 6px padding-y) so the input + first row sit on a uniform pitch + and the popup doesn't have a visibly different "header" zone. + `padding-left: 12px` aligns the placeholder text with the item + labels' icon column (which sits at 1px border + 3px popup padding + + 12px item padding-left = 16px from the popup's outer edge), + giving a clearly-visible left inset on every row. */ +.input { + display: block; + width: 100%; + box-sizing: border-box; + height: 30px; + padding: 0 12px; + margin: 0; + border: none; + background: transparent; + color: var(--ds-text-1); + font-family: var(--ds-font-body); + font-size: var(--ds-text-sm); + /* `line-height: 30px` vertical-centers the single-line text in + the 30px input box without needing flex on a native `input`. */ + line-height: 30px; + outline: none; +} +.input::placeholder { + color: var(--ds-text-3); +} + +.list { + overflow: auto; + max-height: inherit; + outline: none; +} + +/* Item geometry — same vertical metrics as `Menu.module.css → .item` + but with the left padding pushed from 8px → 12px so the icon + column sits at a clearly-visible left inset (1 + 3 + 12 = 16px + from the popup outer edge). The right padding stays asymmetric + at 3px so any trailing inner control (e.g. the recent-row remove + IconButton) gets a uniform 3px halo on top, bottom and right and + reads as nested inside the row, exactly like the trash button on + ServerPicker's saved-server rows. + + Note we DON'T touch border-radius / item left edge — the row's + highlight bg still starts flush with the popup's inner padding + so the concentric-corner geometry on `Popover.module.css → .popup` + stays exact (popup_radius 11px = border 1 + popup padding 3 + + item radius 7). */ +.item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 3px 3px 12px; + border-radius: 7px; + font-size: var(--ds-text-sm); + line-height: 1.3; + font-family: var(--ds-font-body); + color: var(--ds-text-1); + cursor: pointer; + outline: none; + user-select: none; + text-align: start; + width: 100%; + box-sizing: border-box; +} +.item[data-highlighted] { + background: var(--ds-bg-hover); +} +/* Intentionally no font-weight bump for `data-selected` — Menu + doesn't have one and an extra weight made the picker rows read + noticeably heavier than the rest of the app's dropdown chrome. + Selection is communicated by the trailing indicator instead. */ +.item[data-disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +/* Trailing check / dot indicator on the selected row. Auto-margin + pushes it past any flex-1 label column so it sits at the right + edge of the row. */ +.indicator { + display: inline-flex; + align-items: center; + margin-left: auto; + color: var(--ds-text-2); +} + +/* Spans the full popup width by extending into the 3px inner + padding — same trick as `Menu.module.css → .separator`. */ +.separator { + height: 1px; + margin: 3px -3px; + background: var(--ds-divider); +} + +/* Matches `Menu.module.css → .label` so empty-state copy reads as + the same secondary-tone metadata used elsewhere. */ +.empty { + padding: 4px 12px; + font-family: var(--ds-font-body); + font-size: var(--ds-text-xs); + line-height: var(--ds-text-xs-lh); + color: var(--ds-text-3); +} diff --git a/packages/agents-server-ui/src/ui/Combobox.tsx b/packages/agents-server-ui/src/ui/Combobox.tsx new file mode 100644 index 0000000000..e4c03017b2 --- /dev/null +++ b/packages/agents-server-ui/src/ui/Combobox.tsx @@ -0,0 +1,254 @@ +import { Combobox as BaseCombobox } from '@base-ui/react/combobox' +import { Check } from 'lucide-react' +import { forwardRef, type CSSProperties, type ReactNode } from 'react' +import popoverStyles from './Popover.module.css' +import styles from './Combobox.module.css' + +type Side = `top` | `right` | `bottom` | `left` +type Align = `start` | `center` | `end` + +interface RootProps { + value?: V | null + defaultValue?: V | null + /** + * Called when the selected value changes. Receives `null` when the + * user clears the combobox or selects an item whose value is `null` + * (i.e. when the combobox is acting as a clearable input). + */ + onValueChange?: (value: V | null) => void + inputValue?: string + defaultInputValue?: string + onInputValueChange?: (inputValue: string) => void + open?: boolean + defaultOpen?: boolean + onOpenChange?: (open: boolean) => void + disabled?: boolean + /** Submitted form name + value (when used inside a `
`). */ + name?: string + required?: boolean + children?: ReactNode +} + +interface ContentProps { + side?: Side + align?: Align + sideOffset?: number + alignOffset?: number + className?: string + style?: CSSProperties + children?: ReactNode +} + +interface InputProps + extends Omit, `value`> { + className?: string +} + +interface ItemProps + extends Omit, `children`> { + value: V + disabled?: boolean + children: ReactNode +} + +function Root({ + value, + defaultValue, + onValueChange, + inputValue, + defaultInputValue, + onInputValueChange, + open, + defaultOpen, + onOpenChange, + disabled, + name, + required, + children, +}: RootProps): React.ReactElement { + return ( + + value={value as string | null | undefined} + defaultValue={defaultValue as string | null | undefined} + onValueChange={ + onValueChange + ? (v: string | null) => onValueChange(v as V | null) + : undefined + } + inputValue={inputValue} + defaultInputValue={defaultInputValue} + onInputValueChange={ + onInputValueChange ? (v: string) => onInputValueChange(v) : undefined + } + open={open} + defaultOpen={defaultOpen} + onOpenChange={ + onOpenChange ? (next: boolean) => onOpenChange(next) : undefined + } + disabled={disabled} + name={name} + required={required} + > + {children} + + ) +} + +function Content({ + side = `bottom`, + align = `start`, + sideOffset = 6, + alignOffset, + className, + style, + children, +}: ContentProps): React.ReactElement { + const cls = [popoverStyles.popup, styles.popup, className] + .filter(Boolean) + .join(` `) + return ( + + + + {children} + + + + ) +} + +const Input = forwardRef(function Input( + { className, ...rest }, + ref +) { + return ( + + ) +}) + +function List({ + children, + className, +}: { + children: ReactNode + className?: string +}): React.ReactElement { + return ( + + {children} + + ) +} + +function Item({ + value, + disabled, + className, + children, + ...rest +}: ItemProps): React.ReactElement { + return ( + + {children} + + ) +} + +/** + * Trailing indicator for the selected row. Defaults to a check icon + * sized to match the rest of the dropdown chrome — pass `render` to + * substitute a different glyph. + */ +function ItemIndicator({ + className, + render, +}: { + className?: string + render?: React.ReactElement +}): React.ReactElement { + return ( + } + /> + ) +} + +function Empty({ + children, + className, +}: { + children: ReactNode + className?: string +}): React.ReactElement { + return ( + + {children} + + ) +} + +function Separator({ className }: { className?: string }): React.ReactElement { + return ( + + ) +} + +/** + * Filterable list-with-input — wraps `@base-ui/react/combobox`. + * + * Sibling to `Menu` / `Select` / `Popover`, sharing the same popup + * surface tokens (`popoverStyles.popup`) and item geometry. Use it + * when you need a dropdown that lets the user *type* to filter or + * paste a freeform value, in addition to picking from a list. + * + * + * {v ?? `Pick`}} /> + * + * + * + * + * A + * + * + * + * + * + * Generic API mirrors `Select`: `value`, `onValueChange`, + * `defaultValue`, `disabled`, `name`, `required`. The trigger is exposed + * straight from the underlying primitive so consumers control it via + * `render={}`, matching the Menu API. + */ +export const Combobox = { + Root, + Trigger: BaseCombobox.Trigger, + Content, + Input, + List, + Item, + ItemIndicator, + Empty, + Separator, + Group: BaseCombobox.Group, + GroupLabel: BaseCombobox.GroupLabel, +} diff --git a/packages/agents-server-ui/src/ui/index.ts b/packages/agents-server-ui/src/ui/index.ts index 30c0ae4e81..acd0bd8b85 100644 --- a/packages/agents-server-ui/src/ui/index.ts +++ b/packages/agents-server-ui/src/ui/index.ts @@ -55,6 +55,8 @@ export { Tooltip, TooltipProvider } from './Tooltip' export { Select } from './Select' export type { SelectSize } from './Select' +export { Combobox } from './Combobox' + export { ScrollArea } from './ScrollArea' export { DataList } from './DataList' diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 0cabc5bc5d..8abc485b7c 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -327,10 +327,18 @@ function createAssistantHandler(options: { wake: WakeEvent ): Promise { const readSet = new Set() + // `workingDirectory` may be overridden per-spawn — used by the + // desktop UI's directory picker so each Horton session can run + // against its own project root without restarting the runtime. + const effectiveCwd = + typeof ctx.args.workingDirectory === `string` && + ctx.args.workingDirectory.trim().length > 0 + ? ctx.args.workingDirectory + : workingDirectory const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args) const tools = [ ...ctx.electricTools, - ...createHortonTools(workingDirectory, ctx, readSet, { + ...createHortonTools(effectiveCwd, ctx, readSet, { docsSearchTool, modelConfig, }), @@ -391,7 +399,7 @@ function createAssistantHandler(options: { } ctx.useAgent({ - systemPrompt: buildHortonSystemPrompt(workingDirectory, { + systemPrompt: buildHortonSystemPrompt(effectiveCwd, { hasDocsSupport: Boolean(docsSupport), hasSkills, docsUrl, @@ -485,6 +493,12 @@ export function registerHorton( .describe( `Reasoning effort for compatible reasoning models. Auto uses a safe provider default.` ), + workingDirectory: z + .string() + .optional() + .describe( + `Working directory for file operations. Defaults to the server's configured cwd.` + ), }) registry.define(`horton`, { From a6ffbabc651fef267d3ac3a1ebe44a0b0d5eaff2 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:22:47 +0100 Subject: [PATCH 18/57] fix(agents-runtime): match tool call events by id Store provider tool-call IDs on tool call events and use them to match overlapping tool starts and completions reliably. Co-authored-by: Kyle Mathews Co-authored-by: Cursor --- packages/agents-runtime/src/entity-schema.ts | 2 + .../agents-runtime/src/outbound-bridge.ts | 69 +++++++++++++++---- packages/agents-runtime/src/pi-adapter.ts | 7 +- packages/agents-runtime/src/types.ts | 11 ++- .../test/outbound-bridge.test.ts | 62 ++++++++++++++--- 5 files changed, 124 insertions(+), 27 deletions(-) diff --git a/packages/agents-runtime/src/entity-schema.ts b/packages/agents-runtime/src/entity-schema.ts index 663e53efcb..8df08882c5 100644 --- a/packages/agents-runtime/src/entity-schema.ts +++ b/packages/agents-runtime/src/entity-schema.ts @@ -109,6 +109,7 @@ type TextDeltaValue = { type ToolCallValue = { key?: string run_id?: string + tool_call_id?: string tool_name: string status: `started` | `args_complete` | `executing` | `completed` | `failed` args?: unknown @@ -353,6 +354,7 @@ function createToolCallSchema(): Schema { return z.object({ key: z.string().optional(), run_id: z.string().optional(), + tool_call_id: z.string().optional(), tool_name: z.string(), status: z.enum([ `started`, diff --git a/packages/agents-runtime/src/outbound-bridge.ts b/packages/agents-runtime/src/outbound-bridge.ts index e9e4c688e1..2c81851df1 100644 --- a/packages/agents-runtime/src/outbound-bridge.ts +++ b/packages/agents-runtime/src/outbound-bridge.ts @@ -110,8 +110,15 @@ export interface OutboundBridge { onTextStart: () => void onTextDelta: (delta: string) => void onTextEnd: () => void - onToolCallStart: (name: string, args: unknown) => void - onToolCallEnd: (name: string, result: unknown, isError: boolean) => void + onToolCallStart(toolCallId: string, name: string, args: unknown): void + onToolCallStart(name: string, args: unknown): void + onToolCallEnd( + toolCallId: string, + name: string, + result: unknown, + isError: boolean + ): void + onToolCallEnd(name: string, result: unknown, isError: boolean): void } export function createOutboundBridge( @@ -145,9 +152,11 @@ export function createOutboundBridge( let currentStepNumber = 0 let currentMsgKey: string | null = null let currentTextRunKey: string | null = null - let currentTcKey: string | null = null - let currentTcRunKey: string | null = null - let currentTcArgs: unknown = undefined + const toolCallsById = new Map< + string, + { key: string; runKey: string; args: unknown } + >() + const legacyToolCallIdsByName = new Map>() const requireActiveRun = (action: string): string => { if (!currentRunKey) { throw new Error( @@ -268,16 +277,29 @@ export function createOutboundBridge( ) }, - onToolCallStart(name: string, args: unknown) { + onToolCallStart( + toolCallIdOrName: string, + nameOrArgs: string | unknown, + maybeArgs?: unknown + ) { const runKey = requireActiveRun(`onToolCallStart`) - currentTcKey = `tc-${counters.tc++}` + const key = `tc-${counters.tc++}` + const legacyCall = maybeArgs === undefined + const toolCallId = legacyCall ? key : toolCallIdOrName + const name = legacyCall ? toolCallIdOrName : (nameOrArgs as string) + const args = legacyCall ? nameOrArgs : maybeArgs + if (legacyCall) { + const ids = legacyToolCallIdsByName.get(name) ?? [] + ids.push(toolCallId) + legacyToolCallIdsByName.set(name, ids) + } persistSeed() - currentTcRunKey = runKey - currentTcArgs = args + toolCallsById.set(toolCallId, { key, runKey, args }) writeEvent( entityStateSchema.toolCalls.insert({ - key: currentTcKey, + key, value: { + tool_call_id: toolCallId, tool_name: name, status: `started`, args, @@ -287,21 +309,38 @@ export function createOutboundBridge( ) }, - onToolCallEnd(name: string, result: unknown, isError: boolean) { - if (!currentTcKey) return + onToolCallEnd( + toolCallIdOrName: string, + nameOrResult: string | unknown, + resultOrIsError: unknown, + maybeIsError?: boolean + ) { + const legacyCall = maybeIsError === undefined + const name = legacyCall ? toolCallIdOrName : (nameOrResult as string) + const result = legacyCall ? nameOrResult : resultOrIsError + const isError = legacyCall + ? Boolean(resultOrIsError) + : Boolean(maybeIsError) + const toolCallId = legacyCall + ? (legacyToolCallIdsByName.get(name)?.shift() ?? ``) + : toolCallIdOrName + const toolCall = toolCallsById.get(toolCallId) + if (!toolCall) return writeEvent( entityStateSchema.toolCalls.update({ - key: currentTcKey, + key: toolCall.key, value: { + tool_call_id: toolCallId, tool_name: name, status: isError ? `failed` : `completed`, - args: currentTcArgs, + args: toolCall.args, result: typeof result === `string` ? result : JSON.stringify(result), - run_id: currentTcRunKey, + run_id: toolCall.runKey, } as never, }) as ChangeEvent ) + toolCallsById.delete(toolCallId) }, } } diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 04337fa0cb..6520e2ba18 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -297,12 +297,17 @@ export function createPiAgentAdapter( } case `tool_execution_start`: { - bridge.onToolCallStart(event.toolName, event.args) + bridge.onToolCallStart( + event.toolCallId, + event.toolName, + event.args + ) break } case `tool_execution_end`: { bridge.onToolCallEnd( + event.toolCallId, event.toolName, event.result, event.isError diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index 4a04f4e8d5..590ebf220a 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -753,8 +753,15 @@ export interface OutboundBridgeHandle { onTextStart: () => void onTextDelta: (delta: string) => void onTextEnd: () => void - onToolCallStart: (name: string, args: unknown) => void - onToolCallEnd: (name: string, result: unknown, isError: boolean) => void + onToolCallStart(toolCallId: string, name: string, args: unknown): void + onToolCallStart(name: string, args: unknown): void + onToolCallEnd( + toolCallId: string, + name: string, + result: unknown, + isError: boolean + ): void + onToolCallEnd(name: string, result: unknown, isError: boolean): void } export interface AgentHandle { diff --git a/packages/agents-runtime/test/outbound-bridge.test.ts b/packages/agents-runtime/test/outbound-bridge.test.ts index 03b49fe142..0b8094b0ca 100644 --- a/packages/agents-runtime/test/outbound-bridge.test.ts +++ b/packages/agents-runtime/test/outbound-bridge.test.ts @@ -69,7 +69,9 @@ describe(`createOutboundBridge`, () => { it(`rejects tool call outside an active run`, () => { const bridge = createOutboundBridge([], () => {}) - expect(() => bridge.onToolCallStart(`search`, {})).toThrow(/active run/i) + expect(() => bridge.onToolCallStart(`call-search`, `search`, {})).toThrow( + /active run/i + ) }) it(`rejects step start outside an active run`, () => { @@ -84,11 +86,14 @@ describe(`createOutboundBridge`, () => { }) bridge.onRunStart() - bridge.onToolCallStart(`search`, { q: `test` }) + bridge.onToolCallStart(`call-search`, `search`, { q: `test` }) expect(writes).toHaveLength(2) expect(writes[1]!.type).toBe(`tool_call`) expect(writes[1]!.key).toBe(`tc-0`) + expect((writes[1]!.value as Record).tool_call_id).toBe( + `call-search` + ) expect((writes[1]!.value as Record).tool_name).toBe( `search` ) @@ -103,13 +108,16 @@ describe(`createOutboundBridge`, () => { }) bridge.onRunStart() - bridge.onToolCallStart(`search`, { q: `test` }) - bridge.onToolCallEnd(`search`, `3 results`, false) + bridge.onToolCallStart(`call-search`, `search`, { q: `test` }) + bridge.onToolCallEnd(`call-search`, `search`, `3 results`, false) expect(writes).toHaveLength(3) expect(writes[2]!.type).toBe(`tool_call`) expect(writes[2]!.key).toBe(`tc-0`) expect(writes[2]!.headers.operation).toBe(`update`) + expect((writes[2]!.value as Record).tool_call_id).toBe( + `call-search` + ) expect((writes[2]!.value as Record).status).toBe( `completed` ) @@ -126,13 +134,47 @@ describe(`createOutboundBridge`, () => { }) bridge.onRunStart() - bridge.onToolCallStart(`bash`, { cmd: `rm -rf /` }) - bridge.onToolCallEnd(`bash`, `Permission denied`, true) + bridge.onToolCallStart(`call-bash`, `bash`, { cmd: `rm -rf /` }) + bridge.onToolCallEnd(`call-bash`, `bash`, `Permission denied`, true) expect((writes[2]!.value as Record).status).toBe(`failed`) expect((writes[2]!.value as Record).run_id).toBe(`run-0`) }) + it(`matches overlapping tool call starts and ends by provider id`, () => { + const writes: Array = [] + const bridge = createOutboundBridge([], (e) => { + writes.push(e) + }) + + bridge.onRunStart() + bridge.onToolCallStart(`call-a`, `search`, { q: `a` }) + bridge.onToolCallStart(`call-b`, `search`, { q: `b` }) + bridge.onToolCallEnd(`call-a`, `search`, `result a`, false) + bridge.onToolCallEnd(`call-b`, `search`, `result b`, false) + + expect(writes[3]!.key).toBe(`tc-0`) + expect((writes[3]!.value as Record).tool_call_id).toBe( + `call-a` + ) + expect((writes[3]!.value as Record).args).toEqual({ + q: `a`, + }) + expect((writes[3]!.value as Record).result).toBe( + `result a` + ) + expect(writes[4]!.key).toBe(`tc-1`) + expect((writes[4]!.value as Record).tool_call_id).toBe( + `call-b` + ) + expect((writes[4]!.value as Record).args).toEqual({ + q: `b`, + }) + expect((writes[4]!.value as Record).result).toBe( + `result b` + ) + }) + it(`reconstructs ID counters from existing stream events`, () => { const existing: Array = [ ev(`run`, `run-2`, `insert`, { status: `started` }), @@ -152,7 +194,7 @@ describe(`createOutboundBridge`, () => { expect(writes[0]!.key).toBe(`run-3`) expect(writes[1]!.key).toBe(`msg-4`) - bridge.onToolCallStart(`test`, {}) + bridge.onToolCallStart(`call-test`, `test`, {}) expect(writes[2]!.key).toBe(`tc-6`) expect((writes[2]!.value as Record).run_id).toBe(`run-3`) }) @@ -169,7 +211,7 @@ describe(`createOutboundBridge`, () => { bridge.onRunStart() bridge.onStepStart() bridge.onTextStart() - bridge.onToolCallStart(`search`, {}) + bridge.onToolCallStart(`call-search`, `search`, {}) expect(writes[0]!.key).toBe(`run-2`) expect(writes[1]!.key).toBe(`step-4`) @@ -228,7 +270,9 @@ describe(`createOutboundBridge`, () => { bridge.onRunStart() bridge.onRunEnd() - expect(() => bridge.onToolCallStart(`search`, {})).toThrow(/active run/i) + expect(() => bridge.onToolCallStart(`call-search`, `search`, {})).toThrow( + /active run/i + ) }) it(`rejects step start after run ends`, () => { From 26fd6a6a695f223840725a8adb33afcd27b62e4e Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:23:47 +0100 Subject: [PATCH 19/57] fix(agents-runtime): preserve tool pairs during compaction Keep tool_call/tool_result pairs valid under context budget truncation and merge adjacent assistant history blocks so resumed prompts remain API-compatible. Co-authored-by: Kyle Mathews Co-authored-by: Cursor --- .../agents-runtime/src/context-assembly.ts | 31 +++++ packages/agents-runtime/src/pi-adapter.ts | 59 ++++++---- .../agents-runtime/test/pi-adapter.test.ts | 80 +++++++++++++ .../test/use-context-budget.test.ts | 106 ++++++++++++++++++ 4 files changed, 256 insertions(+), 20 deletions(-) diff --git a/packages/agents-runtime/src/context-assembly.ts b/packages/agents-runtime/src/context-assembly.ts index b89b50077e..c6f769773f 100644 --- a/packages/agents-runtime/src/context-assembly.ts +++ b/packages/agents-runtime/src/context-assembly.ts @@ -324,12 +324,43 @@ export async function assembleContext( const message = volatileMessages[i]! const nextTokens = approxTokens(message.content) if (volatileBudgetUsed + nextTokens > remainingBudget) { + if (message.role === `tool_call` || message.role === `tool_result`) { + const stub = `[content truncated — use load_timeline_range({ from: ${message.at}, to: ${message.at} }) to read]` + const stubTokens = approxTokens(stub) + if (volatileBudgetUsed + stubTokens <= remainingBudget) { + volatileBudgetUsed += stubTokens + accepted.push({ ...message, content: stub }) + continue + } + } droppedOffsets.push(message.at) continue } volatileBudgetUsed += nextTokens accepted.push(message) } + + const acceptedCallIds = new Set() + const acceptedResultIds = new Set() + for (const message of accepted) { + const id = (message as VolatileMessage & { toolCallId?: string }).toolCallId + if (!id) continue + if (message.role === `tool_call`) acceptedCallIds.add(id) + else if (message.role === `tool_result`) acceptedResultIds.add(id) + } + for (let i = accepted.length - 1; i >= 0; i--) { + const message = accepted[i]! + const id = (message as VolatileMessage & { toolCallId?: string }).toolCallId + if (!id) continue + if ( + (message.role === `tool_call` && !acceptedResultIds.has(id)) || + (message.role === `tool_result` && !acceptedCallIds.has(id)) + ) { + droppedOffsets.push(message.at) + accepted.splice(i, 1) + } + } + accepted.reverse() if (droppedOffsets.length > 0) { diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 6520e2ba18..cec2d2132b 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -89,6 +89,11 @@ export function toAgentHistory( const history: Array = [] const toolNamesById = new Map() + const lastAssistant = (): AgentMessage | undefined => { + const last = history[history.length - 1] + return last?.role === `assistant` ? last : undefined + } + for (const message of messages) { switch (message.role) { case `user`: @@ -99,30 +104,44 @@ export function toAgentHistory( } as AgentMessage) break - case `assistant`: - history.push({ - role: `assistant`, - content: [{ type: `text`, text: message.content }], - timestamp: Date.now(), - } as AgentMessage) + case `assistant`: { + const prev = lastAssistant() + if (prev) { + ;(prev.content as Array).push({ + type: `text`, + text: message.content, + }) + } else { + history.push({ + role: `assistant`, + content: [{ type: `text`, text: message.content }], + timestamp: Date.now(), + } as AgentMessage) + } break + } - case `tool_call`: + case `tool_call`: { toolNamesById.set(message.toolCallId, message.toolName) - history.push({ - role: `assistant`, - content: [ - { - type: `toolCall`, - id: message.toolCallId, - name: message.toolName, - arguments: - (message.toolArgs as Record | undefined) ?? {}, - }, - ], - timestamp: Date.now(), - } as AgentMessage) + const block = { + type: `toolCall`, + id: message.toolCallId, + name: message.toolName, + arguments: + (message.toolArgs as Record | undefined) ?? {}, + } + const prev = lastAssistant() + if (prev) { + ;(prev.content as Array).push(block) + } else { + history.push({ + role: `assistant`, + content: [block], + timestamp: Date.now(), + } as AgentMessage) + } break + } case `tool_result`: history.push({ diff --git a/packages/agents-runtime/test/pi-adapter.test.ts b/packages/agents-runtime/test/pi-adapter.test.ts index 0cf9539b27..38c4d2841c 100644 --- a/packages/agents-runtime/test/pi-adapter.test.ts +++ b/packages/agents-runtime/test/pi-adapter.test.ts @@ -230,4 +230,84 @@ describe(`toAgentHistory`, () => { expect(first?.role).toBe(`user`) expect(second?.role).toBe(`assistant`) }) + + it(`merges assistant text and tool_call into a single assistant message`, () => { + const messages: Array = [ + { role: `user`, content: `Help me` }, + { role: `assistant`, content: `Let me look that up` }, + { + role: `tool_call`, + content: `lookup`, + toolCallId: `tc-0`, + toolName: `lookup`, + toolArgs: { q: `hello` }, + }, + { + role: `tool_result`, + content: `found it`, + toolCallId: `tc-0`, + isError: false, + }, + ] + + const history = toAgentHistory(messages) + + const assistantMessages = history.filter((m) => m.role === `assistant`) + expect(assistantMessages).toHaveLength(1) + + const assistant = assistantMessages[0] as AssistantMessage + expect(assistant.content).toHaveLength(2) + expect(assistant.content[0]).toMatchObject({ + type: `text`, + text: `Let me look that up`, + }) + expect(assistant.content[1]).toMatchObject({ + type: `toolCall`, + id: `tc-0`, + name: `lookup`, + }) + }) + + it(`keeps separate assistant turns after tool results`, () => { + const messages: Array = [ + { role: `user`, content: `Do two things` }, + { role: `assistant`, content: `I will do both` }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-0`, + toolName: `tool_a`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `a`, + toolCallId: `tc-0`, + isError: false, + }, + { role: `assistant`, content: `Now the second` }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-1`, + toolName: `tool_b`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `b`, + toolCallId: `tc-1`, + isError: false, + }, + { role: `assistant`, content: `All done` }, + ] + + const history = toAgentHistory(messages) + + for (let i = 1; i < history.length; i++) { + if (history[i]!.role === `assistant`) { + expect(history[i - 1]!.role).not.toBe(`assistant`) + } + } + }) }) diff --git a/packages/agents-runtime/test/use-context-budget.test.ts b/packages/agents-runtime/test/use-context-budget.test.ts index 8f968356c8..dd23c5694f 100644 --- a/packages/agents-runtime/test/use-context-budget.test.ts +++ b/packages/agents-runtime/test/use-context-budget.test.ts @@ -62,6 +62,112 @@ describe(`budget enforcement`, () => { ) }) + it(`stubs oversized tool_result content instead of dropping it`, async () => { + const messages = await assembleContext({ + sourceBudget: 100, + sources: { + self: { + content: () => [ + { role: `user` as const, content: `Hi`, at: 1 }, + { role: `assistant` as const, content: `Let me check`, at: 2 }, + { + role: `tool_call` as const, + content: `search`, + toolCallId: `tc-1`, + toolName: `search`, + toolArgs: { q: `hello` }, + at: 3, + }, + { + role: `tool_result` as const, + content: `x`.repeat(5000), + toolCallId: `tc-1`, + isError: false, + at: 4, + }, + { + role: `assistant` as const, + content: `Here is the answer`, + at: 5, + }, + ], + max: 100_000, + cache: `volatile`, + }, + }, + }) + + const toolCalls = messages.filter((m) => m.role === `tool_call`) + const toolResults = messages.filter((m) => m.role === `tool_result`) + + expect(toolCalls).toHaveLength(1) + expect(toolResults).toHaveLength(1) + expect( + (toolCalls[0] as { toolCallId?: string } | undefined)?.toolCallId + ).toBe(`tc-1`) + expect( + (toolResults[0] as { toolCallId?: string } | undefined)?.toolCallId + ).toBe(`tc-1`) + expect(toolResults[0]!.content).toMatch(/\[content truncated/) + expect(toolResults[0]!.content).toMatch(/load_timeline_range/) + }) + + it(`drops orphaned tool_results when their tool_call is budget-truncated`, async () => { + const messages = await assembleContext({ + sourceBudget: 30, + sources: { + self: { + content: () => [ + { role: `assistant` as const, content: `I will search`, at: 1 }, + { + role: `tool_call` as const, + content: `search`, + toolCallId: `tc-old`, + toolName: `search`, + toolArgs: {}, + at: 2, + }, + { + role: `tool_result` as const, + content: `found`, + toolCallId: `tc-old`, + isError: false, + at: 3, + }, + { + role: `assistant` as const, + content: `Here is the answer`, + at: 4, + }, + { role: `user` as const, content: `Thanks`, at: 5 }, + ], + max: 100_000, + cache: `volatile`, + }, + }, + }) + + const toolCalls = messages.filter((m) => m.role === `tool_call`) + const toolResults = messages.filter((m) => m.role === `tool_result`) + + for (const result of toolResults) { + const resultId = (result as { toolCallId?: string }).toolCallId + expect( + toolCalls.some( + (call) => (call as { toolCallId?: string }).toolCallId === resultId + ) + ).toBe(true) + } + for (const call of toolCalls) { + const callId = (call as { toolCallId?: string }).toolCallId + expect( + toolResults.some( + (result) => (result as { toolCallId?: string }).toolCallId === callId + ) + ).toBe(true) + } + }) + it(`does not write a stream event on overflow`, async () => { const logger = vi.fn() await assembleContext( From 9783caf82580ba1bfde711c22b3cea785fac9b5d Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:28:11 +0100 Subject: [PATCH 20/57] fix(agents): use web_search worker tool name Align worker tool selection and prompts with the actual web_search tool name so spawned agents receive consistent instructions. Co-authored-by: Kyle Mathews Co-authored-by: Cursor --- .../references/patterns/manager-worker.md | 6 +++--- .../references/patterns/map-reduce.md | 2 +- .../references/review-checklist.md | 12 ++++++------ .../agents-runtime/test/brave-search-tool.test.ts | 8 ++++++++ packages/agents/src/agents/horton.ts | 6 +++--- packages/agents/src/agents/worker.ts | 2 +- packages/agents/src/tools/spawn-worker.ts | 2 +- 7 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 packages/agents-runtime/test/brave-search-tool.test.ts diff --git a/packages/agents-runtime/skills/designing-entities/references/patterns/manager-worker.md b/packages/agents-runtime/skills/designing-entities/references/patterns/manager-worker.md index 88bfbd3244..f066d9c8df 100644 --- a/packages/agents-runtime/skills/designing-entities/references/patterns/manager-worker.md +++ b/packages/agents-runtime/skills/designing-entities/references/patterns/manager-worker.md @@ -13,7 +13,7 @@ The Electric agent server's built-in `worker` type has a strict contract (`/docs ```ts interface WorkerArgs { systemPrompt: string - tools: Array // non-empty subset of: bash | read | write | edit | brave_search | fetch_url | spawn_worker + tools: Array // non-empty subset of: bash | read | write | edit | web_search | fetch_url | spawn_worker } ``` @@ -69,7 +69,7 @@ async handler(ctx, wake) { `${p.id}`, { systemPrompt: p.systemPrompt, - tools: p.tools, // e.g. ["brave_search", "fetch_url"] — required, least-privilege + tools: p.tools, // e.g. ["web_search", "fetch_url"] — required, least-privilege }, { initialMessage: question, wake: "runFinished" } ) @@ -132,7 +132,7 @@ async handler(ctx, wake) { - **Spawning inside `firstWake` only.** On re-wake after the first tool call, children don't exist in state yet. Spawn inside the tool or on message receipt, always guarded by state lookup. - **Awaiting each child sequentially.** Defeats parallelism; turns manager-worker into an ad-hoc pipeline. - **Per-wake specialist list.** If `PERSPECTIVES` is generated dynamically per wake, the pattern is `map-reduce`, not manager-worker. -- **Secrets in worker prompts.** Don't interpolate API tokens / OAuth bearers / signed URLs into a worker's `systemPrompt` or `initialMessage` — they end up in the entity's persisted streams. For authenticated external APIs, have the manager do the fetch (tokens stay in trusted code) and pass the raw response to the worker as its message. Workers that still need to make their own calls should use built-in tools like `brave_search` that read their API key internally. +- **Secrets in worker prompts.** Don't interpolate API tokens / OAuth bearers / signed URLs into a worker's `systemPrompt` or `initialMessage` — they end up in the entity's persisted streams. For authenticated external APIs, have the manager do the fetch (tokens stay in trusted code) and pass the raw response to the worker as its message. Workers that still need to make their own calls should use built-in tools like `web_search` that read their own API key internally. ## Handling authenticated external data diff --git a/packages/agents-runtime/skills/designing-entities/references/patterns/map-reduce.md b/packages/agents-runtime/skills/designing-entities/references/patterns/map-reduce.md index 858812c126..a9633e7811 100644 --- a/packages/agents-runtime/skills/designing-entities/references/patterns/map-reduce.md +++ b/packages/agents-runtime/skills/designing-entities/references/patterns/map-reduce.md @@ -75,7 +75,7 @@ async handler(ctx, wake) { id, { systemPrompt: task, - tools: chunkTools, // required for built-in worker, e.g. ["brave_search", "fetch_url"] + tools: chunkTools, // required for built-in worker, e.g. ["web_search", "fetch_url"] }, { initialMessage: chunks[i], wake: "runFinished" } ) diff --git a/packages/agents-runtime/skills/designing-entities/references/review-checklist.md b/packages/agents-runtime/skills/designing-entities/references/review-checklist.md index f72c81b714..ec885a9aa5 100644 --- a/packages/agents-runtime/skills/designing-entities/references/review-checklist.md +++ b/packages/agents-runtime/skills/designing-entities/references/review-checklist.md @@ -49,12 +49,12 @@ Treat the rules as the literal contract for the entity — violations are flagge Apply only when the entity `ctx.spawn("worker", ...)` — the Electric Agents server's built-in worker type. -| # | Rule | Why | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| W1 | Every `ctx.spawn("worker", ...)` passes `{ systemPrompt, tools }` with `tools` a non-empty subset of `"bash" \| "read" \| "write" \| "edit" \| "brave_search" \| "fetch_url" \| "spawn_worker"`. | Built-in worker throws `[worker] tools must be a non-empty array` (or `unknown tool name`) at parse time. | -| W2 | Spawn args do **not** include `sharedState`, `sharedStateToolMode`, or `builtinTools`. If those are needed, spawn a custom worker type the app registered, not the built-in `worker`. | The built-in worker is a least-privilege sandbox and ignores these args. For shared-state workflows see the blackboard pattern. | -| W3 | Work that requires runtime primitives (`ctx.electricTools` — cron, arbitrary `send`, etc.) is done in the spawner, not the worker. | Workers do not receive `ctx.electricTools`. | -| W4 | Worker `systemPrompt` and `initialMessage` do **not** contain API tokens, OAuth bearers, cookies, signed URLs, or other secrets. Authenticated fetches happen in the manager (trusted code); the raw response is passed to the worker as data. | Worker prompts and messages are persisted in entity streams — anyone who can read the stream can read the secrets. Interpolating `process.env.*` into a prompt effectively publishes it. Built-in tools like `brave_search` that read their own API key at call-time are fine because the key never touches the prompt. | +| # | Rule | Why | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W1 | Every `ctx.spawn("worker", ...)` passes `{ systemPrompt, tools }` with `tools` a non-empty subset of `"bash" \| "read" \| "write" \| "edit" \| "web_search" \| "fetch_url" \| "spawn_worker"`. | Built-in worker throws `[worker] tools must be a non-empty array` (or `unknown tool name`) at parse time. | +| W2 | Spawn args do **not** include `sharedState`, `sharedStateToolMode`, or `builtinTools`. If those are needed, spawn a custom worker type the app registered, not the built-in `worker`. | The built-in worker is a least-privilege sandbox and ignores these args. For shared-state workflows see the blackboard pattern. | +| W3 | Work that requires runtime primitives (`ctx.electricTools` — cron, arbitrary `send`, etc.) is done in the spawner, not the worker. | Workers do not receive `ctx.electricTools`. | +| W4 | Worker `systemPrompt` and `initialMessage` do **not** contain API tokens, OAuth bearers, cookies, signed URLs, or other secrets. Authenticated fetches happen in the manager (trusted code); the raw response is passed to the worker as data. | Worker prompts and messages are persisted in entity streams — anyone who can read the stream can read the secrets. Interpolating `process.env.*` into a prompt effectively publishes it. Built-in tools like `web_search` that read their own API key at call-time are fine because the key never touches the prompt. | ## App wiring diff --git a/packages/agents-runtime/test/brave-search-tool.test.ts b/packages/agents-runtime/test/brave-search-tool.test.ts new file mode 100644 index 0000000000..89ef86992f --- /dev/null +++ b/packages/agents-runtime/test/brave-search-tool.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { braveSearchTool } from '../src/tools' + +describe(`braveSearchTool`, () => { + it(`is exposed to agents as web_search`, () => { + expect(braveSearchTool.name).toBe(`web_search`) + }) +}) diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 8abc485b7c..447c0ecc16 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -169,7 +169,7 @@ export function buildHortonSystemPrompt( ? `\n- use_skill: load a skill (knowledge, instructions, or a tutorial) into your context to help with the user's request\n- remove_skill: unload a skill from context when you're done with it` : `` const docsGuidance = opts.hasDocsSupport - ? `\n- For ANY question about Electric Agents, Durable Agents, or this framework, ALWAYS use search_durable_agents_docs FIRST. Do not use brave_search or fetch_url for Electric Agents topics unless the docs search returns no useful results.\n- The search tool returns chunk content directly — you do not need to read the source files.\n- Use repo read/bash tools only for non-doc files or when you need to inspect exact implementation code in the workspace.` + ? `\n- For ANY question about Electric Agents, Durable Agents, or this framework, ALWAYS use search_durable_agents_docs FIRST. Do not use web_search or fetch_url for Electric Agents topics unless the docs search returns no useful results.\n- The search tool returns chunk content directly — you do not need to read the source files.\n- Use repo read/bash tools only for non-doc files or when you need to inspect exact implementation code in the workspace.` : `` const skillsGuidance = opts.hasSkills ? `\n# Skills\nYou have access to skills — specialized knowledge and guided workflows you can load on demand. Your context includes a skills catalog listing what's available. When the user's request matches a skill's description or keywords, load it with use_skill. @@ -209,7 +209,7 @@ Don't force onboarding. If someone just wants to chat or code, let them. When in - ${opts.hasDocsSupport ? `If search_durable_agents_docs is available, use it first (faster, hybrid search).` : `Use fetch_url to look up documentation pages.`} - The Electric Agents docs site is at ${opts.docsUrl} - The docs site covers: Usage (entity definition, handlers, tools, state, spawning, coordination, waking, shared state, client integration, app setup), Reference (handler context, entity definitions, configurations, tools, state proxies, wake events, registries), Entities (Horton, Worker), and Patterns (Manager-Worker, Pipeline, Map-Reduce, Dispatcher, Blackboard, Reactive Observers). -- For general coding questions unrelated to Electric Agents, use brave_search or your own knowledge.` +- For general coding questions unrelated to Electric Agents, use web_search or your own knowledge.` : `` const modelGuidance = opts.modelProvider && opts.modelId @@ -226,7 +226,7 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem - read: read a file - write: create or overwrite a file - edit: targeted string replacement in an existing file (you must read the file first) -- brave_search: search the web +- web_search: search the web - fetch_url: fetch and convert a URL to markdown - spawn_worker: dispatch a subagent for an isolated task ${docsTools}${skillsTools} diff --git a/packages/agents/src/agents/worker.ts b/packages/agents/src/agents/worker.ts index 6fc820aa39..e3d79acbfc 100644 --- a/packages/agents/src/agents/worker.ts +++ b/packages/agents/src/agents/worker.ts @@ -132,7 +132,7 @@ function buildToolsForWorker( case `edit`: out.push(createEditTool(workingDirectory, readSet)) break - case `brave_search`: + case `web_search`: out.push(braveSearchTool) break case `fetch_url`: diff --git a/packages/agents/src/tools/spawn-worker.ts b/packages/agents/src/tools/spawn-worker.ts index 7d9493d396..dd2f50e04a 100644 --- a/packages/agents/src/tools/spawn-worker.ts +++ b/packages/agents/src/tools/spawn-worker.ts @@ -10,7 +10,7 @@ export const WORKER_TOOL_NAMES = [ `read`, `write`, `edit`, - `brave_search`, + `web_search`, `fetch_url`, `spawn_worker`, ] as const From ca98a9ca3292a3ad1f4ceb308c3ba504e8a1c036 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:28:37 +0100 Subject: [PATCH 21/57] style(agents-ui): round badge labels Use the full-radius token for badges so status labels render as rounded pills. Co-authored-by: Kyle Mathews Co-authored-by: Cursor --- packages/agents-server-ui/src/ui/Badge.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-server-ui/src/ui/Badge.module.css b/packages/agents-server-ui/src/ui/Badge.module.css index 35b332100f..9172a9ce9d 100644 --- a/packages/agents-server-ui/src/ui/Badge.module.css +++ b/packages/agents-server-ui/src/ui/Badge.module.css @@ -2,7 +2,7 @@ display: inline-flex; align-items: center; gap: 4px; - border-radius: var(--ds-radius-2); + border-radius: var(--ds-radius-full); font-family: var(--ds-font-body); font-weight: 500; white-space: nowrap; From 3c7134756611994ffa7e4c09f35bf0303ced6ed5 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:30:46 +0100 Subject: [PATCH 22/57] feat(agents-ui): preload session streams from sidebar Warm entity stream connections through the route loader on sidebar intent so session timelines can reuse a preloaded StreamDB when opened. Co-authored-by: Kyle Mathews Co-authored-by: Cursor --- .../src/components/Sidebar.tsx | 3 + .../src/components/SidebarRow.tsx | 4 + .../src/components/SidebarTree.tsx | 4 + .../src/hooks/useServerConnection.tsx | 5 + .../src/lib/entity-connection.ts | 104 +++++++++++++++++- packages/agents-server-ui/src/router.tsx | 24 ++++ 6 files changed, 143 insertions(+), 1 deletion(-) diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index ab8c702798..3a3e499979 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -52,6 +52,7 @@ export function Sidebar({ selectedEntityUrl, onSelectEntity, onOpenEntityInSplit, + onPreloadEntity, pinnedUrls, onTogglePin, }: { @@ -63,6 +64,7 @@ export function Sidebar({ * the workspace helpers in `RootShell`. */ onOpenEntityInSplit?: (url: string) => void + onPreloadEntity?: (url: string) => void pinnedUrls: Array onTogglePin: (url: string) => void }): React.ReactElement { @@ -164,6 +166,7 @@ export function Sidebar({ selectedEntityUrl, onSelectEntity, onOpenEntityInSplit, + onPreloadEntity, pinnedUrls, onTogglePin, hoverHandle, diff --git a/packages/agents-server-ui/src/components/SidebarRow.tsx b/packages/agents-server-ui/src/components/SidebarRow.tsx index e812c5baf4..66615ad935 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.tsx +++ b/packages/agents-server-ui/src/components/SidebarRow.tsx @@ -47,6 +47,7 @@ type SidebarRowProps = { * `helpers.openEntity(url, { target: { groupId, position: 'split-right' }})`. */ onOpenInSplit?: () => void + onPreload?: () => void depth?: number /** Number of immediate children. 0 means no expand affordance. */ childCount?: number @@ -94,6 +95,7 @@ export const SidebarRow = memo(function SidebarRow({ selected, onSelect, onOpenInSplit, + onPreload, depth = 0, childCount = 0, expanded = false, @@ -131,6 +133,8 @@ export const SidebarRow = memo(function SidebarRow({ tabIndex={0} className={className} draggable + onMouseEnter={onPreload} + onFocus={onPreload} onDragStart={(e) => { setDragPayload(e, { kind: `sidebar-entity`, diff --git a/packages/agents-server-ui/src/components/SidebarTree.tsx b/packages/agents-server-ui/src/components/SidebarTree.tsx index 732a1c3a0b..955eaa6bf2 100644 --- a/packages/agents-server-ui/src/components/SidebarTree.tsx +++ b/packages/agents-server-ui/src/components/SidebarTree.tsx @@ -13,6 +13,7 @@ type SidebarTreeProps = { onSelectEntity: (url: string) => void /** Optional ⌘/Ctrl-click handler — opens the entity in a new split. */ onOpenEntityInSplit?: (url: string) => void + onPreloadEntity?: (url: string) => void pinnedUrls: ReadonlyArray onTogglePin: (url: string) => void depth?: number @@ -68,6 +69,7 @@ export const SidebarTree = memo(function SidebarTree({ selectedEntityUrl, onSelectEntity, onOpenEntityInSplit, + onPreloadEntity, pinnedUrls, onTogglePin, depth = 0, @@ -98,6 +100,7 @@ export const SidebarTree = memo(function SidebarTree({ ? () => onOpenEntityInSplit(entity.url) : undefined } + onPreload={() => onPreloadEntity?.(entity.url)} depth={depth} childCount={children.length} expanded={expanded} @@ -116,6 +119,7 @@ export const SidebarTree = memo(function SidebarTree({ selectedEntityUrl={selectedEntityUrl} onSelectEntity={onSelectEntity} onOpenEntityInSplit={onOpenEntityInSplit} + onPreloadEntity={onPreloadEntity} pinnedUrls={pinnedUrls} onTogglePin={onTogglePin} depth={depth + 1} diff --git a/packages/agents-server-ui/src/hooks/useServerConnection.tsx b/packages/agents-server-ui/src/hooks/useServerConnection.tsx index ae013a7fe0..4dcd2ff7f0 100644 --- a/packages/agents-server-ui/src/hooks/useServerConnection.tsx +++ b/packages/agents-server-ui/src/hooks/useServerConnection.tsx @@ -12,6 +12,7 @@ import { saveActiveServer, saveServers, } from '../lib/server-connection' +import { registerActiveBaseUrl } from '../lib/entity-connection' import type { ReactNode } from 'react' import type { ServerConfig } from '../lib/types' @@ -87,6 +88,10 @@ export function ServerConnectionProvider({ } }, []) + useEffect(() => { + registerActiveBaseUrl(activeServer?.url ?? null) + }, [activeServer]) + useEffect(() => { if (!activeServer) { setConnected(false) diff --git a/packages/agents-server-ui/src/lib/entity-connection.ts b/packages/agents-server-ui/src/lib/entity-connection.ts index ca9becce71..9f380884e7 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.ts @@ -13,13 +13,115 @@ function getMainStreamPath(entityUrl: string): string { */ export type UICustomState = Record +let activeBaseUrl: string | null = null + +export function registerActiveBaseUrl(url: string | null): void { + activeBaseUrl = url +} + +export function getActiveBaseUrl(): string | null { + return activeBaseUrl +} + +type CachedConnection = { + promise: Promise<{ db: EntityStreamDBWithActions; close: () => void }> + refs: number + evictionTimer: ReturnType | null +} + +const connectionCache = new Map() + +function cacheKey(baseUrl: string, entityUrl: string): string { + return `${baseUrl}${entityUrl}` +} + +function clearEvictionTimer(entry: CachedConnection): void { + if (entry.evictionTimer) { + clearTimeout(entry.evictionTimer) + entry.evictionTimer = null + } +} + +function scheduleEviction(key: string, entry: CachedConnection): void { + clearEvictionTimer(entry) + entry.evictionTimer = setTimeout(() => { + if (entry.refs > 0 || connectionCache.get(key) !== entry) return + connectionCache.delete(key) + entry.promise + .then(({ close }) => close()) + .catch(() => { + // Failed preload entries are removed by their rejection handler. + }) + }, 30_000) +} + +function getOrCreateConnection(opts: { + baseUrl: string + entityUrl: string + customState?: UICustomState +}): { key: string; entry: CachedConnection } { + const { baseUrl, entityUrl, customState } = opts + const key = cacheKey(baseUrl, entityUrl) + const existing = connectionCache.get(key) + if (existing) { + clearEvictionTimer(existing) + return { key, entry: existing } + } + + const promise = connectEntityStreamFresh({ baseUrl, entityUrl, customState }) + const entry: CachedConnection = { promise, refs: 0, evictionTimer: null } + connectionCache.set(key, entry) + promise.catch(() => { + if (connectionCache.get(key) === entry) connectionCache.delete(key) + }) + return { key, entry } +} + +export async function preloadEntityStream(opts: { + baseUrl: string + entityUrl: string + customState?: UICustomState +}): Promise { + const { key, entry } = getOrCreateConnection(opts) + try { + await entry.promise + if (entry.refs === 0) scheduleEviction(key, entry) + } catch { + // Route preloading should not surface as navigation failure. + } +} + export async function connectEntityStream(opts: { baseUrl: string entityUrl: string customState?: UICustomState }): Promise<{ db: EntityStreamDBWithActions; close: () => void }> { - const { baseUrl, entityUrl, customState } = opts + const { key, entry } = getOrCreateConnection(opts) + entry.refs += 1 + try { + const { db } = await entry.promise + let closed = false + return { + db, + close: () => { + if (closed) return + closed = true + entry.refs = Math.max(0, entry.refs - 1) + if (entry.refs === 0) scheduleEviction(key, entry) + }, + } + } catch (err) { + entry.refs = Math.max(0, entry.refs - 1) + throw err + } +} +async function connectEntityStreamFresh(opts: { + baseUrl: string + entityUrl: string + customState?: UICustomState +}): Promise<{ db: EntityStreamDBWithActions; close: () => void }> { + const { baseUrl, entityUrl, customState } = opts const res = await fetch(`${baseUrl}${entityUrl}`, { headers: { accept: `application/json` }, }) diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index e7780d2c67..3971175b81 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -9,8 +9,10 @@ import { useLocation, useNavigate, useParams, + useRouter, } from '@tanstack/react-router' import { z } from 'zod' +import { getActiveBaseUrl, preloadEntityStream } from './lib/entity-connection' import { usePinnedEntities } from './hooks/usePinnedEntities' import { SidebarCollapsedProvider, @@ -63,6 +65,7 @@ function RootLayout(): React.ReactElement { function RootShell(): React.ReactElement { const { pinnedUrls, togglePin } = usePinnedEntities() const navigate = useNavigate() + const router = useRouter() const { collapsed, toggle } = useSidebarCollapsed() const search = useSearchPalette() const { workspace, helpers } = useWorkspace() @@ -174,6 +177,16 @@ function RootShell(): React.ReactElement { [helpers, navigateToEntity] ) + const preloadEntity = useCallback( + (entityUrl: string) => { + void router.preloadRoute({ + to: `/entity/$`, + params: { _splat: entityUrl.replace(/^\//, ``) }, + }) + }, + [router] + ) + const params = useParams({ strict: false }) const splat = (params as Record)._splat const selectedEntityUrl = splat ? `/${splat}` : null @@ -199,6 +212,7 @@ function RootShell(): React.ReactElement { selectedEntityUrl={selectedEntityUrl} onSelectEntity={navigateToEntity} onOpenEntityInSplit={openEntityInSplit} + onPreloadEntity={preloadEntity} pinnedUrls={pinnedUrls} onTogglePin={togglePin} /> @@ -271,6 +285,15 @@ const indexRoute = createRoute({ const entityRoute = createRoute({ getParentRoute: () => rootRoute, path: `/entity/$`, + loader: async ({ params }): Promise => { + const baseUrl = getActiveBaseUrl() + if (!baseUrl) return null + await preloadEntityStream({ + baseUrl, + entityUrl: `/${params._splat}`, + }) + return null + }, component: WorkspacePage, validateSearch: workspaceSearchSchema, }) @@ -325,6 +348,7 @@ const routeTree = rootRoute.addChildren([ export const router = createRouter({ routeTree, history: createHashHistory(), + defaultPreload: `intent`, }) // eslint-disable-next-line quotes From 2151f6702ffad255d1edf58661404062766c657e Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:33:21 +0100 Subject: [PATCH 23/57] style(agents-ui): simplify new session chrome Use the static hero title from the projects design and remove duplicate new-session title chrome from standalone tiles. Co-authored-by: Cursor --- .../src/components/NewSessionPage.module.css | 6 +++++- .../src/components/views/NewSessionView.tsx | 4 ++-- .../components/workspace/TileContainer.module.css | 9 --------- .../src/components/workspace/TileContainer.tsx | 13 ++----------- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/packages/agents-server-ui/src/components/NewSessionPage.module.css b/packages/agents-server-ui/src/components/NewSessionPage.module.css index e078260fe9..c2f10d5b0c 100644 --- a/packages/agents-server-ui/src/components/NewSessionPage.module.css +++ b/packages/agents-server-ui/src/components/NewSessionPage.module.css @@ -74,12 +74,16 @@ display: flex; flex-direction: column; gap: var(--ds-space-5); + align-items: center; } .heading { display: flex; flex-direction: column; - gap: var(--ds-space-1); + align-items: center; + gap: var(--ds-space-2); + padding-top: var(--ds-space-7); + text-align: center; } .headingTitle { diff --git a/packages/agents-server-ui/src/components/views/NewSessionView.tsx b/packages/agents-server-ui/src/components/views/NewSessionView.tsx index 8c7b5fddb6..da24a6205e 100644 --- a/packages/agents-server-ui/src/components/views/NewSessionView.tsx +++ b/packages/agents-server-ui/src/components/views/NewSessionView.tsx @@ -210,8 +210,8 @@ function Picker({ return (
- - Start a new session + + Let’s ship {defaultAgent diff --git a/packages/agents-server-ui/src/components/workspace/TileContainer.module.css b/packages/agents-server-ui/src/components/workspace/TileContainer.module.css index f056fd8fe3..28d45a7465 100644 --- a/packages/agents-server-ui/src/components/workspace/TileContainer.module.css +++ b/packages/agents-server-ui/src/components/workspace/TileContainer.module.css @@ -31,12 +31,3 @@ --chat-col-width: 80ch; } } - -/* Title slot for a standalone (entity-less) tile — icon + label, sized - * to match how `` lays out an entity title. */ -.standaloneTitle { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--ds-text-1); -} diff --git a/packages/agents-server-ui/src/components/workspace/TileContainer.tsx b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx index 06bf86c685..ae863d8506 100644 --- a/packages/agents-server-ui/src/components/workspace/TileContainer.tsx +++ b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx @@ -8,7 +8,7 @@ import { getView } from '../../lib/workspace/viewRegistry' import { setDragPayload } from '../../lib/workspace/dragPayload' import { EntityHeader } from '../EntityHeader' import { MainHeader } from '../MainHeader' -import { Stack, Text } from '../../ui' +import { Stack } from '../../ui' import { SplitMenu } from './SplitMenu' import { DropOverlay } from './DropOverlay' import type { Tile } from '../../lib/workspace/types' @@ -155,7 +155,6 @@ function EntityTileBody({ function StandaloneTileBody({ tile }: { tile: Tile }): React.ReactElement { const { activeServer } = useServerConnection() const viewDef = getView(tile.viewId) - const Icon = viewDef?.icon const baseUrl = activeServer?.url ?? `` // Same drag-by-header trick as the entity tile body — the whole @@ -184,15 +183,7 @@ function StandaloneTileBody({ tile }: { tile: Tile }): React.ReactElement { draggable onDragStart={onHeaderDragStart} > - - {Icon && } - {viewDef.label} - - } - actions={} - /> + } /> ) From 1140d7c2998be298db90dfedac092739339a68b1 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:37:01 +0100 Subject: [PATCH 24/57] style(agents-ui): randomize new session hero title Pick one of the hero title phrases when the new-session view mounts, without rotating or animating after load. Co-authored-by: Kyle Mathews Co-authored-by: Cursor --- .../src/components/views/NewSessionView.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/agents-server-ui/src/components/views/NewSessionView.tsx b/packages/agents-server-ui/src/components/views/NewSessionView.tsx index da24a6205e..30eee268e7 100644 --- a/packages/agents-server-ui/src/components/views/NewSessionView.tsx +++ b/packages/agents-server-ui/src/components/views/NewSessionView.tsx @@ -21,6 +21,17 @@ import type { StandaloneViewProps } from '../../lib/workspace/viewRegistry' */ const DEFAULT_AGENT_NAME = `horton` +const HERO_TITLES = [ + `Let’s ship`, + `Let’s create`, + `Let’s build`, + `Let’s explore`, + `Let’s debug`, + `Let’s design`, + `Let’s hack`, + `Let’s improve`, +] as const + interface SchemaProperty { type?: string enum?: Array @@ -206,12 +217,15 @@ function Picker({ onChangeWorkingDirectory: (path: string | null) => void }): React.ReactElement { const hasAnyAgent = defaultAgent !== null || otherAgents.length > 0 + const [heroTitle] = useState( + () => HERO_TITLES[Math.floor(Math.random() * HERO_TITLES.length)] + ) return (
- Let’s ship + {heroTitle} {defaultAgent From 91be7da26801a638290c022972fffe63facf55c8 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:39:37 +0100 Subject: [PATCH 25/57] refactor(agents-ui): tildified working-dir labels + drop all-caps headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related polish passes: • Sidebar working-dir grouping now labels each bucket with a tildified, head-truncated path (`~/Code/electric`, `…/projects/acme`) instead of the bare basename, with the full absolute path surfaced as a `title` tooltip on hover. Truncation drops *leading* segments so the project folder stays visible — CSS ellipsis would lose it from the end. Path helpers extracted to `lib/pathDisplay.ts` and shared with `WorkingDirectoryPicker`. • Removed `text-transform: uppercase` (and paired `letter-spacing`) from every section/group heading across the app — sidebar, search palette, new-session screen, state-explorer headers, split menu, dropdown group labels. Bumped `font-weight: 500` (and 10 → 11px on the small sidebar / search labels) to keep heading prominence without relying on uppercase letterforms. Co-authored-by: Cursor --- .../src/components/NewSessionPage.module.css | 5 +- .../src/components/SearchPalette.module.css | 5 +- .../src/components/Sidebar.module.css | 14 +- .../src/components/Sidebar.tsx | 12 +- .../src/components/WorkingDirectoryPicker.tsx | 40 +----- .../stateExplorer/EventSidebar.module.css | 2 +- .../StateExplorerPanel.module.css | 2 +- .../stateExplorer/StateTable.module.css | 2 +- .../stateExplorer/TypeList.module.css | 2 +- .../components/workspace/SplitMenu.module.css | 3 +- .../agents-server-ui/src/lib/pathDisplay.ts | 124 ++++++++++++++++++ .../agents-server-ui/src/lib/sessionGroups.ts | 43 ++++-- .../agents-server-ui/src/ui/Menu.module.css | 3 +- 13 files changed, 188 insertions(+), 69 deletions(-) create mode 100644 packages/agents-server-ui/src/lib/pathDisplay.ts diff --git a/packages/agents-server-ui/src/components/NewSessionPage.module.css b/packages/agents-server-ui/src/components/NewSessionPage.module.css index c2f10d5b0c..a80a88b80f 100644 --- a/packages/agents-server-ui/src/components/NewSessionPage.module.css +++ b/packages/agents-server-ui/src/components/NewSessionPage.module.css @@ -342,7 +342,6 @@ gap: var(--ds-space-2); } .otherAgentsLabel { - text-transform: uppercase; - letter-spacing: 0.06em; - font-size: 10px; + font-size: 11px; + font-weight: 500; } diff --git a/packages/agents-server-ui/src/components/SearchPalette.module.css b/packages/agents-server-ui/src/components/SearchPalette.module.css index a98f05050e..7223e5e61c 100644 --- a/packages/agents-server-ui/src/components/SearchPalette.module.css +++ b/packages/agents-server-ui/src/components/SearchPalette.module.css @@ -78,9 +78,8 @@ .groupLabel { display: block; - text-transform: uppercase; - letter-spacing: 0.06em; - font-size: 10px; + font-size: 11px; + font-weight: 500; color: var(--ds-text-3); padding: 10px 12px 4px; } diff --git a/packages/agents-server-ui/src/components/Sidebar.module.css b/packages/agents-server-ui/src/components/Sidebar.module.css index 4770713840..69988d9245 100644 --- a/packages/agents-server-ui/src/components/Sidebar.module.css +++ b/packages/agents-server-ui/src/components/Sidebar.module.css @@ -125,12 +125,18 @@ /* Section label ("Pinned", "Today", "Last 7 days", …) — text aligns with the icon column above so the column reads cleanly - from top to bottom. */ + from top to bottom. Sentence-case (no `text-transform`) so + working-dir labels like `~/Code/electric` render naturally; + font-weight is the prominence cue instead of uppercase letterforms. + Single-line ellipsis is a safety net for the working-dir grouping + mode where a label can be a long abbreviated path. */ .sectionLabel { display: block; - text-transform: uppercase; - letter-spacing: 0.06em; - font-size: 10px; + font-size: 11px; + font-weight: 500; padding: 14px 4px 4px 8px; color: var(--ds-text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 3a3e499979..292be63fe5 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -231,7 +231,7 @@ export function Sidebar({ {ungroupedBuckets.map((group) => (
- {group.label} + {group.label} {group.items.map((root) => ( ))} @@ -302,11 +302,19 @@ function buildEntityTree(entities: ReadonlyArray): { function SectionLabel({ children, + title, }: { children: React.ReactNode + /** + * Optional longer-form text surfaced as a native tooltip on hover. + * Used by the working-directory grouping mode where `children` is + * an abbreviated path (e.g. `…/projects/acme`) and the full path + * is worth showing on hover. + */ + title?: string }): React.ReactElement { return ( - + {children} ) diff --git a/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx index e6d7557673..0e749a8961 100644 --- a/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx +++ b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import { Check, Folder, FolderOpen, Home, X } from 'lucide-react' import { Combobox, IconButton } from '../ui' import { useRecentWorkingDirectories } from '../hooks/useRecentWorkingDirectories' +import { detectHomeDir, tildifyPath } from '../lib/pathDisplay' import styles from './WorkingDirectoryPicker.module.css' type WorkingDirectoryPickerProps = { @@ -52,12 +53,12 @@ export function WorkingDirectoryPicker({ typeof window !== `undefined` && Boolean(window.electronAPI?.pickDirectory) const homeDir = useMemo( - () => detectHomeDir(recents, defaultPath), + () => detectHomeDir([defaultPath, ...recents]), [recents, defaultPath] ) const triggerLabel = useMemo( - () => (value ? tildify(value, homeDir) : `None`), + () => (value ? tildifyPath(value, homeDir) : `None`), [value, homeDir] ) @@ -169,7 +170,7 @@ export function WorkingDirectoryPicker({ - {tildify(path, homeDir)} + {tildifyPath(path, homeDir)} {/* Trailing slot is a 24×24 box (matching IconButton size={1}) with the check and the @@ -225,36 +226,3 @@ export function WorkingDirectoryPicker({ ) } - -/** - * Replace `$HOME` with `~` in a path for display. We don't have a - * platform `os.homedir()` in the renderer, so we sniff the longest - * common prefix among recent paths and `defaultPath` that looks like - * a home dir (`/Users/` on macOS, `/home/` on Linux, - * `C:\\Users\\` on Windows). Degrades gracefully — if we can't - * find one, paths render as-is. - */ -function tildify(path: string, homeDir: string | null): string { - if (!homeDir) return path - if (path === homeDir) return `~` - if (path.startsWith(homeDir + `/`)) return `~${path.slice(homeDir.length)}` - if (path.startsWith(homeDir + `\\`)) return `~${path.slice(homeDir.length)}` - return path -} - -function detectHomeDir( - recents: ReadonlyArray, - defaultPath?: string | null -): string | null { - const candidates = [defaultPath, ...recents].filter( - (p): p is string => typeof p === `string` && p.length > 0 - ) - for (const p of candidates) { - const m = - p.match(/^(\/Users\/[^/]+)/) || - p.match(/^(\/home\/[^/]+)/) || - p.match(/^([A-Za-z]:\\Users\\[^\\]+)/) - if (m) return m[1] ?? null - } - return null -} diff --git a/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.module.css b/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.module.css index b6fdc3dd57..7d2f9b4426 100644 --- a/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.module.css +++ b/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.module.css @@ -7,7 +7,7 @@ } .headerLabel { - text-transform: uppercase; + font-weight: 500; } .eventListScroll { diff --git a/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.module.css b/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.module.css index ef170ab4e6..9e260bbd30 100644 --- a/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.module.css +++ b/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.module.css @@ -10,7 +10,7 @@ } .headerLabel { - text-transform: uppercase; + font-weight: 500; } .trigger { diff --git a/packages/agents-server-ui/src/components/stateExplorer/StateTable.module.css b/packages/agents-server-ui/src/components/stateExplorer/StateTable.module.css index f951dea4b7..a1e1feda07 100644 --- a/packages/agents-server-ui/src/components/stateExplorer/StateTable.module.css +++ b/packages/agents-server-ui/src/components/stateExplorer/StateTable.module.css @@ -3,7 +3,7 @@ } .headerLabel { - text-transform: uppercase; + font-weight: 500; } .scrollContainer { diff --git a/packages/agents-server-ui/src/components/stateExplorer/TypeList.module.css b/packages/agents-server-ui/src/components/stateExplorer/TypeList.module.css index 7a5afad2f8..dfa3e18ff6 100644 --- a/packages/agents-server-ui/src/components/stateExplorer/TypeList.module.css +++ b/packages/agents-server-ui/src/components/stateExplorer/TypeList.module.css @@ -8,7 +8,7 @@ } .headerLabel { - text-transform: uppercase; + font-weight: 500; } .list { diff --git a/packages/agents-server-ui/src/components/workspace/SplitMenu.module.css b/packages/agents-server-ui/src/components/workspace/SplitMenu.module.css index 49ccf935d7..bc52b471c5 100644 --- a/packages/agents-server-ui/src/components/workspace/SplitMenu.module.css +++ b/packages/agents-server-ui/src/components/workspace/SplitMenu.module.css @@ -10,8 +10,7 @@ .sectionLabel { padding: 6px 8px 2px; font-size: var(--ds-font-size-1, 11px); - text-transform: uppercase; - letter-spacing: 0.04em; + font-weight: 500; color: var(--ds-gray-11); user-select: none; } diff --git a/packages/agents-server-ui/src/lib/pathDisplay.ts b/packages/agents-server-ui/src/lib/pathDisplay.ts new file mode 100644 index 0000000000..5f431c0cb8 --- /dev/null +++ b/packages/agents-server-ui/src/lib/pathDisplay.ts @@ -0,0 +1,124 @@ +/** + * Display helpers for filesystem paths in the renderer. + * + * The renderer doesn't have access to `os.homedir()` or `path.sep`, + * so all of these helpers work off heuristics: they recognise the + * standard `/Users/` (macOS), `/home/` (Linux), and + * `C:\Users\` (Windows) prefixes for home dirs, and accept + * either `/` or `\` as a path separator. They degrade gracefully + * when their inputs don't fit those shapes — paths render as-is + * rather than throwing. + */ + +const HOME_PREFIX_PATTERNS: ReadonlyArray = [ + /^(\/Users\/[^/]+)/, + /^(\/home\/[^/]+)/, + /^([A-Za-z]:\\Users\\[^\\]+)/, +] + +/** + * Sniff a likely home directory from a list of absolute paths. + * Returns the first prefix that matches one of the known shapes + * (`/Users/`, `/home/`, `C:\Users\`) or `null` + * when no candidate matches. Used by the picker (recents) and by + * the sidebar grouping label so both UIs render the same `~`-style + * abbreviations without needing IPC to ask the main process. + */ +export function detectHomeDir( + paths: ReadonlyArray +): string | null { + for (const p of paths) { + if (typeof p !== `string` || p.length === 0) continue + for (const pattern of HOME_PREFIX_PATTERNS) { + const m = p.match(pattern) + if (m) return m[1] ?? null + } + } + return null +} + +/** + * Replace the home directory prefix with `~` in a display path. + * Idempotent on paths that don't start with `homeDir`. Honours + * either `/` or `\` after the home dir so the same helper works + * on macOS, Linux, and Windows-style paths. + */ +export function tildifyPath(path: string, homeDir: string | null): string { + if (!homeDir) return path + if (path === homeDir) return `~` + if (path.startsWith(homeDir + `/`)) return `~${path.slice(homeDir.length)}` + if (path.startsWith(homeDir + `\\`)) return `~${path.slice(homeDir.length)}` + return path +} + +/** + * Abbreviate a (preferably already-tildified) path so it fits in a + * confined column. If the path fits within `maxLength` it's + * returned as-is; otherwise we drop leading segments and prefix + * `…/` so the deepest segments — usually the project folder — + * stay visible. CSS ellipsis on its own would truncate the *end* + * of the string, which is the most informative part for a + * working-directory label, so we pre-abbreviate from the start + * here instead. + * + * `~/Code/electric` (15) → `~/Code/electric` + * `~/Documents/work/projects/acme` (30) → `…/projects/acme` + * `~/very/deep/nested/repo/src/app` (32) → `…/src/app` + * + * If even the trailing segment exceeds the budget, it's truncated + * with `…` at the start. + */ +export function abbreviatePath( + path: string, + opts?: { maxLength?: number } +): string { + const maxLength = opts?.maxLength ?? 28 + if (path.length <= maxLength) return path + + // Use whichever separator dominates so we don't accidentally + // re-join Windows segments with `/` or vice versa. + const sep = path.includes(`\\`) && !path.includes(`/`) ? `\\` : `/` + const segments = path.split(/[\\/]+/).filter((s) => s.length > 0) + if (segments.length <= 1) { + // Nothing to drop — single-segment path. Just chop the head. + return `…${path.slice(path.length - (maxLength - 1))}` + } + + const ellipsis = `…${sep}` + let budget = maxLength - ellipsis.length + const tail: Array = [] + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]! + // +1 for the joining separator between segments. The very + // last segment doesn't need one but keeping the math simple + // (always +1) just trims one char's worth of budget — fine. + const cost = seg.length + 1 + if (cost > budget) break + tail.unshift(seg) + budget -= cost + } + + if (tail.length === 0) { + // Even the deepest segment is too long for the budget. Show + // an ellipsis-prefixed truncation of just that segment so the + // user still sees its tail (project name). + const last = segments[segments.length - 1]! + return `…${last.slice(last.length - (maxLength - 1))}` + } + return `${ellipsis}${tail.join(sep)}` +} + +/** + * One-shot helper used by sidebar grouping: detect the home dir + * from the input list, tildify the target path against it, and + * abbreviate to fit. Equivalent to chaining the three primitives + * above but folds the common case into a single call. + */ +export function displayWorkingDirectory( + path: string, + contextPaths: ReadonlyArray = [], + opts?: { maxLength?: number } +): string { + const homeDir = detectHomeDir([path, ...contextPaths]) + return abbreviatePath(tildifyPath(path, homeDir), opts) +} diff --git a/packages/agents-server-ui/src/lib/sessionGroups.ts b/packages/agents-server-ui/src/lib/sessionGroups.ts index e4f423e055..78f8d393d0 100644 --- a/packages/agents-server-ui/src/lib/sessionGroups.ts +++ b/packages/agents-server-ui/src/lib/sessionGroups.ts @@ -1,4 +1,5 @@ import type { ElectricEntity } from './ElectricAgentsProvider' +import { abbreviatePath, detectHomeDir, tildifyPath } from './pathDisplay' /** * Session list grouping by recency. @@ -33,6 +34,13 @@ export type SessionGroup = { id: string key: BucketKey label: string + /** + * Optional longer-form text — useful as a tooltip when the + * `label` had to be abbreviated to fit a confined column (e.g. + * working-directory labels in the sidebar). Falls back to + * `label` when omitted. + */ + title?: string items: Array } @@ -118,7 +126,8 @@ class Group implements SessionGroup { constructor( public id: string, public key: BucketKey, - public label: string + public label: string, + public title?: string ) {} } @@ -196,9 +205,12 @@ function formatLabel(id: string): string { * spawned through paths that don't carry a cwd (e.g. older sessions, * agent types other than horton). * - * Buckets are labelled with the basename of the path; the full path - * is stashed on `id` for tooltip / debugging purposes downstream. - * Sort order: most-populous first, alphabetical tiebreaker. + * Group labels are tildified and abbreviated to fit a sidebar + * column (`~/Code/electric`, `…/projects/acme`) — see + * `pathDisplay.abbreviatePath` for the truncation rule. The full + * absolute path is preserved on `title` so the sidebar can surface + * it as a tooltip on hover. Sort order: most-populous first, + * alphabetical tiebreaker on the label. */ export function groupByWorkingDirectory( entities: ReadonlyArray @@ -206,9 +218,19 @@ export function groupByWorkingDirectory( const buckets = new Map() const noDir = new Group(`cwd:none`, `older`, `None`) - for (const entity of [...entities].sort( + // Two passes: collect all paths first so `detectHomeDir` can sniff + // the home prefix from the full set, then label each bucket using + // that consistent home dir. Doing it per-entity would re-detect + // home for each path and risk inconsistent labels across groups. + const sortedEntities = [...entities].sort( (a, b) => b.updated_at - a.updated_at - )) { + ) + const allPaths = sortedEntities + .map((e) => e.spawn_args?.workingDirectory) + .filter((p): p is string => typeof p === `string` && p.trim().length > 0) + const homeDir = detectHomeDir(allPaths) + + for (const entity of sortedEntities) { const raw = entity.spawn_args?.workingDirectory const cwd = typeof raw === `string` && raw.trim().length > 0 ? raw : null if (cwd === null) { @@ -217,7 +239,8 @@ export function groupByWorkingDirectory( } let group = buckets.get(cwd) if (!group) { - group = new Group(`cwd:${cwd}`, `older`, basenameOrPath(cwd)) + const label = abbreviatePath(tildifyPath(cwd, homeDir)) + group = new Group(`cwd:${cwd}`, `older`, label, cwd) buckets.set(cwd, group) } group.items.push(entity) @@ -232,9 +255,3 @@ export function groupByWorkingDirectory( // visual top of the list. return noDir.items.length > 0 ? [...dirGroups, noDir] : dirGroups } - -function basenameOrPath(path: string): string { - const trimmed = path.replace(/[/\\]+$/, ``) - const idx = Math.max(trimmed.lastIndexOf(`/`), trimmed.lastIndexOf(`\\`)) - return idx === -1 ? trimmed : trimmed.slice(idx + 1) || trimmed -} diff --git a/packages/agents-server-ui/src/ui/Menu.module.css b/packages/agents-server-ui/src/ui/Menu.module.css index 734a171ba1..876e3db49c 100644 --- a/packages/agents-server-ui/src/ui/Menu.module.css +++ b/packages/agents-server-ui/src/ui/Menu.module.css @@ -60,7 +60,6 @@ padding: 4px 8px 2px; font-size: var(--ds-text-xs); line-height: var(--ds-text-xs-lh); + font-weight: 500; color: var(--ds-text-3); - text-transform: uppercase; - letter-spacing: 0.04em; } From b4afd5155015006ab9e8b85b9a618889728089af Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:41:14 +0100 Subject: [PATCH 26/57] fix(agents-desktop): raise per-host connection limit Co-authored-by: Cursor --- packages/agents-desktop/src/main.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/agents-desktop/src/main.ts b/packages/agents-desktop/src/main.ts index 0c89dd0258..e36dbaea0d 100644 --- a/packages/agents-desktop/src/main.ts +++ b/packages/agents-desktop/src/main.ts @@ -96,6 +96,15 @@ const TRAY_ICON_2X_PATH = path.resolve( ) const APP_ICON_PATH = path.resolve(PACKAGE_DIR, `assets/icon.png`) const APP_DISPLAY_NAME = `Electric Agents` +const MAX_CONNECTIONS_PER_HOST = `256` + +// Electric streams can hold many long-polling HTTP requests open to the same +// agents server. Raise Chromium's default per-host connection cap before +// Electron creates its network context so those streams do not queue behind it. +app.commandLine.appendSwitch( + `max-connections-per-host`, + MAX_CONNECTIONS_PER_HOST +) /** * When set, the renderer is loaded from this dev-server URL instead From cd9ca37d311257a9d94f805757688c7c8c0b45e4 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 17:41:19 +0100 Subject: [PATCH 27/57] chore: changeset for desktop shell + agents-ui workspace Patch release across the four touched packages: new `@electric-ax/agents-desktop` Electron shell, the tile-based workspace + dropdown / settings rework in `agents-server-ui`, Horton's new `workingDirectory` spawn arg, and the runtime tool-pair / event-matching fixes surfaced while building it. Co-authored-by: Cursor --- .../electron-desktop-and-agents-ui-tiles.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/electron-desktop-and-agents-ui-tiles.md diff --git a/.changeset/electron-desktop-and-agents-ui-tiles.md b/.changeset/electron-desktop-and-agents-ui-tiles.md new file mode 100644 index 0000000000..76ada6341e --- /dev/null +++ b/.changeset/electron-desktop-and-agents-ui-tiles.md @@ -0,0 +1,29 @@ +--- +'@electric-ax/agents-desktop': patch +'@electric-ax/agents-server-ui': patch +'@electric-ax/agents-runtime': patch +'@electric-ax/agents': patch +--- + +Electron desktop shell, tile-based workspace, and per-session +working-directory picker. + + - `@electric-ax/agents-desktop`: new package — Electron app + bundling a local Horton runtime, system-tray status, multi- + window support, frameless windows with in-app title bars, + native menus, About dialog, on-launch API key prompt + (Anthropic / OpenAI / Brave), localhost agent-server discovery, + and HMR via `vite-plugin-electron`. + - `@electric-ax/agents-server-ui`: tile-based workspace (DnD, + splits, persisted layouts, shareable URLs), redesigned new- + session screen, refreshed dropdown chrome (`Combobox` + primitive, sentence-case section headings, ServerPicker-style + rows), sidebar filter & view menu with grouping by date / + type / status / working dir, full Settings screen with + General / Appearance / Local Runtime categories. + - `@electric-ax/agents`: Horton accepts an optional + `workingDirectory` spawn arg so each session can run against + its own project root without restarting the runtime. + - `@electric-ax/agents-runtime`: tool-pair preservation during + compaction and matching tool-call events by id (bug fixes + surfaced while building the desktop UI). From 0b690addd543ff1f96693fb03b956a3574004b85 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 5 May 2026 18:22:41 +0100 Subject: [PATCH 28/57] fix(agents-ui): stop preloading sessions on hover Co-authored-by: Cursor --- .../src/components/Sidebar.tsx | 3 - .../src/components/SidebarRow.tsx | 4 -- .../src/components/SidebarTree.tsx | 4 -- .../src/lib/entity-connection.ts | 60 +++++++++++++++++-- packages/agents-server-ui/src/router.tsx | 16 +---- 5 files changed, 58 insertions(+), 29 deletions(-) diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 292be63fe5..62900dd382 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -52,7 +52,6 @@ export function Sidebar({ selectedEntityUrl, onSelectEntity, onOpenEntityInSplit, - onPreloadEntity, pinnedUrls, onTogglePin, }: { @@ -64,7 +63,6 @@ export function Sidebar({ * the workspace helpers in `RootShell`. */ onOpenEntityInSplit?: (url: string) => void - onPreloadEntity?: (url: string) => void pinnedUrls: Array onTogglePin: (url: string) => void }): React.ReactElement { @@ -166,7 +164,6 @@ export function Sidebar({ selectedEntityUrl, onSelectEntity, onOpenEntityInSplit, - onPreloadEntity, pinnedUrls, onTogglePin, hoverHandle, diff --git a/packages/agents-server-ui/src/components/SidebarRow.tsx b/packages/agents-server-ui/src/components/SidebarRow.tsx index 66615ad935..e812c5baf4 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.tsx +++ b/packages/agents-server-ui/src/components/SidebarRow.tsx @@ -47,7 +47,6 @@ type SidebarRowProps = { * `helpers.openEntity(url, { target: { groupId, position: 'split-right' }})`. */ onOpenInSplit?: () => void - onPreload?: () => void depth?: number /** Number of immediate children. 0 means no expand affordance. */ childCount?: number @@ -95,7 +94,6 @@ export const SidebarRow = memo(function SidebarRow({ selected, onSelect, onOpenInSplit, - onPreload, depth = 0, childCount = 0, expanded = false, @@ -133,8 +131,6 @@ export const SidebarRow = memo(function SidebarRow({ tabIndex={0} className={className} draggable - onMouseEnter={onPreload} - onFocus={onPreload} onDragStart={(e) => { setDragPayload(e, { kind: `sidebar-entity`, diff --git a/packages/agents-server-ui/src/components/SidebarTree.tsx b/packages/agents-server-ui/src/components/SidebarTree.tsx index 955eaa6bf2..732a1c3a0b 100644 --- a/packages/agents-server-ui/src/components/SidebarTree.tsx +++ b/packages/agents-server-ui/src/components/SidebarTree.tsx @@ -13,7 +13,6 @@ type SidebarTreeProps = { onSelectEntity: (url: string) => void /** Optional ⌘/Ctrl-click handler — opens the entity in a new split. */ onOpenEntityInSplit?: (url: string) => void - onPreloadEntity?: (url: string) => void pinnedUrls: ReadonlyArray onTogglePin: (url: string) => void depth?: number @@ -69,7 +68,6 @@ export const SidebarTree = memo(function SidebarTree({ selectedEntityUrl, onSelectEntity, onOpenEntityInSplit, - onPreloadEntity, pinnedUrls, onTogglePin, depth = 0, @@ -100,7 +98,6 @@ export const SidebarTree = memo(function SidebarTree({ ? () => onOpenEntityInSplit(entity.url) : undefined } - onPreload={() => onPreloadEntity?.(entity.url)} depth={depth} childCount={children.length} expanded={expanded} @@ -119,7 +116,6 @@ export const SidebarTree = memo(function SidebarTree({ selectedEntityUrl={selectedEntityUrl} onSelectEntity={onSelectEntity} onOpenEntityInSplit={onOpenEntityInSplit} - onPreloadEntity={onPreloadEntity} pinnedUrls={pinnedUrls} onTogglePin={onTogglePin} depth={depth + 1} diff --git a/packages/agents-server-ui/src/lib/entity-connection.ts b/packages/agents-server-ui/src/lib/entity-connection.ts index 9f380884e7..f83817c8f1 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.ts @@ -55,12 +55,23 @@ function scheduleEviction(key: string, entry: CachedConnection): void { }, 30_000) } +function abortError(): DOMException { + return new DOMException(`Entity stream preload was aborted`, `AbortError`) +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError() + } +} + function getOrCreateConnection(opts: { baseUrl: string entityUrl: string customState?: UICustomState + signal?: AbortSignal }): { key: string; entry: CachedConnection } { - const { baseUrl, entityUrl, customState } = opts + const { baseUrl, entityUrl, customState, signal } = opts const key = cacheKey(baseUrl, entityUrl) const existing = connectionCache.get(key) if (existing) { @@ -68,7 +79,12 @@ function getOrCreateConnection(opts: { return { key, entry: existing } } - const promise = connectEntityStreamFresh({ baseUrl, entityUrl, customState }) + const promise = connectEntityStreamFresh({ + baseUrl, + entityUrl, + customState, + signal, + }) const entry: CachedConnection = { promise, refs: 0, evictionTimer: null } connectionCache.set(key, entry) promise.catch(() => { @@ -77,10 +93,37 @@ function getOrCreateConnection(opts: { return { key, entry } } +async function preloadWithAbort( + db: EntityStreamDBWithActions, + signal?: AbortSignal +): Promise { + if (!signal) { + await db.preload() + return + } + + throwIfAborted(signal) + + let abort: (() => void) | null = null + const aborted = new Promise((_, reject) => { + abort = () => reject(abortError()) + signal.addEventListener(`abort`, abort, { once: true }) + }) + + try { + await Promise.race([db.preload(), aborted]) + } finally { + if (abort) { + signal.removeEventListener(`abort`, abort) + } + } +} + export async function preloadEntityStream(opts: { baseUrl: string entityUrl: string customState?: UICustomState + signal?: AbortSignal }): Promise { const { key, entry } = getOrCreateConnection(opts) try { @@ -120,21 +163,30 @@ async function connectEntityStreamFresh(opts: { baseUrl: string entityUrl: string customState?: UICustomState + signal?: AbortSignal }): Promise<{ db: EntityStreamDBWithActions; close: () => void }> { - const { baseUrl, entityUrl, customState } = opts + const { baseUrl, entityUrl, customState, signal } = opts + throwIfAborted(signal) const res = await fetch(`${baseUrl}${entityUrl}`, { headers: { accept: `application/json` }, + signal, }) if (!res.ok) { throw new Error(`Failed to fetch entity at ${entityUrl}: ${res.statusText}`) } await res.body?.cancel() + throwIfAborted(signal) const streamUrl = `${baseUrl}${getMainStreamPath(entityUrl)}` const db = createEntityStreamDB( streamUrl, customState as unknown as Parameters[1] ) - await db.preload() + try { + await preloadWithAbort(db, signal) + } catch (err) { + db.close() + throw err + } return { db, close: () => db.close() } } diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index 3971175b81..18460c1696 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -9,7 +9,6 @@ import { useLocation, useNavigate, useParams, - useRouter, } from '@tanstack/react-router' import { z } from 'zod' import { getActiveBaseUrl, preloadEntityStream } from './lib/entity-connection' @@ -65,7 +64,6 @@ function RootLayout(): React.ReactElement { function RootShell(): React.ReactElement { const { pinnedUrls, togglePin } = usePinnedEntities() const navigate = useNavigate() - const router = useRouter() const { collapsed, toggle } = useSidebarCollapsed() const search = useSearchPalette() const { workspace, helpers } = useWorkspace() @@ -177,16 +175,6 @@ function RootShell(): React.ReactElement { [helpers, navigateToEntity] ) - const preloadEntity = useCallback( - (entityUrl: string) => { - void router.preloadRoute({ - to: `/entity/$`, - params: { _splat: entityUrl.replace(/^\//, ``) }, - }) - }, - [router] - ) - const params = useParams({ strict: false }) const splat = (params as Record)._splat const selectedEntityUrl = splat ? `/${splat}` : null @@ -212,7 +200,6 @@ function RootShell(): React.ReactElement { selectedEntityUrl={selectedEntityUrl} onSelectEntity={navigateToEntity} onOpenEntityInSplit={openEntityInSplit} - onPreloadEntity={preloadEntity} pinnedUrls={pinnedUrls} onTogglePin={togglePin} /> @@ -285,12 +272,13 @@ const indexRoute = createRoute({ const entityRoute = createRoute({ getParentRoute: () => rootRoute, path: `/entity/$`, - loader: async ({ params }): Promise => { + loader: async ({ abortController, params }): Promise => { const baseUrl = getActiveBaseUrl() if (!baseUrl) return null await preloadEntityStream({ baseUrl, entityUrl: `/${params._splat}`, + signal: abortController.signal, }) return null }, From c0125ca2c40450e659dd08891c291dbb5902173d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 5 May 2026 11:42:28 -0600 Subject: [PATCH 29/57] Remember last picked new session model --- .../src/components/views/NewSessionView.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/agents-server-ui/src/components/views/NewSessionView.tsx b/packages/agents-server-ui/src/components/views/NewSessionView.tsx index 30eee268e7..bcfb2cb23f 100644 --- a/packages/agents-server-ui/src/components/views/NewSessionView.tsx +++ b/packages/agents-server-ui/src/components/views/NewSessionView.tsx @@ -32,6 +32,36 @@ const HERO_TITLES = [ `Let’s improve`, ] as const +const LAST_PICKED_MODEL_STORAGE_KEY = `electric-agents-ui.new-session.last-picked-model` + +function isModelProperty(key: string): boolean { + const normalized = key.toLowerCase() + return ( + normalized === `model` || + normalized === `modelid` || + normalized === `model_id` + ) +} + +function readLastPickedModel(options: Array): string | null { + if (typeof window === `undefined`) return null + try { + const value = window.localStorage.getItem(LAST_PICKED_MODEL_STORAGE_KEY) + return value && options.includes(value) ? value : null + } catch { + return null + } +} + +function persistLastPickedModel(value: string): void { + if (typeof window === `undefined`) return + try { + window.localStorage.setItem(LAST_PICKED_MODEL_STORAGE_KEY, value) + } catch { + // Quota / private mode — silent. This is only a picker convenience. + } +} + interface SchemaProperty { type?: string enum?: Array @@ -376,6 +406,15 @@ function DefaultAgentComposer({ const [args, setArgs] = useState>(() => { const init: Record = {} for (const { key, prop } of inlineProps) { + if (prop.enum && prop.enum.length > 0 && isModelProperty(key)) { + const options = prop.enum.map((v) => String(v)) + const lastPicked = readLastPickedModel(options) + if (lastPicked !== null) { + init[key] = + prop.enum.find((v) => String(v) === lastPicked) ?? lastPicked + continue + } + } if (prop.default !== undefined) { init[key] = prop.default } else if (prop.enum && prop.enum.length > 0) { @@ -434,6 +473,7 @@ function DefaultAgentComposer({ options={prop.enum.map((v) => String(v))} onChange={(next) => { const original = prop.enum!.find((v) => String(v) === next) + if (isModelProperty(key)) persistLastPickedModel(next) setArgs((prev) => ({ ...prev, [key]: original ?? next })) }} disabled={submitting || disabled} From 63a7ba358e43d89269dbb4bce95217eabcb87ff1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 5 May 2026 11:43:48 -0600 Subject: [PATCH 30/57] Inject AGENTS.md into Horton context --- packages/agents/src/agents/horton.ts | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 447c0ecc16..66d8ce7149 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs' +import path from 'node:path' import Anthropic from '@anthropic-ai/sdk' import { z } from 'zod' import { serverLog } from '../log' @@ -302,6 +304,23 @@ export function extractFirstUserMessage( type HortonDocsSupport = NonNullable> +function readAgentsMd(workingDirectory: string): string | null { + const agentsMdPath = path.join(workingDirectory, `AGENTS.md`) + try { + if (!fs.existsSync(agentsMdPath) || !fs.statSync(agentsMdPath).isFile()) { + return null + } + const content = fs.readFileSync(agentsMdPath, `utf8`) + return [ + ``, + content, + ``, + ].join(`\n`) + } catch { + return null + } +} + function createAssistantHandler(options: { workingDirectory: string streamFn?: StreamFn @@ -336,6 +355,7 @@ function createAssistantHandler(options: { ? ctx.args.workingDirectory : workingDirectory const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args) + const agentsMd = readAgentsMd(effectiveCwd) const tools = [ ...ctx.electricTools, ...createHortonTools(effectiveCwd, ctx, readSet, { @@ -370,6 +390,15 @@ function createAssistantHandler(options: { content: () => ctx.timelineMessages(), cache: `volatile`, }, + ...(agentsMd + ? { + agents_md: { + content: () => agentsMd, + max: 20_000, + cache: `stable` as const, + }, + } + : {}), ...(skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: { @@ -394,6 +423,30 @@ function createAssistantHandler(options: { content: () => ctx.timelineMessages(), cache: `volatile`, }, + ...(agentsMd + ? { + agents_md: { + content: () => agentsMd, + max: 20_000, + cache: `stable` as const, + }, + } + : {}), + }, + }) + } else if (agentsMd) { + ctx.useContext({ + sourceBudget: 100_000, + sources: { + conversation: { + content: () => ctx.timelineMessages(), + cache: `volatile`, + }, + agents_md: { + content: () => agentsMd, + max: 20_000, + cache: `stable`, + }, }, }) } From 50686856ae6c5f0f50c36078c2f004e9fb76a88d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 5 May 2026 13:25:34 -0600 Subject: [PATCH 31/57] fix(agents-desktop): rework vite build to bundle deps and output CJS - Add resolve aliases to compile workspace packages from source TS - Invert externalization: bundle all deps except native addons (better-sqlite3, sqlite-vec), optional native peer deps (canvas, bufferutil, utf-8-validate), filesystem-dependent (jsdom), and worker-thread-based (pino, pino-pretty) - Switch main process output from ESM to CJS (array-wrap output config to override vite-plugin-electron's forced ESM format) - Add externalized packages as direct deps for runtime resolution Co-Authored-By: Claude Opus 4.6 --- packages/agents-desktop/package.json | 11 +++- packages/agents-desktop/vite.config.ts | 74 +++++++++++++++++--------- pnpm-lock.yaml | 70 ++++++++++++------------ 3 files changed, 94 insertions(+), 61 deletions(-) diff --git a/packages/agents-desktop/package.json b/packages/agents-desktop/package.json index c1d363afe9..bb31121c84 100644 --- a/packages/agents-desktop/package.json +++ b/packages/agents-desktop/package.json @@ -4,7 +4,7 @@ "private": true, "version": "0.0.0", "type": "module", - "main": "./dist/main.js", + "main": "./dist/main.cjs", "scripts": { "build": "pnpm --filter @electric-ax/agents build && pnpm --filter @electric-ax/agents-server-ui build:desktop && vite build", "dev": "pnpm --filter @electric-ax/agents build && pnpm run ensure:electron && concurrently -k -n ui,desktop -c cyan,green \"pnpm run dev:ui\" \"pnpm run dev:desktop\"", @@ -16,7 +16,14 @@ }, "dependencies": { "@electric-ax/agents": "workspace:*", - "@electric-ax/agents-server-ui": "workspace:*" + "@electric-ax/agents-server-ui": "workspace:*", + "@mixmark-io/domino": "^2.2.0", + "better-sqlite3": "^11.10.0", + "pino": "^10.3.1", + "pino-pretty": "^13.0.0", + "turndown-plugin-gfm": "^1.0.2", + "jsdom": "^28.1.0", + "sqlite-vec": "^0.1.9" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/packages/agents-desktop/vite.config.ts b/packages/agents-desktop/vite.config.ts index 81081eb476..ccc65b4d2a 100644 --- a/packages/agents-desktop/vite.config.ts +++ b/packages/agents-desktop/vite.config.ts @@ -1,33 +1,39 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { defineConfig, type PluginOption } from 'vite' import electron from 'vite-plugin-electron/simple' const RENDERER_DEV_SERVER_URL = `http://localhost:5183` +const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.resolve(PACKAGE_DIR, `../..`) + +const MUST_EXTERNALIZE = new Set([ + `electron`, + `better-sqlite3`, + `sqlite-vec`, + `canvas`, + `bufferutil`, + `utf-8-validate`, + `jsdom`, + `pino`, + `pino-pretty`, +]) -/** - * Treat any bare module specifier as external — i.e. let Node - * resolve it from `node_modules` at runtime. This is the standard - * pattern for Electron main / preload bundles: - * - * - Avoids dragging optional native deps (jsdom → canvas, sharp, - * keytar, …) into the bundle and failing the build when they're - * not actually installed. - * - Keeps the bundled `main.js` small (just our own source) so any - * rebuild during dev stays sub-second. - * - Works in dev (workspace `node_modules` is symlinked) and in - * production (electron-builder ships the package's `node_modules` - * alongside the bundled main). - * - * Entry modules and any path-like import (relative, absolute) stay - * internal so they actually get bundled. - */ function externalizeBareImports( id: string, parent: string | undefined ): boolean { if (parent === undefined) return false - if (id.startsWith(`.`)) return false - if (id.startsWith(`/`) || /^[A-Za-z]:[\\/]/.test(id)) return false - return true + if (MUST_EXTERNALIZE.has(id)) return true + const pkgName = id.startsWith(`@`) + ? id.split(`/`).slice(0, 2).join(`/`) + : id.split(`/`)[0] + if (MUST_EXTERNALIZE.has(pkgName)) return true + if (id.includes(`node_modules`)) { + const match = id.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/) + if (match && MUST_EXTERNALIZE.has(match[1])) return true + } + return false } // vite-plugin-electron ships its own bundled `Plugin` type derived @@ -65,6 +71,22 @@ const electronPlugin = electron as unknown as ( * separately by `agents-server-ui`'s `build:desktop` script. */ export default defineConfig({ + resolve: { + alias: { + '@electric-ax/agents': path.resolve( + REPO_ROOT, + `packages/agents/src/index.ts` + ), + '@electric-ax/agents-runtime': path.resolve( + REPO_ROOT, + `packages/agents-runtime/src/index.ts` + ), + '@electric-ax/agents-runtime/tools': path.resolve( + REPO_ROOT, + `packages/agents-runtime/src/tools.ts` + ), + }, + }, server: { port: 0, strictPort: false, @@ -90,11 +112,13 @@ export default defineConfig({ minify: false, rollupOptions: { external: externalizeBareImports, - output: { - entryFileNames: `main.js`, - format: `es`, - inlineDynamicImports: true, - }, + output: [ + { + entryFileNames: `main.cjs`, + format: `cjs`, + inlineDynamicImports: true, + }, + ], }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff5d49c1db..1b1030345b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -918,34 +918,6 @@ importers: specifier: ^4.18.1 version: 4.24.4 - examples/replay-loop-repro: - dependencies: - '@electric-sql/client': - specifier: workspace:* - version: link:../../packages/typescript-client - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - devDependencies: - '@types/react': - specifier: ^18.3.3 - version: 18.3.12 - '@types/react-dom': - specifier: ^18.3.0 - version: 18.3.1 - '@vitejs/plugin-react': - specifier: ^4.3.1 - version: 4.3.3(vite@5.4.10(@types/node@25.6.0)(lightningcss@1.30.1)(terser@5.46.2)) - typescript: - specifier: ^5.5.3 - version: 5.8.3 - vite: - specifier: ^5.3.4 - version: 5.4.10(@types/node@25.6.0)(lightningcss@1.30.1)(terser@5.46.2) - examples/tanstack: dependencies: '@electric-sql/client': @@ -1603,6 +1575,27 @@ importers: '@electric-ax/agents-server-ui': specifier: workspace:* version: link:../agents-server-ui + '@mixmark-io/domino': + specifier: ^2.2.0 + version: 2.2.0 + better-sqlite3: + specifier: ^11.10.0 + version: 11.10.0 + jsdom: + specifier: ^28.1.0 + version: 28.1.0(@noble/hashes@2.0.1) + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-pretty: + specifier: ^13.0.0 + version: 13.1.3 + sqlite-vec: + specifier: ^0.1.9 + version: 0.1.9 + turndown-plugin-gfm: + specifier: ^1.0.2 + version: 1.0.2 devDependencies: '@types/node': specifier: ^22.19.17 @@ -1661,6 +1654,9 @@ importers: cron-parser: specifier: ^5.5.0 version: 5.5.0 + diff: + specifier: ^9.0.0 + version: 9.0.0 jsdom: specifier: ^28.1.0 version: 28.1.0(@noble/hashes@2.0.1) @@ -11835,6 +11831,10 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -19518,7 +19518,7 @@ snapshots: dependencies: '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 - css-tree: 3.1.0 + css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 lru-cache: 11.3.5 @@ -30583,8 +30583,8 @@ snapshots: cssstyle@6.2.0: dependencies: '@asamuzakjp/css-color': 5.1.11 - '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.1.0) - css-tree: 3.1.0 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + css-tree: 3.2.1 lru-cache: 11.3.5 csstype@3.1.3: {} @@ -30975,6 +30975,8 @@ snapshots: diff@8.0.2: {} + diff@9.0.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -33916,10 +33918,10 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - parse5: 8.0.0 + parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.0 + tough-cookie: 6.0.1 undici: 7.25.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 @@ -36150,7 +36152,7 @@ snapshots: prebuild-install@7.1.3: dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.2 expand-template: 2.0.3 github-from-package: 0.0.0 minimist: 1.2.8 From 9ec8f5e4473648d3994eadbe5668522bed111989 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 5 May 2026 14:49:44 -0600 Subject: [PATCH 32/57] feat(agents): show unified diffs for edit/write tool calls Generate unified diff patches in the edit and write tools using the diff package, and render them in the UI with syntax-colored lines. Replaces the old separate "Removed"/"Added" blocks with a single diff view. Edit and write tool calls now default to expanded. Co-Authored-By: Claude Opus 4.6 --- packages/agents-runtime/package.json | 1 + packages/agents-runtime/src/tools/edit.ts | 10 +- packages/agents-runtime/src/tools/write.ts | 24 +++- .../src/components/ToolCallView.module.css | 32 ++++++ .../src/components/ToolCallView.tsx | 108 +++++++++++------- 5 files changed, 129 insertions(+), 46 deletions(-) diff --git a/packages/agents-runtime/package.json b/packages/agents-runtime/package.json index 12f07cf60c..c5df8e37d3 100644 --- a/packages/agents-runtime/package.json +++ b/packages/agents-runtime/package.json @@ -79,6 +79,7 @@ "@standard-schema/spec": "^1.1.0", "@tanstack/db": "^0.6.4", "cron-parser": "^5.5.0", + "diff": "^9.0.0", "jsdom": "^28.1.0", "pino": "^10.3.1", "pino-pretty": "^13.0.0", diff --git a/packages/agents-runtime/src/tools/edit.ts b/packages/agents-runtime/src/tools/edit.ts index 3819b96c0b..c66def3426 100644 --- a/packages/agents-runtime/src/tools/edit.ts +++ b/packages/agents-runtime/src/tools/edit.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from 'node:fs/promises' import { relative, resolve } from 'node:path' +import { createTwoFilesPatch } from 'diff' import { Type } from '@sinclair/typebox' import { runtimeLog } from '../log' import type { AgentTool } from '@mariozechner/pi-agent-core' @@ -98,6 +99,7 @@ export function createEditTool( new_string + original.slice(first + old_string.length) await writeFile(resolved, updated, `utf-8`) + const patch = createTwoFilesPatch(rel, rel, original, updated) return { content: [ { @@ -105,7 +107,7 @@ export function createEditTool( text: `Edited ${rel}: 1 replacement`, }, ], - details: { replacements: 1 }, + details: { replacements: 1, diff: patch }, } } @@ -122,7 +124,9 @@ export function createEditTool( details: { replacements: 0 }, } } - await writeFile(resolved, parts.join(new_string), `utf-8`) + const updated = parts.join(new_string) + await writeFile(resolved, updated, `utf-8`) + const patch = createTwoFilesPatch(rel, rel, original, updated) return { content: [ { @@ -130,7 +134,7 @@ export function createEditTool( text: `Edited ${rel}: ${count} occurrences replaced`, }, ], - details: { replacements: count }, + details: { replacements: count, diff: patch }, } } catch (err) { runtimeLog.warn( diff --git a/packages/agents-runtime/src/tools/write.ts b/packages/agents-runtime/src/tools/write.ts index cb0e341315..9ba9079f91 100644 --- a/packages/agents-runtime/src/tools/write.ts +++ b/packages/agents-runtime/src/tools/write.ts @@ -1,5 +1,6 @@ -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, relative, resolve } from 'node:path' +import { createTwoFilesPatch } from 'diff' import { Type } from '@sinclair/typebox' import { runtimeLog } from '../log' import type { AgentTool } from '@mariozechner/pi-agent-core' @@ -40,11 +41,30 @@ export function createWriteTool( } } + let original = `` + let existed = true + try { + original = await readFile(resolved, `utf-8`) + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== `ENOENT`) throw err + existed = false + } + await mkdir(dirname(resolved), { recursive: true }) await writeFile(resolved, content, `utf-8`) readSet?.add(resolved) const bytesWritten = Buffer.byteLength(content, `utf-8`) + const patch = createTwoFilesPatch( + existed ? rel : `/dev/null`, + rel, + original, + content, + undefined, + undefined, + { context: 3 } + ) return { content: [ { @@ -52,7 +72,7 @@ export function createWriteTool( text: `Wrote ${bytesWritten} bytes to ${rel}`, }, ], - details: { bytesWritten }, + details: { bytesWritten, diff: patch, existed }, } } catch (err) { runtimeLog.warn( diff --git a/packages/agents-server-ui/src/components/ToolCallView.module.css b/packages/agents-server-ui/src/components/ToolCallView.module.css index 0296041fca..6424f7f4a4 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.module.css +++ b/packages/agents-server-ui/src/components/ToolCallView.module.css @@ -17,3 +17,35 @@ .sentMessageBody { white-space: pre-wrap; } + +.diffBlock { + padding: 0; + white-space: pre; + word-break: normal; + overflow-x: auto; +} + +.diffBlock span { + display: block; + padding: 0 10px; + min-height: 1.6em; +} + +.diffLineAdded { + background: var(--ds-green-a2); + color: var(--ds-green-11); +} + +.diffLineRemoved { + background: var(--ds-red-a2); + color: var(--ds-red-11); +} + +.diffLineMeta { + background: var(--ds-gray-a2); + color: var(--ds-text-2); +} + +.diffLineContext { + color: var(--ds-text-1); +} diff --git a/packages/agents-server-ui/src/components/ToolCallView.tsx b/packages/agents-server-ui/src/components/ToolCallView.tsx index d19b5b1e80..20cd6e80cd 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.tsx +++ b/packages/agents-server-ui/src/components/ToolCallView.tsx @@ -37,6 +37,51 @@ function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + `…` : s } +function createInlinePatch( + path: string, + oldText: string, + newText: string +): string { + const oldLines = oldText.split(`\n`) + const newLines = newText.split(`\n`) + return [ + `--- ${path}`, + `+++ ${path}`, + `@@ -1,${oldLines.length} +1,${newLines.length} @@`, + ...oldLines.map((line) => `-${line}`), + ...newLines.map((line) => `+${line}`), + ].join(`\n`) +} + +function DiffView({ diff }: { diff: string }): React.ReactElement { + return ( +
+      {diff.split(`\n`).map((line, i) => {
+        let className = styles.diffLineContext
+        if (line.startsWith(`+`) && !line.startsWith(`+++`)) {
+          className = styles.diffLineAdded
+        } else if (line.startsWith(`-`) && !line.startsWith(`---`)) {
+          className = styles.diffLineRemoved
+        } else if (
+          line.startsWith(`diff `) ||
+          line.startsWith(`index `) ||
+          line.startsWith(`---`) ||
+          line.startsWith(`+++`) ||
+          line.startsWith(`@@`)
+        ) {
+          className = styles.diffLineMeta
+        }
+        return (
+          
+            {line || ` `}
+            {`\n`}
+          
+        )
+      })}
+    
+ ) +} + function getSummary(toolName: string, args: Record): string { switch (toolName) { case `bash`: @@ -97,6 +142,19 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { const args = item.args const r = parseResult(item.result) + const fallbackDiff = + item.toolName === `edit` && + typeof args.old_string === `string` && + typeof args.new_string === `string` + ? createInlinePatch( + (args.path as string | undefined) ?? `file`, + args.old_string, + args.new_string + ) + : item.toolName === `write` && typeof args.content === `string` + ? createInlinePatch(`/dev/null`, ``, truncate(args.content, 4000)) + : null + switch (item.toolName) { case `bash`: { const exitCode = r.details.exitCode as number | undefined @@ -138,51 +196,17 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { ) case `edit`: - return ( - - {typeof args.old_string === `string` && ( - <> - - Removed - -
-                {truncate(args.old_string, 500)}
-              
- - )} - {typeof args.new_string === `string` && ( - <> - - Added - -
-                {truncate(args.new_string, 500)}
-              
- - )} - {r.text && ( - - {r.text} - - )} -
- ) - case `write`: return ( - {typeof args.content === `string` && ( + {typeof r.details.diff === `string` || fallbackDiff ? ( <> - Content -
-                {truncate(args.content, 1000)}
-              
+ Diff + - )} + ) : null} {r.text && ( {r.text} @@ -241,7 +265,9 @@ export function ToolCallView({ ) } - const [expanded, setExpanded] = useState(false) + const shouldDefaultExpand = + item.toolName === `edit` || item.toolName === `write` + const [expanded, setExpanded] = useState(shouldDefaultExpand) const summary = getSummary(item.toolName, item.args) const badge = statusBadge(item) From ffacb821c97a50f8693a0063b2fca5cebc5ace51 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 5 May 2026 14:49:53 -0600 Subject: [PATCH 33/57] feat(agents-ui): add copy button to agent responses Co-Authored-By: Claude Opus 4.6 --- .../src/components/AgentResponse.module.css | 22 ++++++++ .../src/components/AgentResponse.tsx | 56 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/agents-server-ui/src/components/AgentResponse.module.css b/packages/agents-server-ui/src/components/AgentResponse.module.css index 10ea0b383e..1825f2df65 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.module.css +++ b/packages/agents-server-ui/src/components/AgentResponse.module.css @@ -20,3 +20,25 @@ color: var(--ds-text-4, var(--ds-text-3)); opacity: 0.7; } + +.copyButton { + display: inline-flex; + align-items: center; + gap: 4px; + border: 0; + border-radius: 6px; + padding: 2px 6px; + background: transparent; + color: var(--ds-text-4, var(--ds-text-3)); + font: inherit; + font-size: 12px; + cursor: pointer; + opacity: 0.75; + -webkit-app-region: no-drag; +} + +.copyButton:hover { + background: var(--ds-gray-a2); + color: var(--ds-text-2); + opacity: 1; +} diff --git a/packages/agents-server-ui/src/components/AgentResponse.tsx b/packages/agents-server-ui/src/components/AgentResponse.tsx index a24e04c9fc..66108db615 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.tsx +++ b/packages/agents-server-ui/src/components/AgentResponse.tsx @@ -1,4 +1,12 @@ -import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Check, Copy } from 'lucide-react' +import { + memo, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { Streamdown } from 'streamdown' import { getCachedMarkdownRender, @@ -212,6 +220,16 @@ const MarkdownSegment = memo(function MarkdownSegment({ ) }) +function toolItemToCopyText(item: EntityTimelineContentItem): string { + if (item.kind === `text`) return item.text + + const parts = [`[tool: ${item.toolName}]`] + const argsText = JSON.stringify(item.args, null, 2) + if (argsText && argsText !== `{}`) parts.push(argsText) + if (item.result) parts.push(item.result) + return parts.join(`\n`) +} + export const AgentResponse = memo(function AgentResponse({ section, isStreaming, @@ -224,6 +242,30 @@ export const AgentResponse = memo(function AgentResponse({ renderWidth?: number }): React.ReactElement { const canCache = !isStreaming && section.done === true + const [copied, setCopied] = useState(false) + const copiedTimerRef = useRef | null>(null) + const copyText = useMemo( + () => + section.items + .map(toolItemToCopyText) + .filter((text) => text.trim().length > 0) + .join(`\n\n`), + [section.items] + ) + + useEffect(() => { + return () => { + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current) + } + }, []) + + const copyResponseText = async () => { + if (!copyText) return + await navigator.clipboard.writeText(copyText) + setCopied(true) + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current) + copiedTimerRef.current = setTimeout(() => setCopied(false), 1200) + } // "Thinking" indicator visibility: // show while the response is mid-stream and there's nothing @@ -278,6 +320,18 @@ export const AgentResponse = memo(function AgentResponse({ {timestamp != null && !isStreaming && ( )} + {copyText && ( + + )}
) From a3f8bd197ade5788b2c618804921543e782579e4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 5 May 2026 14:50:02 -0600 Subject: [PATCH 34/57] fix(agents-server): default embedded streams data dir to cwd When no STREAMS_DATA_DIR env var is set, persist embedded durable streams data under ${cwd}/.streams-data instead of leaving the directory undefined. Co-Authored-By: Claude Opus 4.6 --- packages/agents-server/src/entrypoint-lib.ts | 14 ++++++---- .../agents-server/test/entrypoint.test.ts | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/agents-server/src/entrypoint-lib.ts b/packages/agents-server/src/entrypoint-lib.ts index 25df2f4640..3ebb5b9816 100644 --- a/packages/agents-server/src/entrypoint-lib.ts +++ b/packages/agents-server/src/entrypoint-lib.ts @@ -140,7 +140,8 @@ export function resolveElectricAgentsEntrypointOptions( } function createEmbeddedStreamsServer( - env: EnvSource + env: EnvSource, + cwd: string ): DurableStreamTestServer | undefined { const externalUrl = readEnv(env, [ `ELECTRIC_AGENTS_DURABLE_STREAMS_URL`, @@ -151,6 +152,10 @@ function createEmbeddedStreamsServer( return undefined } + const dataDir = + readEnv(env, [`ELECTRIC_AGENTS_STREAMS_DATA_DIR`, `STREAMS_DATA_DIR`]) ?? + `${cwd}/.streams-data` + return new DurableStreamTestServer({ host: readEnv(env, [`ELECTRIC_AGENTS_STREAMS_HOST`, `STREAMS_HOST`]) ?? @@ -161,10 +166,7 @@ function createEmbeddedStreamsServer( [`ELECTRIC_AGENTS_STREAMS_PORT`, `STREAMS_PORT`], `embedded streams port` ) ?? 0, - dataDir: readEnv(env, [ - `ELECTRIC_AGENTS_STREAMS_DATA_DIR`, - `STREAMS_DATA_DIR`, - ]), + dataDir, webhooks: true, }) } @@ -178,7 +180,7 @@ export async function runElectricAgentsEntrypoint({ server: ElectricAgentsEntrypointServer url: string }> { - const embeddedStreamsServer = createEmbeddedStreamsServer(env) + const embeddedStreamsServer = createEmbeddedStreamsServer(env, cwd) const options = { ...resolveElectricAgentsEntrypointOptions(env, cwd), durableStreamsServer: embeddedStreamsServer, diff --git a/packages/agents-server/test/entrypoint.test.ts b/packages/agents-server/test/entrypoint.test.ts index e930847a40..fcd415c85f 100644 --- a/packages/agents-server/test/entrypoint.test.ts +++ b/packages/agents-server/test/entrypoint.test.ts @@ -176,4 +176,32 @@ describe(`runElectricAgentsEntrypoint`, () => { webhooks: true, }) }) + + it(`persists embedded durable streams under the working directory by default`, async () => { + embeddedStreamsCtorMock.mockReset() + + const createServer = vi.fn( + (options: ElectricAgentsEntrypointOptions) => + ({ + start: vi.fn(() => Promise.resolve(`http://127.0.0.1:4437`)), + stop: vi.fn(() => Promise.resolve()), + options, + }) as const + ) + + await runElectricAgentsEntrypoint({ + env: { + ELECTRIC_AGENTS_DATABASE_URL: `postgres://electric_agents:electric_agents@postgres:5432/electric_agents`, + }, + cwd: `/workspace/app`, + createServer, + }) + + expect(embeddedStreamsCtorMock).toHaveBeenCalledWith({ + dataDir: `/workspace/app/.streams-data`, + host: `127.0.0.1`, + port: 0, + webhooks: true, + }) + }) }) From 4dcdeda17212684c7b8b4eeb6b5a85bc1f1e8ad3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 5 May 2026 15:26:43 -0600 Subject: [PATCH 35/57] Add per-pane find in desktop app --- packages/agents-desktop/src/main.ts | 19 +++ packages/agents-desktop/src/preload.ts | 3 + .../workspace/PaneFindBar.module.css | 47 ++++++ .../src/components/workspace/PaneFindBar.tsx | 148 ++++++++++++++++++ .../workspace/TileContainer.module.css | 3 + .../components/workspace/TileContainer.tsx | 55 ++++--- .../src/hooks/usePaneFind.tsx | 107 +++++++++++++ .../src/lib/server-connection.ts | 3 + packages/agents-server-ui/src/router.tsx | 51 +++++- 9 files changed, 410 insertions(+), 26 deletions(-) create mode 100644 packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css create mode 100644 packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx create mode 100644 packages/agents-server-ui/src/hooks/usePaneFind.tsx diff --git a/packages/agents-desktop/src/main.ts b/packages/agents-desktop/src/main.ts index e36dbaea0d..2fcf475c94 100644 --- a/packages/agents-desktop/src/main.ts +++ b/packages/agents-desktop/src/main.ts @@ -131,6 +131,9 @@ type DesktopCommand = | `close-tile` | `toggle-sidebar` | `open-search` + | `open-find` + | `find-next` + | `find-previous` | `split-right` | `split-down` | `cycle-tile` @@ -979,6 +982,22 @@ function buildApplicationMenu(): void { ] : [{ role: `delete` as const }]), { role: `selectAll` }, + { type: `separator` }, + { + label: `Find in Pane…`, + accelerator: `CommandOrControl+F`, + click: () => sendCommand(`open-find`), + }, + { + label: `Find Next`, + accelerator: `CommandOrControl+G`, + click: () => sendCommand(`find-next`), + }, + { + label: `Find Previous`, + accelerator: `Shift+CommandOrControl+G`, + click: () => sendCommand(`find-previous`), + }, ], }, { diff --git a/packages/agents-desktop/src/preload.ts b/packages/agents-desktop/src/preload.ts index 8cca814cf6..e24017e924 100644 --- a/packages/agents-desktop/src/preload.ts +++ b/packages/agents-desktop/src/preload.ts @@ -60,6 +60,9 @@ type DesktopCommand = | `close-tile` | `toggle-sidebar` | `open-search` + | `open-find` + | `find-next` + | `find-previous` | `split-right` | `split-down` | `cycle-tile` diff --git a/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css b/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css new file mode 100644 index 0000000000..f38958bf71 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css @@ -0,0 +1,47 @@ +.bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-bottom: 1px solid var(--ds-border); + background: var(--ds-bg-elevated, var(--ds-bg)); + -webkit-app-region: no-drag; +} + +.input { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 8px; + border: 1px solid var(--ds-border); + border-radius: 6px; + background: var(--ds-bg); + color: var(--ds-fg); + font: inherit; +} + +.count { + min-width: 52px; + color: var(--ds-fg-muted); + font-size: 12px; + text-align: center; +} + +.button { + height: 28px; + min-width: 28px; + border: 1px solid var(--ds-border); + border-radius: 6px; + background: transparent; + color: var(--ds-fg); +} + +.highlight { + background: #ffe16a; + color: #111; + border-radius: 2px; +} + +.current { + background: #ff9f1a; +} diff --git a/packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx b/packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx new file mode 100644 index 0000000000..d704c71f40 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { usePaneFind, usePaneFindRegistration } from '../../hooks/usePaneFind' +import styles from './PaneFindBar.module.css' + +type Match = { node: Text; start: number; end: number } + +export function PaneFindBar({ + tileId, + rootRef, +}: { + tileId: string + rootRef: React.RefObject +}): React.ReactElement | null { + const { activeTileId, close } = usePaneFind() + const [query, setQuery] = useState(``) + const [index, setIndex] = useState(0) + const [count, setCount] = useState(0) + const inputRef = useRef(null) + const active = activeTileId === tileId + + const open = useCallback(() => { + setTimeout(() => inputRef.current?.focus(), 0) + }, []) + const next = useCallback(() => { + setIndex((i) => (count ? (i + 1) % count : 0)) + }, [count]) + const previous = useCallback(() => { + setIndex((i) => (count ? (i - 1 + count) % count : 0)) + }, [count]) + + usePaneFindRegistration(tileId, { open, next, previous }) + + useEffect(() => { + if (active) open() + }, [active, open]) + + useEffect(() => { + setIndex(0) + }, [query]) + + useEffect(() => { + const root = rootRef.current + clearHighlights(root) + if (!active || !query || !root) { + setCount(0) + return + } + const matches = findMatches(root, query) + setCount(matches.length) + renderHighlights(root, matches, Math.min(index, matches.length - 1)) + return () => clearHighlights(root) + }, [active, query, index, rootRef]) + + if (!active) return null + + return ( +
+ setQuery(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === `Escape`) close() + if (e.key === `Enter`) { + e.preventDefault() + e.shiftKey ? previous() : next() + } + }} + /> + + {count ? `${Math.min(index + 1, count)}/${count}` : query ? `0/0` : ``} + + + + +
+ ) +} + +function findMatches(root: HTMLElement, query: string): Array { + const needle = query.toLocaleLowerCase() + const matches: Array = [] + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement + if (!parent) return NodeFilter.FILTER_REJECT + if (parent.closest(`[data-pane-find-bar], mark[data-pane-find]`)) { + return NodeFilter.FILTER_REJECT + } + if (!node.nodeValue?.trim()) return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_ACCEPT + }, + }) + let node: Text | null + while ((node = walker.nextNode() as Text | null)) { + const text = node.nodeValue ?? `` + const lower = text.toLocaleLowerCase() + let from = 0 + for (;;) { + const start = lower.indexOf(needle, from) + if (start === -1) break + matches.push({ node, start, end: start + query.length }) + from = start + Math.max(query.length, 1) + } + } + return matches +} + +function clearHighlights(root: HTMLElement | null): void { + if (!root) return + root.querySelectorAll(`mark[data-pane-find]`).forEach((mark) => { + mark.replaceWith(document.createTextNode(mark.textContent ?? ``)) + }) + root.normalize() +} + +function renderHighlights( + root: HTMLElement, + matches: Array, + current: number +): void { + let currentMark: HTMLElement | null = null + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i] + if (!match?.node.parentNode) continue + const range = document.createRange() + try { + range.setStart(match.node, match.start) + range.setEnd(match.node, match.end) + } catch { + continue + } + const mark = document.createElement(`mark`) + mark.dataset.paneFind = `true` + mark.className = `${styles.highlight} ${i === current ? styles.current : ``}` + range.surroundContents(mark) + if (i === current) currentMark = mark + } + currentMark?.scrollIntoView({ block: `center`, inline: `nearest` }) +} diff --git a/packages/agents-server-ui/src/components/workspace/TileContainer.module.css b/packages/agents-server-ui/src/components/workspace/TileContainer.module.css index 28d45a7465..e88e6ff4b4 100644 --- a/packages/agents-server-ui/src/components/workspace/TileContainer.module.css +++ b/packages/agents-server-ui/src/components/workspace/TileContainer.module.css @@ -9,6 +9,9 @@ /* Required for `` (position: absolute; inset: 0) to * cover only this tile and not bleed into adjacent tiles. */ position: relative; + user-select: text; + -webkit-user-select: text; + -webkit-app-region: no-drag; } .body { diff --git a/packages/agents-server-ui/src/components/workspace/TileContainer.tsx b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx index ae863d8506..57d5c9910f 100644 --- a/packages/agents-server-ui/src/components/workspace/TileContainer.tsx +++ b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx @@ -11,6 +11,7 @@ import { MainHeader } from '../MainHeader' import { Stack } from '../../ui' import { SplitMenu } from './SplitMenu' import { DropOverlay } from './DropOverlay' +import { PaneFindBar } from './PaneFindBar' import type { Tile } from '../../lib/workspace/types' import type { ViewId } from '../../lib/workspace/viewRegistry' import styles from './TileContainer.module.css' @@ -40,9 +41,13 @@ export function TileContainer({ tile }: { tile: Tile }): React.ReactElement { return (
{tile.entityUrl !== null ? ( - + ) : ( - + )}
@@ -52,9 +57,11 @@ export function TileContainer({ tile }: { tile: Tile }): React.ReactElement { function EntityTileBody({ tile, entityUrl, + rootRef, }: { tile: Tile entityUrl: string + rootRef: React.RefObject }): React.ReactElement { const { activeServer } = useServerConnection() const { entitiesCollection } = useElectricAgents() @@ -114,19 +121,16 @@ function EntityTileBody({ } return ( - - } - /> + +
+ } + /> +
+ {View ? ( +}): React.ReactElement { const { activeServer } = useServerConnection() const viewDef = getView(tile.viewId) const baseUrl = activeServer?.url ?? `` @@ -176,14 +186,11 @@ function StandaloneTileBody({ tile }: { tile: Tile }): React.ReactElement { const View = viewDef.Component return ( - - } /> + +
+ } /> +
+
) diff --git a/packages/agents-server-ui/src/hooks/usePaneFind.tsx b/packages/agents-server-ui/src/hooks/usePaneFind.tsx new file mode 100644 index 0000000000..abed34f828 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/usePaneFind.tsx @@ -0,0 +1,107 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +type PaneFindContextValue = { + activeTileId: string | null + openForTile: (tileId: string) => void + close: () => void +} + +const PaneFindContext = createContext(null) + +export function PaneFindProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactElement { + const [activeTileId, setActiveTileId] = useState(null) + + const value = useMemo( + () => ({ + activeTileId, + openForTile: setActiveTileId, + close: () => setActiveTileId(null), + }), + [activeTileId] + ) + + return ( + + {children} + + ) +} + +export function usePaneFind(): PaneFindContextValue { + const value = useContext(PaneFindContext) + if (!value) + throw new Error(`usePaneFind must be used inside PaneFindProvider`) + return value +} + +export type PaneFindApi = { + open: () => void + next: () => void + previous: () => void +} + +const registry = new Map() + +export function usePaneFindRegistration( + tileId: string, + api: PaneFindApi +): void { + const apiRef = useRef(api) + apiRef.current = api + + // Register a stable proxy once per tile id so callers always hit the + // latest callbacks without re-registering on every render. + useEffect(() => { + const proxy: PaneFindApi = { + open: () => apiRef.current.open(), + next: () => apiRef.current.next(), + previous: () => apiRef.current.previous(), + } + registry.set(tileId, proxy) + return () => { + if (registry.get(tileId) === proxy) registry.delete(tileId) + } + }, [tileId]) +} + +export function unregisterPaneFind(tileId: string): void { + registry.delete(tileId) +} + +export function usePaneFindCommands(): { + openFindForTile: (tileId: string | null) => void + findNextInTile: (tileId: string | null) => void + findPreviousInTile: (tileId: string | null) => void +} { + const { openForTile } = usePaneFind() + return { + openFindForTile: useCallback( + (tileId) => { + if (!tileId) return + openForTile(tileId) + registry.get(tileId)?.open() + }, + [openForTile] + ), + findNextInTile: useCallback((tileId) => { + if (!tileId) return + registry.get(tileId)?.next() + }, []), + findPreviousInTile: useCallback((tileId) => { + if (!tileId) return + registry.get(tileId)?.previous() + }, []), + } +} diff --git a/packages/agents-server-ui/src/lib/server-connection.ts b/packages/agents-server-ui/src/lib/server-connection.ts index b6ec1343b3..c4044cf9e3 100644 --- a/packages/agents-server-ui/src/lib/server-connection.ts +++ b/packages/agents-server-ui/src/lib/server-connection.ts @@ -69,6 +69,9 @@ export type DesktopCommand = | `close-tile` | `toggle-sidebar` | `open-search` + | `open-find` + | `find-next` + | `find-previous` | `split-right` | `split-down` | `cycle-tile` diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index 18460c1696..9f733993a1 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -30,6 +30,7 @@ import { import { useWorkspaceHotkeys } from './hooks/useWorkspaceHotkeys' import { useWorkspacePersistence } from './hooks/useWorkspacePersistence' import { useDocumentTitle } from './hooks/useDocumentTitle' +import { PaneFindProvider, usePaneFindCommands } from './hooks/usePaneFind' import { Sidebar } from './components/Sidebar' import { SearchPalette } from './components/SearchPalette' import { Workspace } from './components/workspace/Workspace' @@ -54,7 +55,9 @@ function RootLayout(): React.ReactElement { - + + + @@ -67,12 +70,38 @@ function RootShell(): React.ReactElement { const { collapsed, toggle } = useSidebarCollapsed() const search = useSearchPalette() const { workspace, helpers } = useWorkspace() + const { openFindForTile, findNextInTile, findPreviousInTile } = + usePaneFindCommands() useHotkey(`mod+b`, toggle) useHotkey(`mod+k`, (e) => { e.preventDefault() search.toggle() }) + useHotkey( + `mod+f`, + (e) => { + e.preventDefault() + openFindForTile(helpers.activeTileId) + }, + { ignoreInputs: false } + ) + useHotkey( + `mod+g`, + (e) => { + e.preventDefault() + findNextInTile(helpers.activeTileId) + }, + { ignoreInputs: false } + ) + useHotkey( + `mod+shift+g`, + (e) => { + e.preventDefault() + findPreviousInTile(helpers.activeTileId) + }, + { ignoreInputs: false } + ) // New session: bind both ⌘N / Ctrl+N (works in Electron) and // ⌘⇧O / Ctrl+Shift+O (works in browsers — `⌘N` is reserved by // browsers for opening a new window and can't be intercepted, so @@ -116,6 +145,15 @@ function RootShell(): React.ReactElement { case `open-search`: search.toggle() break + case `open-find`: + openFindForTile(helpers.activeTileId) + break + case `find-next`: + findNextInTile(helpers.activeTileId) + break + case `find-previous`: + findPreviousInTile(helpers.activeTileId) + break case `close-tile`: { const id = helpers.activeTile?.id if (id) helpers.closeTile(id) @@ -144,7 +182,16 @@ function RootShell(): React.ReactElement { } }) return () => off?.() - }, [openNewSession, toggle, search, helpers, workspace]) + }, [ + openNewSession, + toggle, + search, + helpers, + workspace, + openFindForTile, + findNextInTile, + findPreviousInTile, + ]) const navigateToEntity = useCallback( (entityUrl: string) => { From e495d75844120754d8b05023413a362d12901d5c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 5 May 2026 15:49:35 -0600 Subject: [PATCH 36/57] Show response copy button only when done --- packages/agents-server-ui/src/components/AgentResponse.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-server-ui/src/components/AgentResponse.tsx b/packages/agents-server-ui/src/components/AgentResponse.tsx index 66108db615..d5a05dbe31 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.tsx +++ b/packages/agents-server-ui/src/components/AgentResponse.tsx @@ -320,7 +320,7 @@ export const AgentResponse = memo(function AgentResponse({ {timestamp != null && !isStreaming && ( )} - {copyText && ( + {section.done && copyText && ( - )} +
) } diff --git a/packages/agents-server-ui/src/components/MessageInput.module.css b/packages/agents-server-ui/src/components/MessageInput.module.css index 4f9bc2d0a7..58551509ca 100644 --- a/packages/agents-server-ui/src/components/MessageInput.module.css +++ b/packages/agents-server-ui/src/components/MessageInput.module.css @@ -101,13 +101,27 @@ color: var(--ds-text-3); } -.sendIcon { - color: var(--ds-gray-7); - cursor: default; - transition: color 0.15s ease; +.composerSend { + all: unset; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--ds-radius-full); + background: var(--ds-gray-a3); + color: var(--ds-text-3); + cursor: not-allowed; + transition: + background 0.12s ease, + color 0.12s ease; flex-shrink: 0; } -.sendIcon.active { - color: var(--ds-accent-9); +.composerSend.active { + background: var(--ds-accent-9); + color: var(--ds-text-on-accent); cursor: pointer; } +.composerSend.active:hover { + background: var(--ds-accent-10); +} diff --git a/packages/agents-server-ui/src/components/MessageInput.tsx b/packages/agents-server-ui/src/components/MessageInput.tsx index f22671c8d8..031e384136 100644 --- a/packages/agents-server-ui/src/components/MessageInput.tsx +++ b/packages/agents-server-ui/src/components/MessageInput.tsx @@ -118,13 +118,17 @@ export function MessageInput({ rows={1} className={styles.textarea} /> - + > + + ) diff --git a/packages/agents-server-ui/src/components/NewSessionPage.module.css b/packages/agents-server-ui/src/components/NewSessionPage.module.css index a80a88b80f..c20387aec7 100644 --- a/packages/agents-server-ui/src/components/NewSessionPage.module.css +++ b/packages/agents-server-ui/src/components/NewSessionPage.module.css @@ -116,7 +116,7 @@ padding: var(--ds-space-3) var(--ds-space-4); background: transparent; border: 1px solid var(--ds-border-1); - border-radius: var(--ds-radius-3); + border-radius: var(--ds-radius-5); cursor: pointer; transition: background 0.12s ease, @@ -200,7 +200,7 @@ padding: var(--ds-space-3) var(--ds-space-4); background: var(--ds-input-bg); border: 1px solid var(--ds-gray-a4); - border-radius: var(--ds-radius-4); + border-radius: var(--ds-radius-5); transition: border-color 0.15s ease; margin: 0 calc(-1 * var(--ds-space-4)); } @@ -250,12 +250,19 @@ gap: 4px; flex-wrap: wrap; min-width: 0; + /* Pill triggers have 8px inline padding. Shift the overlay left by + that amount so the first pill's label aligns with the textarea + text column, then drop it 4px so the chip edge has the same + 8px inset from the composer bottom as it has from the side. */ + margin-left: -8px; + transform: translateY(4px); } .composerSendCluster { display: flex; align-items: center; gap: var(--ds-space-3); margin-left: auto; + margin-right: -4px; } .composerHint { font-size: var(--ds-text-xs); @@ -321,17 +328,21 @@ width: 24px; height: 24px; border-radius: var(--ds-radius-full); - color: var(--ds-gray-8); + background: var(--ds-gray-a3); + color: var(--ds-text-3); cursor: not-allowed; - transition: color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease; flex-shrink: 0; } .composerSendActive { - color: var(--ds-accent-9); + background: var(--ds-accent-9); + color: var(--ds-text-on-accent); cursor: pointer; } .composerSendActive:hover { - color: var(--ds-accent-10); + background: var(--ds-accent-10); } /* Other-agents section under the composer ----------------------- */ diff --git a/packages/agents-server-ui/src/components/SettingsMenu.module.css b/packages/agents-server-ui/src/components/SettingsMenu.module.css index cc5cb5ccf9..b991f01532 100644 --- a/packages/agents-server-ui/src/components/SettingsMenu.module.css +++ b/packages/agents-server-ui/src/components/SettingsMenu.module.css @@ -3,6 +3,7 @@ grey — selection is communicated by the check glyph itself. */ .activeMark { margin-left: auto; + margin-right: 5px; color: var(--ds-text-2); } @@ -15,6 +16,7 @@ display: flex; align-items: center; gap: 6px; + min-height: 30px; padding: 3px 3px 3px 8px; border-radius: 7px; font-size: var(--ds-text-sm); @@ -33,5 +35,6 @@ } .submenuChevron { margin-left: auto; + margin-right: 5px; color: var(--ds-text-3); } diff --git a/packages/agents-server-ui/src/components/SidebarViewMenu.module.css b/packages/agents-server-ui/src/components/SidebarViewMenu.module.css index f274287c8f..4a1447a4e2 100644 --- a/packages/agents-server-ui/src/components/SidebarViewMenu.module.css +++ b/packages/agents-server-ui/src/components/SidebarViewMenu.module.css @@ -3,6 +3,7 @@ .activeMark { margin-left: auto; + margin-right: 5px; color: var(--ds-text-2); } @@ -10,6 +11,7 @@ display: flex; align-items: center; gap: 6px; + min-height: 30px; padding: 3px 3px 3px 8px; border-radius: 7px; font-size: var(--ds-text-sm); @@ -28,5 +30,6 @@ } .submenuChevron { margin-left: auto; + margin-right: 5px; color: var(--ds-text-3); } diff --git a/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx index 0e749a8961..33eeffe42e 100644 --- a/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx +++ b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx @@ -130,7 +130,7 @@ export function WorkingDirectoryPicker({ } /> - + ({ return ( {children} + + + ) } diff --git a/packages/agents-server-ui/src/ui/Text.module.css b/packages/agents-server-ui/src/ui/Text.module.css index c0e32175ca..9fcdbaf367 100644 --- a/packages/agents-server-ui/src/ui/Text.module.css +++ b/packages/agents-server-ui/src/ui/Text.module.css @@ -1,7 +1,7 @@ .text { margin: 0; font-family: var(--ds-font-body); - color: var(--ds-text-1); + color: var(--ds-text-color, var(--ds-text-1)); /* min-width:0 prevents shrink-overflow inside flex containers */ min-width: 0; } diff --git a/packages/agents-server-ui/src/ui/tokens.css b/packages/agents-server-ui/src/ui/tokens.css index 3f0457335d..7838eef338 100644 --- a/packages/agents-server-ui/src/ui/tokens.css +++ b/packages/agents-server-ui/src/ui/tokens.css @@ -177,25 +177,19 @@ paired with a layered (but ring-less) drop shadow below. Stacking a border and a tight ring shadow at similar opacities reads as a "double rim" and was an explicit visual bug, so the - ring layer was removed and the border opacity nudged up to - 11% to keep the edge crisp on its own. The dark-mode override - uses 12% white so the same border doubles as a soft rim - highlight against the raised surface. */ - --ds-overlay-border: color-mix(in oklab, var(--ds-gray-12) 11%, transparent); + ring layer was removed so this can match the rest of the UI's + standard border weight. The dark-mode override uses 12% white + so the same border doubles as a soft rim highlight against the + raised surface. */ + --ds-overlay-border: var(--ds-border-1); /* Shadow used by floating surfaces — popovers, menus, dialogs, dropdowns. PURE drop-shadow stack (no ring layer) so the - border above is the single defined edge: - 1. Tight near-shadow (1px) — slight grounding under the edge. - 2. Mid-distance shadow (12px blur) — the primary lift. - 3. Far ambient shadow (32px blur) — soft penumbra that - gives the popup real depth without darkening the page. - Slightly stronger than the previous version so the loss of - the ring layer is compensated by a more pronounced lift — - definition now comes from edge + elevation, not edge + ring. */ + border above is the single defined edge. Keep the shadow tight: + the border provides definition while the shadow only grounds the + floating surface. */ --ds-overlay-shadow: - 0 1px 2px rgba(15, 15, 30, 0.05), 0 4px 12px rgba(15, 15, 30, 0.08), - 0 16px 32px rgba(15, 15, 30, 0.1); + 0 1px 2px rgba(15, 15, 30, 0.05), 0 5px 12px rgba(15, 15, 30, 0.035); /* ---- Accent (light = brand ink) ----------------------------------- * * In light mode the marketing site's brand reads as the deep navy @@ -447,10 +441,8 @@ --ds-shadow-3: 0 8px 24px rgba(0, 0, 0, 0.6), 0 2px 6px rgba(0, 0, 0, 0.4); --ds-shadow-4: 0 16px 40px rgba(0, 0, 0, 0.7), 0 4px 12px rgba(0, 0, 0, 0.5); - /* Dark-mode overlay shadow — same shape as light mode (pure - drop-shadow stack, no ring) but with much darker stops since - the popup needs to lift off a near-black background. */ + /* Dark-mode overlay shadow — same compact shape as light mode + with darker stops so popups still lift off a near-black page. */ --ds-overlay-shadow: - 0 1px 2px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.55), - 0 16px 32px rgba(0, 0, 0, 0.5); + 0 1px 2px rgba(0, 0, 0, 0.36), 0 3px 6px rgba(0, 0, 0, 0.34); } From 8290c510c23e0d02fcce408838bf88673bdbb4ab Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 14:37:45 +0100 Subject: [PATCH 43/57] Refine response copy control Move the response copy action into a muted right-aligned icon button so it matches the response metadata chrome. Co-authored-by: Cursor --- .../src/components/AgentResponse.module.css | 20 +++++--------- .../src/components/AgentResponse.tsx | 26 ++++++++++--------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/agents-server-ui/src/components/AgentResponse.module.css b/packages/agents-server-ui/src/components/AgentResponse.module.css index 1825f2df65..59db053277 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.module.css +++ b/packages/agents-server-ui/src/components/AgentResponse.module.css @@ -21,24 +21,16 @@ opacity: 0.7; } +.metaRow { + width: 100%; +} + .copyButton { - display: inline-flex; - align-items: center; - gap: 4px; - border: 0; - border-radius: 6px; - padding: 2px 6px; - background: transparent; + margin-left: auto; color: var(--ds-text-4, var(--ds-text-3)); - font: inherit; - font-size: 12px; - cursor: pointer; - opacity: 0.75; - -webkit-app-region: no-drag; + opacity: 0.7; } .copyButton:hover { - background: var(--ds-gray-a2); - color: var(--ds-text-2); opacity: 1; } diff --git a/packages/agents-server-ui/src/components/AgentResponse.tsx b/packages/agents-server-ui/src/components/AgentResponse.tsx index d5a05dbe31..e4c32815a8 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.tsx +++ b/packages/agents-server-ui/src/components/AgentResponse.tsx @@ -20,7 +20,7 @@ import { streamdownControls, streamdownPlugins, } from '../lib/streamdownConfig' -import { Stack, Text } from '../ui' +import { IconButton, Stack, Text, Tooltip } from '../ui' import { ToolCallView } from './ToolCallView' import { TimeText } from './TimeText' import { ThinkingIndicator } from './ThinkingIndicator' @@ -301,7 +301,7 @@ export const AgentResponse = memo(function AgentResponse({ return })} - + {showThinking && } {section.done && ( @@ -321,16 +321,18 @@ export const AgentResponse = memo(function AgentResponse({ )} {section.done && copyText && ( - + + void copyResponseText()} + aria-label="Copy response text" + > + {copied ? : } + + )} From 4d218e82d580558f0be986356913d5828b31be04 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 14:41:14 +0100 Subject: [PATCH 44/57] Polish agents header loading chrome Align loading-state typography with the chat stream state and add a little breathing room after the entity status badge. Co-authored-by: Cursor --- .../agents-server-ui/src/components/EntityHeader.module.css | 1 + .../src/components/workspace/TileContainer.tsx | 6 ++++-- .../agents-server-ui/src/components/workspace/Workspace.tsx | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/agents-server-ui/src/components/EntityHeader.module.css b/packages/agents-server-ui/src/components/EntityHeader.module.css index 42d485dd70..ee6c4e1c13 100644 --- a/packages/agents-server-ui/src/components/EntityHeader.module.css +++ b/packages/agents-server-ui/src/components/EntityHeader.module.css @@ -80,6 +80,7 @@ pill from shrinking when the title row gets tight. */ .statusBadge { flex-shrink: 0; + margin-right: 4px; } /* Toggled-on state for icon-only buttons (e.g. state-explorer diff --git a/packages/agents-server-ui/src/components/workspace/TileContainer.tsx b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx index 57d5c9910f..a8d523277d 100644 --- a/packages/agents-server-ui/src/components/workspace/TileContainer.tsx +++ b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx @@ -8,7 +8,7 @@ import { getView } from '../../lib/workspace/viewRegistry' import { setDragPayload } from '../../lib/workspace/dragPayload' import { EntityHeader } from '../EntityHeader' import { MainHeader } from '../MainHeader' -import { Stack } from '../../ui' +import { Stack, Text } from '../../ui' import { SplitMenu } from './SplitMenu' import { DropOverlay } from './DropOverlay' import { PaneFindBar } from './PaneFindBar' @@ -99,7 +99,9 @@ function EntityTileBody({ if (!entity) { return ( - Loading entity... + + Loading entity... + ) } diff --git a/packages/agents-server-ui/src/components/workspace/Workspace.tsx b/packages/agents-server-ui/src/components/workspace/Workspace.tsx index da8ea7c117..f223f84872 100644 --- a/packages/agents-server-ui/src/components/workspace/Workspace.tsx +++ b/packages/agents-server-ui/src/components/workspace/Workspace.tsx @@ -8,6 +8,7 @@ import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' import { decodeLayout } from '../../lib/workspace/layoutCodec' import { NEW_SESSION_VIEW_ID } from '../../lib/workspace/types' +import { Text } from '../../ui' import { NodeRenderer } from './NodeRenderer' import styles from './Workspace.module.css' import type { ViewId } from '../../lib/workspace/viewRegistry' @@ -197,7 +198,9 @@ export function Workspace(): React.ReactElement { return (
- Loading workspace... + + Loading workspace... +
) From a72b706d0f06a980ad130d63c4143ede14ce2755 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 14:44:02 +0100 Subject: [PATCH 45/57] Simplify sidebar filter submenus Remove repeated visibility icons from type and status submenu rows so the checkmark carries the selected-state signal. Co-authored-by: Cursor --- packages/agents-server-ui/src/components/SidebarViewMenu.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/agents-server-ui/src/components/SidebarViewMenu.tsx b/packages/agents-server-ui/src/components/SidebarViewMenu.tsx index ad2667f238..e3f3040527 100644 --- a/packages/agents-server-ui/src/components/SidebarViewMenu.tsx +++ b/packages/agents-server-ui/src/components/SidebarViewMenu.tsx @@ -6,8 +6,6 @@ import { ChevronRight, ChevronsDownUp, ChevronsUpDown, - Eye, - EyeOff, Folder, ListFilter, Tag, @@ -148,7 +146,6 @@ export function SidebarViewMenu(): React.ReactElement { key={t} onSelect={() => toggleSidebarTypeVisibility(t)} > - {visible ? : } {formatLabel(t)} {visible && ( @@ -174,7 +171,6 @@ export function SidebarViewMenu(): React.ReactElement { key={s} onSelect={() => toggleSidebarStatusVisibility(s)} > - {visible ? : } {formatLabel(s)} {visible && ( From 6460b8ce58f0e540ee64172f56c2dd3510644003 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 14:46:05 +0100 Subject: [PATCH 46/57] Extend sidebar subtree connector Let expanded inactive parent rows visually connect their status dot to the child subtree line. Co-authored-by: Cursor --- .../src/components/SidebarRow.module.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/agents-server-ui/src/components/SidebarRow.module.css b/packages/agents-server-ui/src/components/SidebarRow.module.css index 8487000ae0..0ddd77f4b5 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.module.css +++ b/packages/agents-server-ui/src/components/SidebarRow.module.css @@ -286,6 +286,22 @@ --tree-line-color: var(--ds-border-2); --tree-stub-w: 9px; --tree-corner-radius: 6px; + --tree-parent-dot-radius: 3.5px; + position: relative; +} + +/* When the expanded parent row is not selected, extend the trunk up + through the gap between parent and first child so it visually meets + the parent's status dot. Selected rows keep the line out of their + active background. */ +.treeNode:has(> .row:not(.selected)) > .subtree::before { + content: ''; + position: absolute; + left: calc(var(--tree-trunk-x) - 0.5px); + top: calc((var(--ds-row-height-md) / -2) + var(--tree-parent-dot-radius)); + height: calc((var(--ds-row-height-md) / 2) - var(--tree-parent-dot-radius)); + border-left: 1px solid var(--tree-line-color); + pointer-events: none; } /* Continuous vertical trunk through every child row. */ From 78c6d66670136a7d7f6743700421d47945e5fed7 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 14:57:04 +0100 Subject: [PATCH 47/57] Remove search palette keyboard footer Keep the session search overlay focused on results by dropping the redundant keyboard shortcut footer. Co-authored-by: Cursor --- .../src/components/SearchPalette.module.css | 18 ------------------ .../src/components/SearchPalette.tsx | 13 ------------- 2 files changed, 31 deletions(-) diff --git a/packages/agents-server-ui/src/components/SearchPalette.module.css b/packages/agents-server-ui/src/components/SearchPalette.module.css index 7223e5e61c..0f4b3f67a1 100644 --- a/packages/agents-server-ui/src/components/SearchPalette.module.css +++ b/packages/agents-server-ui/src/components/SearchPalette.module.css @@ -116,21 +116,3 @@ color: var(--ds-text-3); text-transform: lowercase; } - -.footer { - display: flex; - align-items: center; - gap: 12px; - padding: 6px 12px; - border-top: 1px solid var(--ds-divider); - font-size: var(--ds-text-xs); - color: var(--ds-text-3); - flex-shrink: 0; - background: var(--ds-bg-subtle); -} - -.hint { - display: inline-flex; - align-items: center; - gap: 4px; -} diff --git a/packages/agents-server-ui/src/components/SearchPalette.tsx b/packages/agents-server-ui/src/components/SearchPalette.tsx index c65abb6631..68750b4f5e 100644 --- a/packages/agents-server-ui/src/components/SearchPalette.tsx +++ b/packages/agents-server-ui/src/components/SearchPalette.tsx @@ -10,7 +10,6 @@ import { Dialog as BaseDialog } from '@base-ui/react/dialog' import { useNavigate } from '@tanstack/react-router' import { useLiveQuery } from '@tanstack/react-db' import { Search } from 'lucide-react' -import { Kbd } from '../ui' import { StatusDot } from './StatusDot' import { useSearchPalette } from '../hooks/useSearchPalette' import { useElectricAgents } from '../lib/ElectricAgentsProvider' @@ -190,18 +189,6 @@ export function SearchPalette(): React.ReactElement | null {
))}
-
- - - Navigate - - - Open - - - esc Close - -
From ce9a01799f88215e18b9440aa535f8eaf5472b40 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 15:14:05 +0100 Subject: [PATCH 48/57] Add responsive agents sidebar Co-authored-by: Cursor --- .../src/components/Sidebar.module.css | 70 +++++ .../src/components/Sidebar.tsx | 271 +++++++++++------- .../settings/SettingsScreen.module.css | 27 +- .../components/settings/SettingsScreen.tsx | 31 +- .../settings/SettingsSidebar.module.css | 53 ++++ .../components/settings/SettingsSidebar.tsx | 110 ++++--- .../src/hooks/useNarrowViewport.ts | 45 +++ .../agents-server-ui/src/router.module.css | 5 + packages/agents-server-ui/src/router.tsx | 13 +- 9 files changed, 482 insertions(+), 143 deletions(-) create mode 100644 packages/agents-server-ui/src/hooks/useNarrowViewport.ts diff --git a/packages/agents-server-ui/src/components/Sidebar.module.css b/packages/agents-server-ui/src/components/Sidebar.module.css index 69988d9245..b47a6acb7b 100644 --- a/packages/agents-server-ui/src/components/Sidebar.module.css +++ b/packages/agents-server-ui/src/components/Sidebar.module.css @@ -5,6 +5,76 @@ border-right: 1px solid var(--ds-border-1); } +/* Narrow-viewport overlay mode — see `useNarrowViewport`. The + sidebar pops out of flex flow and floats over the main column + (with a backdrop sibling, `.backdrop` below) so it can stay open + without compressing the chat area on small screens. The shadow + provides the elevation cue, and the higher z-index ensures we + sit above the main content but below modals/toasts (which use + `--ds-z-modal` / `--ds-z-popover`). + + The slide-in/out transition is driven by `data-state` toggled in + Sidebar.tsx (`open` ↔ `closed`). The parent keeps us mounted in + overlay mode regardless of `collapsed` so the exit transition + has time to run before unmount. `will-change` keeps the GPU + layer warm so the first frame of either direction doesn't jank. */ +.overlay { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: var(--ds-z-overlay); + box-shadow: var(--ds-overlay-shadow); + transition: + transform 220ms cubic-bezier(0.32, 0.72, 0, 1), + box-shadow 220ms ease; + will-change: transform; +} +.overlay[data-state='closed'] { + /* Translate by 100% of the sidebar's own width — slides exactly + off the left edge regardless of the user's saved width or the + viewport cap. Drop the shadow simultaneously so it doesn't + bleed into the dimmed backdrop. */ + transform: translateX(-100%); + box-shadow: none; + pointer-events: none; +} +.overlay[data-state='open'] { + transform: translateX(0); +} + +/* Backdrop sibling rendered next to `.overlay` (see Sidebar.tsx). + Sits above the main content but below the sidebar so a click + anywhere outside the sidebar dismisses it. Uses the same + `--ds-overlay` scrim token as Dialog so the dimming reads + consistently with other modal-style overlays. */ +.backdrop { + position: absolute; + inset: 0; + z-index: calc(var(--ds-z-overlay) - 1); + background: var(--ds-overlay); + cursor: pointer; + transition: opacity 220ms ease; +} +.backdrop[data-state='closed'] { + opacity: 0; + pointer-events: none; +} +.backdrop[data-state='open'] { + opacity: 1; +} + +/* Respect the OS-level "reduce motion" preference — snap the + sidebar in/out instead of sliding. The sidebar still hides + correctly because the `transform` and `opacity` rules above + still apply, just without the animated interpolation. */ +@media (prefers-reduced-motion: reduce) { + .overlay, + .backdrop { + transition: none; + } +} + .resizeHandle { position: absolute; top: 0; diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 62900dd382..e09e402c06 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -10,6 +10,8 @@ import { groupByWorkingDirectory, } from '../lib/sessionGroups' import { useSidebarView } from '../hooks/useSidebarView' +import { useSidebarCollapsed } from '../hooks/useSidebarCollapsed' +import { useNarrowViewport } from '../hooks/useNarrowViewport' import { HoverCard, ScrollArea, Stack, Text } from '../ui' import { NewSessionKey } from '../lib/keyLabels' import { setDragPayload } from '../lib/workspace/dragPayload' @@ -71,6 +73,42 @@ export function Sidebar({ const [width, setWidth] = useSidebarWidth() const [resizeHandleHover, setResizeHandleHover] = useState(false) const [resizing, setResizing] = useState(false) + // Narrow viewports flip the sidebar from a push-displace flex + // column into an absolute-positioned overlay that floats above + // the main content with a backdrop. Selecting any sidebar row + // auto-collapses the sidebar in overlay mode (standard mobile + // drawer pattern) so the user is dropped straight into the new + // content without an extra dismiss tap. + const narrow = useNarrowViewport() + const { collapsed, setCollapsed } = useSidebarCollapsed() + // `data-state` drives the slide/fade transitions in CSS. + // - In wide mode the sidebar is always visible (or unmounted by + // the parent), so no transition state is needed. + // - In narrow mode the parent keeps us mounted regardless of + // `collapsed` so the exit transition can run before unmount, + // and we toggle between `open`/`closed` here. + const overlayState: `open` | `closed` | undefined = narrow + ? collapsed + ? `closed` + : `open` + : undefined + const closeIfOverlay = useCallback(() => { + if (narrow) setCollapsed(true) + }, [narrow, setCollapsed]) + const wrappedSelectEntity = useCallback( + (url: string) => { + onSelectEntity(url) + closeIfOverlay() + }, + [onSelectEntity, closeIfOverlay] + ) + const wrappedOpenInSplit = useMemo(() => { + if (!onOpenEntityInSplit) return undefined + return (url: string) => { + onOpenEntityInSplit(url) + closeIfOverlay() + } + }, [onOpenEntityInSplit, closeIfOverlay]) const hoverHandle = HoverCard.useHandle() @@ -157,123 +195,154 @@ export function Sidebar({ const handleNewSession = useCallback(() => { navigate({ to: `/` }) - }, [navigate]) + closeIfOverlay() + }, [navigate, closeIfOverlay]) const treeProps = { childrenByParent, selectedEntityUrl, - onSelectEntity, - onOpenEntityInSplit, + onSelectEntity: wrappedSelectEntity, + onOpenEntityInSplit: wrappedOpenInSplit, pinnedUrls, onTogglePin, hoverHandle, } return ( - -
setResizeHandleHover(true)} - onMouseLeave={() => setResizeHandleHover(false)} - className={`${styles.resizeHandle} ${ - resizing || resizeHandleHover ? styles.resizeHandleActive : `` - }`} - /> - + <> + {narrow && ( +
setCollapsed(true)} + aria-hidden={collapsed ? `true` : undefined} + /> + )} + + {/* Resize handle is push-mode-only — dragging an overlaid + sidebar wider doesn't make sense when there's no flex + sibling to take the displaced space. */} + {!narrow && ( +
setResizeHandleHover(true)} + onMouseLeave={() => setResizeHandleHover(false)} + className={`${styles.resizeHandle} ${ + resizing || resizeHandleHover ? styles.resizeHandleActive : `` + }`} + /> + )} + - - - + + + - {pinnedEntities.length > 0 && ( - <> - Pinned - {pinnedEntities.map((entity) => ( - - ))} - - )} + {pinnedEntities.length > 0 && ( + <> + Pinned + {pinnedEntities.map((entity) => ( + + ))} + + )} - {ungroupedBuckets.map((group) => ( -
- {group.label} - {group.items.map((root) => ( - - ))} -
- ))} + {ungroupedBuckets.map((group) => ( +
+ {group.label} + {group.items.map((root) => ( + + ))} +
+ ))} - {entities.length === 0 && ( - - No sessions - - )} - {entities.length > 0 && visibleEntities.length === 0 && ( - - No sessions match the current filters - - )} -
-
+ {entities.length === 0 && ( + + No sessions + + )} + {entities.length > 0 && visibleEntities.length === 0 && ( + + No sessions match the current filters + + )} +
+
- + - - {({ payload }: { payload: SidebarRowInfoPayload | undefined }) => ( - - {payload ? : null} - - )} - - + + {({ payload }: { payload: SidebarRowInfoPayload | undefined }) => ( + + {payload ? : null} + + )} + + + ) } diff --git a/packages/agents-server-ui/src/components/settings/SettingsScreen.module.css b/packages/agents-server-ui/src/components/settings/SettingsScreen.module.css index 9973f7a1df..81f40a161c 100644 --- a/packages/agents-server-ui/src/components/settings/SettingsScreen.module.css +++ b/packages/agents-server-ui/src/components/settings/SettingsScreen.module.css @@ -17,18 +17,43 @@ flex-shrink: 0; display: flex; align-items: center; + gap: var(--ds-space-2); height: 44px; - padding: 0 16px; + padding: 0 10px; background: var(--ds-bg); } +/* Sidebar-toggle cluster — only rendered on narrow viewports, so + the chrome cluster pushes the title text right by one icon + slot when it's present and disappears entirely on wide. Same + 24x24 icon column as MainHeader so the toggle lands at the + exact same screen-x as the workspace one. */ +.chrome { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + :global(html[data-electric-desktop='true']) .header { -webkit-app-region: drag; } +/* On Electron the SettingsScreen header is the only window-drag + region in the right column, so we need to push past the macOS + traffic lights when the SettingsSidebar is collapsed (overlay + on narrow viewports) — otherwise our header content would sit + under the lights. We *also* shift the toggle past the lights + when it's the leftmost element. Mirrors MainHeader's `:has` + rule. */ +:global(html[data-electric-desktop='true']) .header:has(.chrome) { + padding-left: 84px; +} + :global(html[data-electric-desktop='true']) .header button, :global(html[data-electric-desktop='true']) .header a, :global(html[data-electric-desktop='true']) .header input, +:global(html[data-electric-desktop='true']) .header [role='button'], :global(html[data-electric-desktop='true']) .header [data-no-drag] { -webkit-app-region: no-drag; } diff --git a/packages/agents-server-ui/src/components/settings/SettingsScreen.tsx b/packages/agents-server-ui/src/components/settings/SettingsScreen.tsx index b81cfbd80e..743071b984 100644 --- a/packages/agents-server-ui/src/components/settings/SettingsScreen.tsx +++ b/packages/agents-server-ui/src/components/settings/SettingsScreen.tsx @@ -1,5 +1,9 @@ import type { ReactNode } from 'react' -import { ScrollArea, Stack, Text } from '../../ui' +import { PanelLeft } from 'lucide-react' +import { IconButton, ScrollArea, Stack, Text, Tooltip } from '../../ui' +import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed' +import { useNarrowViewport } from '../../hooks/useNarrowViewport' +import { modKeyLabel } from '../../lib/keyLabels' import styles from './SettingsScreen.module.css' /** @@ -24,9 +28,34 @@ export function SettingsScreen({ title: string children: ReactNode }): React.ReactElement { + // On narrow viewports the SettingsSidebar floats over the + // content as an overlay (see SettingsSidebar.tsx). Once dismissed + // via the backdrop, the SettingsScreen is the only thing on + // screen — without an affordance here the user has no visual + // way to bring the sidebar back. Mirrors MainHeader's pattern + // of only showing the chrome cluster when the sidebar is + // collapsed; while it's open the backdrop is the close UX. + const narrow = useNarrowViewport() + const { collapsed, toggle: toggleSidebar } = useSidebarCollapsed() + const showToggle = narrow && collapsed return (
+ {showToggle && ( + + + + + + + + )} {title} diff --git a/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css b/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css index 41a0047e68..17898dba83 100644 --- a/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css +++ b/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css @@ -10,6 +10,59 @@ position: relative; } +/* Narrow-viewport overlay mode — see `useNarrowViewport` and the + matching rules in `Sidebar.module.css`. Slide/fade transitions + are driven by `data-state` toggled in SettingsSidebar.tsx + (`open` ↔ `closed`). We override `min-width` here because the + fixed 240px floor would otherwise hold the panel wider than the + viewport on very small screens. */ +.overlay { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: var(--ds-z-overlay); + box-shadow: var(--ds-overlay-shadow); + width: min(85vw, 320px); + min-width: 0; + max-width: 85vw; + transition: + transform 220ms cubic-bezier(0.32, 0.72, 0, 1), + box-shadow 220ms ease; + will-change: transform; +} +.overlay[data-state='closed'] { + transform: translateX(-100%); + box-shadow: none; + pointer-events: none; +} +.overlay[data-state='open'] { + transform: translateX(0); +} + +.backdrop { + position: absolute; + inset: 0; + z-index: calc(var(--ds-z-overlay) - 1); + background: var(--ds-overlay); + cursor: pointer; + transition: opacity 220ms ease; +} +.backdrop[data-state='closed'] { + opacity: 0; + pointer-events: none; +} +.backdrop[data-state='open'] { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .overlay, + .backdrop { + transition: none; + } +} + /* Top header row mirrors the geometry of `SidebarHeader` (44px tall, 10px gutter) so the in-app titlebar height stays consistent across the two sidebars. The button itself opts out of the drag region via diff --git a/packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx b/packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx index 28046ac4a1..c96583b4af 100644 --- a/packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx +++ b/packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { ArrowLeft, Cpu, Palette, Settings as SettingsIcon } from 'lucide-react' import { ScrollArea, Stack, Text } from '../../ui' @@ -7,6 +7,8 @@ import { onDesktopStateChanged, type DesktopState, } from '../../lib/server-connection' +import { useNarrowViewport } from '../../hooks/useNarrowViewport' +import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed' import styles from './SettingsSidebar.module.css' export type SettingsCategoryId = `general` | `appearance` | `local-runtime` @@ -39,6 +41,18 @@ export function SettingsSidebar({ const navigate = useNavigate() const [desktopState, setDesktopState] = useState(null) const isDesktop = typeof window !== `undefined` && Boolean(window.electronAPI) + // See Sidebar.tsx — same overlay pattern so settings reads + // consistently with the workspace sidebar on narrow viewports. + const narrow = useNarrowViewport() + const { collapsed, setCollapsed } = useSidebarCollapsed() + const overlayState: `open` | `closed` | undefined = narrow + ? collapsed + ? `closed` + : `open` + : undefined + const closeIfOverlay = useCallback(() => { + if (narrow) setCollapsed(true) + }, [narrow, setCollapsed]) useEffect(() => { if (!window.electronAPI?.getDesktopState) return @@ -71,44 +85,62 @@ export function SettingsSidebar({ ] return ( - -
- -
+ <> + {narrow && ( +
setCollapsed(true)} + aria-hidden={collapsed ? `true` : undefined} + /> + )} + +
+ +
- - - {categories - .filter((c) => c.visible) - .map((c) => ( - - ))} - - -
+ + + {categories + .filter((c) => c.visible) + .map((c) => ( + + ))} + + + + ) } diff --git a/packages/agents-server-ui/src/hooks/useNarrowViewport.ts b/packages/agents-server-ui/src/hooks/useNarrowViewport.ts new file mode 100644 index 0000000000..d1f51e5340 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useNarrowViewport.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' + +/** + * Default breakpoint at which the app treats the viewport as + * "narrow". 768px is the standard tablet/mobile cutoff and matches + * the point at which the sidebar (240px default) starts eating an + * uncomfortable share of the chat column. + */ +export const NARROW_VIEWPORT_BREAKPOINT_PX = 768 + +/** + * Returns `true` when the viewport's CSS width is at or below the + * `breakpoint` (default 768px), tracking `window.matchMedia` so the + * value updates on resize / orientation change without a manual + * `resize` listener. + * + * SSR-safe: returns `false` on the very first render before + * `window` is available, then resyncs from `matchMedia` on mount. + * + * Used by the sidebar to switch between push-displace and overlay + * modes — sized once here so every consumer (sidebar, settings + * sidebar, future drawers) shares the same threshold. + */ +export function useNarrowViewport( + breakpoint: number = NARROW_VIEWPORT_BREAKPOINT_PX +): boolean { + const [narrow, setNarrow] = useState(() => { + if (typeof window === `undefined`) return false + return window.matchMedia(`(max-width: ${breakpoint}px)`).matches + }) + + useEffect(() => { + if (typeof window === `undefined`) return + const mql = window.matchMedia(`(max-width: ${breakpoint}px)`) + const onChange = (e: MediaQueryListEvent): void => setNarrow(e.matches) + // Sync immediately in case the breakpoint was crossed between + // the initial render and the effect running (e.g. window + // resized during hydration). + setNarrow(mql.matches) + mql.addEventListener(`change`, onChange) + return () => mql.removeEventListener(`change`, onChange) + }, [breakpoint]) + + return narrow +} diff --git a/packages/agents-server-ui/src/router.module.css b/packages/agents-server-ui/src/router.module.css index b2de2d6ef1..4f56a68971 100644 --- a/packages/agents-server-ui/src/router.module.css +++ b/packages/agents-server-ui/src/router.module.css @@ -4,6 +4,11 @@ min-height: 0; overflow: hidden; background: var(--ds-bg); + /* Positioning context for the sidebar's narrow-viewport overlay + (`Sidebar.module.css → .overlay` + `.backdrop`). Without this, + the absolutely-positioned overlay would resolve against the + viewport and end up floating over modals/toasts higher up. */ + position: relative; } .entityShell { diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index 9f733993a1..cf5f52a436 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -17,6 +17,7 @@ import { SidebarCollapsedProvider, useSidebarCollapsed, } from './hooks/useSidebarCollapsed' +import { useNarrowViewport } from './hooks/useNarrowViewport' import { useHotkey } from './hooks/useHotkey' import { SearchPaletteProvider, @@ -237,12 +238,22 @@ function RootShell(): React.ReactElement { const settingsCategory = parseSettingsCategory(location.pathname) const inSettings = settingsCategory !== null + // On narrow viewports the sidebar floats over content as an + // overlay (see Sidebar.tsx + useNarrowViewport). We keep the + // component mounted regardless of `collapsed` while in overlay + // mode so the exit transition (slide-out + backdrop fade) can + // run before unmount. In wide mode we keep the existing + // mount-on-demand behaviour — there's no animation, so unmounting + // immediately on collapse is the cheapest correct option. + const narrow = useNarrowViewport() + const showWorkspaceSidebar = narrow || !collapsed + return (
{inSettings ? ( ) : ( - !collapsed && ( + showWorkspaceSidebar && ( Date: Wed, 6 May 2026 15:22:41 +0100 Subject: [PATCH 49/57] Add actions to search palette Unify session search and runnable command actions in the command palette, with active-tile-aware actions and capped session results. Co-authored-by: Cursor --- .../src/components/SearchPalette.module.css | 30 ++ .../src/components/SearchPalette.tsx | 442 ++++++++++++++++-- 2 files changed, 428 insertions(+), 44 deletions(-) diff --git a/packages/agents-server-ui/src/components/SearchPalette.module.css b/packages/agents-server-ui/src/components/SearchPalette.module.css index 0f4b3f67a1..c2f6c45151 100644 --- a/packages/agents-server-ui/src/components/SearchPalette.module.css +++ b/packages/agents-server-ui/src/components/SearchPalette.module.css @@ -109,6 +109,20 @@ text-overflow: ellipsis; } +.rowIconSlot { + width: 14px; + height: 14px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.rowIcon { + flex-shrink: 0; + color: var(--ds-text-3); +} + .rowType { flex-shrink: 0; font-size: 10px; @@ -116,3 +130,19 @@ color: var(--ds-text-3); text-transform: lowercase; } + +.rowSubtitle { + flex-shrink: 0; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ds-text-3); + font-size: var(--ds-text-xs); +} + +.rowShortcut { + flex-shrink: 0; + color: var(--ds-text-3); + font-size: var(--ds-text-xs); +} diff --git a/packages/agents-server-ui/src/components/SearchPalette.tsx b/packages/agents-server-ui/src/components/SearchPalette.tsx index 68750b4f5e..5db67dc695 100644 --- a/packages/agents-server-ui/src/components/SearchPalette.tsx +++ b/packages/agents-server-ui/src/components/SearchPalette.tsx @@ -9,23 +9,99 @@ import { import { Dialog as BaseDialog } from '@base-ui/react/dialog' import { useNavigate } from '@tanstack/react-router' import { useLiveQuery } from '@tanstack/react-db' -import { Search } from 'lucide-react' +import { + Copy, + ExternalLink, + GitFork, + LayoutPanelLeft, + PanelLeft, + Pin, + PinOff, + Search, + Settings, + SplitSquareHorizontal, + SplitSquareVertical, + Trash2, + type LucideIcon, +} from 'lucide-react' import { StatusDot } from './StatusDot' import { useSearchPalette } from '../hooks/useSearchPalette' import { useElectricAgents } from '../lib/ElectricAgentsProvider' import { usePinnedEntities } from '../hooks/usePinnedEntities' +import { useSidebarCollapsed } from '../hooks/useSidebarCollapsed' +import { listTiles, useWorkspace } from '../hooks/useWorkspace' +import { usePaneFindCommands } from '../hooks/usePaneFind' import { getEntityDisplayTitle } from '../lib/entityDisplay' +import { encodeLayout } from '../lib/workspace/layoutCodec' +import { listViews } from '../lib/workspace/viewRegistry' import styles from './SearchPalette.module.css' import type { ElectricEntity } from '../lib/ElectricAgentsProvider' -type ResultGroup = { label: string; items: Array } +type PaletteItem = + | { + kind: `action` + id: string + title: string + subtitle?: string + keywords?: Array + shortcut?: string + icon: LucideIcon + run: () => boolean | void | Promise + } + | { + kind: `session` + id: string + title: string + subtitle: string + entity: ElectricEntity + run: () => void + } + +type ResultGroup = { label: string; items: Array } + +const MAX_SESSION_RESULTS = 30 + +function matchesPaletteItem(item: PaletteItem, query: string): boolean { + const needle = query.trim().toLowerCase() + if (!needle) return true + + const haystack = [ + item.title, + item.subtitle, + item.kind, + ...(item.kind === `action` ? (item.keywords ?? []) : [item.entity.url]), + ] + .filter(Boolean) + .join(` `) + .toLowerCase() + + return needle + .split(/\s+/) + .filter(Boolean) + .every((part) => haystack.includes(part)) +} + +function copyWorkspaceLayout( + workspace: ReturnType[`workspace`] +): void { + const encoded = encodeLayout(workspace) + const url = new URL(window.location.href) + const hash = url.hash.replace(/^#/, ``) + const [path, query = ``] = hash.split(`?`) + const params = new URLSearchParams(query) + if (encoded) params.set(`layout`, encoded) + else params.delete(`layout`) + const newQuery = params.toString() + url.hash = `#` + path + (newQuery ? `?` + newQuery : ``) + void navigator.clipboard.writeText(url.toString()) +} /** - * ⌘K session-search palette. + * ⌘K command palette. * * Command-palette-style overlay anchored 12vh from the top of the - * viewport. Searches sessions only — a future command palette will - * land on a separate shortcut for actions (kill / fork / etc.). + * viewport. Searches both sessions and runnable actions, with actions + * gated by the current workspace / active tile context. * * Keyboard: * ↑ / ↓ move highlight (wraps) @@ -34,8 +110,11 @@ type ResultGroup = { label: string; items: Array } */ export function SearchPalette(): React.ReactElement | null { const { isOpen, close } = useSearchPalette() - const { entitiesCollection } = useElectricAgents() - const { pinnedUrls } = usePinnedEntities() + const { entitiesCollection, forkEntity, killEntity } = useElectricAgents() + const { pinnedUrls, togglePin } = usePinnedEntities() + const { collapsed, toggle: toggleSidebar } = useSidebarCollapsed() + const { workspace, helpers } = useWorkspace() + const { openFindForTile } = usePaneFindCommands() const navigate = useNavigate() const [query, setQuery] = useState(``) @@ -52,30 +131,287 @@ export function SearchPalette(): React.ReactElement | null { [entitiesCollection] ) - const groups: Array = useMemo(() => { - const needle = query.trim().toLowerCase() - const matches = (entity: ElectricEntity): boolean => { - if (!needle) return true - const slug = entity.url.split(`/`).pop() ?? `` - const { title } = getEntityDisplayTitle(entity) - return ( - slug.toLowerCase().includes(needle) || - entity.type.toLowerCase().includes(needle) || - title.toLowerCase().includes(needle) || - entity.url.toLowerCase().includes(needle) + const tiles = useMemo(() => listTiles(workspace.root), [workspace.root]) + const activeTile = helpers.activeTile + const activeEntity = activeTile?.entityUrl + ? entities.find((entity) => entity.url === activeTile.entityUrl) + : undefined + const activeEntityTitle = activeEntity + ? getEntityDisplayTitle(activeEntity).title + : undefined + + const actions = useMemo>(() => { + const out: Array = [ + { + kind: `action`, + id: `new-session`, + title: `New session`, + subtitle: `Open a fresh agent session tile`, + keywords: [`new chat`, `start`, `agent`], + shortcut: `⌘N`, + icon: ExternalLink, + run: () => navigate({ to: `/` }), + }, + { + kind: `action`, + id: `toggle-sidebar`, + title: collapsed ? `Show sidebar` : `Hide sidebar`, + subtitle: `Toggle the session sidebar`, + keywords: [`sidebar`, `panel`, `navigator`], + shortcut: `⌘B`, + icon: PanelLeft, + run: toggleSidebar, + }, + { + kind: `action`, + id: `open-settings`, + title: `Open settings`, + subtitle: `Show application settings`, + keywords: [`preferences`, `config`], + icon: Settings, + run: () => + navigate({ + to: `/settings/$category`, + params: { category: `general` }, + }), + }, + ] + + if (activeTile) { + out.push( + { + kind: `action`, + id: `find-current-pane`, + title: `Find in current pane`, + subtitle: `Search within the active tile`, + keywords: [`search`, `current`, `tile`, `pane`], + shortcut: `⌘F`, + icon: Search, + run: () => openFindForTile(activeTile.id), + }, + { + kind: `action`, + id: `split-right`, + title: `Split right`, + subtitle: `Duplicate the active tile to the right`, + keywords: [`layout`, `pane`, `tile`], + shortcut: `⌘D`, + icon: SplitSquareHorizontal, + run: () => helpers.splitTile(activeTile.id, `right`), + }, + { + kind: `action`, + id: `split-down`, + title: `Split down`, + subtitle: `Duplicate the active tile below`, + keywords: [`layout`, `pane`, `tile`], + shortcut: `⇧⌘D`, + icon: SplitSquareVertical, + run: () => helpers.splitTile(activeTile.id, `down`), + } ) + + if (tiles.length > 1) { + out.push( + { + kind: `action`, + id: `close-tile`, + title: `Close tile`, + subtitle: `Close the active tile`, + keywords: [`pane`, `tab`, `window`], + shortcut: `⌘W`, + icon: Trash2, + run: () => helpers.closeTile(activeTile.id), + }, + { + kind: `action`, + id: `cycle-tile`, + title: `Cycle to next tile`, + subtitle: `Focus the next tile in the workspace`, + keywords: [`next`, `focus`, `pane`], + shortcut: `⌘\\`, + icon: LayoutPanelLeft, + run: () => { + const currentIdx = tiles.findIndex((t) => t.id === activeTile.id) + const next = tiles[(currentIdx + 1) % tiles.length] + if (next) helpers.setActiveTile(next.id) + }, + } + ) + } } - const filtered = entities.filter(matches) + + if (workspace.root) { + out.push({ + kind: `action`, + id: `copy-layout-link`, + title: `Copy layout link`, + subtitle: `Copy a URL for the current workspace layout`, + keywords: [`share`, `url`, `workspace`], + icon: Copy, + run: () => copyWorkspaceLayout(workspace), + }) + } + + if (activeTile && activeEntity && activeTile.entityUrl) { + const isPinned = pinnedUrls.includes(activeTile.entityUrl) + out.push( + { + kind: `action`, + id: `copy-current-entity-url`, + title: `Copy current entity URL`, + subtitle: activeEntityTitle, + keywords: [`copy`, `session`, `url`], + icon: Copy, + run: () => { + if (activeTile.entityUrl) { + void navigator.clipboard.writeText(activeTile.entityUrl) + } + }, + }, + { + kind: `action`, + id: `toggle-pin-current-entity`, + title: isPinned ? `Unpin current entity` : `Pin current entity`, + subtitle: activeEntityTitle, + keywords: [`pin`, `sidebar`, `session`], + icon: isPinned ? PinOff : Pin, + run: () => { + if (activeTile.entityUrl) togglePin(activeTile.entityUrl) + }, + } + ) + + listViews(activeEntity).forEach((view) => { + if (view.id === activeTile.viewId) return + out.push({ + kind: `action`, + id: `show-view-${view.id}`, + title: `Show ${view.label}`, + subtitle: `Switch the active tile view`, + keywords: [`view`, `switch`, view.id], + icon: view.icon, + run: () => helpers.setTileView(activeTile.id, view.id), + }) + }) + + if ( + forkEntity && + !activeEntity.parent && + activeEntity.status !== `stopped` + ) { + out.push({ + kind: `action`, + id: `fork-current-subtree`, + title: `Fork current subtree`, + subtitle: activeEntityTitle, + keywords: [`fork`, `session`, `agent`], + icon: GitFork, + run: () => { + if (!activeTile.entityUrl) return + void forkEntity(activeTile.entityUrl) + .then((root) => + navigate({ + to: `/entity/$`, + params: { _splat: root.url.replace(/^\//, ``) }, + }) + ) + .catch(() => {}) + }, + }) + } + + if (killEntity && activeEntity.status !== `stopped`) { + out.push({ + kind: `action`, + id: `kill-current-entity`, + title: `Kill current entity`, + subtitle: activeEntityTitle, + keywords: [`stop`, `terminate`, `agent`, `session`], + icon: Trash2, + run: () => { + if (!activeTile.entityUrl) return false + if ( + !window.confirm( + `Kill ${activeEntityTitle ?? activeTile.entityUrl}?` + ) + ) { + return false + } + const tx = killEntity(activeTile.entityUrl) + tx.isPersisted.promise.catch(() => {}) + }, + }) + } + } + + return out + }, [ + activeEntity, + activeEntityTitle, + activeTile, + collapsed, + forkEntity, + helpers, + killEntity, + navigate, + openFindForTile, + pinnedUrls, + tiles, + togglePin, + toggleSidebar, + workspace, + ]) + + const groups: Array = useMemo(() => { const pinnedSet = new Set(pinnedUrls) - const pinned = filtered.filter((e) => pinnedSet.has(e.url)) - const sessions = filtered.filter((e) => !pinnedSet.has(e.url)) + const actionItems = actions.filter((item) => + matchesPaletteItem(item, query) + ) + const sessionItems = entities + .map((entity) => { + const { title } = getEntityDisplayTitle(entity) + return { + kind: `session`, + id: entity.url, + title, + subtitle: entity.type, + entity, + run: () => + navigate({ + to: `/entity/$`, + params: { _splat: entity.url.replace(/^\//, ``) }, + }), + } + }) + .filter((item) => matchesPaletteItem(item, query)) + const pinned = sessionItems.filter( + (item): item is Extract => + item.kind === `session` && pinnedSet.has(item.entity.url) + ) + const sessions = sessionItems.filter( + (item): item is Extract => + item.kind === `session` && !pinnedSet.has(item.entity.url) + ) const out: Array = [] - if (pinned.length > 0) out.push({ label: `Pinned`, items: pinned }) - if (sessions.length > 0) out.push({ label: `Sessions`, items: sessions }) + if (actionItems.length > 0) + out.push({ label: `Actions`, items: actionItems }) + if (pinned.length > 0) { + out.push({ label: `Pinned`, items: pinned.slice(0, MAX_SESSION_RESULTS) }) + } + if (sessions.length > 0) { + out.push({ + label: `Sessions`, + items: sessions.slice(0, MAX_SESSION_RESULTS), + }) + } return out - }, [entities, pinnedUrls, query]) + }, [actions, entities, navigate, pinnedUrls, query]) - const flatResults = useMemo(() => groups.flatMap((g) => g.items), [groups]) + const flatResults = useMemo>( + () => groups.flatMap((g) => g.items), + [groups] + ) // Reset selection when query or open state changes. useEffect(() => { @@ -103,18 +439,16 @@ export function SearchPalette(): React.ReactElement | null { return () => window.cancelAnimationFrame(id) }, [isOpen]) - const openResult = useCallback( - (entity: ElectricEntity) => { - navigate({ - to: `/entity/$`, - params: { _splat: entity.url.replace(/^\//, ``) }, - }) + const runItem = useCallback( + (item: PaletteItem) => { + const shouldClose = item.run() + if (shouldClose === false) return // Defer close to the next frame so React commits the navigation // before the dialog dismount; closing in the same render seems to // get coalesced and the dialog stays mounted. window.requestAnimationFrame(close) }, - [close, navigate] + [close] ) const onInputKeyDown = useCallback( @@ -129,10 +463,10 @@ export function SearchPalette(): React.ReactElement | null { } else if (e.key === `Enter`) { e.preventDefault() const target = flatResults[highlight] - if (target) openResult(target) + if (target) runItem(target) } }, - [flatResults, highlight, openResult] + [flatResults, highlight, runItem] ) let cursor = 0 @@ -147,7 +481,7 @@ export function SearchPalette(): React.ReactElement | null { ref={inputRef} type="search" className={styles.searchInput} - placeholder="Search sessions…" + placeholder="Search sessions and actions…" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onInputKeyDown} @@ -158,31 +492,51 @@ export function SearchPalette(): React.ReactElement | null {
{flatResults.length === 0 && (
- {query ? `No matches` : `No sessions yet`} + {query ? `No matches` : `No sessions or actions`}
)} {groups.map((group) => (
{group.label} - {group.items.map((entity) => { + {group.items.map((item) => { const idx = cursor++ const active = idx === highlight - const { title } = getEntityDisplayTitle(entity) return (
setHighlight(idx)} - onClick={() => openResult(entity)} + onClick={() => runItem(item)} > - - - {title} + + {item.kind === `session` ? ( + + ) : ( + + )} + + + {item.title} - {entity.type} + {item.subtitle && ( + + {item.subtitle} + + )} + {item.kind === `action` && item.shortcut && ( + + {item.shortcut} + + )}
) })} From 0dac29802726daebc5c9f2d14d48d5d6cf1015ca Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 15:34:58 +0100 Subject: [PATCH 50/57] Restyle pane find overlay Make the in-pane find control match the compact dropdown chrome and sit flush under the tile toolbar. Co-authored-by: Cursor --- .../workspace/PaneFindBar.module.css | 90 +++++++++++++++---- .../src/components/workspace/PaneFindBar.tsx | 35 ++++++-- 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css b/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css index d7bab96626..468da6c4b1 100644 --- a/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css +++ b/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css @@ -1,39 +1,93 @@ .bar { + position: absolute; + top: 44px; + right: var(--ds-space-3); + z-index: 25; display: flex; align-items: center; gap: 6px; - padding: 6px 8px; - border-bottom: 1px solid var(--ds-border); - background: var(--ds-bg-elevated, var(--ds-bg)); + width: min(320px, calc(100% - var(--ds-space-6))); + min-height: 30px; + padding: 3px 6px 3px 3px; + border: 1px solid var(--ds-overlay-border); + border-radius: 11px; + background: var(--ds-surface-raised); + box-shadow: var(--ds-overlay-shadow); -webkit-app-region: no-drag; } +.searchIcon { + flex: 0 0 auto; + margin-left: 8px; + color: var(--ds-text-3); +} + .input { flex: 1; min-width: 0; - height: 28px; - padding: 0 8px; - border: 1px solid var(--ds-border); - border-radius: 6px; - background: var(--ds-bg); - color: var(--ds-fg); - font: inherit; + height: 24px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--ds-text-1); + font-family: var(--ds-font-body); + font-size: var(--ds-text-sm); + line-height: var(--ds-text-sm-lh); +} + +.input::placeholder { + color: var(--ds-text-3); } .count { - min-width: 52px; - color: var(--ds-fg-muted); - font-size: 12px; + min-width: 36px; + color: var(--ds-text-3); + font-size: var(--ds-text-xs); + line-height: var(--ds-text-xs-lh); text-align: center; + font-variant-numeric: tabular-nums; } .button { - height: 28px; - min-width: 28px; - border: 1px solid var(--ds-border); - border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 24px; + height: 24px; + box-sizing: border-box; + border: 0; + border-radius: var(--ds-radius-2); background: transparent; - color: var(--ds-fg); + color: var(--ds-text-3); + cursor: pointer; + padding: 0; + transition: + background 0.12s ease, + color 0.12s ease; +} + +.button:hover { + background: var(--ds-bg-hover); + color: var(--ds-text-1); +} + +.button:focus-visible { + outline: 1px solid var(--ds-focus-ring); + outline-offset: -1px; +} + +.divider { + flex: 0 0 auto; + width: 1px; + height: 24px; + background: var(--ds-divider); + margin: 0 2px; +} + +.closeButton { + color: var(--ds-text-2); } :global(::highlight(electric-pane-find-match)) { diff --git a/packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx b/packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx index d2433f7f96..cb00fd1f3f 100644 --- a/packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx +++ b/packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { ChevronDown, ChevronUp, Search, X } from 'lucide-react' import { usePaneFind, usePaneFindRegistration } from '../../hooks/usePaneFind' import styles from './PaneFindBar.module.css' import type { PaneFindAdapter, PaneFindMatch } from '../../hooks/usePaneFind' @@ -149,11 +150,12 @@ export function PaneFindBar({ return (
+
) From ae9dd3a08220368dd01055fa8263d43328b119ac Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 15:36:14 +0100 Subject: [PATCH 51/57] pnpm lock --- pnpm-lock.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b1030345b..03dd6709dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -918,6 +918,34 @@ importers: specifier: ^4.18.1 version: 4.24.4 + examples/replay-loop-repro: + dependencies: + '@electric-sql/client': + specifier: workspace:* + version: link:../../packages/typescript-client + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.3 + version: 18.3.12 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.1 + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.3.3(vite@5.4.10(@types/node@25.6.0)(lightningcss@1.30.1)(terser@5.46.2)) + typescript: + specifier: ^5.5.3 + version: 5.8.3 + vite: + specifier: ^5.3.4 + version: 5.4.10(@types/node@25.6.0)(lightningcss@1.30.1)(terser@5.46.2) + examples/tanstack: dependencies: '@electric-sql/client': From 4cbbfb27e1f232867b83b5aa8f4406d56f2e6cbd Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 16:33:30 +0100 Subject: [PATCH 52/57] Nudge new session shortcut badge Adjust the sidebar new-session shortcut badge so its hover spacing reads evenly. Co-authored-by: Cursor --- packages/agents-server-ui/src/components/Sidebar.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents-server-ui/src/components/Sidebar.module.css b/packages/agents-server-ui/src/components/Sidebar.module.css index b47a6acb7b..9af6fa4372 100644 --- a/packages/agents-server-ui/src/components/Sidebar.module.css +++ b/packages/agents-server-ui/src/components/Sidebar.module.css @@ -150,7 +150,7 @@ at rest and only fully opaque while the row is hovered/focused so it never competes with the label. - `margin-right: 5px` gives the badge breathing room from the row's + `margin-right: 3px` gives the badge breathing room from the row's right edge — without it the keycap sits flush against the row padding (3px, sized for the concentric halo of inline buttons in sibling SidebarRow items) and visually crowds the rounded corner. */ @@ -160,7 +160,7 @@ align-items: center; gap: 2px; margin-left: auto; - margin-right: 5px; + margin-right: 3px; opacity: 0.55; transition: opacity 0.1s ease; } From 23da729ffe7957aaf0abf53f90056b40fc23b9d1 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 17:24:11 +0100 Subject: [PATCH 53/57] Clean up agents UI CSS tokens Co-authored-by: Cursor --- .../src/components/AgentResponse.module.css | 6 +++--- .../components/EntityContextDrawer.module.css | 8 ++++---- .../src/components/EntityTimeline.module.css | 2 +- .../src/components/MessageInput.module.css | 2 +- .../src/components/SearchPalette.module.css | 2 +- .../src/components/SettingsMenu.module.css | 2 +- .../src/components/Sidebar.module.css | 19 +++++++++---------- .../src/components/SidebarRow.module.css | 4 ++-- .../src/components/SidebarViewMenu.module.css | 2 +- .../src/components/UserMessage.module.css | 2 +- .../settings/SettingsSidebar.module.css | 2 +- .../src/components/toolBlock.module.css | 2 +- packages/agents-server-ui/src/markdown.css | 8 ++++---- .../src/ui/Combobox.module.css | 2 +- .../agents-server-ui/src/ui/Kbd.module.css | 2 +- .../agents-server-ui/src/ui/Menu.module.css | 2 +- .../agents-server-ui/src/ui/Select.module.css | 2 +- packages/agents-server-ui/src/ui/tokens.css | 2 ++ 18 files changed, 36 insertions(+), 35 deletions(-) diff --git a/packages/agents-server-ui/src/components/AgentResponse.module.css b/packages/agents-server-ui/src/components/AgentResponse.module.css index 59db053277..fb21b2afc8 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.module.css +++ b/packages/agents-server-ui/src/components/AgentResponse.module.css @@ -12,12 +12,12 @@ } .doneText { - color: var(--ds-text-4, var(--ds-text-3)); + color: var(--ds-text-4); opacity: 0.8; } .timeText { - color: var(--ds-text-4, var(--ds-text-3)); + color: var(--ds-text-4); opacity: 0.7; } @@ -27,7 +27,7 @@ .copyButton { margin-left: auto; - color: var(--ds-text-4, var(--ds-text-3)); + color: var(--ds-text-4); opacity: 0.7; } diff --git a/packages/agents-server-ui/src/components/EntityContextDrawer.module.css b/packages/agents-server-ui/src/components/EntityContextDrawer.module.css index 84cc8a9622..7261b9535d 100644 --- a/packages/agents-server-ui/src/components/EntityContextDrawer.module.css +++ b/packages/agents-server-ui/src/components/EntityContextDrawer.module.css @@ -24,7 +24,7 @@ composer rather than a second raised card stacked under it. */ background: var(--ds-bg); border: 1px solid var(--ds-gray-a4); - border-radius: 12px; + border-radius: var(--ds-radius-5); padding: 4px; /* Bottom padding = composer overlap (20px) + 1px drawer bottom border (covered by the composer) + 4px breathing room so the @@ -65,7 +65,7 @@ display: flex; align-items: center; /* Match `Menu.item`: gap 6px, vertical padding 3px, 1.3 line - height, 7px border-radius. Without these overrides the drawer + height, the item border-radius. Without these overrides the drawer rows ended up ~5px taller than dropdown menu items because `` sets `line-height: 1.5` (--ds-text-sm-lh) and the row carried 4px vertical padding. The line-height on @@ -79,8 +79,8 @@ border: 0; background: transparent; /* Concentric with drawer corner: 1px border + 4px drawer padding - + 7px row radius = 12px outer drawer radius. */ - border-radius: 7px; + + item row radius = 12px outer drawer radius. */ + border-radius: var(--ds-radius-item); cursor: pointer; font: inherit; color: var(--ds-text-1); diff --git a/packages/agents-server-ui/src/components/EntityTimeline.module.css b/packages/agents-server-ui/src/components/EntityTimeline.module.css index d46aee9d12..db9e10aed2 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.module.css +++ b/packages/agents-server-ui/src/components/EntityTimeline.module.css @@ -69,7 +69,7 @@ .statusPill { padding: 2px 0 2px 10px; border-left: 2px solid var(--ds-gray-a3); - color: var(--ds-text-4, var(--ds-text-3)); + color: var(--ds-text-4); letter-spacing: 0.02em; } diff --git a/packages/agents-server-ui/src/components/MessageInput.module.css b/packages/agents-server-ui/src/components/MessageInput.module.css index 58551509ca..a60fdd2b3b 100644 --- a/packages/agents-server-ui/src/components/MessageInput.module.css +++ b/packages/agents-server-ui/src/components/MessageInput.module.css @@ -34,7 +34,7 @@ semantic. */ background: var(--ds-surface-raised); border: 1px solid var(--ds-border-1); - border-radius: 12px; + border-radius: var(--ds-radius-5); /* Soft drop shadow lifts the composer slightly off the chat surface so the docked feel reads even when the bottom-fade mask thins out the chat content right above it. */ diff --git a/packages/agents-server-ui/src/components/SearchPalette.module.css b/packages/agents-server-ui/src/components/SearchPalette.module.css index c2f6c45151..54829ca3bf 100644 --- a/packages/agents-server-ui/src/components/SearchPalette.module.css +++ b/packages/agents-server-ui/src/components/SearchPalette.module.css @@ -125,7 +125,7 @@ .rowType { flex-shrink: 0; - font-size: 10px; + font-size: var(--ds-text-2xs); letter-spacing: 0.04em; color: var(--ds-text-3); text-transform: lowercase; diff --git a/packages/agents-server-ui/src/components/SettingsMenu.module.css b/packages/agents-server-ui/src/components/SettingsMenu.module.css index b991f01532..ca4ccd9714 100644 --- a/packages/agents-server-ui/src/components/SettingsMenu.module.css +++ b/packages/agents-server-ui/src/components/SettingsMenu.module.css @@ -18,7 +18,7 @@ gap: 6px; min-height: 30px; padding: 3px 3px 3px 8px; - border-radius: 7px; + border-radius: var(--ds-radius-item); font-size: var(--ds-text-sm); line-height: 1.3; font-family: var(--ds-font-body); diff --git a/packages/agents-server-ui/src/components/Sidebar.module.css b/packages/agents-server-ui/src/components/Sidebar.module.css index 9af6fa4372..af3e1fd627 100644 --- a/packages/agents-server-ui/src/components/Sidebar.module.css +++ b/packages/agents-server-ui/src/components/Sidebar.module.css @@ -108,7 +108,7 @@ x=22 — exactly where the SidebarHeader chrome icons (padding-left 11px) and the SidebarRow status dots (BASE_PADDING_LEFT 3px) live. - `padding-right: 3px` and `border-radius: 7px` follow the same + `padding-right: 3px` and the item radius follow the same concentric-halo rule as `.row` in SidebarRow.module.css, so the New session row rounds identically to the entity rows below it. */ .newSessionRow { @@ -119,7 +119,7 @@ height: var(--ds-row-height-md); padding-left: 3px; padding-right: 3px; - border-radius: 7px; + border-radius: var(--ds-radius-item); cursor: pointer; color: var(--ds-text-1); transition: background 0.08s ease; @@ -179,14 +179,13 @@ * lower-density auxiliary nav and its 8px gutter still leaves room * to spare. Targets Base UI's `` via its `data-orientation` * attribute (the inner class name is module-hashed and not reachable - * from here). `!important` because the ScrollArea module's - * `.scrollbar[data-orientation=...]` rule is the same specificity — - * cascade order between sibling CSS modules isn't guaranteed across - * builds, so we make the intent explicit. The 1px inner padding - * keeps the thumb at 4px, in proportion with the narrower track. */ -.scrollFlex [data-orientation='vertical'] { - width: 6px !important; - padding: 1px !important; + * from here). `.root` increases specificity beyond the shared + * ScrollArea rule, so the override is deterministic without a + * priority escape hatch. The 1px inner padding keeps the thumb at 4px, in + * proportion with the narrower track. */ +.root .scrollFlex [data-orientation='vertical'] { + width: 6px; + padding: 1px; } .emptyTreeText { diff --git a/packages/agents-server-ui/src/components/SidebarRow.module.css b/packages/agents-server-ui/src/components/SidebarRow.module.css index 0ddd77f4b5..3c987f1816 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.module.css +++ b/packages/agents-server-ui/src/components/SidebarRow.module.css @@ -25,7 +25,7 @@ gap: 6px; height: var(--ds-row-height-md); padding-right: 3px; - border-radius: 7px; + border-radius: var(--ds-radius-item); cursor: pointer; user-select: none; color: var(--ds-text-1); @@ -130,7 +130,7 @@ /* Sized so the type label's cap-height optically matches the x-height of the title text next to it — 10px caps ≈ 7px tall, same height as the lowercase letters of the 13px title. */ - font-size: 10px; + font-size: var(--ds-text-2xs); color: var(--ds-text-3); text-transform: lowercase; line-height: 1; diff --git a/packages/agents-server-ui/src/components/SidebarViewMenu.module.css b/packages/agents-server-ui/src/components/SidebarViewMenu.module.css index 4a1447a4e2..3d19e58065 100644 --- a/packages/agents-server-ui/src/components/SidebarViewMenu.module.css +++ b/packages/agents-server-ui/src/components/SidebarViewMenu.module.css @@ -13,7 +13,7 @@ gap: 6px; min-height: 30px; padding: 3px 3px 3px 8px; - border-radius: 7px; + border-radius: var(--ds-radius-item); font-size: var(--ds-text-sm); line-height: 1.3; font-family: var(--ds-font-body); diff --git a/packages/agents-server-ui/src/components/UserMessage.module.css b/packages/agents-server-ui/src/components/UserMessage.module.css index 4a9e86f7ac..c95c1cee96 100644 --- a/packages/agents-server-ui/src/components/UserMessage.module.css +++ b/packages/agents-server-ui/src/components/UserMessage.module.css @@ -15,7 +15,7 @@ .bubble { background: var(--ds-input-bg); border: 1px solid var(--ds-gray-a3); - border-radius: 12px; + border-radius: var(--ds-radius-5); } .body { diff --git a/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css b/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css index 17898dba83..9220ccc50d 100644 --- a/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css +++ b/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css @@ -128,7 +128,7 @@ gap: 8px; height: var(--ds-row-height-md); padding: 0 8px; - border-radius: 7px; + border-radius: var(--ds-radius-item); cursor: pointer; color: var(--ds-text-1); transition: background 0.08s ease; diff --git a/packages/agents-server-ui/src/components/toolBlock.module.css b/packages/agents-server-ui/src/components/toolBlock.module.css index 6f5a76b2fd..723c2b28d9 100644 --- a/packages/agents-server-ui/src/components/toolBlock.module.css +++ b/packages/agents-server-ui/src/components/toolBlock.module.css @@ -29,7 +29,7 @@ /* Hairline shadow — same lift the composer uses, just enough to separate the card from the chat surface without competing with prose. */ - box-shadow: 0 1px 2px rgba(15, 15, 30, 0.04); + box-shadow: var(--ds-shadow-1); } /* The header is metadata about the tool call, not part of the diff --git a/packages/agents-server-ui/src/markdown.css b/packages/agents-server-ui/src/markdown.css index ca0c9a7ed0..96e06799a8 100644 --- a/packages/agents-server-ui/src/markdown.css +++ b/packages/agents-server-ui/src/markdown.css @@ -456,7 +456,7 @@ padding: 12px 16px; border: 1px solid var(--ds-gray-a4); border-radius: 8px; - background: var(--ds-background); + background: var(--ds-bg); overflow-x: auto; font-size: 1.05em; line-height: 1.4; @@ -506,7 +506,7 @@ padding: 16px; border: 1px solid var(--ds-gray-a4); border-radius: 8px; - background: var(--ds-background); + background: var(--ds-bg); overflow-x: auto; display: flex; justify-content: center; @@ -542,14 +542,14 @@ display: block; padding: 12px 14px; background: var(--ds-red-a3, var(--ds-gray-a3)); - border-color: var(--ds-red-a6, var(--ds-gray-a4)); + border-color: var(--ds-red-a5); } .agent-ui-markdown [data-md-mermaid-block][data-md-mermaid-block-error] [data-md-mermaid-block-error-message] { font-size: 12px; - color: var(--ds-red-11, var(--ds-foreground-muted)); + color: var(--ds-red-11); margin-bottom: 8px; font-family: var(--font-mono); } diff --git a/packages/agents-server-ui/src/ui/Combobox.module.css b/packages/agents-server-ui/src/ui/Combobox.module.css index e2eadbbf1b..37b967c8e4 100644 --- a/packages/agents-server-ui/src/ui/Combobox.module.css +++ b/packages/agents-server-ui/src/ui/Combobox.module.css @@ -72,7 +72,7 @@ gap: 6px; min-height: 30px; padding: 3px 3px 3px 12px; - border-radius: 7px; + border-radius: var(--ds-radius-item); font-size: var(--ds-text-sm); line-height: 1.3; font-family: var(--ds-font-body); diff --git a/packages/agents-server-ui/src/ui/Kbd.module.css b/packages/agents-server-ui/src/ui/Kbd.module.css index 626f4a11c6..58afee25fb 100644 --- a/packages/agents-server-ui/src/ui/Kbd.module.css +++ b/packages/agents-server-ui/src/ui/Kbd.module.css @@ -12,7 +12,7 @@ /* Body font, not mono — many monospace fonts (incl. Source Code Pro) lack glyphs for ⌘/⌥/⇧/⌃, falling back to system fonts mid-pill. */ font-family: var(--ds-font-body); - font-size: 10px; + font-size: var(--ds-text-2xs); line-height: 1; letter-spacing: 0; font-weight: 500; diff --git a/packages/agents-server-ui/src/ui/Menu.module.css b/packages/agents-server-ui/src/ui/Menu.module.css index e0be471773..0b55fe3674 100644 --- a/packages/agents-server-ui/src/ui/Menu.module.css +++ b/packages/agents-server-ui/src/ui/Menu.module.css @@ -26,7 +26,7 @@ gap: 6px; min-height: 30px; padding: 3px 3px 3px 8px; - border-radius: 7px; + border-radius: var(--ds-radius-item); font-size: var(--ds-text-sm); line-height: 1.3; font-family: var(--ds-font-body); diff --git a/packages/agents-server-ui/src/ui/Select.module.css b/packages/agents-server-ui/src/ui/Select.module.css index e2a7ffb9e7..449f477eb5 100644 --- a/packages/agents-server-ui/src/ui/Select.module.css +++ b/packages/agents-server-ui/src/ui/Select.module.css @@ -107,7 +107,7 @@ gap: 6px; min-height: 30px; padding: 3px 28px 3px 8px; - border-radius: 7px; + border-radius: var(--ds-radius-item); font-size: var(--ds-text-sm); line-height: var(--ds-text-sm-lh); font-family: var(--ds-font-body); diff --git a/packages/agents-server-ui/src/ui/tokens.css b/packages/agents-server-ui/src/ui/tokens.css index 7838eef338..c7a2fe27fb 100644 --- a/packages/agents-server-ui/src/ui/tokens.css +++ b/packages/agents-server-ui/src/ui/tokens.css @@ -35,6 +35,7 @@ --ds-radius-4: 8px; --ds-radius-5: 12px; --ds-radius-6: 16px; + --ds-radius-item: 7px; --ds-radius-full: 9999px; /* ---- Type scale (flat — no Capsize trim) --------------------------- * @@ -42,6 +43,7 @@ * controlled by the parent container's `gap`, not per-element trim. * Sizes intentionally keep close to Radix's font-size-1..6 mapping * so existing `size="1"`..`size="6"` migrations are visually 1:1. */ + --ds-text-2xs: 10px; --ds-text-xs: 11px; --ds-text-xs-lh: 1.45; --ds-text-sm: 13px; From 8d0f772164b60e97d981bc00b5d9bfd4976b87f3 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 19:15:37 +0100 Subject: [PATCH 54/57] fix icon --- packages/agents-desktop/assets/icon.png | Bin 19664 -> 21702 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/agents-desktop/assets/icon.png b/packages/agents-desktop/assets/icon.png index 3a09e39c973f2fe0f848fa1aa0a3fc6024ca857e..5f47f5924daf0319f2948fa24a5c83c7bd79df49 100644 GIT binary patch literal 21702 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 literal 19664 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 Date: Wed, 6 May 2026 19:18:32 +0100 Subject: [PATCH 55/57] Refine markdown typography spacing Co-authored-by: Cursor --- packages/agents-server-ui/src/markdown.css | 78 ++++++++++++++++------ 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/packages/agents-server-ui/src/markdown.css b/packages/agents-server-ui/src/markdown.css index 96e06799a8..9580f39f42 100644 --- a/packages/agents-server-ui/src/markdown.css +++ b/packages/agents-server-ui/src/markdown.css @@ -47,18 +47,24 @@ /* --- Vertical rhythm ------------------------------------------------ */ .agent-ui-markdown > div { - display: flex; - flex-direction: column; - gap: 14px; + display: block; } -/* Cancel any auto-margins on direct children — the parent flex `gap` - is the single source of truth for inter-block spacing. */ -.agent-ui-markdown > div > *, -.agent-ui-markdown > div > * + * { +/* Cancel renderer/browser margins on direct children. Element-specific + rules below own the rhythm so headings, prose, lists, and panels do + not all get the same mechanical spacing. */ +.agent-ui-markdown > div > * { margin: 0; } +.agent-ui-markdown > div > * + * { + margin-top: 12px; +} + +.agent-ui-markdown > div > :is(h1, h2, h3, h4, h5, h6) + * { + margin-top: 8px; +} + /* --- Body copy ------------------------------------------------------ */ .agent-ui-markdown p, @@ -99,19 +105,19 @@ font-size: var(--ds-text-lg); /* 16px */ line-height: 1.35; font-weight: 600; - margin-top: 10px; + margin-top: 18px; } .agent-ui-markdown h2 { font-size: var(--ds-text-base); /* 14px */ line-height: 1.4; font-weight: 600; - margin-top: 8px; + margin-top: 18px; } .agent-ui-markdown h3 { font-size: var(--ds-text-sm); /* 13px */ line-height: 1.45; font-weight: 600; - margin-top: 6px; + margin-top: 14px; } .agent-ui-markdown h4, .agent-ui-markdown h5, @@ -120,6 +126,7 @@ line-height: 1.45; font-weight: 600; color: var(--ds-text-2); + margin-top: 12px; } /* First heading in the message has no extra top margin. */ @@ -132,7 +139,7 @@ .agent-ui-markdown ul, .agent-ui-markdown ol { list-style-position: outside; - padding-left: 1.75em; + padding-left: 1.55em; } .agent-ui-markdown ul { list-style: disc; @@ -141,7 +148,10 @@ list-style: decimal; } .agent-ui-markdown li + li { - margin-top: 6px; + margin-top: 5px; +} +.agent-ui-markdown li > :is(ul, ol) { + margin-top: 5px; } /* Streamdown wraps each list item's text in a paragraph; collapse it so list items render on a single line by default, and only break @@ -154,6 +164,30 @@ font-size: 0.95em; } +/* GFM task lists render native disabled checkboxes. Remove the normal + list marker and let the checkbox become the marker so we do not show + both a bullet and a control. */ +.agent-ui-markdown li:has(input[type='checkbox']) { + list-style: none; + margin-left: -1.25em; +} +.agent-ui-markdown li li:has(input[type='checkbox']) { + margin-left: 0; +} +.agent-ui-markdown li:has(input[type='checkbox'])::marker { + content: ''; +} +.agent-ui-markdown li input[type='checkbox'] { + width: 12px; + height: 12px; + margin: 0 6px 0 0; + vertical-align: -2px; + accent-color: var(--ds-accent-9); +} +.agent-ui-markdown li input[type='checkbox']:disabled { + opacity: 0.65; +} + /* --- Inline code ---------------------------------------------------- */ .agent-ui-markdown [data-md-inline-code] { @@ -340,7 +374,7 @@ .agent-ui-markdown [data-md-code-block] { display: flex; flex-direction: column; - gap: 6px; + gap: 5px; } /* Header + action toolbar share a single row above the bordered @@ -351,7 +385,7 @@ display: flex; align-items: center; justify-content: space-between; - height: 18px; + height: 16px; padding: 0 2px; gap: 8px; } @@ -389,7 +423,7 @@ background: var(--ds-surface-raised); border: 1px solid var(--ds-gray-a4); border-radius: var(--ds-radius-3); - padding: 10px 12px; + padding: 9px 11px; overflow-x: auto; } @@ -403,7 +437,7 @@ .agent-ui-markdown [data-md-code-block-body] code { font-family: var(--ds-font-mono); font-size: var(--ds-text-xs); - line-height: 1.55; + line-height: 1.5; background: none; padding: 0; border-radius: 0; @@ -452,10 +486,10 @@ * ----------------------------------------------------------------------- */ .agent-ui-markdown [data-md-math-block] { - margin: 0.75em 0; - padding: 12px 16px; + margin: 0; + padding: 10px 12px; border: 1px solid var(--ds-gray-a4); - border-radius: 8px; + border-radius: var(--ds-radius-3); background: var(--ds-bg); overflow-x: auto; font-size: 1.05em; @@ -502,10 +536,10 @@ * ----------------------------------------------------------------------- */ .agent-ui-markdown [data-md-mermaid-block] { - margin: 0.75em 0; - padding: 16px; + margin: 0; + padding: 12px; border: 1px solid var(--ds-gray-a4); - border-radius: 8px; + border-radius: var(--ds-radius-3); background: var(--ds-bg); overflow-x: auto; display: flex; From 71c39608f68d44c9b7a1a1d7262c8674da86d899 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 19:21:43 +0100 Subject: [PATCH 56/57] Fix agents desktop CI coverage Co-authored-by: Cursor --- .changeset/electron-desktop-and-agents-ui-tiles.md | 3 +++ packages/agents-desktop/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.changeset/electron-desktop-and-agents-ui-tiles.md b/.changeset/electron-desktop-and-agents-ui-tiles.md index 76ada6341e..c8a411ffa5 100644 --- a/.changeset/electron-desktop-and-agents-ui-tiles.md +++ b/.changeset/electron-desktop-and-agents-ui-tiles.md @@ -1,5 +1,6 @@ --- '@electric-ax/agents-desktop': patch +'@electric-ax/agents-server': patch '@electric-ax/agents-server-ui': patch '@electric-ax/agents-runtime': patch '@electric-ax/agents': patch @@ -14,6 +15,8 @@ working-directory picker. native menus, About dialog, on-launch API key prompt (Anthropic / OpenAI / Brave), localhost agent-server discovery, and HMR via `vite-plugin-electron`. + - `@electric-ax/agents-server`: entrypoint support for the local + desktop runtime wiring. - `@electric-ax/agents-server-ui`: tile-based workspace (DnD, splits, persisted layouts, shareable URLs), redesigned new- session screen, refreshed dropdown chrome (`Combobox` diff --git a/packages/agents-desktop/package.json b/packages/agents-desktop/package.json index bb31121c84..12530de927 100644 --- a/packages/agents-desktop/package.json +++ b/packages/agents-desktop/package.json @@ -12,6 +12,7 @@ "dev:desktop": "wait-on -d 200 http-get://localhost:5183 && vite", "ensure:electron": "node ./node_modules/electron/install.js", "start": "pnpm run ensure:electron && electron .", + "coverage": "true", "typecheck": "tsc --noEmit" }, "dependencies": { From 72b94ce2b3e23eb3f23b8364f1065de767a5cfbd Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 6 May 2026 19:31:56 +0100 Subject: [PATCH 57/57] Fix DOM abort signal typechecks Co-authored-by: Cursor --- .changeset/electron-desktop-and-agents-ui-tiles.md | 4 ++++ packages/experimental/tsconfig.json | 1 + packages/react-hooks/tsconfig.json | 1 + 3 files changed, 6 insertions(+) diff --git a/.changeset/electron-desktop-and-agents-ui-tiles.md b/.changeset/electron-desktop-and-agents-ui-tiles.md index c8a411ffa5..77e7fb8927 100644 --- a/.changeset/electron-desktop-and-agents-ui-tiles.md +++ b/.changeset/electron-desktop-and-agents-ui-tiles.md @@ -4,6 +4,8 @@ '@electric-ax/agents-server-ui': patch '@electric-ax/agents-runtime': patch '@electric-ax/agents': patch +'@electric-sql/experimental': patch +'@electric-sql/react': patch --- Electron desktop shell, tile-based workspace, and per-session @@ -30,3 +32,5 @@ working-directory picker. - `@electric-ax/agents-runtime`: tool-pair preservation during compaction and matching tool-call events by id (bug fixes surfaced while building the desktop UI). + - `@electric-sql/experimental`, `@electric-sql/react`: align test + type configuration with DOM AbortSignal types used by the client. diff --git a/packages/experimental/tsconfig.json b/packages/experimental/tsconfig.json index b5c3207611..6d00cc7499 100644 --- a/packages/experimental/tsconfig.json +++ b/packages/experimental/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], "paths": { "@electric-sql/client": ["../typescript-client/src"] } diff --git a/packages/react-hooks/tsconfig.json b/packages/react-hooks/tsconfig.json index b5c3207611..6d00cc7499 100644 --- a/packages/react-hooks/tsconfig.json +++ b/packages/react-hooks/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], "paths": { "@electric-sql/client": ["../typescript-client/src"] }