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..77e7fb8927 --- /dev/null +++ b/.changeset/electron-desktop-and-agents-ui-tiles.md @@ -0,0 +1,36 @@ +--- +'@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 +'@electric-sql/experimental': patch +'@electric-sql/react': 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`: 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` + 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). + - `@electric-sql/experimental`, `@electric-sql/react`: align test + type configuration with DOM AbortSignal types used by the client. diff --git a/.gitignore b/.gitignore index 125871ff67..bf451d23de 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ _artifacts **/.env .opentrace/ .opentrace-index.log + +# npm local cache/logs +.npm/ diff --git a/AGENTS_DESKTOP_PLAN.md b/AGENTS_DESKTOP_PLAN.md new file mode 100644 index 0000000000..3b3a687ff1 --- /dev/null +++ b/AGENTS_DESKTOP_PLAN.md @@ -0,0 +1,374 @@ +# Agents Desktop Plan + +## Goal + +Build a desktop app package at `packages/agents-desktop` that reuses the existing +`packages/agents-server-ui` React app while adding local desktop functionality. + +The first version should not bundle an Agents server, Postgres, or Electric. It +should bundle and manage the local builtin agents runtime from `@electric-ax/agents` +so Horton and related background agents can run locally while connecting to an +external Agents server. + +## Product Shape + +The desktop app is both: + +- A desktop windowed version of the Agents UI. +- A background local agents runtime indicator/controller. + +On macOS this should include a menu bar icon next to the clock. On Windows and +Linux the equivalent should be a tray/status area icon. + +The tray/menu bar app should make it clear that the local agents runtime is +running even when all UI windows are closed. This matters because a CLI version +of the interface may also use the same background runtime for agents. + +The app should support multiple windows. Closing the last window should not +necessarily stop the local runtime; quitting the app explicitly should. + +## Version 1 Scope + +In scope: + +- Create `packages/agents-desktop`. +- Package the shared `agents-server-ui` renderer inside Electron. +- Start and stop a local `BuiltinAgentsServer` from `@electric-ax/agents`. +- Register Horton and worker agent types with the selected external Agents server. +- Persist desktop settings using Electron-side storage rather than only browser + `localStorage`. +- Show runtime status in both the app UI and tray/menu bar. +- Support multiple app windows. +- Allow the app to continue running in the background after windows are closed. + +Out of scope for v1: + +- Bundling `@electric-ax/agents-server`. +- Bundling or managing Postgres. +- Bundling or managing Electric. +- Solving remote callback tunnelling for non-local Agents servers. +- Full auto-update/signing/release polish, unless needed for internal testing. + +## Important Constraint + +The selected Agents server must be able to call the local builtin agents runtime +webhook. + +For local development, this is straightforward: + +```text +Desktop app starts BuiltinAgentsServer on 127.0.0.1: +Agents UI connects to http://127.0.0.1:4437 +Agents server calls back to http://127.0.0.1:/_electric/builtin-agent-handler +``` + +For a remote Agents server, `127.0.0.1` would refer to the remote machine, not +the user's desktop. Remote support needs a later tunnel/relay/public callback +design. V1 should clearly communicate that the connected Agents server must be +able to reach the local runtime URL. + +## Package Structure + +Proposed package: + +```text +packages/agents-desktop/ + package.json + tsconfig.json + vite.config.ts + electron/ + main.ts + preload.ts + src/ + renderer-entry.tsx, if needed + assets/ + tray icons +``` + +The package should depend on: + +- `@electric-ax/agents-server-ui` +- `@electric-ax/agents` +- `electron` +- an Electron packaging/build tool + +The exact packaging tool can be chosen during implementation. Prefer the +simplest setup that works cleanly in the pnpm monorepo and supports macOS first, +with a path to Windows/Linux packaging. + +## Renderer Strategy + +Keep `packages/agents-server-ui` as the single shared renderer implementation. + +The web server build currently uses: + +```ts +base: `/__agent_ui/` +``` + +Electron should use a renderer build with a file-friendly asset base, likely: + +```ts +base: `./` +``` + +Implementation options: + +1. Add an environment-controlled base to `agents-server-ui/vite.config.ts`. +2. Add a second build command in `agents-server-ui`, for example + `build:desktop`. +3. Let `agents-desktop` invoke or reference that desktop build. + +Prefer keeping the app code shared and changing only the build base. + +## Electron Main Process + +The Electron main process should own: + +- app lifecycle +- tray/menu bar icon +- window creation +- multi-window tracking +- local builtin agents runtime lifecycle +- desktop settings persistence +- IPC handlers exposed through preload + +Runtime startup should use the existing exported API: + +```ts +import { BuiltinAgentsServer } from '@electric-ax/agents' + +const runtime = new BuiltinAgentsServer({ + agentServerUrl, + host: `127.0.0.1`, + port: 0, + workingDirectory, +}) + +await runtime.start() +``` + +Use `port: 0` so the OS selects a free port. The returned URL can be shown in +debug/status UI and used for health checks. + +When the active Agents server changes, the desktop app should stop the current +runtime and start a new one registered against the new `agentServerUrl`. + +## Tray/Menu Bar Behavior + +The tray/menu bar icon should indicate the runtime state: + +- Starting +- Running +- Error +- Stopped + +Suggested menu items: + +- Open Agents +- New Window +- Runtime status +- Connected server +- Restart local runtime +- Stop local runtime +- Settings +- Quit + +On macOS: + +- Closing a window should close that window but keep the app and runtime alive. +- Cmd+Q or the tray Quit action should stop the runtime and quit the app. +- Clicking the dock icon should reopen or create a window. + +On Windows/Linux: + +- Closing the last window should minimize-to-tray behavior unless the user chose + Quit. +- Tray Quit should stop the runtime and exit. + +## Multiple Windows + +The desktop app should support multiple independent renderer windows. + +Each window can load the same renderer build and share the same Electron main +process state: + +- saved server list +- active server +- runtime status +- working directory +- API key availability/status + +The active server should probably be global for v1 because there is one local +builtin agents runtime process. Per-window active servers would imply multiple +runtime registrations and more complex lifecycle semantics. + +## Preload API + +Extend the existing `window.electronAPI` shape rather than exposing Node APIs to +the renderer. + +Initial API: + +```ts +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 +} +``` + +Example state: + +```ts +type DesktopRuntimeStatus = `stopped` | `starting` | `running` | `error` + +interface DesktopState { + runtimeStatus: DesktopRuntimeStatus + runtimeUrl: string | null + activeServer: ServerConfig | null + workingDirectory: string | null + error: string | null +} +``` + +The renderer should use this for desktop-only behavior while continuing to work +in a normal browser without `window.electronAPI`. + +## UI Changes + +Keep UI changes modest: + +- Show a local runtime status surface near the existing server picker or settings + menu. +- Distinguish between "connected to Agents server" and "local Horton runtime is + registered/running". +- If the runtime fails because no model provider key is configured, show a clear + message for `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`. +- If the selected server is remote, warn that the server may not be able to call + back to the local runtime unless a public callback URL is configured. + +## Configuration + +Desktop settings should include: + +- saved Agents servers +- active Agents server +- working directory +- start runtime on launch +- keep running after windows close +- optional public callback/base URL override + +API keys need a product decision: + +- For v1 internal/dev use, reading from environment may be sufficient. +- For packaged app users, store credentials in the OS keychain or equivalent. + +Do not store model provider API keys in plaintext JSON settings. + +## Development Workflow + +Suggested scripts: + +```json +{ + "dev": "run Electron with Vite renderer", + "build": "build renderer, main, and preload", + "package": "create unpacked app", + "dist": "create distributable app" +} +``` + +During development, the Electron app can load the Vite dev server. In packaged +builds, it should load the built renderer from disk. + +The external Agents server still needs to be started separately, for example: + +```sh +DATABASE_URL=postgresql://... \ +ELECTRIC_AGENTS_ELECTRIC_URL=http://localhost:3060 \ +ELECTRIC_INSECURE=true \ +node packages/agents-server/dist/entrypoint.js +``` + +Then the desktop app connects to that server and starts its local builtin agents +runtime against it. + +## Implementation Phases + +### Phase 1: Package Skeleton + +- Add `packages/agents-desktop`. +- Add Electron main/preload TypeScript build. +- Add a basic window loading the shared UI. +- Add macOS tray/menu bar icon with Open, New Window, and Quit. + +### Phase 2: Shared Renderer Build + +- Add a desktop renderer build path for `agents-server-ui`. +- Ensure hash routing and static assets work from Electron's file URL. +- Keep the web `/__agent_ui/` build unchanged. + +### Phase 3: Settings and IPC + +- Move saved server persistence through Electron for desktop. +- Add desktop state IPC. +- Keep browser fallback behavior intact. + +### Phase 4: Builtin Runtime Lifecycle + +- Start `BuiltinAgentsServer` when an active server is selected. +- Restart it when the active server changes. +- Stop it on explicit app quit. +- Surface status and errors in tray/menu bar and UI. + +### Phase 5: Multi-Window Polish + +- Add New Window support. +- Share desktop state updates across windows. +- Decide and implement close/minimize-to-tray behavior per platform. + +### Phase 6: Local Runtime UX + +- Add runtime status component to the UI. +- Add restart/stop actions. +- Add missing-key and unreachable-server guidance. +- Add optional working directory picker. + +### Phase 7: Packaging + +- Produce a macOS build for internal testing. +- Add icons and app metadata. +- Document Windows/Linux packaging gaps and test the tray behavior there. + +## Open Questions + +- Should the desktop app own the active server globally, or should each window be + able to choose independently? V1 should probably keep it global. +- Should the runtime start immediately on launch or only after the first window + chooses a server? +- Where should working directory default to: app data, user's home directory, or + the last selected project directory? +- How should packaged users configure `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`? +- Is remote Agents server support required before public release, or can v1 + explicitly target local Agents servers? +- Should the CLI and desktop app coordinate over a shared local runtime lock/API? + +## Success Criteria + +- The Electron app opens the existing Agents UI. +- Multiple windows can be opened. +- The tray/menu bar icon remains present after all windows are closed. +- The local builtin agents runtime starts and registers Horton with the selected + Agents server. +- The UI can start a Horton session through the external Agents server. +- Quitting the app cleanly stops the local runtime. diff --git a/packages/agents-desktop/.gitignore b/packages/agents-desktop/.gitignore new file mode 100644 index 0000000000..8e7dc7fcbf --- /dev/null +++ b/packages/agents-desktop/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +logs +*.log +.DS_Store diff --git a/packages/agents-desktop/assets/icon.png b/packages/agents-desktop/assets/icon.png new file mode 100644 index 0000000000..5f47f5924d Binary files /dev/null and b/packages/agents-desktop/assets/icon.png differ diff --git a/packages/agents-desktop/assets/trayTemplate.png b/packages/agents-desktop/assets/trayTemplate.png new file mode 100644 index 0000000000..ed45dd0287 Binary files /dev/null and b/packages/agents-desktop/assets/trayTemplate.png differ diff --git a/packages/agents-desktop/assets/trayTemplate@2x.png b/packages/agents-desktop/assets/trayTemplate@2x.png new file mode 100644 index 0000000000..ecf66f6e16 Binary files /dev/null and b/packages/agents-desktop/assets/trayTemplate@2x.png differ diff --git a/packages/agents-desktop/package.json b/packages/agents-desktop/package.json new file mode 100644 index 0000000000..12530de927 --- /dev/null +++ b/packages/agents-desktop/package.json @@ -0,0 +1,38 @@ +{ + "name": "@electric-ax/agents-desktop", + "productName": "Electric Agents", + "private": true, + "version": "0.0.0", + "type": "module", + "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\"", + "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 .", + "coverage": "true", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@electric-ax/agents": "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", + "concurrently": "^8.2.2", + "electron": "^41.5.0", + "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 new file mode 100644 index 0000000000..2fcf475c94 --- /dev/null +++ b/packages/agents-desktop/src/main.ts @@ -0,0 +1,1191 @@ +import { BuiltinAgentsServer } from '@electric-ax/agents' +import { + BrowserWindow, + Menu, + Tray, + app, + 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' + +type ServerConfig = { + name: string + url: string +} + +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)) +const PACKAGE_DIR = path.resolve(MODULE_DIR, `..`) +const RENDERER_INDEX = path.resolve( + PACKAGE_DIR, + `../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` +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 + * 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 + * 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` + | `open-find` + | `find-next` + | `find-previous` + | `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 } +let state: DesktopState = { + runtimeStatus: `stopped`, + runtimeUrl: null, + 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() + +function settingsPath(): string { + return path.join(app.getPath(`userData`), `settings.json`) +} + +function normalizeServer(value: unknown): ServerConfig | null { + if (!value || typeof value !== `object`) return null + const maybe = value as Partial + if (typeof maybe.name !== `string` || typeof maybe.url !== `string`) { + return null + } + const name = maybe.name.trim() + const url = maybe.url.trim() + if (!name || !url) return null + try { + new URL(url) + } catch { + return null + } + return { name, url } +} + +function normalizeServers(value: unknown): Array { + if (!Array.isArray(value)) return [] + const byUrl = new Map() + for (const entry of value) { + const server = normalizeServer(entry) + if (server) byUrl.set(server.url, server) + } + return [...byUrl.values()] +} + +function serverInList( + server: ServerConfig | null, + servers: Array +): boolean { + 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`) + const parsed = JSON.parse(raw) as Partial + const servers = normalizeServers(parsed.servers) + const activeServer = normalizeServer(parsed.activeServer) + settings = { + servers, + activeServer: serverInList(activeServer, servers) ? activeServer : null, + workingDirectory: + typeof parsed.workingDirectory === `string` + ? parsed.workingDirectory + : null, + apiKeys: normalizeApiKeys(parsed.apiKeys), + } + } catch { + settings = { ...DEFAULT_SETTINGS } + } + + state = { + ...state, + activeServer: settings.activeServer, + workingDirectory: settings.workingDirectory, + } + + applyApiKeys() +} + +async function saveSettings(): Promise { + await mkdir(path.dirname(settingsPath()), { recursive: true }) + await writeFile(settingsPath(), JSON.stringify(settings, null, 2)) +} + +function statusLabel(status: DesktopRuntimeStatus): string { + switch (status) { + case `starting`: + return `Starting` + case `running`: + return `Running` + case `error`: + return `Error` + case `stopped`: + return `Stopped` + } +} + +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 { + // 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) + } + return icon +} + +function updateTray(): void { + if (!tray) return + + const runtimeLabel = statusLabel(state.runtimeStatus) + const serverLabel = state.activeServer?.name ?? `No server selected` + tray.setToolTip(`Electric Agents: ${runtimeLabel}`) + + const menu = Menu.buildFromTemplate([ + { + label: `Open Agents`, + click: () => createWindow(), + }, + { + label: `New Window`, + click: () => createWindow(), + }, + { type: `separator` }, + { + label: `Runtime: ${runtimeLabel}`, + enabled: false, + }, + { + label: `Server: ${serverLabel}`, + enabled: false, + }, + { + label: `Restart Local Runtime`, + enabled: Boolean(state.activeServer), + click: () => { + void restartRuntime() + }, + }, + { + label: `Stop Local Runtime`, + enabled: + state.runtimeStatus === `running` || state.runtimeStatus === `starting`, + click: () => { + void stopRuntime() + }, + }, + { type: `separator` }, + { + label: `Quit`, + click: () => { + void quitApp() + }, + }, + ]) + + tray.setContextMenu(menu) +} + +function broadcastState(): void { + for (const win of windows) { + win.webContents.send(`desktop:state-changed`, state) + } +} + +function setState(patch: Partial): void { + state = { ...state, ...patch } + updateTray() + broadcastState() +} + +function createWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 1280, + height: 900, + minWidth: 960, + minHeight: 640, + title: `Electric Agents`, + // macOS: hide the native titlebar but keep the traffic-light buttons + // overlaid on the window content. The renderer paints the toolbar + // with extra left-padding so its icons sit to the right of the + // traffic lights and the row reads as a single chrome strip. + // Other platforms get a frameless window with custom in-app + // title bars. + titleBarStyle: process.platform === `darwin` ? `hiddenInset` : `hidden`, + frame: process.platform === `darwin`, + // Standard macOS hiddenInset traffic-light origin (top-left of the + // leftmost light). The renderer matches the 44px desktop header + // height so the 24px IconButton glyphs flex-center to the same y as + // the light centers — the row reads as a single chrome strip. + trafficLightPosition: + process.platform === `darwin` ? { x: 16, y: 14 } : undefined, + webPreferences: { + preload: PRELOAD_PATH, + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }) + + 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` })) + // 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 +} + +function showOrCreateWindow(): void { + const existing = [...windows].find((win) => !win.isDestroyed()) + if (existing) { + existing.show() + existing.focus() + return + } + createWindow() +} + +async function stopExistingRuntime(): Promise { + const current = runtime + runtime = null + if (current) { + await current.stop() + } +} + +async function restartRuntime(): Promise { + const generation = ++runtimeGeneration + await stopExistingRuntime() + + const activeServer = settings.activeServer + if (!activeServer) { + setState({ runtimeStatus: `stopped`, runtimeUrl: null, error: null }) + return + } + + setState({ runtimeStatus: `starting`, runtimeUrl: null, error: null }) + + const nextRuntime = new BuiltinAgentsServer({ + agentServerUrl: activeServer.url, + host: `127.0.0.1`, + port: 0, + workingDirectory: settings.workingDirectory ?? app.getPath(`home`), + }) + runtime = nextRuntime + + try { + const runtimeUrl = await nextRuntime.start() + if (generation !== runtimeGeneration) { + await nextRuntime.stop() + return + } + setState({ runtimeStatus: `running`, runtimeUrl, error: null }) + } catch (error) { + if (runtime === nextRuntime) { + runtime = null + } + setState({ + runtimeStatus: `error`, + runtimeUrl: null, + error: error instanceof Error ? error.message : String(error), + }) + } +} + +async function stopRuntime(): Promise { + runtimeGeneration += 1 + await stopExistingRuntime() + 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) + 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() + if (!same) { + await restartRuntime() + } +} + +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( + `desktop:save-servers`, + async (_event, servers: Array) => { + settings.servers = normalizeServers(servers) + if (!serverInList(settings.activeServer, settings.servers)) { + settings.activeServer = null + setState({ activeServer: null }) + await restartRuntime() + } + await saveSettings() + } + ) + ipcMain.handle(`desktop:get-state`, () => state) + ipcMain.handle( + `desktop:set-active-server`, + async (_event, server: ServerConfig | null) => { + await setActiveServer(server) + } + ) + ipcMain.handle(`desktop:restart-runtime`, async () => { + await restartRuntime() + }) + 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 + ) + ipcMain.handle(`desktop:choose-working-directory`, async () => { + const result = await dialog.showOpenDialog({ + properties: [`openDirectory`, `createDirectory`], + }) + if (result.canceled) return settings.workingDirectory + settings.workingDirectory = result.filePaths[0] ?? null + setState({ workingDirectory: settings.workingDirectory }) + await saveSettings() + if (settings.activeServer) { + await restartRuntime() + } + 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 { + 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` }, + { 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`), + }, + ], + }, + { + 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 + } + + app.on(`second-instance`, () => { + showOrCreateWindow() + }) + + app.on(`window-all-closed`, () => { + // Keep the tray/menu bar runtime alive until the user explicitly quits. + }) + + app.on(`activate`, () => { + 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() + void quitApp() + }) + + await app.whenReady() + await loadSettings() + registerIpcHandlers() + + 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() + + 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 new file mode 100644 index 0000000000..e24017e924 --- /dev/null +++ b/packages/agents-desktop/src/preload.ts @@ -0,0 +1,114 @@ +import { contextBridge, ipcRenderer } from 'electron' + +// The Vite desktop build already stamps `` +// into the index, so CSS that targets `html[data-electric-desktop='true']` +// matches from the first paint. We re-apply it here as a safety net in +// case anything (HMR, navigation, etc.) drops the attribute. Wrapped in +// try/catch so a DOM hiccup can never block `contextBridge.exposeInMainWorld` +// further down — losing `window.electronAPI` would break the whole UI. +try { + if (typeof document !== `undefined` && document.documentElement) { + document.documentElement.dataset.electricDesktop = `true` + } else if (typeof window !== `undefined`) { + window.addEventListener(`DOMContentLoaded`, () => { + document.documentElement.dataset.electricDesktop = `true` + }) + } +} catch { + // Non-fatal — the static attribute in index.html is the source of truth. +} + +type ServerConfig = { + name: string + url: string +} + +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` + | `open-find` + | `find-next` + | `find-previous` + | `split-right` + | `split-down` + | `cycle-tile` + +const api = { + getServers: (): Promise> => + ipcRenderer.invoke(`desktop:get-servers`), + saveServers: (servers: Array): Promise => + ipcRenderer.invoke(`desktop:save-servers`, servers), + getDesktopState: (): Promise => + ipcRenderer.invoke(`desktop:get-state`), + setActiveServer: (server: ServerConfig | null): Promise => + ipcRenderer.invoke(`desktop:set-active-server`, server), + 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 => + ipcRenderer.invoke(`desktop:choose-working-directory`), + pickDirectory: (options?: { defaultPath?: string }): Promise => + ipcRenderer.invoke(`desktop:pick-directory`, options), + onDesktopStateChanged: ( + callback: (state: DesktopState) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, state: DesktopState) => + callback(state) + 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-desktop/tsconfig.json b/packages/agents-desktop/tsconfig.json new file mode 100644 index 0000000000..5886ab83be --- /dev/null +++ b/packages/agents-desktop/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "isolatedDeclarations": false, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ES2022", + "lib": ["ESNext", "DOM"], + "allowJs": false, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "outDir": "./dist" + }, + "include": ["src/**/*", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agents-desktop/vite.config.ts b/packages/agents-desktop/vite.config.ts new file mode 100644 index 0000000000..ccc65b4d2a --- /dev/null +++ b/packages/agents-desktop/vite.config.ts @@ -0,0 +1,147 @@ +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`, +]) + +function externalizeBareImports( + id: string, + parent: string | undefined +): boolean { + if (parent === undefined) return false + 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 +// 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({ + 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, + 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.cjs`, + format: `cjs`, + 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-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/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/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/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..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({ @@ -297,12 +316,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/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-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/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-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`, () => { 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( diff --git a/packages/agents-server-ui/.gitignore b/packages/agents-server-ui/.gitignore index de4d1f007d..49a5cb8afa 100644 --- a/packages/agents-server-ui/.gitignore +++ b/packages/agents-server-ui/.gitignore @@ -1,2 +1,3 @@ dist +dist-desktop node_modules diff --git a/packages/agents-server-ui/package.json b/packages/agents-server-ui/package.json index 3852a54bb2..78fe4d7a2e 100644 --- a/packages/agents-server-ui/package.json +++ b/packages/agents-server-ui/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "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/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..fb21b2afc8 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.module.css +++ b/packages/agents-server-ui/src/components/AgentResponse.module.css @@ -12,9 +12,25 @@ } .doneText { - opacity: 0.5; + color: var(--ds-text-4); + opacity: 0.8; } .timeText { - opacity: 0.4; + color: var(--ds-text-4); + opacity: 0.7; +} + +.metaRow { + width: 100%; +} + +.copyButton { + margin-left: auto; + color: var(--ds-text-4); + opacity: 0.7; +} + +.copyButton:hover { + opacity: 1; } diff --git a/packages/agents-server-ui/src/components/AgentResponse.tsx b/packages/agents-server-ui/src/components/AgentResponse.tsx index a24e04c9fc..e4c32815a8 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, @@ -12,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' @@ -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 @@ -259,7 +301,7 @@ export const AgentResponse = memo(function AgentResponse({ return })} - + {showThinking && } {section.done && ( @@ -278,6 +320,20 @@ export const AgentResponse = memo(function AgentResponse({ {timestamp != null && !isStreaming && ( )} + {section.done && copyText && ( + + void copyResponseText()} + aria-label="Copy response text" + > + {copied ? : } + + + )} ) diff --git a/packages/agents-server-ui/src/components/ApiKeysForm.module.css b/packages/agents-server-ui/src/components/ApiKeysForm.module.css new file mode 100644 index 0000000000..be3f6bd192 --- /dev/null +++ b/packages/agents-server-ui/src/components/ApiKeysForm.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/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 new file mode 100644 index 0000000000..6faf9ac9f8 --- /dev/null +++ b/packages/agents-server-ui/src/components/ApiKeysModal.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import { + loadApiKeysStatus, + saveApiKeys as persistApiKeys, + type ApiKeysStatus, +} from '../lib/server-connection' +import { Dialog } from '../ui' +import { ApiKeysForm } from './ApiKeysForm' + +/** + * 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 (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) + 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 (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) + }} + > + + 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) + const next = await loadApiKeysStatus() + if (next) setStatus(next) + }} + onSecondary={() => setOpen(false)} + secondaryLabel="Skip for now" + /> + + + ) +} diff --git a/packages/agents-server-ui/src/components/EntityContextDrawer.module.css b/packages/agents-server-ui/src/components/EntityContextDrawer.module.css index 9370132e00..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); @@ -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..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 @@ -98,7 +99,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/EntityHeader.tsx b/packages/agents-server-ui/src/components/EntityHeader.tsx index 7c1053f23e..4795d8cb89 100644 --- a/packages/agents-server-ui/src/components/EntityHeader.tsx +++ b/packages/agents-server-ui/src/components/EntityHeader.tsx @@ -1,28 +1,10 @@ -import { useEffect, useRef, useState } from 'react' -import { - Check, - Copy, - Database, - 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' import styles from './EntityHeader.module.css' import type { ElectricEntity } from '../lib/ElectricAgentsProvider' @@ -36,39 +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 - 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 + /** + * 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) => ( @@ -122,7 +115,12 @@ function EntityTitle({ > {sessionId} - @@ -132,17 +130,22 @@ function EntityTitle({ function EntityActions({ entity, - pinned, - onTogglePin, - onFork, - onKill, - forking, - stateExplorerOpen, - onToggleStateExplorer, -}: EntityHeaderProps): React.ReactElement { - const [showInspect, setShowInspect] = useState(false) - const [showKillConfirm, setShowKillConfirm] = useState(false) - const { title: instanceName } = getEntityDisplayTitle(entity) + currentViewId, + onSetView, + 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 + // 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,134 +157,28 @@ function EntityActions({ {entity.status} - {onToggleStateExplorer && ( - - - - - - )} - - - - - - } - /> - - setShowInspect(true)}> - - Inspect - - {onToggleStateExplorer && ( - - - - {stateExplorerOpen ? `Hide state explorer` : `State explorer`} - - - )} - { - 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 - - } - /> - -
-
+ {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} + > + + + + ) + })} - - - 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/EntityTimeline.module.css b/packages/agents-server-ui/src/components/EntityTimeline.module.css index b39c9de612..db9e10aed2 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); letter-spacing: 0.02em; } @@ -86,25 +89,40 @@ width: 100%; } +/* Jump-to-bottom affordance — centered over the chat column so it + relates to the conversation rather than the viewport edge. It stays + mounted and toggles visibility with opacity/translate for a soft + fade in/out. */ .jumpToBottom { position: absolute; - bottom: 24px; + bottom: 32px; left: 50%; - transform: translateX(-50%); 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-border-1); 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; + pointer-events: none; + transform: translate(-50%, 6px); + transition: + opacity 160ms ease, + transform 160ms ease, + background 120ms ease; +} +.jumpToBottom[data-visible='true'] { + opacity: 0.85; + pointer-events: auto; + transform: translate(-50%, 0); } .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..78fb76f3b8 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -16,16 +16,22 @@ import { loadTimelineRowHeights, persistTimelineRowHeights, } from '../lib/timelineRowHeights' +import { usePaneFindAdapterRegistration } from '../hooks/usePaneFind' import { warmMarkdownRenderCache } from '../lib/markdownRenderCache' import { ScrollArea, Stack, Text, Tooltip } from '../ui' import { UserMessage } from './UserMessage' import { AgentResponse } from './AgentResponse' +import { + getCurrentMatchIndexInRoot, + getTextMatchStarts, +} from './workspace/PaneFindBar' import { formatAbsoluteDateTimeVerbose, formatShortTime, } from '../lib/formatTime' import styles from './EntityTimeline.module.css' import type { EntityTimelineEntry } from '@electric-ax/agents-runtime' +import type { PaneFindAdapter, PaneFindMatch } from '../hooks/usePaneFind' /** * Width-aware row-height estimate used as the initial size hint for the @@ -69,10 +75,64 @@ function estimateRowHeight( return Math.max(120, 32 + lines * lineHeight) } -const SCROLL_THRESHOLD = 80 +const SCROLL_THRESHOLD = 200 const ROW_GAP = 24 const ROW_SETTLE_MS = 500 +type TimelinePaneFindMatch = PaneFindMatch & { + rowKey: string + rowIndex: number + rowOccurrence: number +} + +function timelineRowSearchText(row: EntityTimelineEntry): string { + const { section } = row + if (section.kind === `user_message`) return section.text + + return section.items + .map((item) => { + if (item.kind === `text`) return item.text + const parts = [ + item.toolName, + JSON.stringify(item.args, null, 2), + item.result ?? ``, + ] + return parts.filter((part) => part.trim().length > 0).join(`\n`) + }) + .filter((part) => part.trim().length > 0) + .join(`\n\n`) +} + +function timelineRowLabel(row: EntityTimelineEntry): string { + return row.section.kind === `user_message` ? `User message` : `Agent response` +} + +function excerptAround( + text: string, + start: number, + queryLength: number +): string { + const context = 48 + const from = Math.max(0, start - context) + const to = Math.min(text.length, start + queryLength + context) + const prefix = from > 0 ? `...` : `` + const suffix = to < text.length ? `...` : `` + return `${prefix}${text.slice(from, to).replace(/\s+/g, ` `)}${suffix}` +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())) +} + +function isTimelineFindMatch( + match: PaneFindMatch +): match is TimelinePaneFindMatch { + return ( + typeof (match as TimelinePaneFindMatch).rowKey === `string` && + typeof (match as TimelinePaneFindMatch).rowIndex === `number` + ) +} + // `section` and `responseTimestamp` are pulled out of the parent // `EntityTimelineEntry` so React.memo's shallow compare can hit on // the *section* identity. `buildTimelineEntries` returns a fresh @@ -116,12 +176,14 @@ export function EntityTimeline({ error, entityStopped, cacheKey, + tileId, }: { entries: Array loading: boolean error: string | null entityStopped: boolean cacheKey?: string | null + tileId?: string | null }): React.ReactElement { const rows = useMemo(() => entries, [entries]) const [viewport, setViewport] = useState(null) @@ -234,6 +296,55 @@ export function EntityTimeline({ enabled: rows.length > 0, }) + const paneFindAdapter = useMemo(() => { + const getHighlightRoot = (match: PaneFindMatch): HTMLElement | null => { + if (!contentElement || !isTimelineFindMatch(match)) return null + return contentElement.querySelector( + `[data-pane-find-row-key="${CSS.escape(match.rowKey)}"]` + ) + } + + return { + search(query) { + const matches: Array = [] + if (!query.trim()) return matches + + rows.forEach((row, rowIndex) => { + const text = timelineRowSearchText(row) + const starts = getTextMatchStarts(text, query) + starts.forEach((start, rowOccurrence) => { + matches.push({ + id: `${row.key}:${rowOccurrence}`, + rowKey: row.key, + rowIndex, + rowOccurrence, + label: timelineRowLabel(row), + excerpt: excerptAround(text, start, query.length), + }) + }) + }) + return matches + }, + async reveal(match) { + if (!isTimelineFindMatch(match)) return + rowVirtualizer.scrollToIndex(match.rowIndex, { align: `center` }) + for (let i = 0; i < 8; i++) { + await nextFrame() + if (getHighlightRoot(match)) return + } + }, + getHighlightRoot, + getCurrentMatchIndex(match, query) { + if (!isTimelineFindMatch(match)) return 0 + const root = getHighlightRoot(match) + if (!root) return 0 + return getCurrentMatchIndexInRoot(root, query, match) + }, + } + }, [contentElement, rowVirtualizer, rows]) + + usePaneFindAdapterRegistration(tileId ?? null, paneFindAdapter) + useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false }, [rowVirtualizer]) @@ -414,7 +525,7 @@ export function EntityTimeline({ scrollbars="vertical" >
- + {spawnTime ? ( @@ -460,6 +571,7 @@ export function EntityTimeline({ ref={rowVirtualizer.measureElement} data-index={virtualRow.index} data-item-key={row.key} + data-pane-find-row-key={row.key} className={styles.virtualRow} style={{ transform: `translateY(${virtualRow.start}px)` }} > @@ -477,7 +589,7 @@ export function EntityTimeline({ )} {entityStopped && ( - + stopped @@ -486,16 +598,17 @@ export function EntityTimeline({
- {showJumpToBottom && ( - - )} +
) } 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/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..a60fdd2b3b 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-radius: 12px;
+  border: 1px solid var(--ds-border-1);
+  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. */
+  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
@@ -96,13 +101,27 @@
   color: var(--ds-text-3);
 }
 
-.sendIcon {
-  color: var(--ds-gray-8);
-  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 fa7b5ddc3a..c20387aec7 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 {
@@ -22,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 {
@@ -60,14 +116,14 @@
   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,
     border-color 0.12s ease;
 }
 .typeCard:hover {
-  background: var(--ds-gray-a2);
+  background: var(--ds-surface-raised);
   border-color: var(--ds-border-2);
 }
 .typeCardName {
@@ -144,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));
 }
@@ -194,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);
@@ -227,14 +290,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 {
@@ -260,70 +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);
-}
-
-/* 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;
+  background: var(--ds-accent-10);
 }
 
 /* Other-agents section under the composer ----------------------- */
@@ -334,7 +353,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 13866aa072..54829ca3bf 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;
 }
@@ -98,7 +97,7 @@
 }
 .row[data-active='true'],
 .row:hover {
-  background: var(--ds-gray-a3);
+  background: var(--ds-bg-hover);
 }
 
 .rowTitle {
@@ -110,28 +109,40 @@
   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;
+  font-size: var(--ds-text-2xs);
   letter-spacing: 0.04em;
   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);
+.rowSubtitle {
   flex-shrink: 0;
-  background: var(--ds-bg-subtle);
+  max-width: 220px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: var(--ds-text-3);
+  font-size: var(--ds-text-xs);
 }
 
-.hint {
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
+.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 c65abb6631..5db67dc695 100644
--- a/packages/agents-server-ui/src/components/SearchPalette.tsx
+++ b/packages/agents-server-ui/src/components/SearchPalette.tsx
@@ -9,24 +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 { Kbd } from '../ui'
+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)
@@ -35,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(``)
@@ -53,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)
+            },
+          }
+        )
+      }
+    }
+
+    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(() => {})
+          },
+        })
+      }
     }
-    const filtered = entities.filter(matches)
+
+    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(() => {
@@ -104,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(
@@ -130,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
@@ -148,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}
@@ -159,49 +492,57 @@ 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` ? ( + + ) : ( + + )} - {entity.type} + + {item.title} + + {item.subtitle && ( + + {item.subtitle} + + )} + {item.kind === `action` && item.shortcut && ( + + {item.shortcut} + + )}
) })}
))}
-
- - - Navigate - - - Open - - - esc Close - -
diff --git a/packages/agents-server-ui/src/components/ServerPicker.module.css b/packages/agents-server-ui/src/components/ServerPicker.module.css index bd8f141922..459e2484fc 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 { @@ -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({ - + + + - {pinnedEntities.length > 0 && ( - <> - Pinned - {pinnedEntities.map((entity) => ( - - ))} - - )} + {pinnedEntities.length > 0 && ( + <> + Pinned + {pinnedEntities.map((entity) => ( + + ))} + + )} - {projectSections.map((section) => { - const collapsed = collapsedProjects.has(section.id) - return ( -
- - {!collapsed && - section.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 + + )} + + - {ungroupedBuckets.map((group) => ( -
- {group.label} - {group.items.map((root) => ( - - ))} -
- ))} + - {entities.length === 0 && ( - + {({ payload }: { payload: SidebarRowInfoPayload | undefined }) => ( + - No sessions - + {payload ? : null} + )} - - - - - - - {({ payload }: { payload: SidebarRowInfoPayload | undefined }) => ( - - {payload ? : null} - - )} - - + + + ) } -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> @@ -332,11 +368,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/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/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/components/SidebarRow.module.css b/packages/agents-server-ui/src/components/SidebarRow.module.css index 163cb67ca8..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); @@ -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: var(--ds-text-2xs); 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, @@ -273,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. */ 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/SidebarViewMenu.module.css b/packages/agents-server-ui/src/components/SidebarViewMenu.module.css new file mode 100644 index 0000000000..3d19e58065 --- /dev/null +++ b/packages/agents-server-ui/src/components/SidebarViewMenu.module.css @@ -0,0 +1,35 @@ +/* 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; + margin-right: 5px; + color: var(--ds-text-2); +} + +.submenuTrigger { + display: flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 3px 3px 3px 8px; + border-radius: var(--ds-radius-item); + 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; + margin-right: 5px; + 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..e3f3040527 --- /dev/null +++ b/packages/agents-server-ui/src/components/SidebarViewMenu.tsx @@ -0,0 +1,201 @@ +import { useMemo } from 'react' +import { + Activity, + CalendarClock, + Check, + ChevronRight, + ChevronsDownUp, + ChevronsUpDown, + Folder, + 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: , + workingDir: , +} + +/** + * 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)} + > + {formatLabel(t)} + {visible && ( + + )} + + ) + }) + )} + + + + + + + Status + + + + {STATUSES.map((s) => { + const visible = !view.hiddenStatuses.has(s) + return ( + toggleSidebarStatusVisibility(s)} + > + {formatLabel(s)} + {visible && ( + + )} + + ) + })} + + + + + + + expandAllUrls(expandableUrls)} + disabled={expandableUrls.length === 0} + > + + Expand all + + collapseAllExpanded()}> + + Collapse all + + + + ) +} 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 ecad059f25..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,22 +142,31 @@ 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 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 +188,7 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { case `read`: return ( - - Content - + Content
             {r.text ? truncate(r.text, 2000) : `(empty)`}
           
@@ -144,53 +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} @@ -202,17 +218,13 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { default: return ( - - Input - + Input
             {JSON.stringify(args, null, 2)}
           
{r.text && ( <> - - Output - + Output
{r.text}
)} @@ -253,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) diff --git a/packages/agents-server-ui/src/components/UserMessage.module.css b/packages/agents-server-ui/src/components/UserMessage.module.css index 198a5bb9a5..c95c1cee96 100644 --- a/packages/agents-server-ui/src/components/UserMessage.module.css +++ b/packages/agents-server-ui/src/components/UserMessage.module.css @@ -14,8 +14,8 @@ user's "voice" turn. */ .bubble { background: var(--ds-input-bg); - border: 1px solid var(--ds-gray-a4); - border-radius: 12px; + border: 1px solid var(--ds-gray-a3); + border-radius: var(--ds-radius-5); } .body { 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..33eeffe42e --- /dev/null +++ b/packages/agents-server-ui/src/components/WorkingDirectoryPicker.tsx @@ -0,0 +1,228 @@ +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 = { + 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([defaultPath, ...recents]), + [recents, defaultPath] + ) + + const triggerLabel = useMemo( + () => (value ? tildifyPath(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 ( + + + + + {tildifyPath(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… + + + + )} + + + + ) +} 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..81f40a161c --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/SettingsScreen.module.css @@ -0,0 +1,165 @@ +/* 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; + gap: var(--ds-space-2); + height: 44px; + 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; +} + +.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..743071b984 --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/SettingsScreen.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from 'react' +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' + +/** + * 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 { + // 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} + +
+ +
+

{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..9220ccc50d --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/SettingsSidebar.module.css @@ -0,0 +1,167 @@ +/* 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; +} + +/* 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 + `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: var(--ds-radius-item); + 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..c96583b4af --- /dev/null +++ b/packages/agents-server-ui/src/components/settings/SettingsSidebar.tsx @@ -0,0 +1,146 @@ +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' +import { + loadDesktopState, + 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` + +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) + // 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 + 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 ( + <> + {narrow && ( +
setCollapsed(true)} + aria-hidden={collapsed ? `true` : undefined} + /> + )} + +
+ +
+ + + + {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/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 59f08ad047..9e260bbd30 100644 --- a/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.module.css +++ b/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.module.css @@ -5,12 +5,12 @@ } .header { - border-bottom: 1px solid var(--ds-gray-a5); + border-bottom: 1px solid var(--ds-border-1); flex-shrink: 0; } .headerLabel { - text-transform: uppercase; + font-weight: 500; } .trigger { @@ -23,17 +23,6 @@ overflow: hidden; } -.splitter { - height: 4px; - cursor: row-resize; - flex-shrink: 0; - background: var(--ds-gray-a5); -} - -.splitter:hover { - background: var(--ds-accent-a6); -} - .empty { padding: var(--ds-space-8) 0; } diff --git a/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.tsx b/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.tsx index 06c7c79f6b..e6b38d685c 100644 --- a/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.tsx +++ b/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.tsx @@ -6,6 +6,7 @@ import { } from '@durable-streams/state' import { stream as createStream } from '@durable-streams/client' import { Badge, Select, Stack, Text } from '../../ui' +import { Splitter } from '../workspace/Splitter' import { TypeList } from './TypeList' import { StateTable } from './StateTable' import { EventSidebar } from './EventSidebar' @@ -404,33 +405,17 @@ export function StateExplorerPanel({ /> -
{ - e.preventDefault() - const container = containerRef.current - if (!container) return - const startY = e.clientY - const startRatio = splitRatio - const rect = container.getBoundingClientRect() - const onMouseMove = (ev: MouseEvent) => { - const dy = ev.clientY - startY - const newRatio = Math.min( - 0.8, - Math.max(0.15, startRatio + dy / rect.height) - ) - setSplitRatio(newRatio) - } - const onMouseUp = () => { - document.removeEventListener(`mousemove`, onMouseMove) - document.removeEventListener(`mouseup`, onMouseUp) - document.body.style.cursor = `` - document.body.style.userSelect = `` - } - document.body.style.cursor = `row-resize` - document.body.style.userSelect = `none` - document.addEventListener(`mousemove`, onMouseMove) - document.addEventListener(`mouseup`, onMouseUp) + + containerRef.current?.getBoundingClientRect().height ?? 0 + } + onResize={(delta) => { + setSplitRatio(Math.min(0.8, Math.max(0.15, splitRatio + delta))) }} /> 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 081faf00ed..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 { @@ -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..723c2b28d9 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: var(--ds-shadow-1); } /* 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 ` ) } - -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/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/components/workspace/DropOverlay.module.css b/packages/agents-server-ui/src/components/workspace/DropOverlay.module.css new file mode 100644 index 0000000000..c344f18d8e --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/DropOverlay.module.css @@ -0,0 +1,70 @@ +/* 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); +} + +/* Four edge zones — geometry computed in JS via diagonals so each + * point in the tile maps to exactly one zone (no neutral middle). + * The visual rectangles are sized at 50% so the highlight matches + * the half of the tile the drop will actually occupy. Adjacent + * zones overlap in the corners (e.g. top-right is in both .north's + * top-half and .east's right-half) but only one zone is highlighted + * at any moment — the JS picks via diagonal — so the user always + * sees a single, half-tile preview of where the new split will land. + * + * Drops always create a new split; there is intentionally no centre + * zone. To swap the contents of an existing tile in place, switch + * the view from the tile menu or click the entity in the sidebar. */ +.north { + top: 0; + left: 0; + right: 0; + height: 50%; +} + +.east { + top: 0; + right: 0; + bottom: 0; + width: 50%; +} + +.south { + bottom: 0; + left: 0; + right: 0; + height: 50%; +} + +.west { + top: 0; + left: 0; + bottom: 0; + width: 50%; +} 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..3b2e5735ab --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/DropOverlay.tsx @@ -0,0 +1,164 @@ +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 4-edge drop target on top of a Tile. + * + * 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 4 edges the cursor is closest to and highlight + * that zone. On `drop` we either: + * + * - move an existing tile (`tile` payload) to that side of this tile, or + * - open a sidebar entity (`sidebar-entity` payload) as a new split. + * + * There is intentionally no centre zone: drops always create a new + * split. To swap the contents of an existing tile in place, switch the + * view from the tile menu or click the entity in the sidebar. + */ +export function DropOverlay({ + tileId, + containerRef, +}: { + tileId: 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 + // Pick the dominant edge by relative distance from the centre. + // Comparing |x-.5| vs |y-.5| splits the tile into four triangles + // joined at the centre point — there's no neutral middle, so any + // drop inside the tile falls into exactly one edge zone. + 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`) { + // Drop-on-self: no-op (the reducer also guards this, but a + // local check saves a dispatch + render). + if (payload.tileId === tileId) return + helpers.moveTile(payload.tileId, { tileId, position }) + } else if (payload.kind === `sidebar-new-session`) { + // Always create a *fresh* standalone tile — the click flow on + // the same button focuses an existing new-session tile, this + // drag flow is the user's explicit "give me another one" + // gesture. + helpers.openNewSession({ target: { tileId, position } }) + } else { + helpers.openEntity(payload.entityUrl, { + viewId: payload.viewId, + target: { tileId, position }, + }) + } + }, + [computeZone, helpers, tileId] + ) + + const cls = [styles.overlay, armed ? styles.armed : null] + .filter(Boolean) + .join(` `) + + return ( +
+ {([`north`, `east`, `south`, `west`] as const).map((z) => ( +
+ ))} +
+ ) +} + +type Zone = `north` | `east` | `south` | `west` + +const ZONE_TO_POSITION: Record = { + north: `split-up`, + east: `split-right`, + south: `split-down`, + west: `split-left`, +} 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..8d1d71287d --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/NodeRenderer.tsx @@ -0,0 +1,20 @@ +import { SplitContainer } from './SplitContainer' +import { TileContainer } from './TileContainer' +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/PaneFindBar.module.css b/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css new file mode 100644 index 0000000000..468da6c4b1 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/PaneFindBar.module.css @@ -0,0 +1,100 @@ +.bar { + position: absolute; + top: 44px; + right: var(--ds-space-3); + z-index: 25; + display: flex; + align-items: center; + gap: 6px; + 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: 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: 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 { + 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-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)) { + background: rgba(255, 225, 106, 0.42); +} + +:global(::highlight(electric-pane-find-current)) { + background: rgba(255, 159, 26, 0.72); + color: #111; +} 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..cb00fd1f3f --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/PaneFindBar.tsx @@ -0,0 +1,369 @@ +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' + +type Match = { node: Text; start: number; end: number } + +const MATCH_HIGHLIGHT_NAME = `electric-pane-find-match` +const CURRENT_HIGHLIGHT_NAME = `electric-pane-find-current` + +type HighlightRegistry = { + set: (name: string, highlight: unknown) => void + delete: (name: string) => void +} + +type HighlightConstructor = new (...ranges: Array) => unknown + +function getHighlightApi(): { + highlights: HighlightRegistry + Highlight: HighlightConstructor +} | null { + if (typeof window === `undefined`) return null + const css = window.CSS as unknown as { highlights?: HighlightRegistry } + const HighlightCtor = ( + window as unknown as { Highlight?: HighlightConstructor } + ).Highlight + if (!css.highlights || !HighlightCtor) return null + return { highlights: css.highlights, Highlight: HighlightCtor } +} + +export function supportsPaneFind(): boolean { + return getHighlightApi() !== null +} + +export function PaneFindBar({ + tileId, + rootRef, +}: { + tileId: string + rootRef: React.RefObject +}): React.ReactElement | null { + const { activeTileId, close, getAdapter } = usePaneFind() + const [query, setQuery] = useState(``) + const [index, setIndex] = useState(0) + const [count, setCount] = useState(0) + const [domVersion, setDomVersion] = useState(0) + const inputRef = useRef(null) + const navigationKeyRef = useRef(null) + const supported = supportsPaneFind() + 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, supported ? { open, next, previous } : null) + + useEffect(() => { + if (active) open() + }, [active, open]) + + useEffect(() => { + setIndex(0) + }, [query]) + + useEffect(() => { + const root = rootRef.current + if (!supported || !active || !query || !root) return + + let frame = 0 + const observer = new MutationObserver(() => { + if (frame !== 0) return + frame = requestAnimationFrame(() => { + frame = 0 + setDomVersion((v) => v + 1) + }) + }) + + observer.observe(root, { + childList: true, + subtree: true, + characterData: true, + }) + + return () => { + observer.disconnect() + if (frame !== 0) cancelAnimationFrame(frame) + } + }, [active, query, rootRef, supported]) + + useEffect(() => { + const root = rootRef.current + clearHighlights() + if (!supported || !active || !query || !root) { + setCount(0) + navigationKeyRef.current = null + return + } + + const navigationKey = `${tileId}\0${query}\0${index}` + const shouldReveal = navigationKeyRef.current !== navigationKey + navigationKeyRef.current = navigationKey + + const adapter = getAdapter(tileId) + if (adapter) { + let cancelled = false + const matches = adapter.search(query) + const nextCount = matches.length + setCount(nextCount) + const match = matches[Math.min(index, nextCount - 1)] + if (!match) return () => clearHighlights() + + const paint = () => { + if (cancelled) return + renderAdapterHighlights(adapter, matches, match, query, shouldReveal) + } + + if (shouldReveal) { + void Promise.resolve(adapter.reveal(match)).then(paint) + } else { + paint() + } + + return () => { + cancelled = true + clearHighlights() + } + } + + const matches = findMatches(root, query) + const nextCount = matches.length + setCount(nextCount) + renderRootHighlights( + root, + matches, + Math.min(index, nextCount - 1), + shouldReveal + ) + return () => clearHighlights() + }, [active, domVersion, getAdapter, index, query, rootRef, supported, tileId]) + + if (!supported || !active) return null + + return ( +
+
+ ) +} + +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]`)) { + 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 +} + +export function getTextMatchStarts(text: string, query: string): Array { + const needle = query.toLocaleLowerCase() + if (!needle) return [] + const haystack = text.toLocaleLowerCase() + const starts: Array = [] + let from = 0 + for (;;) { + const start = haystack.indexOf(needle, from) + if (start === -1) break + starts.push(start) + from = start + Math.max(query.length, 1) + } + return starts +} + +export function getCurrentMatchIndexInRoot( + root: HTMLElement, + query: string, + match: PaneFindMatch & { rowOccurrence?: number } +): number { + if (typeof match.rowOccurrence !== `number`) return 0 + const count = findMatches(root, query).length + if (count === 0) return 0 + return Math.min(match.rowOccurrence, count - 1) +} + +function clearHighlights(): void { + const api = getHighlightApi() + api?.highlights.delete(MATCH_HIGHLIGHT_NAME) + api?.highlights.delete(CURRENT_HIGHLIGHT_NAME) +} + +function createRange(match: Match): Range | null { + const range = document.createRange() + try { + range.setStart(match.node, match.start) + range.setEnd(match.node, match.end) + return range + } catch { + return null + } +} + +function renderRootHighlights( + root: HTMLElement, + matches: Array, + current: number, + scrollCurrent: boolean +): void { + const matchRanges: Array = [] + let currentRange: Range | null = null + + for (let i = 0; i < matches.length; i++) { + const range = createRange(matches[i]!) + if (!range) continue + if (i === current) { + currentRange = range + } else { + matchRanges.push(range) + } + } + + renderHighlightRanges(matchRanges, currentRange, root, scrollCurrent) +} + +function renderAdapterHighlights( + adapter: PaneFindAdapter, + matches: Array, + currentMatch: PaneFindMatch, + query: string, + scrollCurrent: boolean +): void { + const rootCurrentIndexes = new Map() + + for (const match of matches) { + const root = adapter.getHighlightRoot(match) + if (!root) continue + if (!rootCurrentIndexes.has(root)) rootCurrentIndexes.set(root, null) + if (match === currentMatch) { + rootCurrentIndexes.set( + root, + adapter.getCurrentMatchIndex?.(match, query) ?? 0 + ) + } + } + + const matchRanges: Array = [] + let currentRange: Range | null = null + let currentRoot: HTMLElement | null = null + + for (const [root, currentIndex] of rootCurrentIndexes) { + const rootMatches = findMatches(root, query) + for (let i = 0; i < rootMatches.length; i++) { + const range = createRange(rootMatches[i]!) + if (!range) continue + if (currentIndex !== null && i === currentIndex) { + currentRange = range + currentRoot = root + } else { + matchRanges.push(range) + } + } + } + + renderHighlightRanges(matchRanges, currentRange, currentRoot, scrollCurrent) +} + +function renderHighlightRanges( + matchRanges: Array, + currentRange: Range | null, + currentRoot: HTMLElement | null, + scrollCurrent: boolean +): void { + const api = getHighlightApi() + if (!api) return + + if (matchRanges.length > 0) { + api.highlights.set(MATCH_HIGHLIGHT_NAME, new api.Highlight(...matchRanges)) + } + if (currentRange) { + api.highlights.set(CURRENT_HIGHLIGHT_NAME, new api.Highlight(currentRange)) + if (scrollCurrent && currentRoot) { + scrollRangeIntoView(currentRoot, currentRange) + } + } +} + +function scrollRangeIntoView(root: HTMLElement, range: Range): void { + const rect = range.getBoundingClientRect() + if (rect.width > 0 || rect.height > 0) { + const rootRect = root.getBoundingClientRect() + if (rect.top >= rootRect.top && rect.bottom <= rootRect.bottom) return + } + range.startContainer.parentElement?.scrollIntoView({ + block: `center`, + inline: `nearest`, + }) +} 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/SplitMenu.module.css b/packages/agents-server-ui/src/components/workspace/SplitMenu.module.css new file mode 100644 index 0000000000..b30033251a --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/SplitMenu.module.css @@ -0,0 +1,95 @@ +.shortcut { + margin-left: auto; + margin-right: 5px; + color: var(--ds-gray-11); + font-size: var(--ds-font-size-1, 12px); + padding-left: 16px; +} + +/* Section label inside Menu.Content. Plain styled span (not Base UI's + * Menu.GroupLabel, which crashes when used outside a Menu.Group). */ +.sectionLabel { + padding: 6px 8px 2px; + font-size: var(--ds-font-size-1, 11px); + font-weight: 500; + color: var(--ds-gray-11); + user-select: none; +} + +/* ---- ViewRow ----------------------------------------------------- */ +/* Layout inside the Menu.Item: + * [icon] Label [✓?] (spacer) [→][↓] + * We rely on the parent Menu.Item's padding + hover styles, and just + * lay out our children with flex. The trailing buttons claim a small + * fixed width and stop click propagation so the row's onSelect (open + * here) doesn't fire when they're targeted. */ + +.viewRow { + display: flex; + align-items: center; + gap: 8px; +} + +.viewActiveTick { + margin-right: 5px; + color: var(--ds-gray-11); + font-size: var(--ds-font-size-1, 12px); +} + +.viewRowSpacer { + flex: 1 1 auto; + /* Min-width so there's always at least a small visual gap between + * the label/checkmark and the trailing action buttons even on a + * narrow popup. */ + min-width: 12px; +} + +.viewRowAction { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: transparent; + border: 0; + border-radius: 4px; + color: var(--ds-gray-11); + cursor: pointer; + padding: 0; +} + +/* The row uses `gap: 8px` to separate label/icon/checkmark, but the + * two trailing action buttons should sit right next to each other — + * counter the row gap with a negative margin so they end up touching + * (a single 22px button width worth of pairing, not gapped). */ +.viewRowAction + .viewRowAction { + margin-left: -8px; +} + +.viewRowAction:hover { + background: var(--ds-gray-a5); + color: var(--ds-gray-12); +} + +.viewRowAction:focus-visible { + outline: 2px solid var(--ds-accent-a8); + outline-offset: -1px; +} + +.inspectPre { + background: var(--ds-gray-a3); + padding: 16px; + border-radius: 8px; + overflow: auto; + font-size: 12px; + max-height: 400px; + font-family: var(--ds-font-mono); +} + +.dialogActions { + margin-top: var(--ds-space-3); +} + +.killActions { + margin-top: var(--ds-space-4); +} diff --git a/packages/agents-server-ui/src/components/workspace/SplitMenu.tsx b/packages/agents-server-ui/src/components/workspace/SplitMenu.tsx new file mode 100644 index 0000000000..9a84a67e4c --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/SplitMenu.tsx @@ -0,0 +1,393 @@ +import { useState } from 'react' +import { + Copy, + Eye, + GitFork, + Link2, + MoreHorizontal, + Pin, + PinOff, + SplitSquareHorizontal, + SplitSquareVertical, + Trash2, + X, +} from 'lucide-react' +import { useNavigate } from '@tanstack/react-router' +import { useWorkspace, listTiles } from '../../hooks/useWorkspace' +import { useElectricAgents } from '../../lib/ElectricAgentsProvider' +import { usePinnedEntities } from '../../hooks/usePinnedEntities' +import { listViews } from '../../lib/workspace/viewRegistry' +import type { EntityViewDefinition } 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' +import type { ElectricEntity } from '../../lib/ElectricAgentsProvider' +import type { Tile } from '../../lib/workspace/types' +import styles from './SplitMenu.module.css' + +/** + * Per-tile workspace menu. Shown in the tile header (the `…` button) + * and contains, in order: + * + * - **Inspect** open the entity JSON in a dialog. + * - **View** (label) section header for the inline view rows. + * - **{view rows}** one row per available view. The row's + * main label clicks "open this view here" + * (swap in place); the trailing two icon + * buttons split the tile to the side + * ([→]) or below ([↓]) with that view. + * - **Split right / down** duplicate the active tile into a new + * split (current view, right / down). + * - **Copy URL · Copy layout link · Pin · Fork** entity-level actions. + * - **Close tile** remove this tile (collapses parent split). + * - **Kill entity** (destructive) confirmation-gated. + * + * Replaces the previous "View ▸" / "Move tile to ▸" submenu design — + * Base UI's nested-menu interactions were brittle (clicking the parent + * row only opened the submenu, never the default action) and the user + * preferred direct, in-line controls. + */ +export function SplitMenu({ + tile, + entity, +}: { + tile: Tile + /** + * The live entity for this tile, or `null` for a standalone tile + * (new-session). When null, entity-specific items (Inspect, Pin, + * Fork, Copy URL, Kill) are hidden — only the layout-level items + * (split, close, copy layout link) remain. + */ + entity: ElectricEntity | null +}): React.ReactElement { + const { workspace, helpers } = useWorkspace() + const { forkEntity, killEntity } = useElectricAgents() + const { pinnedUrls, togglePin } = usePinnedEntities() + const navigate = useNavigate() + const hasEntity = entity !== null && tile.entityUrl !== null + const entityUrl = tile.entityUrl + const pinned = entityUrl !== null && pinnedUrls.includes(entityUrl) + // Hide "Close tile" when this is the only tile in the workspace — + // closing it would leave the workspace empty (which the URL ↔ + // workspace effect would immediately re-bootstrap), so the action + // is at best a no-op flicker and at worst confusing. + const isOnlyTile = listTiles(workspace.root).length <= 1 + const [menuOpen, setMenuOpen] = useState(false) + const [showInspect, setShowInspect] = useState(false) + const [showKillConfirm, setShowKillConfirm] = useState(false) + const instanceName = entity ? getEntityDisplayTitle(entity).title : `` + + const close = () => setMenuOpen(false) + /** Wraps a handler so it dispatches and then closes the menu. */ + const run = (fn: () => void) => () => { + fn() + close() + } + + // Entity tiles get the full per-entity view list; standalone tiles + // only ever have their own (standalone) view, which doesn't belong + // in the entity view-switcher — so the View section just stays + // hidden for them. + const availableViews = entity ? listViews(entity) : [] + + const handleFork = () => { + if (!forkEntity || entityUrl === null) return + void forkEntity(entityUrl) + .then((root) => + navigate({ + to: `/entity/$`, + params: { _splat: root.url.replace(/^\//, ``) }, + }) + ) + .catch(() => {}) + } + + const handleKill = () => { + if (!killEntity || entityUrl === null) return + const tx = killEntity(entityUrl) + 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()) + } + + // The menu and the dialogs are siblings — keeping them in the same + // portal subtree caused focus / unmount races (Base UI + // tears the menu popup down on close, and any dialog mounted inside + // that subtree got caught in the teardown). + return ( + <> + + + + + } + /> + + {hasEntity && ( + <> + setShowInspect(true)}> + + Inspect + + + + + )} + + {availableViews.length > 0 && ( + <> + + {availableViews.map((view) => ( + helpers.setTileView(tile.id, view.id))} + onSplitRight={run(() => + helpers.splitTileWithView(tile.id, view.id, `right`) + )} + onSplitDown={run(() => + helpers.splitTileWithView(tile.id, view.id, `down`) + )} + /> + ))} + + + + )} + + helpers.splitTile(tile.id, `right`)}> + + Split right + {modKeyLabel(`d`)} + + helpers.splitTile(tile.id, `down`)}> + + Split down + + {modKeyLabel({ letter: `d`, shift: true })} + + + + + + {hasEntity && entityUrl !== null && ( + <> + { + void navigator.clipboard.writeText(entityUrl) + }} + > + + Copy URL + + + )} + + + Copy layout link + + {hasEntity && entityUrl !== null && ( + togglePin(entityUrl)}> + {pinned ? : } + {pinned ? `Unpin` : `Pin`} + + )} + {hasEntity && entity && forkEntity && !entity.parent && ( + + + Fork subtree + + )} + + {!isOnlyTile && ( + <> + + helpers.closeTile(tile.id)}> + + Close tile + {modKeyLabel(`w`)} + + + )} + + {hasEntity && entity && entity.status !== `stopped` && killEntity && ( + <> + + setShowKillConfirm(true)} + tone="danger" + > + + Kill entity + + + )} + + + + {hasEntity && entity && ( + + + Entity details +
+              {JSON.stringify(entity, null, 2)}
+            
+ + + Close + + } + /> + +
+
+ )} + + {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 + + } + /> + + + + + )} + + ) +} + +/** + * 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, + onSplitRight, + onSplitDown, +}: { + view: EntityViewDefinition + isActive: boolean + onOpenHere: () => void + onSplitRight: () => void + onSplitDown: () => void +}): React.ReactElement { + 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 ( + + + {view.label} + {isActive && ( + + ✓ + + )} + + + + + ) +} 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..eef5432c9f --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/Splitter.module.css @@ -0,0 +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-border-1); + position: relative; +} + +.horizontal { + width: 1px; + cursor: col-resize; +} + +.vertical { + height: 1px; + cursor: row-resize; +} + +.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/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/TileContainer.module.css b/packages/agents-server-ui/src/components/workspace/TileContainer.module.css new file mode 100644 index 0000000000..e88e6ff4b4 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/TileContainer.module.css @@ -0,0 +1,36 @@ +.tile { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--ds-bg); + /* 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 { + 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 where they + * sit in the workspace. */ + --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/TileContainer.tsx b/packages/agents-server-ui/src/components/workspace/TileContainer.tsx new file mode 100644 index 0000000000..a8d523277d --- /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 { PaneFindBar } from './PaneFindBar' +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, + rootRef, +}: { + tile: Tile + entityUrl: string + rootRef: React.RefObject +}): 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, + rootRef, +}: { + tile: Tile + rootRef: React.RefObject +}): React.ReactElement { + const { activeServer } = useServerConnection() + const viewDef = getView(tile.viewId) + 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 ( + +
+ } /> +
+ + +
+ ) +} 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..f223f84872 --- /dev/null +++ b/packages/agents-server-ui/src/components/workspace/Workspace.tsx @@ -0,0 +1,214 @@ +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 { Text } from '../../ui' +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. + * + * 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() + const params = useParams({ strict: false }) + 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`. + // + // 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 + 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. + 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( + (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 ------------------------------------------------- + // 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. 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(() => { + // 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 = + requestedViewId && availableViews.some((v) => v.id === requestedViewId) + ? requestedViewId + : defaultViewId + const key = `${entityUrl}::${desiredViewId}` + if (lastSyncedKey.current === key) return + + 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.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, helpers]) + + // ---- Workspace → URL ------------------------------------------------- + // Whenever the active tile changes, mirror its (entityUrl, viewId) + // 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 === 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(/^\//, ``) }, + 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/fonts/OpenSauceOne-Black.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-Black.woff2 new file mode 100644 index 0000000000..0c0f44dd41 Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-Black.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-Bold.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-Bold.woff2 new file mode 100644 index 0000000000..c8da678c77 Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-Bold.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-BoldItalic.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-BoldItalic.woff2 new file mode 100644 index 0000000000..07ab96a0d5 Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-BoldItalic.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-ExtraBold.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-ExtraBold.woff2 new file mode 100644 index 0000000000..2371203a02 Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-ExtraBold.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-Italic.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-Italic.woff2 new file mode 100644 index 0000000000..b27abb35d4 Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-Italic.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-Light.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-Light.woff2 new file mode 100644 index 0000000000..b9263c7e59 Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-Light.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-LightItalic.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-LightItalic.woff2 new file mode 100644 index 0000000000..76c722007d Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-LightItalic.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-Medium.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-Medium.woff2 new file mode 100644 index 0000000000..7c79e27b3c Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-Medium.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-MediumItalic.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-MediumItalic.woff2 new file mode 100644 index 0000000000..5b7e21ea85 Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-MediumItalic.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/OpenSauceOne-Regular.woff2 b/packages/agents-server-ui/src/fonts/OpenSauceOne-Regular.woff2 new file mode 100644 index 0000000000..186c8e399e Binary files /dev/null and b/packages/agents-server-ui/src/fonts/OpenSauceOne-Regular.woff2 differ diff --git a/packages/agents-server-ui/src/fonts/SourceCodePro-Regular.woff2 b/packages/agents-server-ui/src/fonts/SourceCodePro-Regular.woff2 new file mode 100755 index 0000000000..18d2199ea4 Binary files /dev/null and b/packages/agents-server-ui/src/fonts/SourceCodePro-Regular.woff2 differ diff --git a/packages/agents-server-ui/src/hooks/useDocumentTitle.ts b/packages/agents-server-ui/src/hooks/useDocumentTitle.ts new file mode 100644 index 0000000000..06a67253d8 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useDocumentTitle.ts @@ -0,0 +1,74 @@ +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' +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 + * — Electric Agents". In the Electron desktop build the main process + * listens to `page-title-updated` on each window and uses the title + * to label that window in the Window menu — so changing this hook + * also changes how windows are named in the menu bar. + * + * Falls back to just the app name when there's no active entity (the + * empty workspace, the new-session tile, etc.) so the chrome stays + * clean. + */ +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) => { + if (!entitiesCollection || !activeEntityUrl) return undefined + return q + .from({ e: entitiesCollection }) + .where(({ e }) => eq(e.url, activeEntityUrl)) + }, + [entitiesCollection, activeEntityUrl] + ) + const entity = matches[0] + + useEffect(() => { + if (typeof document === `undefined`) return + if (settingsLabel) { + document.title = `${settingsLabel} — Settings — ${APP_NAME}` + return + } + if (!activeEntityUrl) { + document.title = APP_NAME + return + } + const sessionLabel = entity + ? getEntityDisplayTitle(entity).title + : activeEntityUrl.replace(/^\//, ``) + document.title = `${sessionLabel} — ${APP_NAME}` + }, [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/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/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/hooks/usePaneFind.tsx b/packages/agents-server-ui/src/hooks/usePaneFind.tsx new file mode 100644 index 0000000000..a9116671f4 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/usePaneFind.tsx @@ -0,0 +1,163 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +type PaneFindContextValue = { + activeTileId: string | null + openForTile: (tileId: string) => void + close: () => void + getAdapter: (tileId: string) => PaneFindAdapter | null +} + +const PaneFindContext = createContext(null) + +export function PaneFindProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactElement { + const [activeTileId, setActiveTileId] = useState(null) + + const getAdapter = useCallback( + (tileId: string) => adapterRegistry.get(tileId) ?? null, + [] + ) + + const value = useMemo( + () => ({ + activeTileId, + openForTile: setActiveTileId, + close: () => setActiveTileId(null), + getAdapter, + }), + [activeTileId, getAdapter] + ) + + 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 +} + +export type PaneFindMatch = { + id: string + label?: string + excerpt?: string + rowOccurrence?: number +} + +export type PaneFindAdapter = { + search: (query: string) => Array + reveal: (match: PaneFindMatch) => void | Promise + getHighlightRoot: (match: PaneFindMatch) => HTMLElement | null + getCurrentMatchIndex?: (match: PaneFindMatch, query: string) => number +} + +const registry = new Map() +const adapterRegistry = new Map() + +export function usePaneFindRegistration( + tileId: string, + api: PaneFindApi | null +): 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(() => { + if (!apiRef.current) { + registry.delete(tileId) + return + } + 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 usePaneFindAdapterRegistration( + tileId: string | null, + adapter: PaneFindAdapter | null +): void { + const adapterRef = useRef(adapter) + adapterRef.current = adapter + + useEffect(() => { + if (!tileId) return + if (!adapterRef.current) { + adapterRegistry.delete(tileId) + return + } + const proxy: PaneFindAdapter = { + search: (query) => adapterRef.current?.search(query) ?? [], + reveal: (match) => adapterRef.current?.reveal(match), + getHighlightRoot: (match) => + adapterRef.current?.getHighlightRoot(match) ?? null, + getCurrentMatchIndex: (match, query) => + adapterRef.current?.getCurrentMatchIndex?.(match, query) ?? 0, + } + adapterRegistry.set(tileId, proxy) + return () => { + if (adapterRegistry.get(tileId) === proxy) adapterRegistry.delete(tileId) + } + }, [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 + const api = registry.get(tileId) + if (!api) return + openForTile(tileId) + api.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/hooks/useProjects.tsx b/packages/agents-server-ui/src/hooks/useProjects.tsx deleted file mode 100644 index 611663bf78..0000000000 --- a/packages/agents-server-ui/src/hooks/useProjects.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { createContext, useCallback, useContext, useState } from 'react' -import { nanoid } from 'nanoid' -import type { ReactNode } from 'react' - -export interface Project { - id: string - name: string - createdAt: number -} - -interface ProjectsState { - projects: Array - activeProjectId: string | null - setActiveProjectId: (id: string | null) => void - createProject: (name: string) => Project - deleteProject: (id: string) => void - renameProject: (id: string, name: string) => void -} - -const ProjectsContext = createContext(null) - -const STORAGE_KEY = `electric-agents-projects` -const ACTIVE_PROJECT_KEY = `electric-agents-active-project` - -function loadProjects(): Array { - try { - return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? `[]`) - } catch { - return [] - } -} - -function persistProjects(projects: Array): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(projects)) - } catch { - // Ignore quota errors - } -} - -export function ProjectsProvider({ - children, -}: { - children: ReactNode -}): React.ReactElement { - const [projects, setProjects] = useState>(loadProjects) - const [activeProjectId, setActiveProjectIdRaw] = useState( - () => localStorage.getItem(ACTIVE_PROJECT_KEY) ?? null - ) - - const setActiveProjectId = useCallback((id: string | null) => { - setActiveProjectIdRaw(id) - try { - if (id) { - localStorage.setItem(ACTIVE_PROJECT_KEY, id) - } else { - localStorage.removeItem(ACTIVE_PROJECT_KEY) - } - } catch { - // Ignore - } - }, []) - - const createProject = useCallback((name: string): Project => { - const project: Project = { id: nanoid(8), name, createdAt: Date.now() } - setProjects((prev) => { - const next = [...prev, project] - persistProjects(next) - return next - }) - return project - }, []) - - const deleteProject = useCallback((id: string) => { - setProjects((prev) => { - const next = prev.filter((p) => p.id !== id) - persistProjects(next) - return next - }) - setActiveProjectIdRaw((prev) => (prev === id ? null : prev)) - }, []) - - const renameProject = useCallback((id: string, name: string) => { - setProjects((prev) => { - const next = prev.map((p) => (p.id === id ? { ...p, name } : p)) - persistProjects(next) - return next - }) - }, []) - - return ( - - {children} - - ) -} - -export function useProjects(): ProjectsState { - const ctx = useContext(ProjectsContext) - if (!ctx) throw new Error(`useProjects must be inside ProjectsProvider`) - return ctx -} 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/useServerConnection.tsx b/packages/agents-server-ui/src/hooks/useServerConnection.tsx index 684b2a4c8b..4dcd2ff7f0 100644 --- a/packages/agents-server-ui/src/hooks/useServerConnection.tsx +++ b/packages/agents-server-ui/src/hooks/useServerConnection.tsx @@ -5,7 +5,14 @@ import { useEffect, useState, } from 'react' -import { loadServers, saveServers } from '../lib/server-connection' +import { + loadDesktopState, + loadServers, + onDesktopStateChanged, + saveActiveServer, + saveServers, +} from '../lib/server-connection' +import { registerActiveBaseUrl } from '../lib/entity-connection' import type { ReactNode } from 'react' import type { ServerConfig } from '../lib/types' @@ -21,7 +28,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 +49,49 @@ 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(() => { + registerActiveBaseUrl(activeServer?.url ?? null) + }, [activeServer]) + useEffect(() => { if (!activeServer) { setConnected(false) @@ -86,13 +119,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 +139,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 +153,7 @@ export function ServerConnectionProvider({ servers, activeServer, connected, - setActiveServer: setActiveServerState, + setActiveServer, addServer, removeServer, }} 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..b3accd45a4 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useSidebarView.ts @@ -0,0 +1,165 @@ +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` | `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 { + 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/hooks/useWorkspace.tsx b/packages/agents-server-ui/src/hooks/useWorkspace.tsx new file mode 100644 index 0000000000..3edb11859e --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useWorkspace.tsx @@ -0,0 +1,212 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, +} from 'react' +import type { Dispatch, ReactNode } from 'react' +import { + workspaceReducer, + findTile, + listTiles, +} 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 + helpers: WorkspaceHelpers +} + +export type WorkspaceHelpers = { + /** Open `entityUrl` (with `viewId`) — defaults to replacing the active tile. */ + openEntity: ( + entityUrl: string, + options?: { viewId?: ViewId; target?: DropTarget } + ) => void + /** + * 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 + /** Mark a tile as the active tile (drives URL sync + ⌘W target). */ + setActiveTile: (tileId: string) => void + /** Swap a tile's view in place — preserves tile id (and per-tile state). */ + setTileView: (tileId: string, viewId: ViewId) => void + /** Split a tile and put a different view in the new tile. */ + splitTileWithView: ( + tileId: string, + viewId: ViewId, + direction: SplitDirection + ) => void + /** Convenience: split a tile, copying its current view into the new one. */ + splitTile: (tileId: string, direction: SplitDirection) => void + resizeSplit: (splitId: string, sizes: Array) => void + replaceWorkspace: (workspace: Workspace) => void + + // ---- Read-side conveniences (computed from the latest workspace). ---- + /** The active tile, or `null` for an empty workspace. */ + activeTile: Tile | null + activeTileId: 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 openNewSession = useCallback( + (options) => { + dispatch({ type: `open-new-session-tile`, 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 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 activeTile = + (workspace.activeTileId && + findTile(workspace.root, workspace.activeTileId)) || + listTiles(workspace.root)[0] || + null + return { + openEntity, + openNewSession, + closeTile, + moveTile, + setActiveTile, + setTileView, + splitTileWithView, + splitTile, + resizeSplit, + replaceWorkspace, + activeTile, + activeTileId: workspace.activeTileId, + } + }, [ + workspace, + openEntity, + openNewSession, + closeTile, + moveTile, + setActiveTile, + 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 +} + +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 new file mode 100644 index 0000000000..f696f0c9cc --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useWorkspaceHotkeys.ts @@ -0,0 +1,48 @@ +import { useHotkey } from './useHotkey' +import { useWorkspace, listTiles } from './useWorkspace' + +/** + * 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 + * - `⌘\` 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). + */ +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 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) + }) +} 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..bdb183d5ef --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useWorkspacePersistence.ts @@ -0,0 +1,194 @@ +import { useEffect, useRef } from 'react' +import { useWorkspace, listTiles } 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..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 + * 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 = 2 +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 + * splits cascade-collapse to `null` (or to their sole survivor when + * one child remains); the root collapses to `null` if every tile is + * dead. + * + * activeTileId is reset to whichever tile 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, activeTileId: null } + const tiles = listTiles(root) + const stillThere = + workspace.activeTileId !== null && + tiles.some((t) => t.id === workspace.activeTileId) + return { + root, + activeTileId: stillThere ? workspace.activeTileId : (tiles[0]?.id ?? null), + } +} + +function pruneNode( + node: WorkspaceNode | null, + liveUrls: Set +): WorkspaceNode | null { + if (!node) return null + 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) { + 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: number, 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/codeHighlighter.ts b/packages/agents-server-ui/src/lib/codeHighlighter.ts index 83d9c464d8..8f54f0f3c7 100644 --- a/packages/agents-server-ui/src/lib/codeHighlighter.ts +++ b/packages/agents-server-ui/src/lib/codeHighlighter.ts @@ -127,6 +127,15 @@ function doHighlight( const result = h.codeToTokens(code, { lang: lang as any, themes: { light: LIGHT_THEME, dark: DARK_THEME }, + // Emit BOTH themes as CSS variables (`--shiki-light` and + // `--shiki-dark`) instead of stamping the default theme's hex + // directly on the `color` property. Without this, Shiki's + // default (`defaultColor: 'light'`) means tokens come back with + // `color: '#xxxLight'` and only `--shiki-dark` set as a + // variable — which would leave our `var(--shiki-light, inherit)` + // CSS rule in `markdown.css` falling back to `inherit` (i.e. + // unhighlighted) in light mode. + defaultColor: false, }) const bg = typeof result.bg === `string` ? result.bg.split(`;`)[0] : undefined diff --git a/packages/agents-server-ui/src/lib/entity-connection.ts b/packages/agents-server-ui/src/lib/entity-connection.ts index ca9becce71..f83817c8f1 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.ts @@ -13,26 +13,180 @@ 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 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, signal } = 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, + signal, + }) + 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 } +} + +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 { + 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 + signal?: AbortSignal +}): Promise<{ db: EntityStreamDBWithActions; close: () => void }> { + 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/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/server-connection.ts b/packages/agents-server-ui/src/lib/server-connection.ts index 9cc722aaa5..c4044cf9e3 100644 --- a/packages/agents-server-ui/src/lib/server-connection.ts +++ b/packages/agents-server-ui/src/lib/server-connection.ts @@ -1,10 +1,111 @@ import type { ServerConfig } from './types' +export type DesktopRuntimeStatus = `stopped` | `starting` | `running` | `error` + +/** + * An agents-server detected by the Electron main-process scan of + * localhost (see `runDiscovery()` in `packages/agents-desktop/src/main.ts`). + * The renderer surfaces these as one-click "add to saved servers" + * suggestions in `ServerPicker`. + */ +export interface DiscoveredServer { + url: string + port: number + /** Epoch ms — when the main process last saw a healthy probe. */ + lastSeen: number +} + +export interface DesktopState { + runtimeStatus: DesktopRuntimeStatus + runtimeUrl: string | null + activeServer: ServerConfig | null + workingDirectory: string | null + error: string | null + discoveredServers: Array +} + +/** + * Provider API keys round-tripped between the Electron main process + * (where they're persisted in `settings.json` and mirrored into + * `process.env` for Horton) and the renderer's first-launch dialog. + * `null` means "not set". The renderer never reads these from + * `process.env` directly — that only exists in main. + * + * - `anthropic` / `openai`: at least one is required for the local + * Horton runtime to be useful; the dialog auto-opens until one is + * set. + * - `brave`: optional. Mirrored to `BRAVE_SEARCH_API_KEY` to enable + * Horton's `brave_search` tool; without it, web search falls back + * to Anthropic's built-in search. + */ +export interface ApiKeys { + anthropic: string | null + openai: string | null + brave: string | null +} + +export interface ApiKeysStatus { + /** `true` when at least one provider key is already saved. */ + hasAnyKey: boolean + saved: ApiKeys + /** + * Per-slot ENV-derived suggestions: a value is provided only for + * slots that are NOT already saved, so the dialog can pre-fill + * empty inputs from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` without + * overwriting the user's saved choice. + */ + suggested: ApiKeys +} + +/** + * Commands fired from the Electron application menu / tray over the + * `desktop:command` IPC channel. The renderer's command handler (see + * `RootShell` in `router.tsx`) maps each one to the same UI action a + * button or hotkey would trigger, so menu / button / keyboard channels + * all stay in lockstep. + */ +export type DesktopCommand = + | `new-chat` + | `close-tile` + | `toggle-sidebar` + | `open-search` + | `open-find` + | `find-next` + | `find-previous` + | `split-right` + | `split-down` + | `cycle-tile` + declare global { interface Window { electronAPI?: { getServers: () => Promise> saveServers: (servers: Array) => Promise + getDesktopState?: () => Promise + setActiveServer?: (server: ServerConfig | null) => Promise + restartRuntime?: () => Promise + stopRuntime?: () => Promise + rescanServers?: () => Promise> + getApiKeysStatus?: () => Promise + 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 + onDesktopCommand?: ( + callback: (command: DesktopCommand) => void + ) => () => void } } } @@ -33,3 +134,39 @@ 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 +} + +/** + * Trigger an immediate rescan of localhost ports for running + * agents-server instances. Returns the freshly-discovered set so + * callers can show inline feedback while waiting for the broadcast + * via `onDesktopStateChanged` to update React state. + */ +export async function rescanDiscoveredServers(): Promise< + Array +> { + return (await window.electronAPI?.rescanServers?.()) ?? [] +} + +export async function loadApiKeysStatus(): Promise { + return (await window.electronAPI?.getApiKeysStatus?.()) ?? null +} + +export async function saveApiKeys(keys: ApiKeys): Promise { + await window.electronAPI?.saveApiKeys?.(keys) +} diff --git a/packages/agents-server-ui/src/lib/sessionGroups.ts b/packages/agents-server-ui/src/lib/sessionGroups.ts index 71205baa26..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,6 +126,132 @@ class Group implements SessionGroup { constructor( public id: string, public key: BucketKey, - public label: string + public label: string, + public title?: 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()) +} + +/** + * 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). + * + * 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 +): Array { + const buckets = new Map() + const noDir = new Group(`cwd:none`, `older`, `None`) + + // 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) { + noDir.items.push(entity) + continue + } + let group = buckets.get(cwd) + if (!group) { + const label = abbreviatePath(tildifyPath(cwd, homeDir)) + group = new Group(`cwd:${cwd}`, `older`, label, 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 +} 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..149aac3efe --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/dragPayload.ts @@ -0,0 +1,112 @@ +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. + * + * 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 = + | { + kind: `sidebar-entity` + entityUrl: string + viewId?: ViewId + } + | { + kind: `sidebar-new-session` + } + | { + kind: `tile` + tileId: 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 === `sidebar-new-session`) { + return parsed + } + if (parsed.kind === `tile` && typeof parsed.tileId === `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 { + 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 new file mode 100644 index 0000000000..fef4e35ba6 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/layoutCodec.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest' +import { decodeLayout, encodeLayout } from './layoutCodec' +import type { Split, Tile, Workspace, WorkspaceNode } from './types' + +/** + * 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 === `tile`) { + return { + kind: `tile`, + entityUrl: node.entityUrl, + viewId: node.viewId, + } + } + 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: `tile`, + entityUrl: `/horton/foo`, + viewId: `chat`, + }) + 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: `tile`, + entityUrl: `/horton/foo`, + viewId: `chat`, + }, + size: 0.6, + }, + { + node: { + kind: `tile`, + entityUrl: `/horton/foo`, + viewId: `state-explorer`, + }, + 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: `tile`, + entityUrl: `/horton/foo`, + viewId: `chat`, + }, + size: 0.5, + }, + { + node: { + kind: `split`, + direction: `vertical`, + children: [ + { + node: { + kind: `tile`, + entityUrl: `/horton/bar`, + viewId: `chat`, + }, + size: 0.5, + }, + { + node: { + kind: `tile`, + entityUrl: `/horton/baz`, + viewId: `chat`, + }, + 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 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):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) + }) + + 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 new file mode 100644 index 0000000000..4eb997936d --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/layoutCodec.ts @@ -0,0 +1,279 @@ +import type { Split, Tile, Workspace, WorkspaceNode } from './types' +import { 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 := 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 +// tile := ? '.' viewId // entityPath is urlEncoded, +// with the conventional +// leading '/' stripped. +// Empty entityPath ('.viewId') +// encodes a standalone tile — +// e.g. the new-session tile. +// +// Examples (canonical forms produced by `encodeLayout`): +// horton%2Ffoo.chat +// .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)) +// +// 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 +// 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) : encodeTile(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 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 + // `/`, 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, activeTileId: null } } + const p = new Parser(input) + try { + const node = p.parseNode() + p.expectEnd() + const firstTile = findFirstTile(node) + return { + kind: `ok`, + workspace: { root: node, activeTileId: firstTile?.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.parseTile() + } + + 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: number, 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, + } + } + + 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`). + // + // 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++ + } + 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 rawPath = raw.slice(0, dot) + const viewId: ViewId = raw.slice(dot + 1) + if (!viewId) { + throw new ParseError(`empty viewId in tile spec '${raw}'`, start) + } + 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 { + 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 === `:` +} + +function describeChar(c: string): string { + return c.length === 0 ? `` : `'${c}'` +} + +function findFirstTile(node: WorkspaceNode): Tile | null { + if (node.kind === `tile`) return node + for (const c of node.children) { + 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 new file mode 100644 index 0000000000..e887aeb9bb --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/registerViews.ts @@ -0,0 +1,51 @@ +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 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, + description: `Conversation timeline and message composer`, + Component: ChatView, +}) + +registerView({ + kind: `entity`, + id: `state-explorer`, + label: `State Explorer`, + icon: Database, + description: `Inspect shared state and the event log`, + 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 new file mode 100644 index 0000000000..65728a6dd4 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/types.ts @@ -0,0 +1,106 @@ +import type { ViewId } from './viewRegistry' + +/** + * 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. + * + * `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 | null + viewId: ViewId +} + +/** + * 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 const NEW_SESSION_VIEW_ID = `new-session` + +/** + * 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 | Tile + +/** + * The full workspace state. `root === null` represents the empty + * 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 + activeTileId: string | null +} + +/** The empty workspace — the initial state on first load. */ +export const EMPTY_WORKSPACE: Workspace = { + root: null, + activeTileId: null, +} + +/** + * 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` + | `split-right` + | `split-down` + | `split-left` + | `split-up` + +export type DropTarget = { + /** The id of the tile being targeted. */ + tileId: 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/viewRegistry.tsx b/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx new file mode 100644 index 0000000000..c7203207f2 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/viewRegistry.tsx @@ -0,0 +1,119 @@ +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 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 EntityViewProps = { + baseUrl: string + entityUrl: string + entity: ElectricEntity + entityStopped: boolean + isSpawning: boolean + tileId: string +} + +/** + * 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 menu and on the inline view-strip. */ + label: string + icon: LucideIcon + shortLabel?: string + 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 + 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 { + registry.set(def.id, def) +} + +export function getView(id: ViewId): ViewDefinition | undefined { + return registry.get(id) +} + +/** + * 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 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` + ) +} + +/** + * 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/lib/workspace/workspaceReducer.test.ts b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts new file mode 100644 index 0000000000..19b2a67a8b --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it } from 'vitest' +import { findTile, listTiles, workspaceReducer } from './workspaceReducer' +import { EMPTY_WORKSPACE } from './types' +import type { Split, Tile, Workspace } from './types' + +function run( + initial: Workspace, + ...actions: Array[1]> +): Workspace { + 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 root tile`, () => { + const ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const tile = rootAsTile(ws) + expect(tile.entityUrl).toBe(`/horton/foo`) + expect(tile.viewId).toBe(`chat`) + expect(ws.activeTileId).toBe(tile.id) + }) + + 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 ws = workspaceReducer(after1, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + }) + 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 tile in a horizontal split`, () => { + const after1 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const fooId = rootAsTile(after1).id + const ws = workspaceReducer(after1, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { tileId: fooId, position: `split-right` }, + }) + 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) + 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`, () => { + const after1 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const fooId = rootAsTile(after1).id + const ws = workspaceReducer(after1, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { tileId: fooId, position: `split-up` }, + }) + const split = rootAsSplit(ws) + expect(split.direction).toBe(`vertical`) + const top = split.children[0].node as Tile + expect(top.entityUrl).toBe(`/horton/bar`) + }) + }) + + describe(`close-tile`, () => { + it(`closing the only tile empties the workspace`, () => { + const ws0 = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const tileId = rootAsTile(ws0).id + const ws = workspaceReducer(ws0, { type: `close-tile`, tileId }) + expect(ws.root).toBeNull() + expect(ws.activeTileId).toBeNull() + }) + + 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 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(`reassigns activeTileId when the active tile is closed`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const fooId = rootAsTile(ws).id + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + 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 to the other side of an existing tile`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + 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-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: 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(`drops on self are no-ops`, () => { + let ws = run(EMPTY_WORKSPACE, { + type: `open-tile`, + tile: { entityUrl: `/horton/foo`, viewId: `chat` }, + }) + const tileId = rootAsTile(ws).id + const before = ws + ws = workspaceReducer(ws, { + type: `move-tile`, + tileId, + target: { tileId, position: `split-right` }, + }) + expect(ws).toBe(before) + }) + }) + + 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 = rootAsTile(ws).id + ws = workspaceReducer(ws, { + type: `set-tile-view`, + tileId, + viewId: `state-explorer`, + }) + const tile = rootAsTile(ws) + // Same tile id (state preserved across view swap). + expect(tile.id).toBe(tileId) + expect(tile.viewId).toBe(`state-explorer`) + }) + }) + + 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 = rootAsTile(ws).id + ws = workspaceReducer(ws, { + type: `split-tile-with-view`, + tileId, + viewId: `state-explorer`, + direction: `right`, + }) + 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`, + }) + expect(states).toContainEqual({ + entityUrl: `/horton/foo`, + viewId: `state-explorer`, + }) + // Active follows the new tile. + const activeTile = findTile(ws.root, ws.activeTileId!) + expect(activeTile?.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 fooId = rootAsTile(ws).id + ws = workspaceReducer(ws, { + type: `open-tile`, + tile: { entityUrl: `/horton/bar`, viewId: `chat` }, + target: { tileId: fooId, position: `split-right` }, + }) + const split = rootAsSplit(ws) + ws = workspaceReducer(ws, { + type: `resize-split`, + splitId: split.id, + sizes: [3, 1], + }) + const next = rootAsSplit(ws) + expect(next.children[0].size).toBeCloseTo(0.75) + expect(next.children[1].size).toBeCloseTo(0.25) + }) + }) + + 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` }, + }) + 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/foo`, viewId: `chat` }, + }) + const fooId = rootAsTile(ws).id + ws = workspaceReducer(ws, { + type: `open-new-session-tile`, + target: { tileId: fooId, position: `split-right` }, + }) + 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: `open-new-session-tile`, + target: { tileId: firstId, position: `split-right` }, + }) + 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(`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` }, + }) + 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 new file mode 100644 index 0000000000..be03e616a9 --- /dev/null +++ b/packages/agents-server-ui/src/lib/workspace/workspaceReducer.ts @@ -0,0 +1,439 @@ +import { nanoid } from 'nanoid' +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 → 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. `activeTileId` always references a tile present in the tree, or +// `null` iff `root === null`. +// 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 | 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-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 `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-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 makeSplitId(): string { + return `spl_${nanoid(8)}` +} + +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 +} + +// --------------------------------------------------------------------------- +// Tree walkers (read-only). These traverse without copying so they're +// safe to call inside reducer cases for lookups. +// --------------------------------------------------------------------------- + +export function findTile( + node: WorkspaceNode | null, + tileId: string +): Tile | null { + if (node === null) return null + if (node.kind === `tile`) return node.id === tileId ? node : null + for (const child of node.children) { + const found = findTile(child.node, tileId) + if (found) return found + } + return null +} + +export function listTiles(node: WorkspaceNode | null): Array { + if (node === null) return [] + if (node.kind === `tile`) return [node] + return node.children.flatMap((c) => listTiles(c.node)) +} + +// --------------------------------------------------------------------------- +// Open tile +// --------------------------------------------------------------------------- + +function openTile( + state: Workspace, + tile: { entityUrl: string | null; viewId: ViewId }, + target?: DropTarget +): Workspace { + const newTile = makeTile(tile.entityUrl, tile.viewId) + + // Empty workspace → bootstrap with the new tile as the root. + if (state.root === null) { + return { root: newTile, activeTileId: newTile.id } + } + + // 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` + 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 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 applyToTile( + state: Workspace, + tileId: string, + fn: (tile: Tile) => WorkspaceNode | null +): Workspace { + if (state.root === null) return state + const replaced = replaceTileInTree(state.root, tileId, fn) + return finaliseWorkspace(state, replaced) +} + +function replaceTileInTree( + node: WorkspaceNode, + tileId: string, + fn: (tile: Tile) => WorkspaceNode | null +): WorkspaceNode | null { + 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 = replaceTileInTree(child.node, tileId, 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 }) +} + +/** + * 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 insertTileAt( + target: Tile, + incoming: Tile, + position: DropTarget[`position`] +): WorkspaceNode { + if (position === `replace`) return incoming + return wrapInSplit(target, incoming, 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 + // Sole tile → workspace becomes empty. + if (state.root.kind === `tile` && state.root.id === tileId) { + return { root: null, activeTileId: null } + } + return applyToTile(state, tileId, () => null) +} + +// --------------------------------------------------------------------------- +// Move tile (drag-and-drop primitive) +// --------------------------------------------------------------------------- + +function moveTile( + state: Workspace, + tileId: string, + target: DropTarget +): Workspace { + if (state.root === null) return state + if (tileId === target.tileId) return state // dropping on self + const tile = findTile(state.root, tileId) + if (!tile) return state + + // Detach the tile from its source first. + const detached = closeTile(state, tileId) + // 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) { + return { root: tile, activeTileId: tile.id } + } + return detached + } + return applyToTile(detached, target.tileId, (existing) => + insertTileAt(existing, tile, target.position) + ) +} + +// --------------------------------------------------------------------------- +// Set active tile / view +// --------------------------------------------------------------------------- + +function setActiveTile(state: Workspace, tileId: string): Workspace { + if (state.activeTileId === tileId) return state + if (!findTile(state.root, tileId)) return state + return { ...state, activeTileId: tileId } +} + +function setTileView( + state: Workspace, + tileId: string, + viewId: ViewId +): Workspace { + return applyToTile(state, tileId, (tile) => + tile.viewId === viewId ? tile : { ...tile, viewId } + ) +} + +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 newTile = makeTile(tile.entityUrl, viewId) + const next = applyToTile(state, tileId, (existing) => + wrapInSplit(existing, newTile, `split-${direction}`) + ) + // Focus follows split. + return { ...next, activeTileId: newTile.id } +} + +// --------------------------------------------------------------------------- +// 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 === `tile`) 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 + ) { + 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 `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, activeTileId: null } + } + const tiles = listTiles(newRoot) + const stillThere = + prev.activeTileId !== null && tiles.some((t) => t.id === prev.activeTileId) + return { + root: newRoot, + activeTileId: stillThere ? prev.activeTileId : tiles[0].id, + } +} 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/markdown.css b/packages/agents-server-ui/src/markdown.css index 616a100989..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: 12px; + 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: 4px; + margin-top: 18px; } .agent-ui-markdown h2 { font-size: var(--ds-text-base); /* 14px */ line-height: 1.4; font-weight: 600; - margin-top: 4px; + margin-top: 18px; } .agent-ui-markdown h3 { font-size: var(--ds-text-sm); /* 13px */ line-height: 1.45; font-weight: 600; - margin-top: 2px; + 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.5em; + 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: 4px; + 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,12 +164,39 @@ 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] { font-family: var(--ds-font-mono); font-size: 0.92em; - background: var(--ds-gray-a3); + /* Solid chip surface — matches the marketing site's `--vp-code-bg` + and the inline `` primitive. Avoids the muddy mid-grey + produced by an alpha-white tint over the dark chat surface. */ + background: var(--ds-chip-bg); padding: 0.1em 0.4em; border-radius: var(--ds-radius-1); color: var(--ds-text-1); @@ -270,7 +307,7 @@ display: none; } .agent-ui-markdown summary:hover { - background: var(--ds-gray-a3); + background: var(--ds-bg-hover); } /* Chevron — SVG mask of lucide's `chevron-right` so the colour @@ -337,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 @@ -348,7 +385,7 @@ display: flex; align-items: center; justify-content: space-between; - height: 18px; + height: 16px; padding: 0 2px; gap: 8px; } @@ -376,15 +413,17 @@ } /* The code area itself — the only part that carries chrome. - `--ds-gray-a2` reads as a faint surface tint over the chat - background; the 1px hairline + 6px corner radius mirror the - inline-code chip and the table wrapper so all three feel like - variants of the same surface. */ + `--ds-surface-raised` matches the marketing site's + `--vp-code-bg = --vp-c-bg-elv` so code wells sit at the same + elevation as the rest of the chrome (inputs, popovers). The + 1px hairline + 6px corner radius mirror the inline-code chip + and the table wrapper so all three feel like variants of the + same surface. */ .agent-ui-markdown [data-md-code-block-body] { - background: var(--ds-gray-a2); + 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; } @@ -398,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; @@ -415,10 +454,14 @@ } /* Shiki returns each token with an inline `style` containing both - `--shiki-light` and `--shiki-dark` CSS variables plus - `color: var(--shiki-light)`. The default already lights up - correctly in light mode; for dark mode swap to the dark variable - on every token span inside a code block body. */ + `--shiki-light` and `--shiki-dark` CSS variables. The inline + `color` it would normally also stamp on is stripped in + `MarkdownCodeBlock.tsx` (`tokenStyle`) — that lets these CSS + rules pick the right variable per theme without losing to + inline-style specificity. */ +.agent-ui-markdown [data-md-code-block-line] span[style*='--shiki'] { + color: var(--shiki-light, inherit); +} [data-theme='dark'] .agent-ui-markdown [data-md-code-block-line] @@ -443,11 +486,11 @@ * ----------------------------------------------------------------------- */ .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; - background: var(--ds-background); + border-radius: var(--ds-radius-3); + background: var(--ds-bg); overflow-x: auto; font-size: 1.05em; line-height: 1.4; @@ -468,7 +511,7 @@ .agent-ui-markdown [data-md-math-block][data-md-math-block-streaming] { padding: 8px 12px; font-size: 13px; - background: var(--ds-gray-a3); + background: var(--ds-chip-bg); } .agent-ui-markdown [data-md-math-block][data-md-math-block-streaming] pre { @@ -493,11 +536,11 @@ * ----------------------------------------------------------------------- */ .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; - background: var(--ds-background); + border-radius: var(--ds-radius-3); + background: var(--ds-bg); overflow-x: auto; display: flex; justify-content: center; @@ -516,7 +559,7 @@ .agent-ui-markdown [data-md-mermaid-block][data-md-mermaid-block-pending] { padding: 8px 12px; font-size: 13px; - background: var(--ds-gray-a3); + background: var(--ds-chip-bg); display: block; } @@ -533,14 +576,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); } @@ -608,7 +651,9 @@ } .agent-ui-markdown [data-streamdown='table-header'] { - background: var(--ds-gray-a2); + /* Subtle band that distinguishes header from body without + producing a muddy mid-grey wash on the dark chat surface. */ + background: var(--ds-bg-subtle); } .agent-ui-markdown [data-streamdown='table-header-cell'] { diff --git a/packages/agents-server-ui/src/router.module.css b/packages/agents-server-ui/src/router.module.css index 8d1ef695b3..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 { @@ -44,22 +49,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 97b694480d..cf5f52a436 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -1,45 +1,65 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect } from 'react' import { Outlet, createHashHistory, createRootRoute, createRoute, createRouter, + redirect, + useLocation, useNavigate, useParams, } from '@tanstack/react-router' -import { useLiveQuery } from '@tanstack/react-db' -import { eq } from '@tanstack/db' -import { useServerConnection } from './hooks/useServerConnection' +import { z } from 'zod' +import { getActiveBaseUrl, preloadEntityStream } from './lib/entity-connection' import { usePinnedEntities } from './hooks/usePinnedEntities' -import { useElectricAgents } from './lib/ElectricAgentsProvider' -import type { ElectricEntity } from './lib/ElectricAgentsProvider' -import { useEntityTimeline } from './hooks/useEntityTimeline' import { SidebarCollapsedProvider, useSidebarCollapsed, } from './hooks/useSidebarCollapsed' +import { useNarrowViewport } from './hooks/useNarrowViewport' import { useHotkey } from './hooks/useHotkey' import { SearchPaletteProvider, useSearchPalette, } from './hooks/useSearchPalette' +import { + WorkspaceProvider, + useWorkspace, + listTiles, +} from './hooks/useWorkspace' +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 { 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 { NewSessionPage } from './components/NewSessionPage' -import { Stack } from './ui' +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 ( - + + + + + ) @@ -50,27 +70,129 @@ function RootShell(): React.ReactElement { const navigate = useNavigate() 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 // we fall back to a combo that isn't claimed by the chrome). // The displayed shortcut hint switches per environment via // `NewSessionKey` / `newSessionLabel`. - const openNewSession = useCallback( - (e: KeyboardEvent) => { - e.preventDefault() - navigate({ to: `/` }) - }, - [navigate] - ) - useHotkey(`mod+n`, openNewSession) - useHotkey(`mod+shift+o`, openNewSession) + // + // 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(() => { + navigate({ to: `/` }) + }, [navigate]) + useHotkey(`mod+n`, (e) => { + e.preventDefault() + openNewSession() + }) + useHotkey(`mod+shift+o`, (e) => { + e.preventDefault() + openNewSession() + }) + + useWorkspaceHotkeys() + useWorkspacePersistence() + useDocumentTitle() + + // In Electron, the application menu and tray fire `desktop:command` + // IPC events that map 1:1 to the actions above. Subscribing here + // means menu items, on-screen buttons and keyboard shortcuts share + // the same code path — the menu is just another invocation channel. + useEffect(() => { + const off = window.electronAPI?.onDesktopCommand?.((command) => { + switch (command) { + case `new-chat`: + openNewSession() + break + case `toggle-sidebar`: + toggle() + break + 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) + break + } + case `split-right`: { + const id = helpers.activeTile?.id + if (id) helpers.splitTile(id, `right`) + break + } + case `split-down`: { + const id = helpers.activeTile?.id + if (id) helpers.splitTile(id, `down`) + break + } + case `cycle-tile`: { + const tiles = listTiles(workspace.root) + if (tiles.length < 2) break + const currentIdx = tiles.findIndex( + (t) => t.id === workspace.activeTileId + ) + const next = tiles[(currentIdx + 1) % tiles.length] + if (next) helpers.setActiveTile(next.id) + break + } + } + }) + return () => off?.() + }, [ + openNewSession, + toggle, + search, + helpers, + workspace, + openFindForTile, + findNextInTile, + findPreviousInTile, + ]) const navigateToEntity = useCallback( (entityUrl: string) => { @@ -82,210 +204,118 @@ function RootShell(): React.ReactElement { [navigate] ) + // ⌘/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 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: { tileId, position: `split-right` }, + }) + }, + [helpers, navigateToEntity] + ) + const params = useParams({ strict: false }) 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 + + // 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 (
- {!collapsed && ( - + {inSettings ? ( + + ) : ( + showWorkspaceSidebar && ( + + ) )} +
) } -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 { 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` - - const [stateExplorerOpen, setStateExplorerOpen] = useState(false) - const [statePanelWidth, setStatePanelWidth] = useState(0.5) - const containerRef = useRef(null) - 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 connectUrl = isSpawning ? null : entityUrl - - return ( - - togglePin(entityUrl)} - onKill={handleKill} - killError={killError} - onFork={forkEntity && !selectedEntity.parent ? handleFork : undefined} - forkError={forkError} - forking={forking} - stateExplorerOpen={stateExplorerOpen} - onToggleStateExplorer={() => setStateExplorerOpen((prev) => !prev)} - /> - - - - - {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) - }} - /> - - - - - )} - - - ) +/** + * 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 } -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]) +/** + * 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 + * `/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". + * + * 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 workspaceSearchSchema = z.object({ + view: z.string().optional(), + layout: z.string().optional(), +}) - return ( - <> - - } - /> - - ) +/** + * 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 + * (or whether the new-session tile is active), which lets per-tile + * state (scroll, selection, etc.) survive navigation between tiles. + */ +function WorkspacePage(): React.ReactElement { + return } const rootRoute = createRootRoute({ component: RootLayout }) @@ -293,20 +323,78 @@ 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, + loader: async ({ abortController, params }): Promise => { + const baseUrl = getActiveBaseUrl() + if (!baseUrl) return null + await preloadEntityStream({ + baseUrl, + entityUrl: `/${params._splat}`, + signal: abortController.signal, + }) + return null + }, + component: WorkspacePage, + 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, history: createHashHistory(), + defaultPreload: `intent`, }) // eslint-disable-next-line quotes 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; diff --git a/packages/agents-server-ui/src/ui/Button.module.css b/packages/agents-server-ui/src/ui/Button.module.css index 93de82876a..a531902d76 100644 --- a/packages/agents-server-ui/src/ui/Button.module.css +++ b/packages/agents-server-ui/src/ui/Button.module.css @@ -1,7 +1,12 @@ .button { --btn-bg: transparent; --btn-fg: var(--ds-text-1); - --btn-bg-hover: var(--ds-gray-a3); + /* Universal interactive hover — clean cool-grey lift. Shared with + drop-down items, sidebar rows and chip triggers so every + pointer-targeted control hovers to the same colour regardless + of which surface it sits on. Replaces the `--ds-gray-a3` alpha + tint that produced a muddy mid-grey wash on the dark page bg. */ + --btn-bg-hover: var(--ds-bg-hover); --btn-bg-active: var(--ds-gray-a4); --btn-border: transparent; @@ -80,9 +85,13 @@ --btn-solid-bg: var(--ds-gray-12); --btn-solid-fg: var(--ds-gray-1); --btn-solid-bg-hover: var(--ds-gray-11); - --btn-soft-bg: var(--ds-gray-a3); + /* Solid neutral chip surface for soft-variant neutral buttons — + matches inline pills, code chips and dropdown triggers so the + whole neutral-chrome family reads as one consistent step above + the page bg, instead of a muddy alpha tint. */ + --btn-soft-bg: var(--ds-chip-bg); --btn-soft-fg: var(--ds-text-1); - --btn-soft-bg-hover: var(--ds-gray-a4); + --btn-soft-bg-hover: var(--ds-bg-hover); } .tone-accent { --btn-solid-bg: var(--ds-accent-9); @@ -122,10 +131,16 @@ background: transparent; color: var(--btn-soft-fg); } +/* Ghost hover uses the universal `--btn-bg-hover` lift (clean cool + grey) rather than the per-tone soft fill, so icon buttons hover + to the SAME colour as drop-down items and sidebar rows regardless + of tone or which surface they sit on. The icon/label colour + already conveys the tone (accent, danger), so the bg doesn't + need to repeat it. */ .variant-ghost:hover:not(:disabled), .variant-ghost[data-popup-open]:not(:disabled), .variant-ghost[data-pressed]:not(:disabled) { - background: var(--btn-soft-bg); + background: var(--btn-bg-hover); } .variant-outline { diff --git a/packages/agents-server-ui/src/ui/Code.module.css b/packages/agents-server-ui/src/ui/Code.module.css index d73ac7c6fe..1cfbdaeb4e 100644 --- a/packages/agents-server-ui/src/ui/Code.module.css +++ b/packages/agents-server-ui/src/ui/Code.module.css @@ -18,7 +18,10 @@ } .variant-soft { - background: var(--ds-gray-a3); + /* Inline code chip — same elevation as the marketing site's + `--vp-code-bg`. Solid surface so the chip reads as crisp chrome + on whatever surface it's embedded in. */ + background: var(--ds-chip-bg); padding: 0.1em 0.4em; border-radius: var(--ds-radius-2); } 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..37b967c8e4 --- /dev/null +++ b/packages/agents-server-ui/src/ui/Combobox.module.css @@ -0,0 +1,126 @@ +/* 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; + min-height: 30px; + padding: 3px 3px 3px 12px; + border-radius: var(--ds-radius-item); + 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; + margin-right: 5px; + 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/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 b20c6fc5e6..0b55fe3674 100644 --- a/packages/agents-server-ui/src/ui/Menu.module.css +++ b/packages/agents-server-ui/src/ui/Menu.module.css @@ -14,15 +14,24 @@ item highlight and the inner button highlight share a center point and look like one nested rounded rectangle. */ .item { + --ds-menu-item-fg: color-mix( + in oklab, + var(--ds-text-1) 86%, + var(--ds-text-2) + ); + --ds-text-color: var(--ds-menu-item-fg); + display: flex; align-items: center; 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); - color: var(--ds-text-1); + color: var(--ds-menu-item-fg); + font-weight: 400; cursor: pointer; outline: none; user-select: none; @@ -34,8 +43,12 @@ box-sizing: border-box; } +.item svg { + stroke-width: 1.75; +} + .item[data-highlighted] { - background: var(--ds-gray-a3); + background: var(--ds-bg-hover); } .item[data-disabled] { @@ -44,6 +57,7 @@ } .item[data-tone='danger'] { + --ds-text-color: var(--ds-red-11); color: var(--ds-red-11); } .item[data-tone='danger'][data-highlighted] { @@ -52,7 +66,7 @@ .separator { height: 1px; - background: var(--ds-divider); + background: color-mix(in oklab, var(--ds-gray-12) 10%, transparent); margin: 3px -3px; } @@ -60,7 +74,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; } diff --git a/packages/agents-server-ui/src/ui/Select.module.css b/packages/agents-server-ui/src/ui/Select.module.css index c53bf6a9fa..449f477eb5 100644 --- a/packages/agents-server-ui/src/ui/Select.module.css +++ b/packages/agents-server-ui/src/ui/Select.module.css @@ -50,7 +50,10 @@ line-height: 1; font-family: var(--ds-font-body); color: var(--ds-text-2); - background: var(--ds-gray-a3); + /* Solid chip surface (matches inline pills, project picker pill, + ``, code chips). Avoids the muddy mid-grey produced by an + alpha-white tint over the dark page bg. */ + background: var(--ds-chip-bg); border: none; border-radius: var(--ds-radius-2); cursor: pointer; @@ -61,7 +64,7 @@ } .triggerPill:hover:not(:disabled) { - background: var(--ds-gray-a4); + background: var(--ds-bg-hover); color: var(--ds-text-1); } @@ -98,11 +101,13 @@ padding can stay symmetric (8px), but the radius is bumped to 7px so the highlight radius matches the rest of the system. */ .item { + position: relative; display: flex; align-items: center; gap: 6px; - padding: 3px 8px; - border-radius: 7px; + min-height: 30px; + padding: 3px 28px 3px 8px; + 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); @@ -110,15 +115,23 @@ cursor: pointer; outline: none; user-select: none; + box-sizing: border-box; } .item[data-highlighted] { - background: var(--ds-gray-a3); -} -.item[data-selected] { - font-weight: 500; + background: var(--ds-bg-hover); } .item[data-disabled] { opacity: 0.5; cursor: not-allowed; } + +.indicator { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + color: var(--ds-text-2); +} diff --git a/packages/agents-server-ui/src/ui/Select.tsx b/packages/agents-server-ui/src/ui/Select.tsx index f9fa849979..8a1bdc2830 100644 --- a/packages/agents-server-ui/src/ui/Select.tsx +++ b/packages/agents-server-ui/src/ui/Select.tsx @@ -1,5 +1,5 @@ import { Select as BaseSelect } from '@base-ui/react/select' -import { ChevronDown } from 'lucide-react' +import { Check, ChevronDown } from 'lucide-react' import type { CSSProperties, ReactNode } from 'react' import popoverStyles from './Popover.module.css' import styles from './Select.module.css' @@ -135,6 +135,9 @@ function Item({ 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/fonts.css b/packages/agents-server-ui/src/ui/fonts.css new file mode 100644 index 0000000000..77615d8c2d --- /dev/null +++ b/packages/agents-server-ui/src/ui/fonts.css @@ -0,0 +1,95 @@ +/* ========================================================================== + * Font assets + * + * Self-hosted copies of the brand fonts used across the site: + * - OpenSauceOne (body / heading) + * - SourceCodePro (mono) + * + * The .woff2 files live next to this stylesheet under `../fonts/` and + * are bundled by Vite via the relative URL imports below. Mirrors the + * @font-face declarations in `website/.vitepress/theme/custom.css`. + * ========================================================================== */ + +@font-face { + font-family: OpenSauceOne; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url('../fonts/OpenSauceOne-Black.woff2') format('woff2'); +} + +@font-face { + font-family: OpenSauceOne; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../fonts/OpenSauceOne-ExtraBold.woff2') format('woff2'); +} + +@font-face { + font-family: OpenSauceOne; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('../fonts/OpenSauceOne-Bold.woff2') format('woff2'); +} +@font-face { + font-family: OpenSauceOne; + font-style: italic; + font-weight: 600; + font-display: swap; + src: url('../fonts/OpenSauceOne-BoldItalic.woff2') format('woff2'); +} + +@font-face { + font-family: OpenSauceOne; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('../fonts/OpenSauceOne-Medium.woff2') format('woff2'); +} +@font-face { + font-family: OpenSauceOne; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('../fonts/OpenSauceOne-MediumItalic.woff2') format('woff2'); +} + +@font-face { + font-family: OpenSauceOne; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../fonts/OpenSauceOne-Regular.woff2') format('woff2'); +} +@font-face { + font-family: OpenSauceOne; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('../fonts/OpenSauceOne-Italic.woff2') format('woff2'); +} + +@font-face { + font-family: OpenSauceOne; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url('../fonts/OpenSauceOne-Light.woff2') format('woff2'); +} +@font-face { + font-family: OpenSauceOne; + font-style: italic; + font-weight: 300; + font-display: swap; + src: url('../fonts/OpenSauceOne-LightItalic.woff2') format('woff2'); +} + +@font-face { + font-family: SourceCodePro; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../fonts/SourceCodePro-Regular.woff2') format('woff2'); +} diff --git a/packages/agents-server-ui/src/ui/index.ts b/packages/agents-server-ui/src/ui/index.ts index 7e0c0fa0d2..acd0bd8b85 100644 --- a/packages/agents-server-ui/src/ui/index.ts +++ b/packages/agents-server-ui/src/ui/index.ts @@ -3,6 +3,7 @@ // Token / global stylesheets are imported as side effects so any consumer // of `./ui` gets the styles automatically. Components are re-exported. +import './fonts.css' import './tokens.css' import './global.css' @@ -54,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-server-ui/src/ui/tokens.css b/packages/agents-server-ui/src/ui/tokens.css index 7689e332db..c7a2fe27fb 100644 --- a/packages/agents-server-ui/src/ui/tokens.css +++ b/packages/agents-server-ui/src/ui/tokens.css @@ -5,6 +5,14 @@ * Light values default; dark overrides apply when the document root has * `data-theme="dark"`. The in `src/ui/ThemeProvider.tsx` * is responsible for setting that attribute. + * + * Palette + typography are kept in lock-step with the marketing site's + * `website/.vitepress/theme/custom.css`: + * - Surfaces → --vp-c-bg / --vp-c-bg-soft / --ec-surface-1 / --vp-c-bg-elv + * - Greys (dark) → --vp-c-gray-1..3, --vp-c-divider, --vp-c-gutter + * - Text → --vp-c-text-1..3 (warm translucent in dark, solid ink in light) + * - Brand / accent→ navy `#1a1a2e` in light, accent teal `#75fbfd` in dark + * - Body font → OpenSauceOne, Mono → SourceCodePro * ========================================================================== */ :root, @@ -27,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) --------------------------- * @@ -34,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; @@ -49,9 +59,12 @@ --ds-text-3xl: 28px; --ds-text-3xl-lh: 1.25; - /* ---- Font families ------------------------------------------------- */ + /* ---- Font families ------------------------------------------------- * + * Matches `--vp-font-family-base` / `--vp-font-family-mono` on the + * marketing site. OpenSauceOne + SourceCodePro are bundled locally + * via `./fonts.css`. */ --ds-font-body: - Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + OpenSauceOne, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --ds-font-heading: var(--ds-font-body); --ds-font-mono: @@ -68,19 +81,48 @@ 0 16px 40px rgba(15, 15, 30, 0.14), 0 4px 12px rgba(15, 15, 30, 0.08); /* ---- Surfaces ------------------------------------------------------ * - * `bg` is the page; `surface` sits on top (cards, popovers); `input-bg` - * is the dedicated background for editable controls (text inputs, - * textareas, native selects, composer wrappers). In light mode this - * is solid white so inputs read clearly against the off-white page; - * in dark mode it stays a translucent grey so inputs blend with - * raised surfaces (panels, popovers) wherever they're used. */ + * Mirrors the website's warm-stone ladder: + * --vp-c-bg #f7f7f5 → --ds-bg + * --vp-c-bg-soft #f0efed → --ds-bg-subtle (also --ec-surface-1) + * --ec-surface-2 #e8e7e3 → --ds-surface + * --vp-c-bg-elv #ffffff → --ds-surface-raised (cards / popovers) + * Inputs always sit on the raised-surface fill so editable controls + * read as elevated above the page. In light mode that's solid white; + * in dark mode it's the website's `--vp-c-bg-elv` `#22252f`. */ --ds-bg: #f7f7f5; --ds-bg-subtle: #f0efed; --ds-surface: #ffffff; --ds-surface-raised: #ffffff; - --ds-input-bg: #ffffff; + --ds-input-bg: var(--ds-surface-raised); --ds-overlay: rgba(15, 15, 30, 0.45); + /* ---- Chip / chrome surfaces ----------------------------------- * + * Shared semantic for the small bordered/filled boxes that scatter + * across the UI: pill triggers, inline code chips, `` keys, + * "input pre" panes, etc. Mirrors the marketing site's + * `--vp-code-bg = --vp-c-bg-elv` pattern — chips always sit at + * the raised-surface elevation so they read as one consistent + * chrome family regardless of which surface they live on. */ + --ds-chip-bg: var(--ds-surface-raised); + --ds-chip-border: var(--ds-border-1); + + /* Universal "interactive hover" surface — used by drop-down items, + * sidebar rows, search results, chip triggers, ghost icon buttons + * and any other pointer-targeted row that needs a hover lift. + * + * Per-theme strategy: + * - Light: `--ds-gray-a3` (≈10% black tint). Alpha-black composes + * cleanly on a warm-white page bg, producing a visible warm + * grey lift on every surface; light mode never had the muddy + * compositing problem dark mode does. + * - Dark (override below): a SOLID cool-grey one step above + * `--ds-surface-raised`, so the hover never mixes with the + * deep navy page bg and produces a muddy mid-grey wash. + * + * Asymmetric on purpose — each mode picks the technique that gives + * the cleanest result for its underlying palette. */ + --ds-bg-hover: var(--ds-gray-a3); + /* ---- Gray scale (slate-flavoured neutral) -------------------------- * * Solid steps roughly mirror Radix's slate scale; alpha steps are * generated via color-mix to keep the file compact and tweakable. */ @@ -110,17 +152,26 @@ --ds-gray-a11: color-mix(in oklab, var(--ds-gray-12) 70%, transparent); --ds-gray-a12: color-mix(in oklab, var(--ds-gray-12) 95%, transparent); - /* ---- Text -------------------------------------------------------- */ - --ds-text-1: var(--ds-gray-12); - --ds-text-2: var(--ds-gray-11); - --ds-text-3: var(--ds-gray-10); - --ds-text-on-accent: #1a1a1a; - - /* ---- Borders / dividers ---------------------------------------- */ + /* ---- Text -------------------------------------------------------- * + * Light values come straight from the marketing site: + * --vp-c-text-1 #1a1a2e → --ds-text-1 (deep ink, doubles as brand) + * --vp-c-text-2 #5c5c6e → --ds-text-2 + * --vp-c-text-3 #999999 → --ds-text-3 + * --ds-text-4 is a faint label tone with no direct site equivalent. */ + --ds-text-1: #1a1a2e; + --ds-text-2: #5c5c6e; + --ds-text-3: #999999; + --ds-text-4: rgba(26, 26, 46, 0.4); + --ds-text-on-accent: #ffffff; + + /* ---- Borders / dividers ---------------------------------------- * + * `--ds-divider` mirrors the website's `--vp-c-divider: #e4e3e0` — + * a solid warm-stone hairline so dividers read consistently + * regardless of what surface they sit on. */ --ds-border-1: var(--ds-gray-a4); --ds-border-2: var(--ds-gray-a5); --ds-border-3: var(--ds-gray-a6); - --ds-divider: var(--ds-gray-a4); + --ds-divider: #e4e3e0; --ds-focus-ring: color-mix(in oklab, var(--ds-accent-9) 60%, transparent); /* Hairline used by floating surfaces (popovers, dropdowns, modals, @@ -128,62 +179,59 @@ 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); - - /* ---- Accent (teal, derived from a single base) --------------------- * - * Generated via color-mix against the page background so the lower - * steps stay legible on light backgrounds. Mirrors the formula that - * was previously inlined in the .radix-themes override block. */ - --ds-accent-base: #56e8ea; - --ds-accent-hover: #3cd5d8; - --ds-accent-active: #27bfc2; - --ds-accent-text: #0b6f78; - - --ds-accent-1: color-mix(in oklab, var(--ds-accent-base) 8%, var(--ds-bg)); - --ds-accent-2: color-mix(in oklab, var(--ds-accent-base) 13%, var(--ds-bg)); - --ds-accent-3: color-mix(in oklab, var(--ds-accent-base) 20%, var(--ds-bg)); - --ds-accent-4: color-mix(in oklab, var(--ds-accent-base) 28%, var(--ds-bg)); - --ds-accent-5: color-mix(in oklab, var(--ds-accent-base) 38%, var(--ds-bg)); - --ds-accent-6: color-mix(in oklab, var(--ds-accent-base) 50%, var(--ds-bg)); - --ds-accent-7: color-mix(in oklab, var(--ds-accent-base) 64%, var(--ds-bg)); - --ds-accent-8: color-mix(in oklab, var(--ds-accent-base) 78%, var(--ds-bg)); + 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 + * ink (`--vp-c-brand-1: #1a1a2e`); accent teal is reserved for dark. + * The accent ramp is generated via color-mix against the page + * background so the lower steps stay legible on the warm-stone bg. */ + --ds-accent-base: #1a1a2e; + --ds-accent-hover: #3a3a56; + --ds-accent-active: #0f0f1e; + --ds-accent-text: #1a1a2e; + + --ds-accent-1: color-mix(in oklab, var(--ds-accent-base) 4%, var(--ds-bg)); + --ds-accent-2: color-mix(in oklab, var(--ds-accent-base) 8%, var(--ds-bg)); + --ds-accent-3: color-mix(in oklab, var(--ds-accent-base) 14%, var(--ds-bg)); + --ds-accent-4: color-mix(in oklab, var(--ds-accent-base) 22%, var(--ds-bg)); + --ds-accent-5: color-mix(in oklab, var(--ds-accent-base) 32%, var(--ds-bg)); + --ds-accent-6: color-mix(in oklab, var(--ds-accent-base) 44%, var(--ds-bg)); + --ds-accent-7: color-mix(in oklab, var(--ds-accent-base) 58%, var(--ds-bg)); + --ds-accent-8: color-mix(in oklab, var(--ds-accent-base) 74%, var(--ds-bg)); --ds-accent-9: var(--ds-accent-base); --ds-accent-10: var(--ds-accent-hover); --ds-accent-11: var(--ds-accent-text); - --ds-accent-12: #1a1a2e; - - --ds-accent-a1: color-mix(in oklab, var(--ds-accent-base) 8%, transparent); - --ds-accent-a2: color-mix(in oklab, var(--ds-accent-base) 13%, transparent); - --ds-accent-a3: color-mix(in oklab, var(--ds-accent-base) 20%, transparent); - --ds-accent-a4: color-mix(in oklab, var(--ds-accent-base) 28%, transparent); - --ds-accent-a5: color-mix(in oklab, var(--ds-accent-base) 38%, transparent); - --ds-accent-a6: color-mix(in oklab, var(--ds-accent-base) 50%, transparent); - --ds-accent-a7: color-mix(in oklab, var(--ds-accent-base) 64%, transparent); - --ds-accent-a8: color-mix(in oklab, var(--ds-accent-base) 78%, transparent); + --ds-accent-12: #0f0f1e; + + --ds-accent-a1: color-mix(in oklab, var(--ds-accent-base) 4%, transparent); + --ds-accent-a2: color-mix(in oklab, var(--ds-accent-base) 8%, transparent); + --ds-accent-a3: color-mix(in oklab, var(--ds-accent-base) 14%, transparent); + --ds-accent-a4: color-mix(in oklab, var(--ds-accent-base) 22%, transparent); + --ds-accent-a5: color-mix(in oklab, var(--ds-accent-base) 32%, transparent); + --ds-accent-a6: color-mix(in oklab, var(--ds-accent-base) 44%, transparent); + --ds-accent-a7: color-mix(in oklab, var(--ds-accent-base) 58%, transparent); + --ds-accent-a8: color-mix(in oklab, var(--ds-accent-base) 74%, transparent); --ds-accent-a9: color-mix(in oklab, var(--ds-accent-base) 88%, transparent); --ds-accent-a10: color-mix(in oklab, var(--ds-accent-hover) 88%, transparent); --ds-accent-a11: var(--ds-accent-text); --ds-accent-a12: var(--ds-accent-12); - /* ---- Status colours (badges, timeline rows, errors) ---------------- */ + /* ---- Status colours (badges, timeline rows, errors) ---------------- * + * Hue base values match the website's `--ea-event-*` family — message + * (blue), tool-call (amber), tool-result (green), error (red). */ --ds-red-base: #dc2626; --ds-red-9: var(--ds-red-base); --ds-red-11: #b91c1c; @@ -244,6 +292,17 @@ /* ========================================================================== * Dark theme — triggered by `data-theme="dark"` on . + * + * Anchored to the marketing site's `.dark` block: + * --vp-c-bg #111318 → --ds-bg + * --vp-c-bg-soft #16181f → --ds-bg-subtle + * --ec-surface-1 #1a1d27 → --ds-surface + * --vp-c-bg-elv #22252f → --ds-surface-raised + * --vp-c-divider #2a2d38 → --ds-divider + * --vp-c-gray-1..3 #3a3f52/#2d3142/#22252f → solid steps in --ds-gray-* + * --vp-c-gutter #0a0b0e → --ds-gray-1 + * --vp-c-text-1..3 warm translucent whites → --ds-text-1..3 + * --vp-c-brand-1 #75fbfd (accent teal) → --ds-accent-base * ========================================================================== */ :root[data-theme='dark'] { @@ -251,44 +310,71 @@ --ds-bg-subtle: #16181f; --ds-surface: #1a1d27; --ds-surface-raised: #22252f; - --ds-input-bg: color-mix(in oklab, #ffffff 6%, transparent); + /* Solid raised fill — same value the marketing site uses for its + navbar search button, code blocks, demo cards etc. (`var(--vp-c-bg-elv)`). + Inherited via `--ds-surface-raised` so the two stay in lockstep. */ + --ds-input-bg: var(--ds-surface-raised); + /* Universal hover lift — `#2d3142` (= --vp-c-gray-2 on the + marketing site) is one clean cool-grey step above + `--ds-surface-raised` (#22252f). Used by every drop-down item, + sidebar row, search-result row and chip trigger so they all + hover to the SAME clean cool-grey, regardless of what surface + they sit on. Replaces the `--ds-gray-a*` alpha tint pattern + which produced a muddy mid-grey wash whenever the alpha mixed + with the dark page bg. */ + --ds-bg-hover: #2d3142; --ds-overlay: rgba(0, 0, 0, 0.55); - --ds-gray-1: #111113; - --ds-gray-2: #19191b; - --ds-gray-3: #222325; - --ds-gray-4: #292a2e; - --ds-gray-5: #303136; - --ds-gray-6: #393a40; - --ds-gray-7: #46484f; - --ds-gray-8: #5f606a; - --ds-gray-9: #6c6e79; - --ds-gray-10: #797b86; - --ds-gray-11: #b2b3bd; - --ds-gray-12: #eeeef0; - - --ds-gray-a1: color-mix(in oklab, var(--ds-gray-12) 4%, transparent); - --ds-gray-a2: color-mix(in oklab, var(--ds-gray-12) 7%, transparent); - --ds-gray-a3: color-mix(in oklab, var(--ds-gray-12) 11%, transparent); - --ds-gray-a4: color-mix(in oklab, var(--ds-gray-12) 15%, transparent); - --ds-gray-a5: color-mix(in oklab, var(--ds-gray-12) 19%, transparent); - --ds-gray-a6: color-mix(in oklab, var(--ds-gray-12) 24%, transparent); - --ds-gray-a7: color-mix(in oklab, var(--ds-gray-12) 31%, transparent); - --ds-gray-a8: color-mix(in oklab, var(--ds-gray-12) 42%, transparent); - --ds-gray-a9: color-mix(in oklab, var(--ds-gray-12) 52%, transparent); - --ds-gray-a10: color-mix(in oklab, var(--ds-gray-12) 60%, transparent); - --ds-gray-a11: color-mix(in oklab, var(--ds-gray-12) 78%, transparent); - --ds-gray-a12: color-mix(in oklab, var(--ds-gray-12) 96%, transparent); + /* Cool blue-black ramp — built around the website's three anchor + overrides so any token that picks up a solid grey lands on the + same cool ink-blue family used across the marketing site. The + gutter (#0a0b0e) and bg (#111318) take the bottom of the ramp, + vp-c-gray-3..1 (22252f / 2d3142 / 3a3f52) sit in the mid-band, + and the top of the ramp interpolates up to a warm-tinted white + so text/foreground tokens read as the website's translucent + warm whites do (without going through the alpha layer). */ + --ds-gray-1: #0a0b0e; + --ds-gray-2: #111318; + --ds-gray-3: #16181f; + --ds-gray-4: #1a1d27; + --ds-gray-5: #22252f; + --ds-gray-6: #2a2d38; + --ds-gray-7: #2d3142; + --ds-gray-8: #3a3f52; + --ds-gray-9: #545a6e; + --ds-gray-10: #767c90; + --ds-gray-11: #b8bcc6; + --ds-gray-12: #ededee; + + /* Alpha tints derive from pure white — same base the marketing site + uses for `--vp-c-gray-soft: rgba(255, 255, 255, 0.05)`, which in + turn feeds button alt backgrounds, hover states and dividers + across every dark-mode surface. Keeping the base neutral (rather + than warming it toward the text hue) means hover backgrounds + don't pick up an off yellow cast over the navy page bg. */ + --ds-gray-a1: color-mix(in oklab, #ffffff 4%, transparent); + --ds-gray-a2: color-mix(in oklab, #ffffff 7%, transparent); + --ds-gray-a3: color-mix(in oklab, #ffffff 11%, transparent); + --ds-gray-a4: color-mix(in oklab, #ffffff 15%, transparent); + --ds-gray-a5: color-mix(in oklab, #ffffff 19%, transparent); + --ds-gray-a6: color-mix(in oklab, #ffffff 24%, transparent); + --ds-gray-a7: color-mix(in oklab, #ffffff 31%, transparent); + --ds-gray-a8: color-mix(in oklab, #ffffff 42%, transparent); + --ds-gray-a9: color-mix(in oklab, #ffffff 52%, transparent); + --ds-gray-a10: color-mix(in oklab, #ffffff 60%, transparent); + --ds-gray-a11: color-mix(in oklab, #ffffff 78%, transparent); + --ds-gray-a12: color-mix(in oklab, #ffffff 96%, transparent); --ds-text-1: rgba(255, 255, 245, 0.92); - --ds-text-2: rgba(235, 235, 245, 0.78); - --ds-text-3: rgba(235, 235, 245, 0.62); + --ds-text-2: rgba(235, 235, 245, 0.8); + --ds-text-3: rgba(235, 235, 245, 0.68); + --ds-text-4: rgba(235, 235, 245, 0.5); --ds-text-on-accent: #1a1a1a; --ds-border-1: var(--ds-gray-a3); --ds-border-2: var(--ds-gray-a5); --ds-border-3: var(--ds-gray-a7); - --ds-divider: var(--ds-gray-a4); + --ds-divider: #2a2d38; --ds-focus-ring: color-mix(in oklab, var(--ds-accent-9) 60%, transparent); /* Dark-mode overlay border = 12% of `--ds-gray-12` (which is the @@ -301,22 +387,64 @@ elevation. */ --ds-overlay-border: color-mix(in oklab, var(--ds-gray-12) 12%, transparent); - /* In dark mode the brand teal becomes the accent base. */ + /* In dark mode the brand accent teal takes over (matches + `--vp-c-brand-1: #75fbfd` on the marketing site). */ --ds-accent-base: #75fbfd; --ds-accent-hover: #56e8ea; --ds-accent-active: #3cd5d8; --ds-accent-text: #75fbfd; --ds-accent-12: #b8fdfe; + /* Status hue overrides — pull the website's lighter dark-mode + `--ea-event-*` variants so badges / status dots glow rather + than read muddy against the deep navy bg. */ + --ds-red-base: #f87171; + --ds-red-9: var(--ds-red-base); + --ds-red-11: #fca5a5; + --ds-red-a2: color-mix(in oklab, var(--ds-red-base) 14%, transparent); + --ds-red-a3: color-mix(in oklab, var(--ds-red-base) 22%, transparent); + --ds-red-a5: color-mix(in oklab, var(--ds-red-base) 36%, transparent); + --ds-red-a9: color-mix(in oklab, var(--ds-red-base) 88%, transparent); + + --ds-green-base: #34d399; + --ds-green-9: var(--ds-green-base); + --ds-green-11: #6ee7b7; + --ds-green-a2: color-mix(in oklab, var(--ds-green-base) 14%, transparent); + --ds-green-a3: color-mix(in oklab, var(--ds-green-base) 22%, transparent); + --ds-green-a5: color-mix(in oklab, var(--ds-green-base) 36%, transparent); + --ds-green-a9: color-mix(in oklab, var(--ds-green-base) 88%, transparent); + + --ds-amber-base: #fbbf24; + --ds-amber-9: var(--ds-amber-base); + --ds-amber-11: #fcd34d; + --ds-amber-a2: color-mix(in oklab, var(--ds-amber-base) 14%, transparent); + --ds-amber-a3: color-mix(in oklab, var(--ds-amber-base) 22%, transparent); + --ds-amber-a5: color-mix(in oklab, var(--ds-amber-base) 36%, transparent); + --ds-amber-a9: color-mix(in oklab, var(--ds-amber-base) 88%, transparent); + + --ds-blue-base: #60a5fa; + --ds-blue-9: var(--ds-blue-base); + --ds-blue-11: #93c5fd; + --ds-blue-a2: color-mix(in oklab, var(--ds-blue-base) 14%, transparent); + --ds-blue-a3: color-mix(in oklab, var(--ds-blue-base) 22%, transparent); + --ds-blue-a5: color-mix(in oklab, var(--ds-blue-base) 36%, transparent); + --ds-blue-a9: color-mix(in oklab, var(--ds-blue-base) 88%, transparent); + + --ds-yellow-base: #facc15; + --ds-yellow-9: var(--ds-yellow-base); + --ds-yellow-11: #fde047; + --ds-yellow-a2: color-mix(in oklab, var(--ds-yellow-base) 14%, transparent); + --ds-yellow-a3: color-mix(in oklab, var(--ds-yellow-base) 22%, transparent); + --ds-yellow-a5: color-mix(in oklab, var(--ds-yellow-base) 36%, transparent); + --ds-yellow-a9: color-mix(in oklab, var(--ds-yellow-base) 88%, transparent); + --ds-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); --ds-shadow-2: 0 2px 4px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4); --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); } diff --git a/packages/agents-server-ui/vite.config.ts b/packages/agents-server-ui/vite.config.ts index b7f2390ea4..8cdbb94a3f 100644 --- a/packages/agents-server-ui/vite.config.ts +++ b/packages/agents-server-ui/vite.config.ts @@ -1,11 +1,43 @@ -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(({ 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 ? (desktopServe ? `/` : `./`) : `/__agent_ui/`, + plugins: [react(), ...(desktop ? [desktopHtmlMarker()] : [])], + build: { + outDir: desktop ? `dist-desktop` : `dist`, + emptyOutDir: true, + }, + } }) diff --git a/packages/agents-server/.gitignore b/packages/agents-server/.gitignore index 2eea525d88..63e3897dce 100644 --- a/packages/agents-server/.gitignore +++ b/packages/agents-server/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +.streams-data/ \ No newline at end of file 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, + }) + }) }) diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 0cabc5bc5d..4527b0ea0a 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -1,4 +1,8 @@ +import fs from 'node:fs' +import path from 'node:path' import Anthropic from '@anthropic-ai/sdk' +import { completeSimple, getModel } from '@mariozechner/pi-ai' +import { eq, not, queryOnce } from '@durable-streams/state' import { z } from 'zod' import { serverLog } from '../log' import { createHortonDocsSupport } from '../docs/knowledge-base' @@ -8,7 +12,9 @@ import { modelChoiceValues, REASONING_EFFORT_VALUES, resolveBuiltinModelConfig, + type BuiltinAgentModelConfig, type BuiltinModelCatalog, + type BuiltinModelChoice, } from '../model-catalog' import type { AgentTool, StreamFn } from '@mariozechner/pi-agent-core' import type { @@ -24,7 +30,7 @@ import { braveSearchTool, fetchUrlTool, } from '@electric-ax/agents-runtime/tools' -import type { ChangeEvent } from '@durable-streams/state' +import type { MessageReceived } from '@electric-ax/agents-runtime' import type { SkillsRegistry } from '../skills/types' const TITLE_MODEL = `claude-haiku-4-5-20251001` @@ -39,24 +45,25 @@ function getClient(): Anthropic { return anthropic } -async function defaultHaikuCall(prompt: string): Promise { +const TITLE_SYSTEM_PROMPT = + `You generate concise chat session titles in 3-5 words. ` + + `Respond with only the title, no quotes, no punctuation, no preamble.` + +const TITLE_USER_PROMPT = (userMessage: string): string => + `User request:\n${userMessage}` + +async function defaultHaikuCall(userPrompt: string): Promise { const client = getClient() const res = await client.messages.create({ model: TITLE_MODEL, max_tokens: 64, - messages: [{ role: `user`, content: prompt }], + system: TITLE_SYSTEM_PROMPT, + messages: [{ role: `user`, content: userPrompt }], }) const block = res.content[0] return block?.type === `text` ? block.text : `` } -const TITLE_PROMPT = (userMessage: string): string => - `Summarize the following user request in 3-5 words for use as a chat session title. -Respond with only the title, no quotes, no punctuation, no preamble. - -User request: -${userMessage}` - const TITLE_STOP_WORDS = new Set([ `a`, `an`, @@ -139,15 +146,104 @@ function buildFallbackTitle(userMessage: string): string { return selected.join(` `).slice(0, 80).trim() || `Untitled Chat` } +function selectTitleModelChoice( + catalog: BuiltinModelCatalog, + modelConfig: BuiltinAgentModelConfig +): BuiltinModelChoice { + const configuredProvider = modelConfig.provider ?? `anthropic` + const preferredIdsByProvider: Record> = { + anthropic: [`claude-3-5-haiku-latest`, `claude-3-5-haiku-20241022`], + openai: [`gpt-4.1-nano`, `gpt-4o-mini`, `gpt-4.1-mini`], + 'openai-codex': [`gpt-5.4-mini`, `gpt-5.1-codex-mini`], + } + + for (const provider of [configuredProvider, `openai`, `anthropic`]) { + for (const id of preferredIdsByProvider[provider] ?? []) { + const choice = catalog.choices.find( + (candidate) => candidate.provider === provider && candidate.id === id + ) + if (choice) return choice + } + + const nonReasoningChoice = catalog.choices.find( + (candidate) => + candidate.provider === provider && candidate.reasoning === false + ) + if (nonReasoningChoice) return nonReasoningChoice + } + + return ( + catalog.choices.find( + (candidate) => + candidate.provider === configuredProvider && + candidate.id === String(modelConfig.model) + ) ?? catalog.defaultChoice + ) +} + +function createConfiguredTitleCall( + catalog: BuiltinModelCatalog, + modelConfig: BuiltinAgentModelConfig, + logPrefix: string +): (prompt: string) => Promise { + const choice = selectTitleModelChoice(catalog, modelConfig) + + return async (prompt: string) => { + const model = getModel( + choice.provider, + choice.id as Parameters[1] + ) + if (!model) { + throw new Error( + `unknown title model "${choice.id}" for provider "${choice.provider}"` + ) + } + + serverLog.info( + `${logPrefix} title generation using ${choice.provider}:${choice.id}` + ) + + const apiKey = + choice.provider === modelConfig.provider && modelConfig.getApiKey + ? await modelConfig.getApiKey(choice.provider) + : undefined + const res = await completeSimple( + model, + { + systemPrompt: TITLE_SYSTEM_PROMPT, + messages: [{ role: `user`, content: prompt, timestamp: Date.now() }], + }, + { + maxTokens: choice.reasoning ? 1024 : 64, + ...(choice.reasoning && { reasoning: `low` as const }), + ...(apiKey && { apiKey }), + } + ) + const text = res.content.find((block) => block.type === `text`)?.text + if (!text || text.trim().length === 0) { + const contentTypes = + res.content.map((block) => block.type).join(`,`) || `none` + throw new Error( + `empty LLM title response from ${choice.provider}:${choice.id} stopReason=${res.stopReason} errorMessage=${res.errorMessage ?? `none`} contentTypes=${contentTypes}` + ) + } + return text + } +} + export async function generateTitle( userMessage: string, - llmCall: (prompt: string) => Promise = defaultHaikuCall + llmCall: (prompt: string) => Promise = defaultHaikuCall, + onFallback?: (reason: string) => void ): Promise { try { - const raw = await llmCall(TITLE_PROMPT(userMessage)) + const raw = await llmCall(TITLE_USER_PROMPT(userMessage)) const title = raw.trim() - return title.length > 0 ? title : buildFallbackTitle(userMessage) - } catch { + if (title.length > 0) return title + onFallback?.(`empty LLM title response`) + return buildFallbackTitle(userMessage) + } catch (err) { + onFallback?.(err instanceof Error ? err.message : String(err)) return buildFallbackTitle(userMessage) } } @@ -169,7 +265,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 +305,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 +322,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} @@ -284,24 +380,51 @@ export function createHortonTools( ] } -export function extractFirstUserMessage( - events: Array -): string | null { - for (const event of events) { - if (event.type !== `message_received`) continue - const value = event.value as - | { from?: string; payload?: unknown } - | undefined - if (!value || value.from === `system`) continue - const payload = value.payload - if (typeof payload === `string`) return payload - if (payload != null) return JSON.stringify(payload) +function payloadToTitleText(payload: unknown): string { + if (typeof payload === `string`) return payload + if (payload == null) return `` + if (typeof payload === `object`) { + const text = (payload as Record).text + return typeof text === `string` ? text : JSON.stringify(payload) } - return null + return String(payload) +} + +export async function extractFirstUserMessage( + ctx: HandlerContext +): Promise { + const firstMessage = await queryOnce((q) => + q + .from({ inbox: ctx.db.collections.inbox }) + .where(({ inbox }) => not(eq(inbox.from, `system`))) + .orderBy(({ inbox }) => inbox._seq, `asc`) + .findOne() + ) + + if (!firstMessage) return null + const text = payloadToTitleText((firstMessage as MessageReceived).payload) + return text.length > 0 ? text : null } 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 @@ -327,10 +450,19 @@ 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 agentsMd = readAgentsMd(effectiveCwd) const tools = [ ...ctx.electricTools, - ...createHortonTools(workingDirectory, ctx, readSet, { + ...createHortonTools(effectiveCwd, ctx, readSet, { docsSearchTool, modelConfig, }), @@ -339,6 +471,46 @@ function createAssistantHandler(options: { : []), ] + const titlePromise = + ctx.firstWake && !ctx.tags.title + ? (async () => { + const firstUserMessage = await extractFirstUserMessage(ctx) + if (!firstUserMessage) return + + let title: string | null = null + try { + const result = await generateTitle( + firstUserMessage, + createConfiguredTitleCall( + modelCatalog, + modelConfig, + `[horton ${ctx.entityUrl}]` + ), + (reason) => { + serverLog.warn( + `[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}` + ) + } + ) + if (result.length > 0) title = result + } catch (err) { + serverLog.warn( + `[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + + if (title !== null) { + try { + await ctx.setTag(`title`, title) + } catch (err) { + serverLog.warn( + `[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + })() + : Promise.resolve() + if (docsSupport) { ctx.useContext({ sourceBudget: 100_000, @@ -362,6 +534,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: { @@ -386,12 +567,36 @@ 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`, + }, }, }) } ctx.useAgent({ - systemPrompt: buildHortonSystemPrompt(workingDirectory, { + systemPrompt: buildHortonSystemPrompt(effectiveCwd, { hasDocsSupport: Boolean(docsSupport), hasSkills, docsUrl, @@ -403,30 +608,7 @@ function createAssistantHandler(options: { ...(streamFn && { streamFn }), }) await ctx.agent.run() - - if (ctx.firstWake && !ctx.tags.title) { - const firstUserMessage = extractFirstUserMessage(ctx.events) - if (firstUserMessage) { - let title: string | null = null - try { - const result = await generateTitle(firstUserMessage) - if (result.length > 0) title = result - } catch (err) { - serverLog.warn( - `[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}` - ) - } - if (title !== null) { - try { - await ctx.setTag(`title`, title) - } catch (err) { - serverLog.warn( - `[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}` - ) - } - } - } - } + await titlePromise } } @@ -485,6 +667,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`, { 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 diff --git a/packages/agents/test/generate-title.test.ts b/packages/agents/test/generate-title.test.ts index 2b91bae792..fd894c325e 100644 --- a/packages/agents/test/generate-title.test.ts +++ b/packages/agents/test/generate-title.test.ts @@ -11,8 +11,7 @@ describe(`generateTitle`, () => { expect(result).toBe(`Refactor auth middleware`) expect(llmCall).toHaveBeenCalledTimes(1) const prompt = llmCall.mock.calls[0][0] - expect(prompt).toContain(`Help me refactor the auth middleware`) - expect(prompt).toMatch(/3-5 words/i) + expect(prompt).toBe(`User request:\nHelp me refactor the auth middleware`) }) it(`falls back to a local title if the llm returns an empty response`, async () => { 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"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae2a2dcd4a..03dd6709dc 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,58 @@ 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 + '@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 + version: 22.19.17 + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + electron: + specifier: ^41.5.0 + version: 41.5.0 + 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: '@anthropic-ai/sdk': @@ -1602,6 +1682,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) @@ -2299,13 +2382,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) @@ -2422,8 +2505,9 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@antfu/ni@0.23.2': - resolution: {integrity: sha512-FSEVWXvwroExDXUu8qV6Wqp2X3D1nJ0Li4LFymCyvCVrm7I3lNfG0zZWSWvGU1RE7891eTnFTyh31L3igOwNKQ==} + '@antfu/ni@30.1.0': + resolution: {integrity: sha512-3VuAbPjgY52rQNn4wABaXMhBU2Oq91uy6L8nX49eJ35OLI68CyckGU+HZxcaHix4ymuGM2nFL1D6sLpgODK5xw==} + engines: {node: '>=20.19.0'} hasBin: true '@anthropic-ai/sdk@0.73.0': @@ -4160,6 +4244,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==} @@ -5463,6 +5551,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==} @@ -7714,8 +7822,8 @@ packages: '@types/react-dom': optional: true - '@react-grab/cli@0.1.32': - resolution: {integrity: sha512-TI4SHATLH2yM1DMRXgH3dt/8b3Rj51BplDOqOQiHQKAMOuKVAR9WE2WGWJRT3LwFpl8BXR9ytAM9vrGDrB7QGw==} + '@react-grab/cli@0.1.33': + resolution: {integrity: sha512-UOc3PwN11Osw0NzaxRLK8trP4X+5iW1Dst3gvHRCafe3wXHyadzHYH8H1hdkcXdlIx3gsoD9ASJ+G/JH+A/jqA==} hasBin: true '@react-native/assets-registry@0.80.1': @@ -8282,6 +8390,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==} @@ -8642,6 +8754,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: @@ -9279,6 +9395,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==} @@ -9417,6 +9536,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==} @@ -9444,6 +9566,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==} @@ -9489,6 +9614,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==} @@ -9539,6 +9667,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==} @@ -9596,6 +9727,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} @@ -10481,6 +10615,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==} @@ -10674,6 +10811,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==} @@ -10715,6 +10856,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==} @@ -10755,6 +10899,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'} @@ -10949,6 +11101,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@5.1.0: resolution: {integrity: sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==} engines: {node: '>=20'} @@ -10971,6 +11127,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'} @@ -11047,6 +11206,10 @@ packages: resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} engines: {node: '>=20'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -11609,6 +11772,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'} @@ -11671,6 +11838,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==} @@ -11689,6 +11859,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'} @@ -11983,6 +12157,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==} @@ -12038,6 +12217,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} @@ -12069,10 +12252,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'} @@ -12116,6 +12295,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==} @@ -12700,6 +12882,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==} @@ -12761,6 +12948,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'} @@ -12843,6 +13033,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==} @@ -12864,6 +13063,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'} @@ -12944,6 +13147,9 @@ packages: funtypes@4.2.0: resolution: {integrity: sha512-DvOtjiKvkeuXGV0O8LQh9quUP3bSOTEQPGv537Sao8kDq2rDbg48UsSJ7wlBLPzR2Mn0pV7cyAiq5pYG1oUyCQ==} + fzf@0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + gaxios@7.1.4: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} @@ -12973,10 +13179,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'} @@ -13003,6 +13205,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'} @@ -13045,6 +13251,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'} @@ -13085,13 +13295,14 @@ 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'} + 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==} @@ -13142,18 +13353,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'} @@ -13290,6 +13493,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'} @@ -13305,6 +13511,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'} @@ -13703,10 +13913,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -13858,6 +14064,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==} @@ -13988,6 +14198,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'} @@ -14325,8 +14538,8 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - log-symbols@6.0.0: - resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} log-update@6.1.0: @@ -14349,6 +14562,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==} @@ -14445,6 +14662,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'} @@ -14714,10 +14935,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'} @@ -14747,6 +14964,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'} @@ -15029,6 +15250,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} @@ -15237,9 +15462,9 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - ora@8.2.0: - resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} - engines: {node: '>=18'} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} ordered-binary@1.6.1: resolution: {integrity: sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==} @@ -15273,6 +15498,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'} @@ -15427,6 +15656,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==} @@ -15912,6 +16144,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==} @@ -15972,6 +16208,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: @@ -16065,8 +16305,8 @@ packages: peerDependencies: react: '>=17.0.0' - react-grab@0.1.32: - resolution: {integrity: sha512-ODZkzu4zjwX/5a1VxTdIkagPD6uPnp8IkSN2v5FDgFMZkH5r/YEMq43hIsdpHV5/R2ymqS9zLxp4H7SNSRx5ng==} + react-grab@0.1.33: + resolution: {integrity: sha512-ER919JMsE4TTrb2CpEivqsIjNMSycD4HtS8v7mS3pq67U7WL1K3+C8m9AYOwW4dpuYh+EanC2eJBmfuczHJZ0A==} hasBin: true peerDependencies: react: '>=17.0.0' @@ -16484,6 +16724,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'} @@ -16527,6 +16770,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'} @@ -16567,6 +16813,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==} @@ -16656,6 +16906,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'} @@ -16718,6 +16971,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 @@ -16749,6 +17005,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==} @@ -17034,6 +17294,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] @@ -17196,8 +17459,8 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - stdin-discarder@0.2.2: - resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} stickyfill@1.1.1: @@ -17380,6 +17643,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'} @@ -17551,6 +17818,10 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.10: resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} engines: {node: '>=12.0.0'} @@ -17773,6 +18044,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'} @@ -17849,8 +18124,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 @@ -17891,6 +18166,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==} @@ -18234,6 +18512,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'} @@ -18611,6 +18897,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==} @@ -18999,6 +19290,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'} @@ -19011,6 +19305,10 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -19061,7 +19359,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)': @@ -19179,9 +19477,14 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 - tinyexec: 1.0.2 + tinyexec: 1.1.2 - '@antfu/ni@0.23.2': {} + '@antfu/ni@30.1.0': + dependencies: + fzf: 0.5.2 + package-manager-detector: 1.6.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: @@ -19243,7 +19546,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 @@ -22007,6 +22310,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 @@ -23090,6 +23407,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)': @@ -25411,13 +25744,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@react-grab/cli@0.1.32': + '@react-grab/cli@0.1.33': dependencies: - '@antfu/ni': 0.23.2 - commander: 14.0.1 + '@antfu/ni': 30.1.0 + commander: 14.0.3 ignore: 7.0.5 jsonc-parser: 3.3.1 - ora: 8.2.0 + ora: 9.4.0 picocolors: 1.1.1 prompts: 2.4.2 smol-toml: 1.6.1 @@ -26048,6 +26381,8 @@ snapshots: '@sinclair/typebox@0.34.49': {} + '@sindresorhus/is@4.6.0': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -26560,6 +26895,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 @@ -27379,7 +27718,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: @@ -27391,7 +27737,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.19.17 '@types/d3-array@3.2.2': {} @@ -27554,6 +27900,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': {} @@ -27588,6 +27936,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': {} @@ -27634,6 +27986,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 @@ -27643,13 +27999,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 @@ -27685,7 +28041,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: @@ -27697,6 +28053,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': {} @@ -27738,7 +28098,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 @@ -27755,6 +28115,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 @@ -27845,20 +28210,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 @@ -27925,15 +28290,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 @@ -27964,12 +28329,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 @@ -28010,9 +28375,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: @@ -28085,15 +28450,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 @@ -28214,10 +28579,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 @@ -28225,8 +28590,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 @@ -28299,14 +28664,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 @@ -28929,12 +29294,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: @@ -29263,6 +29629,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 @@ -29554,6 +29928,9 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: + optional: true + bowser@2.14.1: {} bplist-creator@0.1.0: @@ -29604,6 +29981,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: {} @@ -29643,6 +30022,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 @@ -29650,10 +30041,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: @@ -29854,6 +30245,8 @@ snapshots: cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@5.1.0: dependencies: slice-ansi: 7.1.2 @@ -29880,6 +30273,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: {} @@ -29958,6 +30355,8 @@ snapshots: commander@14.0.1: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -29972,7 +30371,7 @@ snapshots: compressible@2.0.18: dependencies: - mime-db: 1.53.0 + mime-db: 1.54.0 compression@1.7.5: dependencies: @@ -30212,8 +30611,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: {} @@ -30508,7 +30907,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 @@ -30541,11 +30940,13 @@ 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 + 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: {} @@ -30587,6 +30988,9 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -30599,6 +31003,8 @@ snapshots: diff@8.0.2: {} + diff@9.0.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -30749,6 +31155,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: {} @@ -30790,13 +31204,15 @@ snapshots: env-editor@0.4.2: {} + env-paths@2.2.1: {} + env-paths@3.0.0: {} environment@1.1.0: {} enzyme-shallow-equal@1.0.7: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 object-is: 1.1.6 enzyme@3.11.0: @@ -30856,7 +31272,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 @@ -30948,10 +31364,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: {} @@ -31000,11 +31412,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: @@ -31018,6 +31430,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: @@ -32009,6 +32424,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: @@ -32084,10 +32509,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 @@ -32187,6 +32620,8 @@ snapshots: dependencies: tabbable: 6.2.0 + follow-redirects@1.16.0: {} + fontfaceobserver@2.3.0: {} for-each@0.3.3: @@ -32210,6 +32645,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: @@ -32278,13 +32721,15 @@ 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: {} funtypes@4.2.0: {} + fzf@0.5.2: {} + gaxios@7.1.4: dependencies: extend: 3.0.2 @@ -32320,14 +32765,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 @@ -32338,7 +32775,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: {} @@ -32356,6 +32793,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: @@ -32410,6 +32851,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: @@ -32453,12 +32904,22 @@ 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: + 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: {} @@ -32505,16 +32966,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: @@ -32738,6 +33195,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 @@ -32764,6 +33223,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 @@ -32901,13 +33365,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: {} @@ -32989,11 +33453,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: @@ -33099,7 +33563,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: {} @@ -33148,8 +33612,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-unicode-supported@1.3.0: {} - is-unicode-supported@2.1.0: {} is-weakmap@2.0.2: {} @@ -33345,6 +33807,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: {} @@ -33474,10 +33946,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 @@ -33546,6 +34018,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: {} @@ -33744,7 +34219,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 @@ -33865,10 +34340,10 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - log-symbols@6.0.0: + log-symbols@7.0.1: dependencies: - chalk: 5.6.2 - is-unicode-supported: 1.3.0 + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 log-update@6.1.0: dependencies: @@ -33892,6 +34367,8 @@ snapshots: dependencies: tslib: 2.8.1 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.3.5: {} @@ -33948,7 +34425,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 makeerror@1.0.12: dependencies: @@ -33979,6 +34456,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: @@ -34605,8 +35087,6 @@ snapshots: mime-db@1.52.0: {} - mime-db@1.53.0: {} - mime-db@1.54.0: {} mime-types@2.1.35: @@ -34625,6 +35105,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@1.0.1: {} + mimic-response@3.1.0: {} mini-svg-data-uri@1.4.4: {} @@ -34902,6 +35384,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: @@ -35151,17 +35635,16 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - ora@8.2.0: + ora@9.4.0: dependencies: chalk: 5.6.2 cli-cursor: 5.0.0 - cli-spinners: 2.9.2 + cli-spinners: 3.4.0 is-interactive: 2.0.0 is-unicode-supported: 2.1.0 - log-symbols: 6.0.0 - stdin-discarder: 0.2.2 - string-width: 7.2.0 - strip-ansi: 7.2.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.0 ordered-binary@1.6.1: {} @@ -35242,6 +35725,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 @@ -35392,6 +35877,8 @@ snapshots: peek-readable@5.4.2: {} + pend@1.2.0: {} + perfect-debounce@1.0.0: {} perfect-scrollbar@1.5.6: {} @@ -35693,7 +36180,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 @@ -35909,6 +36396,8 @@ snapshots: proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + psql-describe@0.1.6: {} pstree.remy@1.1.8: {} @@ -35960,6 +36449,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 @@ -36125,9 +36616,9 @@ snapshots: dependencies: react: 19.1.0 - react-grab@0.1.32(react@19.2.0): + react-grab@0.1.33(react@19.2.0): dependencies: - '@react-grab/cli': 0.1.32 + '@react-grab/cli': 0.1.33 bippy: 0.5.39(react@19.2.0) optionalDependencies: react: 19.2.0 @@ -36341,7 +36832,7 @@ snapshots: prompts: 2.4.2 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - react-grab: 0.1.32(react@19.2.0) + react-grab: 0.1.33(react@19.2.0) optionalDependencies: esbuild: 0.27.7 unplugin: 2.1.0 @@ -36694,6 +37185,8 @@ snapshots: reselect@5.1.1: {} + resolve-alpn@1.2.1: {} + resolve-from@3.0.0: {} resolve-from@4.0.0: {} @@ -36735,6 +37228,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 @@ -36771,12 +37268,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 @@ -36919,6 +37426,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 @@ -36986,6 +37497,9 @@ snapshots: secure-json-parse@4.1.0: {} + semver-compare@1.0.0: + optional: true + semver@6.3.1: {} semver@7.6.3: {} @@ -37030,6 +37544,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 @@ -37079,8 +37598,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: @@ -37237,7 +37756,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: @@ -37378,6 +37897,9 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: + optional: true + sqlite-vec-darwin-arm64@0.1.9: optional: true @@ -37517,7 +38039,7 @@ snapshots: std-env@4.1.0: {} - stdin-discarder@0.2.2: {} + stdin-discarder@0.3.2: {} stickyfill@1.1.1: {} @@ -37733,6 +38255,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 @@ -37948,6 +38476,8 @@ snapshots: tinyexec@1.0.2: {} + tinyexec@1.1.2: {} + tinyglobby@0.2.10: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -37955,8 +38485,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: {} @@ -38067,9 +38597,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: @@ -38099,7 +38629,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 @@ -38226,6 +38756,9 @@ snapshots: type-detect@4.0.8: {} + type-fest@0.13.1: + optional: true + type-fest@0.16.0: {} type-fest@0.20.2: {} @@ -38302,7 +38835,7 @@ snapshots: typescript@5.8.3: {} - typescript@5.9.3: {} + typescript@6.0.3: {} ua-parser-js@1.0.40: {} @@ -38341,6 +38874,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + undici-types@7.19.2: optional: true @@ -38617,6 +39152,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) @@ -38855,12 +39392,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) @@ -38873,7 +39410,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 @@ -39366,6 +39903,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 @@ -39667,13 +40214,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 @@ -39794,6 +40341,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 @@ -39802,6 +40354,8 @@ snapshots: yoctocolors-cjs@2.1.2: {} + yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} zod-to-json-schema@3.24.3(zod@3.24.2):