diff --git a/.cursor/plans/cli-only-streaming-hardening_8cba61e1.plan.md b/.cursor/plans/cli-only-streaming-hardening_8cba61e1.plan.md
new file mode 100644
index 00000000000..3bcb9887296
--- /dev/null
+++ b/.cursor/plans/cli-only-streaming-hardening_8cba61e1.plan.md
@@ -0,0 +1,102 @@
+---
+name: cli-only-streaming-hardening
+overview: Harden the CLI-only web streaming refactor by fixing protocol-level flaws first, then replacing web WS consumers with managed CLI subscribe processes and adding guardrails for dedupe, lifecycle, and long-wait stability.
+todos:
+ - id: fix-subscribe-cli-semantics
+ content: Make `agent --stream-json --subscribe-session-key` long-lived and session-filtered, with tests.
+ status: completed
+ - id: add-subscribe-spawner
+ content: Add `spawnAgentSubscribeProcess` helper in `apps/web/lib/agent-runner.ts` with profile/workspace env wiring.
+ status: completed
+ - id: parent-wait-cli-subscribe
+ content: Refactor `apps/web/lib/active-runs.ts` waiting flow to use managed subscribe child + globalSeq dedupe.
+ status: completed
+ - id: subagent-cli-subscribe
+ content: Refactor `apps/web/lib/subagent-runs.ts` fallback/rehydration to managed subscribe child + globalSeq dedupe.
+ status: completed
+ - id: remove-web-ws-client
+ content: Remove `apps/web/lib/gateway-events.ts` usages and delete file after typecheck passes.
+ status: completed
+ - id: sse-keepalive
+ content: Add keepalive behavior for long idle waiting streams.
+ status: completed
+ - id: verify-regressions
+ content: Run targeted tests/smoke checks for handoff, refresh, replay, and duplicate/cross-session safety.
+ status: completed
+isProject: false
+---
+
+# CLI-Only Streaming Plan (Flaw-Hardened)
+
+## Critical flaws to fix before WS removal
+
+- The current subscribe CLI path in [src/commands/agent-via-gateway.ts](/Users/kumareth/Documents/projects/openclaw/src/commands/agent-via-gateway.ts) calls `callGateway(... expectFinal: false)` and exits after `agent.subscribe` response; it does not remain attached for live events.
+- `agent.subscribe` clients still receive global `agent` broadcasts unless filtered client-side; without filtering, per-session subscribe children can ingest unrelated events and cause cross-session noise/duplication.
+- Handoff/replay can duplicate already-buffered events unless consumers gate by `globalSeq` (`<= lastSeen` ignore).
+- Long βwaiting for subagentsβ SSE windows in [apps/web/app/api/chat/stream/route.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/app/api/chat/stream/route.ts) have no keepalive signal, increasing disconnect risk during quiet periods.
+
+## Revised implementation sequence
+
+1. **Stabilize subscribe transport semantics first**
+
+- Rework subscribe mode in [src/commands/agent-via-gateway.ts](/Users/kumareth/Documents/projects/openclaw/src/commands/agent-via-gateway.ts) to use a long-lived gateway client session (not one-shot `callGateway`) that:
+ - connects,
+ - sends `agent.subscribe { sessionKey, afterSeq }`,
+ - streams events until SIGTERM/SIGINT,
+ - emits only matching `sessionKey` events,
+ - exits cleanly with `aborted` on signal.
+- Add targeted tests for subscribe staying alive and session-key filtering.
+
+2. **Add reusable CLI subscribe spawner**
+
+- In [apps/web/lib/agent-runner.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/lib/agent-runner.ts), add `spawnAgentSubscribeProcess(sessionKey, afterSeq)` using:
+ - `node
-
(filtered by runId, never arrives)"| SRM1[SubagentRunManager]
+ ARM1 -.->|"activateGatewayFallback
(after parent exits, loses early events)"| SRM1
+ end
+
+ subgraph after [New: Independent]
+ GW2[Gateway] --> ParentCLI2[Parent CLI stdout]
+ GW2 --> SubProc[Subscribe Process per subagent]
+ ParentCLI2 --> ARM2[ActiveRunManager]
+ SubProc --> SRM2[SubagentRunManager]
+ end
+```
+
+## Phase 1: Decouple Subagents
+
+### 1. SubagentRunManager ([subagent-runs.ts](apps/web/lib/subagent-runs.ts))
+
+**In `registerSubagent()` (line 266-270)**: replace the comment with:
+
+```typescript
+if (run.status === "running") {
+ startSubagentSubscribeStream(run);
+}
+```
+
+Each subagent immediately gets its own subscribe process (`spawnAgentSubscribeProcess`) that connects to the gateway and streams events for that subagent's sessionKey. No dependency on the parent's stream.
+
+**Remove dead code:**
+
+- `routeRawEvent()` (lines 419-448) -- no longer called; events come from per-subagent subscribe processes
+- `preRegBuffer` from the registry type and `getRegistry()` -- no pre-registration buffering needed; the subscribe process handles everything
+- `activateGatewayFallback()` (lines 368-375) -- no longer needed; subscription starts at registration time
+
+### 2. active-runs.ts ([active-runs.ts](apps/web/lib/active-runs.ts))
+
+**Remove subagent event routing from the parent NDJSON handler**: the block that checks `ev.sessionKey !== parentSessionKey` and calls `routeSubagentEvent()` -- delete it entirely. Parent NDJSON stream now only processes parent events. No imports of `routeRawEvent`, `ensureRegisteredFromDisk`, `hasActiveSubagent` from subagent-runs needed for routing.
+
+**Remove `activateGatewayFallback()` call** from the parent exit handler.
+
+**Keep**: the `waiting-for-subagents` state transition and `hasRunningSubagentsForParent()` check -- the parent still needs to know when all subagents finish so it can finalize.
+
+### 3. No CLI changes needed
+
+The `runId` filter in `src/commands/agent.ts` is correct -- the parent's NDJSON stream should only contain parent events. Subagent events flow independently through their own subscribe processes.
+
+## Phase 2: Unified API Routes
+
+Same primitive, same routes. Dispatch based on session key format (`:subagent:` vs `:web:`).
+
+### 4. SubagentRunManager: interactive methods
+
+- `**persistUserMessage(sessionKey, msg)**` -- append `{type: "user-message", text, id}` to event buffer + JSONL
+- `**reactivateSubagent(sessionKey)**` -- set status to `"running"`, clear `endedAt`, restart subscribe process
+- `**abortSubagent(sessionKey)**` -- spawn CLI `gateway call chat.abort`, mark `"error"`, signal subscribers
+- `**spawnSubagentMessage(sessionKey, message)**` -- spawn CLI `gateway call agent --params '{"message":"...", "sessionKey":"...", "lane":"subagent", ...}'`
+
+### 5. Extend `POST /api/chat` ([route.ts](apps/web/app/api/chat/route.ts))
+
+If `sessionKey` contains `:subagent:`:
+
+- Reject if running (409)
+- `persistUserMessage()` + `reactivateSubagent()` + `spawnSubagentMessage()`
+- Subscribe via `subscribeToSubagent(sessionKey, ..., { replay: false })` for SSE response
+
+Otherwise: existing parent flow.
+
+### 6. Extend `POST /api/chat/stop` ([stop/route.ts](apps/web/app/api/chat/stop/route.ts))
+
+Accept `sessionKey`. If `:subagent:`: `abortSubagent()`. Otherwise: `abortRun()`.
+
+### 7. Extend `GET /api/chat/stream` ([stream/route.ts](apps/web/app/api/chat/stream/route.ts))
+
+Accept `sessionKey`. If `:subagent:`: lazy-register from disk, `ensureSubagentStreamable()`, `subscribeToSubagent()`. Otherwise: existing parent flow.
+
+Remove `apps/web/app/api/chat/subagent-stream/route.ts` after migration.
+
+## Phase 3: Frontend
+
+### 8. Stream parser turn boundaries ([chat-panel.tsx](apps/web/app/components/chat-panel.tsx))
+
+Add `user-message` to `ParsedPart` and `createStreamParser` for multi-turn subagent conversations.
+
+### 9. Rewrite SubagentPanel ([subagent-panel.tsx](apps/web/app/components/subagent-panel.tsx))
+
+Full ChatPanel-like experience: ChatEditor, send/stop/queue buttons, AttachmentStrip, message queue, auto-scroll. Uses the unified routes with `sessionKey`.
diff --git a/.cursor/plans/mention_search_and_entry_modals_8a47eefc.plan.md b/.cursor/plans/mention_search_and_entry_modals_8a47eefc.plan.md
new file mode 100644
index 00000000000..0baa352a9d2
--- /dev/null
+++ b/.cursor/plans/mention_search_and_entry_modals_8a47eefc.plan.md
@@ -0,0 +1,245 @@
+---
+name: Mention Search and Entry Modals
+overview: Overhaul the tiptap @ mention system to search files AND object entries with fast fuzzy matching, fix the broken link system with canonical internal URIs, and add query-param-based entry detail modals accessible from table rows and @ mention links.
+todos:
+ - id: search-index-api
+ content: "Phase 1: Build GET /api/workspace/search-index endpoint -- returns flat JSON of all files + all entries from every DuckDB object with display fields"
+ status: completed
+ - id: fuse-search-hook
+ content: "Phase 2: Add fuse.js dep, create useSearchIndex() hook in lib/search-index.ts, build Fuse instance on fetch"
+ status: completed
+ - id: mention-upgrade
+ content: "Phase 2b: Rewrite createFileMention -> createWorkspaceMention in slash-command.tsx, wire up Fuse search, update CommandList for entry results"
+ status: completed
+ - id: link-utilities
+ content: "Phase 3: Create lib/workspace-links.ts with parseWorkspaceLink/buildEntryLink, define @entry/{object}/{id} format"
+ status: completed
+ - id: link-insert-fix
+ content: "Phase 3b: Update mention insert commands to use canonical link format; update markdown-editor.tsx link click handler to parse workspace links"
+ status: completed
+ - id: entry-api
+ content: "Phase 4: Build GET /api/workspace/objects/[name]/entries/[id] endpoint for single entry data"
+ status: completed
+ - id: entry-modal
+ content: "Phase 4b: Build EntryDetailModal component with full field display, relation links, close/escape handling"
+ status: completed
+ - id: query-param-routing
+ content: "Phase 4c: Wire ?entry= query param in workspace/page.tsx -- render modal, sync URL on open/close, handle initial load"
+ status: completed
+ - id: table-row-click
+ content: "Phase 4d: Make object-table.tsx rows clickable with onEntryClick prop, wire to entry param in parent"
+ status: completed
+ - id: fix-nav-bugs
+ content: "Phase 5: Fix path resolution in onNavigate (knowledge/ prefix handling), sync URL bar with activePath via router.replace()"
+ status: completed
+isProject: false
+---
+
+# @ Mention Search, Link System, and Entry Detail Modals
+
+## Current Problems
+
+1. **@ mention only searches files** -- entries (rows) in objects (tables) are invisible to the search. Uses naive `String.includes()` with no fuzzy matching.
+2. **File links are broken** -- `buildFileItems` in [slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx) sets `href: node.path` (e.g. `knowledge/leads`), but `onNavigate` in [workspace/page.tsx](apps/web/app/workspace/page.tsx) calls `findNode(tree, path)` which may not resolve correctly depending on whether the path includes `knowledge/` prefix. No canonical link format exists.
+3. **No entry detail view** -- table rows in [object-table.tsx](apps/web/app/components/workspace/object-table.tsx) are not clickable. There is no modal, route, or UI to view a single entry.
+4. **No URL-based navigation** -- navigating to content is purely callback-based (state updates). The URL only updates on initial load via `?path=`. Sharing a link to a specific entry is impossible.
+
+---
+
+## Architecture
+
+```mermaid
+flowchart TB
+ subgraph searchIndex [Search Index - API]
+ searchEndpoint["GET /api/workspace/search-index"]
+ duckdb["DuckDB: objects + entries + fields"]
+ tree["Filesystem: tree nodes"]
+ searchEndpoint --> duckdb
+ searchEndpoint --> tree
+ end
+
+ subgraph clientSearch [Client-Side Fuzzy Search]
+ fuseIndex["Fuse.js index - built once on load"]
+ mentionPlugin["@ Mention Plugin"]
+ fuseIndex --> mentionPlugin
+ end
+
+ subgraph routing [Query Param Routing]
+ pathParam["?path=knowledge/leads"]
+ entryParam["&entry=abc123"]
+ pathParam --> workspacePage["Workspace Page"]
+ entryParam --> entryModal["Entry Detail Modal"]
+ end
+
+ searchEndpoint -->|"JSON: files + entries"| fuseIndex
+ mentionPlugin -->|"insert link"| internalLink["dench://entry/leads/abc123"]
+ internalLink -->|"onNavigate resolves"| routing
+```
+
+---
+
+## Phase 1: Search Index API Endpoint
+
+**New file:** `apps/web/app/api/workspace/search-index/route.ts`
+
+Returns a flat JSON array of all searchable items -- both files and entry rows from every object. The client fetches this once on workspace load and rebuilds on tree changes (SSE watcher already triggers refreshes).
+
+```typescript
+type SearchIndexItem = {
+ // Shared
+ id: string; // unique key (path for files, entryId for entries)
+ label: string; // display text (filename or display-field value)
+ sublabel?: string; // secondary text (path for files, object name for entries)
+ kind: "file" | "object" | "entry";
+ icon?: string;
+
+ // For entries
+ objectName?: string;
+ entryId?: string;
+ fields?: Record
-
+ ββββββββββ βββββββ ββββ βββ ββββββββββ ββββββ βββ βββ
+ βββββββββββββββββββββββββ ββββββββββββββ βββββββββββ βββ
+ ββββββββββββββ βββββββββ ββββββ βββ βββββββββββ ββ βββ
+ ββββββββββββββ ββββββββββββββββ βββ ββββββββββββββββββ
+ ββββββ βββββββββββββββ βββββββββββββββββββββββββ βββββββββββββ
+ ββββββ βββ βββββββ βββ βββββ ββββββββββββββββββ βββ ββββββββ
+
- EXFOLIATE! EXFOLIATE! + AI CRM, hosted locally on your Mac.
-
-
+ Chat with your database. Automate outreach. Enrich leads. All from a single prompt.
+
+ Website Β· Docs Β· OpenClaw Framework Β· Discord Β· Skills Store +
-If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. +--- -[Website](https://openclaw.ai) Β· [Docs](https://docs.openclaw.ai) Β· [Vision](VISION.md) Β· [DeepWiki](https://deepwiki.com/openclaw/openclaw) Β· [Getting Started](https://docs.openclaw.ai/start/getting-started) Β· [Updating](https://docs.openclaw.ai/install/updating) Β· [Showcase](https://docs.openclaw.ai/start/showcase) Β· [FAQ](https://docs.openclaw.ai/start/faq) Β· [Wizard](https://docs.openclaw.ai/start/wizard) Β· [Nix](https://github.com/openclaw/nix-openclaw) Β· [Docker](https://docs.openclaw.ai/install/docker) Β· [Discord](https://discord.gg/clawd) +## Install -Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. -The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. -Works with npm, pnpm, or bun. -New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started) - -## Sponsors - -| OpenAI | Blacksmith | -| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| [](https://openai.com/) | [](https://blacksmith.sh/) | - -**Subscriptions (OAuth):** - -- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) -- **[OpenAI](https://openai.com/)** (ChatGPT/Codex) - -Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for longβcontext strength and better promptβinjection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). - -## Models (selection + auth) - -- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models) -- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover) - -## Install (recommended) - -Runtime: **Node β₯22**. +**Runtime: Node 22+** ```bash -npm install -g openclaw@latest -# or: pnpm add -g openclaw@latest - -openclaw onboard --install-daemon +npm i -g ironclaw +ironclaw onboard --install-daemon ``` -The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running. +Opens at `localhost:3100`. That's it. -## Quick start (TL;DR) - -Runtime: **Node β₯22**. - -Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started) - -```bash -openclaw onboard --install-daemon - -openclaw gateway --port 18789 --verbose - -# Send a message -openclaw message send --to +1234567890 --message "Hello from OpenClaw" - -# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat) -openclaw agent --message "Ship checklist" --thinking high -``` - -Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`). - -## Development channels - -- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-` (then the sender is added to a local allowlist store).
-- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`).
-
-Run `openclaw doctor` to surface risky/misconfigured DM policies.
-
-## Highlights
-
-- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** β single control plane for sessions, channels, tools, and events.
-- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** β WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
-- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** β route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
-- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** β always-on speech for macOS/iOS/Android with ElevenLabs.
-- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** β agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
-- **[First-class tools](https://docs.openclaw.ai/tools)** β browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
-- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** β macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
-- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** β wizard-driven setup with bundled/managed/workspace skills.
-
-## Star History
-
-[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
-
-## Everything we built so far
-
-### Core platform
-
-- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
-- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
-- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
-- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups).
-- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
-
-### Channels
-
-- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
-- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
-
-### Apps + nodes
-
-- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
-- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing.
-- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
-- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
-
-### Tools + automation
-
-- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
-- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
-- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
-- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
-- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
-
-### Runtime + safety
-
-- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
-- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
-- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
-- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
-
-### Ops + packaging
-
-- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
-- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
-- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
-- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
-
-## How it works (short)
+Three steps total:
```
-WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
+1. npm i -g ironclaw
+2. ironclaw onboard
+3. ironclaw gateway start
+```
+
+---
+
+## What is Ironclaw?
+
+Ironclaw is a personal AI agent and CRM that runs locally on your machine. It connects to every messaging channel you use, manages structured data through DuckDB, browses the web with your Chrome profile, and gives you a full web UI for pipeline management, analytics, and document management.
+
+Built on [OpenClaw](https://github.com/openclaw/openclaw) with **Vercel AI SDK v6** as the LLM orchestration layer.
+
+**One prompt does everything:**
+
+- "Find YC W26 founders building AI companies" β scrapes YC directory + LinkedIn, returns 127 matches
+- "Enrich all contacts with LinkedIn and email" β fills in profiles with 98% coverage
+- "Send personalized messages to qualified leads" β crafts and sends custom outreach
+- "Show me pipeline stats for this quarter" β generates interactive charts from live data
+- "Set up weekly follow-up sequences for all leads" β creates automation rules that run in the background
+
+---
+
+## Use Cases
+
+### Find Leads
+
+Type a prompt, Ironclaw scrapes the web using your actual Chrome profile (all your auth sessions, cookies, history). It logs into LinkedIn, browses YC batches, pulls company data. No separate login, no API keys for browsing.
+
+### Enrich Data
+
+Point it at your contacts table. It fills in LinkedIn URLs, email addresses, education, company info. Enrichment runs in bulk with real-time progress.
+
+### Send Outreach
+
+Personalized LinkedIn messages, cold emails, follow-up sequences. Each message is customized per lead. You see status (Sent, Sending, Queued) in real time.
+
+### Analyze Pipeline
+
+Ask for analytics in plain English. Ironclaw queries your DuckDB workspace and generates interactive Recharts dashboards inline. Pipeline funnels, outreach activity charts, conversion rates, donut breakdowns.
+
+### Automate Everything
+
+Cron jobs that run on schedule. Follow-up if no reply after 3 days. Move leads to Qualified when they reply. Weekly pipeline reports every Monday. Alert on high-intent replies.
+
+---
+
+## Core Capabilities
+
+### Uses Your Chrome Profile
+
+Unlike other AI tools, Ironclaw copies your existing Chrome profile with all your auth sessions, cookies, and history. It logs into LinkedIn, scrapes YC batches, and sends messages as you. No separate browser login needed.
+
+### Chat with Your Database
+
+Ask questions in plain English. Ironclaw translates to SQL, queries your local DuckDB, and returns structured results. Like having a data analyst on speed dial.
+
+```
+You: "How many founders have we contacted from YC W26?"
+
+β SELECT "Status", COUNT(*) as count FROM v_founders GROUP BY "Status";
+
+You've contacted 67 of 200 founders. 31 are qualified, 13 converted.
+Reply rate is 34%.
+```
+
+### Coding Agent with Diffs
+
+Ironclaw writes code. Review changes in a rich diff viewer before applying. Config changes, automation scripts, data transformations. All with diffs you approve.
+
+### Your Second Brain
+
+Full access to your Mac: files, apps, documents. It remembers context across sessions via persistent memory files. Learns your preferences. Proactively handles tasks during heartbeat checks.
+
+---
+
+## Web UI (Dench)
+
+The web app runs at `localhost:3100` and includes:
+
+- **Chat panel** with streaming responses, chain-of-thought reasoning display, and markdown rendering
+- **Workspace sidebar** with file manager tree, knowledge base, and database viewer
+- **Object tables** powered by TanStack, with sorting, filtering, row selection, and bulk operations
+- **Entry detail modals** with field editing and media previews
+- **Kanban boards** with drag-and-drop that auto-update as leads reply
+- **Interactive report cards** with chart panels (bar, line, area, pie, donut, funnel, scatter, radar) and filter bars
+- **Document editor** with embedded live charts
+- **Media viewer** supporting images, video, audio, and PDFs
+
+---
+
+## Multi-Channel Inbox
+
+One agent, every channel. Connect any messaging platform. Your AI agent responds everywhere, managed from a single terminal.
+
+| Channel | Setup |
+| ------------------- | ------------------------------------------------------------- |
+| **WhatsApp** | `ironclaw channels login` + set `channels.whatsapp.allowFrom` |
+| **Telegram** | Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` |
+| **Slack** | Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` |
+| **Discord** | Set `DISCORD_BOT_TOKEN` or `channels.discord.token` |
+| **Signal** | Requires `signal-cli` + `channels.signal` config |
+| **iMessage** | Via BlueBubbles (recommended) or legacy macOS integration |
+| **Microsoft Teams** | Configure Teams app + Bot Framework |
+| **Google Chat** | Chat API integration |
+| **Matrix** | Extension channel |
+| **WebChat** | Built-in, uses Gateway WebSocket directly |
+
+```
+ WhatsApp Β· Telegram Β· Slack Β· Discord
+ Signal Β· iMessage Β· Teams Β· WebChat
β
βΌ
-βββββββββββββββββββββββββββββββββ
-β Gateway β
-β (control plane) β
-β ws://127.0.0.1:18789 β
-ββββββββββββββββ¬βββββββββββββββββ
- β
- ββ Pi agent (RPC)
- ββ CLI (openclaw β¦)
- ββ WebChat UI
- ββ macOS app
- ββ iOS / Android nodes
+ ββββββββββββββββββββββββββββββ
+ β Ironclaw Gateway β
+ β ws://127.0.0.1:18789 β
+ βββββββββββββββ¬βββββββββββββββ
+ β
+ βββββββββββββΌββββββββββββ
+ β β β
+ βΌ βΌ βΌ
+ AI SDK Web UI CLI
+ Engine (Dench) (ironclaw)
```
-## Key subsystems
+---
-- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** β single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
-- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** β Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
-- **[Browser control](https://docs.openclaw.ai/tools/browser)** β openclawβmanaged Chrome/Chromium with CDP control.
-- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** β agentβdriven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
-- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** β alwaysβon speech and continuous conversation.
-- **[Nodes](https://docs.openclaw.ai/nodes)** β Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOSβonly `system.run`/`system.notify`.
+## Integrations
-## Tailscale access (Gateway dashboard)
+Import your data from anywhere: Google Drive, Notion, Salesforce, HubSpot, Gmail, Calendar, Obsidian, Slack, LinkedIn, Asana, Monday, ClickUp, PostHog, Sheets, Apple Notes, GitHub, and 50+ more via the Skills Store.
-OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
+---
-- `off`: no Tailscale automation (default).
-- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
-- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
+## Skills Platform
-Notes:
+Extend your agent with a single command. Browse skills from [skills.sh](https://skills.sh) and [ClawHub](https://clawhub.com).
-- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
-- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
-- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
-- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
+```bash
+npx skills add vercel-labs/agent-browser
+```
-Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) Β· [Web surfaces](https://docs.openclaw.ai/web)
+Popular skills:
-## Remote Gateway (Linux is great)
+| Skill | Description | Installs |
+| ----------------------- | ---------------------------------------------------------- | -------- |
+| `crm-automation` | CRM workflow automation, lead scoring, pipeline management | 18.2K |
+| `linkedin-outreach` | Automated LinkedIn prospecting and follow-up sequences | 14.8K |
+| `lead-enrichment` | Enrich contacts with LinkedIn, email, and company data | 12.1K |
+| `email-sequences` | Multi-step cold email campaigns with personalization | 9.7K |
+| `agent-browser` | Browser automation and web scraping for agents | 35.8K |
+| `web-design-guidelines` | Best practices for modern web design | 99.4K |
+| `frontend-design` | Expert frontend engineering patterns | 68.9K |
+| `typescript-expert` | Advanced TypeScript patterns and best practices | 15.1K |
-Itβs perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute deviceβlocal actions when needed.
+Or write your own. Skills are just a `SKILL.md` file with instructions + optional scripts.
-- **Gateway host** runs the exec tool and channel connections by default.
-- **Device nodes** run deviceβlocal actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
- In short: exec runs where the Gateway lives; device actions run where the device lives.
+---
-Details: [Remote access](https://docs.openclaw.ai/gateway/remote) Β· [Nodes](https://docs.openclaw.ai/nodes) Β· [Security](https://docs.openclaw.ai/gateway/security)
+## Analytics & Reports
-## macOS permissions via the Gateway protocol
+Ask "show me pipeline analytics" and get interactive charts generated from your live DuckDB data.
-The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
+- **Outreach Activity** β area charts tracking LinkedIn and email volume over time
+- **Pipeline Breakdown** β donut charts showing lead distribution by status
+- **Conversion Funnel** β stage-by-stage conversion rates with overall percentage
+- **Deal Pipeline** β bar charts, funnel views, revenue by stage
+- **Custom Reports** β save as `.report.json` files that render as live dashboards
-- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise youβll get `PERMISSION_MISSING`).
-- `system.notify` posts a user notification and fails if notifications are denied.
-- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
+Reports use the `report-json` format and render inline in chat as interactive Recharts components.
-Elevated bash (host permissions) is separate from macOS TCC:
+---
-- Use `/elevated on|off` to toggle perβsession elevated access when enabled + allowlisted.
-- Gateway persists the perβsession toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
+## Kanban Pipeline
-Details: [Nodes](https://docs.openclaw.ai/nodes) Β· [macOS app](https://docs.openclaw.ai/platforms/macos) Β· [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
+Drag-and-drop kanban boards that auto-update as leads reply. Ironclaw moves cards through your pipeline automatically.
-## Agent to Agent (sessions\_\* tools)
+Columns like New Lead β Contacted β Qualified β Demo Scheduled β Closed map to your sales process. Each card shows the lead name, company, and last action taken.
-- Use these to coordinate work across sessions without jumping between chat surfaces.
-- `sessions_list` β discover active sessions (agents) and their metadata.
-- `sessions_history` β fetch transcript logs for a session.
-- `sessions_send` β message another session; optional replyβback pingβpong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
+---
-Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
+## Documents, Reports & Cron Jobs
-## Skills registry (ClawHub)
+### Documents
-ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed.
+Rich markdown documents with embedded live charts. SOPs, playbooks, onboarding guides. Documents nest under objects or stand alone in the file tree.
-[ClawHub](https://clawhub.com)
+### Cron Jobs
-## Chat commands
+Scheduled automations that run in the background:
-Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
+| Job | Schedule | Description |
+| ---------------------- | -------------- | ------------------------------------ |
+| Weekly pipeline report | `0 9 * * MON` | Auto-generates pipeline summary |
+| Lead enrichment sync | Every 6h | Enriches new contacts |
+| Email follow-up check | Every 30m | Checks for replies needing follow-up |
+| Inbox digest | `0 8,18 * * *` | Morning and evening inbox summary |
+| Competitor monitoring | `0 6 * * *` | Tracks competitor activity |
+| CRM backup to S3 | `0 2 * * *` | Nightly workspace backup |
-- `/status` β compact session status (model + tokens, cost when available)
-- `/new` or `/reset` β reset the session
-- `/compact` β compact session context (summary)
-- `/think ` β off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
-- `/verbose on|off`
-- `/usage off|tokens|full` β per-response usage footer
-- `/restart` β restart the gateway (owner-only in groups)
-- `/activation mention|always` β group activation toggle (groups only)
+```bash
+ironclaw cron list
+```
-## Apps (optional)
+---
-The Gateway alone delivers a great experience. All apps are optional and add extra features.
+## Gateway
-If you plan to build/run companion apps, follow the platform runbooks below.
+The Gateway is the local-first WebSocket control plane that routes everything:
-### macOS (OpenClaw.app) (optional)
+- **Sessions** β main sessions for DMs, isolated sessions for group chats, sub-agent sessions for background tasks
+- **Channels** β route inbound messages from any platform to the right session
+- **Tools** β browser control, canvas, nodes, cron, messaging, file operations
+- **Events** β webhooks, Gmail Pub/Sub, cron triggers, heartbeats
+- **Multi-agent routing** β route channels/accounts/peers to isolated agents with separate workspaces
-- Menu bar control for the Gateway and health.
-- Voice Wake + push-to-talk overlay.
-- WebChat + debug tools.
-- Remote gateway control over SSH.
+### Session Model
-Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
+- `main` β direct 1:1 chats with persistent context
+- `group` β isolated per-group sessions with mention gating
+- `isolated` β sub-agent sessions for background tasks (cron jobs, spawned work)
-### iOS node (optional)
+### Security
-- Pairs as a node via the Bridge.
-- Voice trigger forwarding + Canvas surface.
-- Controlled via `openclaw nodes β¦`.
+- **DM pairing** enabled by default. Unknown senders get a pairing code.
+- Approve with `ironclaw pairing approve `
+- Non-main sessions can be sandboxed in Docker
+- Run `ironclaw doctor` to audit DM policies
-Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
+---
-### Android node (optional)
+## Companion Apps
-- Pairs via the same Bridge + pairing flow as iOS.
-- Exposes Canvas, Camera, and Screen capture commands.
-- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
+- **macOS** β menu bar app with Voice Wake, Push-to-Talk, Talk Mode overlay, WebChat, and debug tools
+- **iOS** β Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing
+- **Android** β Canvas, Talk Mode, camera, screen recording, optional SMS
-## Agent workspace + skills
-
-- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
-- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
-- Skills: `~/.openclaw/workspace/skills//SKILL.md`.
+---
## Configuration
-Minimal `~/.openclaw/openclaw.json` (model + defaults):
+Config lives at `~/.openclaw/openclaw.json`:
-```json5
-{
- agent: {
- model: "anthropic/claude-opus-4-6",
- },
-}
+Supports all latest and greatest mainstream LLM models. BYOK.
+
+---
+
+## Chat Commands
+
+Send these in any connected channel:
+
+| Command | Description |
+| ----------------------------- | ------------------------------- |
+| `/status` | Session status (model + tokens) |
+| `/new` or `/reset` | Reset the session |
+| `/compact` | Compact session context |
+| `/think ` | Set thinking level |
+| `/verbose on\|off` | Toggle verbose output |
+| `/usage off\|tokens\|full` | Per-response usage footer |
+| `/restart` | Restart the gateway |
+| `/activation mention\|always` | Group activation toggle |
+
+---
+
+## DuckDB Workspace
+
+All structured data lives in a local DuckDB database. Objects, fields, entries, relations. EAV pattern with auto-generated PIVOT views so you query like normal tables:
+
+```sql
+SELECT * FROM v_leads WHERE "Status" = 'New' ORDER BY created_at DESC LIMIT 50;
+SELECT "Status", COUNT(*) FROM v_leads GROUP BY "Status";
```
-[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
+Features:
-## Security model (important)
+- Custom objects with typed fields (text, email, phone, number, boolean, date, enum, relation, user)
+- Full-text search
+- Bulk import/export (CSV, Parquet)
+- Automatic view generation
+- Kanban support with drag-and-drop
-- **Default:** tools run on the host for the **main** session, so the agent has full access when itβs just you.
-- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **nonβmain sessions** (groups/channels) inside perβsession Docker sandboxes; bash then runs in Docker for those sessions.
-- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
+---
-Details: [Security guide](https://docs.openclaw.ai/gateway/security) Β· [Docker + sandboxing](https://docs.openclaw.ai/install/docker) Β· [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
+## Quick Start
-### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
+```bash
+# Install
+npm i -g ironclaw
-- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
-- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
-- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
+# Run onboarding wizard
+ironclaw onboard --install-daemon
-### [Telegram](https://docs.openclaw.ai/channels/telegram)
+# Start the gateway
+ironclaw gateway start
-- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
-- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed.
+# Open the web UI
+open http://localhost:3100
-```json5
-{
- channels: {
- telegram: {
- botToken: "123456:ABCDEF",
- },
- },
-}
+# Talk to your agent from CLI
+ironclaw agent --message "Summarize my inbox" --thinking high
+
+# Send a message
+ironclaw message send --to +1234567890 --message "Hello from Ironclaw"
```
-### [Slack](https://docs.openclaw.ai/channels/slack)
+---
-- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
+## From Source
-### [Discord](https://docs.openclaw.ai/channels/discord)
+```bash
+git clone https://github.com/kumarabhirup/ironclaw.git
+cd ironclaw
-- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
-- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
+pnpm install
+pnpm build
-```json5
-{
- channels: {
- discord: {
- token: "1234abcd",
- },
- },
-}
+pnpm dev onboard --install-daemon
```
-### [Signal](https://docs.openclaw.ai/channels/signal)
+Web UI development:
-- Requires `signal-cli` and a `channels.signal` config section.
-
-### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
-
-- **Recommended** iMessage integration.
-- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
-- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
-
-### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
-
-- Legacy macOS-only integration via `imsg` (Messages must be signed in).
-- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
-
-### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
-
-- Configure a Teams app + Bot Framework, then add a `msteams` config section.
-- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
-
-### [WebChat](https://docs.openclaw.ai/web/webchat)
-
-- Uses the Gateway WebSocket; no separate WebChat port/config.
-
-Browser control (optional):
-
-```json5
-{
- browser: {
- enabled: true,
- color: "#FF4500",
- },
-}
+```bash
+cd apps/web
+pnpm install
+pnpm dev
```
-## Docs
+---
-Use these when youβre past the onboarding flow and want the deeper reference.
+## Project Structure
-- [Start with the docs index for navigation and βwhatβs where.β](https://docs.openclaw.ai)
-- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
-- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
-- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
-- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
-- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
-- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard)
-- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
-- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
-- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
-- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
-- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
-- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
+```
+src/ Core CLI, commands, gateway, agent, media pipeline
+apps/web/ Next.js web UI (Dench)
+apps/ios/ iOS companion node
+apps/android/ Android companion node
+apps/macos/ macOS menu bar app
+extensions/ Channel plugins (MS Teams, Matrix, Zalo, voice-call)
+docs/ Documentation
+scripts/ Build, deploy, and utility scripts
+skills/ Workspace skills
+```
-## Advanced docs (discovery + control)
+---
-- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
-- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
-- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
-- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
-- [Control UI](https://docs.openclaw.ai/web/control-ui)
-- [Dashboard](https://docs.openclaw.ai/web/dashboard)
+## Development
-## Operations & troubleshooting
+```bash
+pnpm install # Install deps
+pnpm build # Type-check + build
+pnpm check # Lint + format check
+pnpm test # Run tests (vitest)
+pnpm test:coverage # Tests with coverage
+pnpm dev # Dev mode (auto-reload)
+```
-- [Health checks](https://docs.openclaw.ai/gateway/health)
-- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
-- [Background process](https://docs.openclaw.ai/gateway/background-process)
-- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
-- [Logging](https://docs.openclaw.ai/logging)
+---
-## Deep dives
+## Upstream
-- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
-- [Presence](https://docs.openclaw.ai/concepts/presence)
-- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
-- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
-- [Queue](https://docs.openclaw.ai/concepts/queue)
+Ironclaw is built on [OpenClaw](https://github.com/openclaw/openclaw). To sync with upstream:
-## Workspace & skills
+```bash
+git remote add upstream https://github.com/openclaw/openclaw.git
+git fetch upstream
+git merge upstream/main
+```
-- [Skills config](https://docs.openclaw.ai/tools/skills-config)
-- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
-- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
-- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
-- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
-- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
-- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
-- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
+---
-## Platform internals
+## Open Source
-- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
-- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
-- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
-- [iOS node](https://docs.openclaw.ai/platforms/ios)
-- [Android node](https://docs.openclaw.ai/platforms/android)
-- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
-- [Linux app](https://docs.openclaw.ai/platforms/linux)
+MIT Licensed. Fork it, extend it, make it yours.
-## Email hooks (Gmail)
-
-- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
-
-## Molty
-
-OpenClaw was built for **Molty**, a space lobster AI assistant. π¦
-by Peter Steinberger and the community.
-
-- [openclaw.ai](https://openclaw.ai)
-- [soul.md](https://soul.md)
-- [steipete.me](https://steipete.me)
-- [@openclaw](https://x.com/openclaw)
-
-## Community
-
-See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
-AI/vibe-coded PRs welcome! π€
-
-Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
-[pi-mono](https://github.com/badlogic/pi-mono).
-Special thanks to Adam Doppelt for lobster.bot.
-
-Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/apps/web/app/api/chat/active/route.ts b/apps/web/app/api/chat/active/route.ts
new file mode 100644
index 00000000000..9d7afac2ade
--- /dev/null
+++ b/apps/web/app/api/chat/active/route.ts
@@ -0,0 +1,13 @@
+/**
+ * GET /api/chat/active
+ *
+ * Returns the session IDs of all currently running agent sessions.
+ * Used by the sidebar to show streaming indicators.
+ */
+import { getRunningSessionIds } from "@/lib/active-runs";
+
+export const runtime = "nodejs";
+
+export function GET() {
+ return Response.json({ sessionIds: getRunningSessionIds() });
+}
diff --git a/apps/web/app/api/chat/chat.test.ts b/apps/web/app/api/chat/chat.test.ts
new file mode 100644
index 00000000000..22e5a51c918
--- /dev/null
+++ b/apps/web/app/api/chat/chat.test.ts
@@ -0,0 +1,269 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock active-runs module
+vi.mock("@/lib/active-runs", () => ({
+ startRun: vi.fn(),
+ hasActiveRun: vi.fn(() => false),
+ subscribeToRun: vi.fn(),
+ persistUserMessage: vi.fn(),
+ abortRun: vi.fn(() => false),
+ getActiveRun: vi.fn(),
+ getRunningSessionIds: vi.fn(() => []),
+}));
+
+// Mock workspace module
+vi.mock("@/lib/workspace", () => ({
+ resolveAgentWorkspacePrefix: vi.fn(() => null),
+}));
+
+describe("Chat API routes", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ // Re-wire mocks
+ vi.mock("@/lib/active-runs", () => ({
+ startRun: vi.fn(),
+ hasActiveRun: vi.fn(() => false),
+ subscribeToRun: vi.fn(),
+ persistUserMessage: vi.fn(),
+ abortRun: vi.fn(() => false),
+ getActiveRun: vi.fn(),
+ getRunningSessionIds: vi.fn(() => []),
+ }));
+ vi.mock("@/lib/workspace", () => ({
+ resolveAgentWorkspacePrefix: vi.fn(() => null),
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // βββ POST /api/chat ββββββββββββββββββββββββββββββββββββββββββββββ
+
+ describe("POST /api/chat", () => {
+ it("returns 400 when no user message text", async () => {
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [{ role: "user", parts: [{ type: "text", text: "" }] }],
+ }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 409 when active run exists for session", async () => {
+ const { hasActiveRun } = await import("@/lib/active-runs");
+ vi.mocked(hasActiveRun).mockReturnValue(true);
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [{ role: "user", parts: [{ type: "text", text: "hello" }] }],
+ sessionId: "s1",
+ }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(409);
+ });
+
+ it("starts a run and returns streaming response", async () => {
+ const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
+ vi.mocked(hasActiveRun).mockReturnValue(false);
+ vi.mocked(subscribeToRun).mockReturnValue(() => {});
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [
+ { id: "m1", role: "user", parts: [{ type: "text", text: "hello" }] },
+ ],
+ sessionId: "s1",
+ }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
+ expect(startRun).toHaveBeenCalled();
+ });
+
+ it("persists user message when sessionId provided", async () => {
+ const { hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs");
+ vi.mocked(hasActiveRun).mockReturnValue(false);
+ vi.mocked(subscribeToRun).mockReturnValue(() => {});
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [
+ { id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] },
+ ],
+ sessionId: "s1",
+ }),
+ });
+ await POST(req);
+ expect(persistUserMessage).toHaveBeenCalledWith("s1", expect.objectContaining({ id: "m1" }));
+ });
+
+ it("resolves workspace file paths in message", async () => {
+ const { resolveAgentWorkspacePrefix } = await import("@/lib/workspace");
+ vi.mocked(resolveAgentWorkspacePrefix).mockReturnValue("workspace");
+ const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
+ vi.mocked(hasActiveRun).mockReturnValue(false);
+ vi.mocked(subscribeToRun).mockReturnValue(() => {});
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [
+ {
+ id: "m1",
+ role: "user",
+ parts: [{ type: "text", text: "[Context: workspace file 'doc.md']" }],
+ },
+ ],
+ sessionId: "s1",
+ }),
+ });
+ await POST(req);
+ expect(startRun).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringContaining("workspace/doc.md"),
+ }),
+ );
+ });
+ });
+
+ // βββ POST /api/chat/stop ββββββββββββββββββββββββββββββββββββββββ
+
+ describe("POST /api/chat/stop", () => {
+ it("returns 400 when sessionId missing", async () => {
+ const { POST } = await import("./stop/route.js");
+ const req = new Request("http://localhost/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("aborts run and returns result", async () => {
+ const { abortRun } = await import("@/lib/active-runs");
+ vi.mocked(abortRun).mockReturnValue(true);
+
+ const { POST } = await import("./stop/route.js");
+ const req = new Request("http://localhost/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sessionId: "s1" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.aborted).toBe(true);
+ });
+
+ it("returns aborted=false for unknown session", async () => {
+ const { abortRun } = await import("@/lib/active-runs");
+ vi.mocked(abortRun).mockReturnValue(false);
+
+ const { POST } = await import("./stop/route.js");
+ const req = new Request("http://localhost/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sessionId: "nonexistent" }),
+ });
+ const res = await POST(req);
+ const json = await res.json();
+ expect(json.aborted).toBe(false);
+ });
+
+ it("handles invalid JSON body gracefully", async () => {
+ const { POST } = await import("./stop/route.js");
+ const req = new Request("http://localhost/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+ });
+
+ // βββ GET /api/chat/active ββββββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/chat/active", () => {
+ it("returns empty sessionIds when no active runs", async () => {
+ const { GET } = await import("./active/route.js");
+ const res = GET();
+ const json = await res.json();
+ expect(json.sessionIds).toEqual([]);
+ });
+
+ it("returns active session IDs", async () => {
+ const { getRunningSessionIds } = await import("@/lib/active-runs");
+ vi.mocked(getRunningSessionIds).mockReturnValue(["s1", "s2"]);
+
+ const { GET } = await import("./active/route.js");
+ const res = GET();
+ const json = await res.json();
+ expect(json.sessionIds).toEqual(["s1", "s2"]);
+ });
+ });
+
+ // βββ GET /api/chat/stream βββββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/chat/stream", () => {
+ it("returns 400 when sessionId is missing", async () => {
+ const { GET } = await import("./stream/route.js");
+ const req = new Request("http://localhost/api/chat/stream");
+ const res = await GET(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when no run exists for session", async () => {
+ const { getActiveRun } = await import("@/lib/active-runs");
+ vi.mocked(getActiveRun).mockReturnValue(undefined);
+
+ const { GET } = await import("./stream/route.js");
+ const req = new Request("http://localhost/api/chat/stream?sessionId=nonexistent");
+ const res = await GET(req);
+ expect(res.status).toBe(404);
+ });
+
+ it("returns SSE stream for active run", async () => {
+ const { getActiveRun, subscribeToRun } = await import("@/lib/active-runs");
+ vi.mocked(getActiveRun).mockReturnValue({ status: "running" } as never);
+ vi.mocked(subscribeToRun).mockReturnValue(() => {});
+
+ const { GET } = await import("./stream/route.js");
+ const req = new Request("http://localhost/api/chat/stream?sessionId=s1");
+ const res = await GET(req);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
+ expect(res.headers.get("X-Run-Active")).toBe("true");
+ });
+
+ it("returns X-Run-Active=false for completed run", async () => {
+ const { getActiveRun, subscribeToRun } = await import("@/lib/active-runs");
+ vi.mocked(getActiveRun).mockReturnValue({ status: "completed" } as never);
+ vi.mocked(subscribeToRun).mockReturnValue(() => {});
+
+ const { GET } = await import("./stream/route.js");
+ const req = new Request("http://localhost/api/chat/stream?sessionId=s1");
+ const res = await GET(req);
+ expect(res.headers.get("X-Run-Active")).toBe("false");
+ });
+ });
+});
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
new file mode 100644
index 00000000000..7af4818542e
--- /dev/null
+++ b/apps/web/app/api/chat/route.ts
@@ -0,0 +1,223 @@
+import type { UIMessage } from "ai";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { resolveAgentWorkspacePrefix } from "@/lib/workspace";
+import {
+ startRun,
+ hasActiveRun,
+ subscribeToRun,
+ persistUserMessage,
+ type SseEvent as ParentSseEvent,
+} from "@/lib/active-runs";
+import {
+ hasActiveSubagent,
+ isSubagentRunning,
+ ensureRegisteredFromDisk,
+ subscribeToSubagent,
+ persistUserMessage as persistSubagentUserMessage,
+ reactivateSubagent,
+ spawnSubagentMessage,
+ type SseEvent as SubagentSseEvent,
+} from "@/lib/subagent-runs";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+// Force Node.js runtime (required for child_process)
+export const runtime = "nodejs";
+
+// Allow streaming responses up to 10 minutes
+export const maxDuration = 600;
+
+function deriveSubagentParentSessionId(sessionKey: string): string {
+ const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
+ if (!existsSync(registryPath)) {return "";}
+ try {
+ const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as {
+ runs?: Record>;
+ };
+ for (const entry of Object.values(raw.runs ?? {})) {
+ if (entry.childSessionKey !== sessionKey) {continue;}
+ const requester = typeof entry.requesterSessionKey === "string" ? entry.requesterSessionKey : "";
+ const match = requester.match(/^agent:[^:]+:web:(.+)$/);
+ return match?.[1] ?? "";
+ }
+ } catch {
+ // ignore
+ }
+ return "";
+}
+
+function ensureSubagentRegistered(sessionKey: string): boolean {
+ if (hasActiveSubagent(sessionKey)) {return true;}
+ const parentWebSessionId = deriveSubagentParentSessionId(sessionKey);
+ return ensureRegisteredFromDisk(sessionKey, parentWebSessionId);
+}
+
+export async function POST(req: Request) {
+ const {
+ messages,
+ sessionId,
+ sessionKey,
+ }: { messages: UIMessage[]; sessionId?: string; sessionKey?: string } = await req.json();
+
+ // Extract the latest user message text
+ const lastUserMessage = messages.filter((m) => m.role === "user").pop();
+ const userText =
+ lastUserMessage?.parts
+ ?.filter(
+ (p): p is { type: "text"; text: string } =>
+ p.type === "text",
+ )
+ .map((p) => p.text)
+ .join("\n") ?? "";
+
+ if (!userText.trim()) {
+ return new Response("No message provided", { status: 400 });
+ }
+
+ const isSubagentSession = typeof sessionKey === "string" && sessionKey.includes(":subagent:");
+
+ // Reject if a run is already active for this session.
+ if (!isSubagentSession && sessionId && hasActiveRun(sessionId)) {
+ return new Response("Active run in progress", { status: 409 });
+ }
+ if (isSubagentSession && isSubagentRunning(sessionKey)) {
+ return new Response("Active subagent run in progress", { status: 409 });
+ }
+
+ // Resolve workspace file paths to be agent-cwd-relative.
+ let agentMessage = userText;
+ const wsPrefix = resolveAgentWorkspacePrefix();
+ if (wsPrefix) {
+ agentMessage = userText.replace(
+ /\[Context: workspace file '([^']+)'\]/,
+ `[Context: workspace file '${wsPrefix}/$1']`,
+ );
+ }
+
+ // Persist the user message server-side so it survives a page reload
+ // even if the client never gets a chance to save.
+ if (isSubagentSession && sessionKey && lastUserMessage) {
+ if (!ensureSubagentRegistered(sessionKey)) {
+ return new Response("Subagent not found", { status: 404 });
+ }
+ persistSubagentUserMessage(sessionKey, {
+ id: lastUserMessage.id,
+ text: userText,
+ });
+ } else if (sessionId && lastUserMessage) {
+ persistUserMessage(sessionId, {
+ id: lastUserMessage.id,
+ content: userText,
+ parts: lastUserMessage.parts as unknown[],
+ });
+ }
+
+ // Start the agent run (decoupled from this HTTP connection).
+ // The child process will keep running even if this response is cancelled.
+ if (isSubagentSession && sessionKey) {
+ if (!reactivateSubagent(sessionKey)) {
+ return new Response("Subagent not found", { status: 404 });
+ }
+ if (!spawnSubagentMessage(sessionKey, agentMessage)) {
+ return new Response("Failed to start subagent run", { status: 500 });
+ }
+ } else if (sessionId) {
+ try {
+ startRun({
+ sessionId,
+ message: agentMessage,
+ agentSessionId: sessionId,
+ });
+ } catch (err) {
+ return new Response(
+ err instanceof Error ? err.message : String(err),
+ { status: 500 },
+ );
+ }
+ }
+
+ // Stream SSE events to the client using the AI SDK v6 wire format.
+ const encoder = new TextEncoder();
+ let closed = false;
+ let unsubscribe: (() => void) | null = null;
+
+ const stream = new ReadableStream({
+ start(controller) {
+ if (!sessionId && !sessionKey) {
+ // No session β shouldn't happen but close gracefully.
+ controller.close();
+ return;
+ }
+
+ unsubscribe = isSubagentSession && sessionKey
+ ? subscribeToSubagent(
+ sessionKey,
+ (event: SubagentSseEvent | null) => {
+ if (closed) {return;}
+ if (event === null) {
+ closed = true;
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ return;
+ }
+ try {
+ const json = JSON.stringify(event);
+ controller.enqueue(encoder.encode(`data: ${json}\n\n`));
+ } catch {
+ /* ignore enqueue errors on closed stream */
+ }
+ },
+ { replay: false },
+ )
+ : subscribeToRun(
+ sessionId as string,
+ (event: ParentSseEvent | null) => {
+ if (closed) {return;}
+ if (event === null) {
+ // Run completed β close the SSE stream.
+ closed = true;
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ return;
+ }
+ try {
+ const json = JSON.stringify(event);
+ controller.enqueue(
+ encoder.encode(`data: ${json}\n\n`),
+ );
+ } catch {
+ /* ignore enqueue errors on closed stream */
+ }
+ },
+ // Don't replay β we just created the run, the buffer is empty.
+ { replay: false },
+ );
+
+ if (!unsubscribe) {
+ // Race: run was cleaned up between startRun and subscribe.
+ closed = true;
+ controller.close();
+ }
+ },
+ cancel() {
+ // Client disconnected β unsubscribe but keep the run alive.
+ // The ActiveRunManager continues buffering + persisting in the background.
+ closed = true;
+ unsubscribe?.();
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+}
diff --git a/apps/web/app/api/chat/stop/route.ts b/apps/web/app/api/chat/stop/route.ts
new file mode 100644
index 00000000000..02b1a66e488
--- /dev/null
+++ b/apps/web/app/api/chat/stop/route.ts
@@ -0,0 +1,60 @@
+/**
+ * POST /api/chat/stop
+ *
+ * Abort an active agent run. Called by the Stop button.
+ * The child process is sent SIGTERM and the run transitions to "error" state.
+ */
+import { abortRun } from "@/lib/active-runs";
+import {
+ abortSubagent,
+ hasActiveSubagent,
+ isSubagentRunning,
+ ensureRegisteredFromDisk,
+} from "@/lib/subagent-runs";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const runtime = "nodejs";
+
+function deriveSubagentParentSessionId(sessionKey: string): string {
+ const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
+ if (!existsSync(registryPath)) {return "";}
+ try {
+ const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as {
+ runs?: Record>;
+ };
+ for (const entry of Object.values(raw.runs ?? {})) {
+ if (entry.childSessionKey !== sessionKey) {continue;}
+ const requester = typeof entry.requesterSessionKey === "string" ? entry.requesterSessionKey : "";
+ const match = requester.match(/^agent:[^:]+:web:(.+)$/);
+ return match?.[1] ?? "";
+ }
+ } catch {
+ // ignore
+ }
+ return "";
+}
+
+export async function POST(req: Request) {
+ const body: { sessionId?: string; sessionKey?: string } = await req
+ .json()
+ .catch(() => ({}));
+
+ const isSubagentSession = typeof body.sessionKey === "string" && body.sessionKey.includes(":subagent:");
+ if (isSubagentSession && body.sessionKey) {
+ if (!hasActiveSubagent(body.sessionKey)) {
+ const parentWebSessionId = deriveSubagentParentSessionId(body.sessionKey);
+ ensureRegisteredFromDisk(body.sessionKey, parentWebSessionId);
+ }
+ const aborted = isSubagentRunning(body.sessionKey) ? abortSubagent(body.sessionKey) : false;
+ return Response.json({ aborted });
+ }
+
+ if (!body.sessionId) {
+ return new Response("sessionId or subagent sessionKey required", { status: 400 });
+ }
+
+ const aborted = abortRun(body.sessionId);
+ return Response.json({ aborted });
+}
diff --git a/apps/web/app/api/chat/stream/route.ts b/apps/web/app/api/chat/stream/route.ts
new file mode 100644
index 00000000000..c1c16fd563e
--- /dev/null
+++ b/apps/web/app/api/chat/stream/route.ts
@@ -0,0 +1,201 @@
+/**
+ * GET /api/chat/stream?sessionId=xxx
+ *
+ * Reconnect to an active (or recently-completed) agent run.
+ * Replays all buffered SSE events from the start of the run, then
+ * streams live events until the run finishes.
+ *
+ * Returns 404 if no run exists for the given session.
+ */
+import {
+ getActiveRun,
+ subscribeToRun,
+ type SseEvent as ParentSseEvent,
+} from "@/lib/active-runs";
+import {
+ subscribeToSubagent,
+ hasActiveSubagent,
+ isSubagentRunning,
+ ensureRegisteredFromDisk,
+ ensureSubagentStreamable,
+ type SseEvent as SubagentSseEvent,
+} from "@/lib/subagent-runs";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const runtime = "nodejs";
+export const maxDuration = 600;
+
+function deriveSubagentParentSessionId(sessionKey: string): string {
+ const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
+ if (!existsSync(registryPath)) {return "";}
+ try {
+ const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as {
+ runs?: Record>;
+ };
+ for (const entry of Object.values(raw.runs ?? {})) {
+ if (entry.childSessionKey !== sessionKey) {continue;}
+ const requester = typeof entry.requesterSessionKey === "string" ? entry.requesterSessionKey : "";
+ const match = requester.match(/^agent:[^:]+:web:(.+)$/);
+ return match?.[1] ?? "";
+ }
+ } catch {
+ // ignore
+ }
+ return "";
+}
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const sessionId = url.searchParams.get("sessionId");
+ const sessionKey = url.searchParams.get("sessionKey");
+ const isSubagentSession = typeof sessionKey === "string" && sessionKey.includes(":subagent:");
+
+ if (!sessionId && !sessionKey) {
+ return new Response("sessionId or subagent sessionKey required", { status: 400 });
+ }
+
+ if (isSubagentSession && sessionKey) {
+ if (!hasActiveSubagent(sessionKey)) {
+ const parentWebSessionId = deriveSubagentParentSessionId(sessionKey);
+ const registered = ensureRegisteredFromDisk(sessionKey, parentWebSessionId);
+ if (!registered && !hasActiveSubagent(sessionKey)) {
+ return Response.json({ active: false }, { status: 404 });
+ }
+ }
+ ensureSubagentStreamable(sessionKey);
+ const isActive = isSubagentRunning(sessionKey);
+ const encoder = new TextEncoder();
+ let closed = false;
+ let unsubscribe: (() => void) | null = null;
+
+ const stream = new ReadableStream({
+ start(controller) {
+ unsubscribe = subscribeToSubagent(
+ sessionKey,
+ (event: SubagentSseEvent | null) => {
+ if (closed) {return;}
+ if (event === null) {
+ closed = true;
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ return;
+ }
+ try {
+ const json = JSON.stringify(event);
+ controller.enqueue(encoder.encode(`data: ${json}\n\n`));
+ } catch {
+ /* ignore enqueue errors on closed stream */
+ }
+ },
+ { replay: true },
+ );
+
+ if (!unsubscribe) {
+ closed = true;
+ controller.close();
+ }
+ },
+ cancel() {
+ closed = true;
+ unsubscribe?.();
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ "X-Run-Active": isActive ? "true" : "false",
+ },
+ });
+ }
+ const run = getActiveRun(sessionId as string);
+ if (!run) {
+ return Response.json({ active: false }, { status: 404 });
+ }
+
+ const encoder = new TextEncoder();
+ let closed = false;
+ let unsubscribe: (() => void) | null = null;
+ let keepalive: ReturnType | null = null;
+
+ const stream = new ReadableStream({
+ start(controller) {
+ // Keep idle SSE connections alive while waiting for subagent announcements.
+ keepalive = setInterval(() => {
+ if (closed) {return;}
+ try {
+ controller.enqueue(encoder.encode(": keepalive\n\n"));
+ } catch {
+ /* ignore enqueue errors on closed stream */
+ }
+ }, 15_000);
+
+ // subscribeToRun with replay=true replays the full event buffer
+ // synchronously, then subscribes for live events.
+ unsubscribe = subscribeToRun(
+ sessionId as string,
+ (event: ParentSseEvent | null) => {
+ if (closed) {return;}
+ if (event === null) {
+ // Run completed β close the SSE stream.
+ closed = true;
+ if (keepalive) {
+ clearInterval(keepalive);
+ keepalive = null;
+ }
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ return;
+ }
+ try {
+ const json = JSON.stringify(event);
+ controller.enqueue(
+ encoder.encode(`data: ${json}\n\n`),
+ );
+ } catch {
+ /* ignore enqueue errors on closed stream */
+ }
+ },
+ { replay: true },
+ );
+
+ if (!unsubscribe) {
+ // Run was cleaned up between getActiveRun and subscribe.
+ closed = true;
+ if (keepalive) {
+ clearInterval(keepalive);
+ keepalive = null;
+ }
+ controller.close();
+ }
+ },
+ cancel() {
+ // Client disconnected β unsubscribe only (don't kill the run).
+ closed = true;
+ if (keepalive) {
+ clearInterval(keepalive);
+ keepalive = null;
+ }
+ unsubscribe?.();
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ "X-Run-Active": run.status === "running" || run.status === "waiting-for-subagents" ? "true" : "false",
+ },
+ });
+}
diff --git a/apps/web/app/api/chat/subagents/route.ts b/apps/web/app/api/chat/subagents/route.ts
new file mode 100644
index 00000000000..35f3ab6f811
--- /dev/null
+++ b/apps/web/app/api/chat/subagents/route.ts
@@ -0,0 +1,64 @@
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const runtime = "nodejs";
+
+type RegistryEntry = {
+ runId: string;
+ childSessionKey: string;
+ requesterSessionKey: string;
+ task: string;
+ label?: string;
+ createdAt?: number;
+ endedAt?: number;
+ outcome?: { status: string; error?: string };
+};
+
+function readSubagentRegistry(): RegistryEntry[] {
+ const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
+ if (!existsSync(registryPath)) {return [];}
+
+ try {
+ const raw = JSON.parse(readFileSync(registryPath, "utf-8"));
+ if (!raw || typeof raw !== "object") {return [];}
+ const runs = raw.runs;
+ if (!runs || typeof runs !== "object") {return [];}
+ return Object.values(runs);
+ } catch {
+ return [];
+ }
+}
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const sessionId = url.searchParams.get("sessionId");
+
+ if (!sessionId) {
+ return Response.json({ error: "sessionId required" }, { status: 400 });
+ }
+
+ const webSessionKey = `agent:main:web:${sessionId}`;
+ const entries = readSubagentRegistry();
+
+ const subagents = entries
+ .filter((e) => e.requesterSessionKey === webSessionKey)
+ .map((e) => ({
+ sessionKey: e.childSessionKey,
+ runId: e.runId,
+ task: e.task,
+ label: e.label || undefined,
+ status: resolveStatus(e),
+ startedAt: e.createdAt,
+ endedAt: e.endedAt,
+ }))
+ .toSorted((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
+
+ return Response.json({ subagents });
+}
+
+function resolveStatus(e: RegistryEntry): "running" | "completed" | "error" {
+ if (typeof e.endedAt !== "number") {return "running";}
+ if (e.outcome?.status === "error") {return "error";}
+ return "completed";
+}
diff --git a/apps/web/app/api/cron/cron.test.ts b/apps/web/app/api/cron/cron.test.ts
new file mode 100644
index 00000000000..48d3e71ce6c
--- /dev/null
+++ b/apps/web/app/api/cron/cron.test.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock node:fs
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
+}));
+
+// Mock node:os
+vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+}));
+
+describe("Cron API routes", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
+ }));
+ vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // βββ GET /api/cron/jobs βββββββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/cron/jobs", () => {
+ it("returns empty jobs when no config file", async () => {
+ const { GET } = await import("./jobs/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.jobs).toEqual([]);
+ });
+
+ it("returns jobs from config file", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ const cronStore = {
+ version: 1,
+ jobs: [
+ { id: "j1", name: "Daily sync", schedule: "0 8 * * *", enabled: true, command: "sync" },
+ ],
+ };
+ vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(cronStore) as never);
+
+ const { GET } = await import("./jobs/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.jobs).toHaveLength(1);
+ expect(json.jobs[0].name).toBe("Daily sync");
+ });
+
+ it("handles corrupt jobs file gracefully", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReadFile).mockReturnValue("not json" as never);
+
+ const { GET } = await import("./jobs/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.jobs).toEqual([]);
+ });
+ });
+
+ // βββ GET /api/cron/jobs/[jobId]/runs ββββββββββββββββββββββββββββ
+
+ describe("GET /api/cron/jobs/[jobId]/runs", () => {
+ it("returns empty entries when no runs file", async () => {
+ const { GET } = await import("./jobs/[jobId]/runs/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/cron/jobs/j1/runs"),
+ { params: Promise.resolve({ jobId: "j1" }) },
+ );
+ const json = await res.json();
+ expect(json.entries).toEqual([]);
+ });
+
+ it("returns run entries from jsonl file", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ const lines = [
+ JSON.stringify({ ts: 1000, jobId: "j1", action: "finished", status: "completed", summary: "Done" }),
+ JSON.stringify({ ts: 2000, jobId: "j1", action: "finished", status: "completed", summary: "In progress" }),
+ ].join("\n");
+ vi.mocked(mockReadFile).mockReturnValue(lines as never);
+
+ const { GET } = await import("./jobs/[jobId]/runs/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/cron/jobs/j1/runs"),
+ { params: Promise.resolve({ jobId: "j1" }) },
+ );
+ const json = await res.json();
+ expect(json.entries.length).toBeGreaterThan(0);
+ });
+
+ it("respects limit query param", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ const lines = Array.from({ length: 50 }, (_, i) =>
+ JSON.stringify({ ts: i, status: "completed" }),
+ ).join("\n");
+ vi.mocked(mockReadFile).mockReturnValue(lines as never);
+
+ const { GET } = await import("./jobs/[jobId]/runs/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/cron/jobs/j1/runs?limit=5"),
+ { params: Promise.resolve({ jobId: "j1" }) },
+ );
+ const json = await res.json();
+ expect(json.entries.length).toBeLessThanOrEqual(5);
+ });
+ });
+
+ // βββ GET /api/cron/runs/[sessionId] βββββββββββββββββββββββββββββ
+
+ describe("GET /api/cron/runs/[sessionId]", () => {
+ it("returns 404 when session not found", async () => {
+ const { GET } = await import("./runs/[sessionId]/route.js");
+ const res = await GET(
+ new Request("http://localhost"),
+ { params: Promise.resolve({ sessionId: "nonexistent" }) },
+ );
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // βββ GET /api/cron/runs/search-transcript βββββββββββββββββββββββ
+
+ describe("GET /api/cron/runs/search-transcript", () => {
+ it("returns 400 when missing required params", async () => {
+ const { GET } = await import("./runs/search-transcript/route.js");
+ const req = new Request("http://localhost/api/cron/runs/search-transcript");
+ const res = await GET(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when no transcript found", async () => {
+ const { GET } = await import("./runs/search-transcript/route.js");
+ const req = new Request("http://localhost/api/cron/runs/search-transcript?jobId=j1&runAtMs=1000");
+ const res = await GET(req);
+ expect(res.status).toBe(404);
+ });
+ });
+});
diff --git a/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts b/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts
new file mode 100644
index 00000000000..05fa62cea44
--- /dev/null
+++ b/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts
@@ -0,0 +1,86 @@
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+const CRON_DIR = join(resolveOpenClawStateDir(), "cron");
+
+type CronRunLogEntry = {
+ ts: number;
+ jobId: string;
+ action: "finished";
+ status?: string;
+ error?: string;
+ summary?: string;
+ sessionId?: string;
+ sessionKey?: string;
+ runAtMs?: number;
+ durationMs?: number;
+ nextRunAtMs?: number;
+};
+
+/** Read run log entries from a JSONL file, returning most recent first (then reversed). */
+function readRunLog(filePath: string, limit: number): CronRunLogEntry[] {
+ if (!existsSync(filePath)) {return [];}
+ try {
+ const raw = readFileSync(filePath, "utf-8");
+ if (!raw.trim()) {return [];}
+ const lines = raw.split("\n");
+ const parsed: CronRunLogEntry[] = [];
+ for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) {
+ const line = lines[i]?.trim();
+ if (!line) {continue;}
+ try {
+ const obj = JSON.parse(line) as Partial;
+ if (!obj || typeof obj !== "object") {continue;}
+ if (obj.action !== "finished") {continue;}
+ if (typeof obj.jobId !== "string" || !obj.jobId.trim()) {continue;}
+ if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) {continue;}
+ const entry: CronRunLogEntry = {
+ ts: obj.ts,
+ jobId: obj.jobId,
+ action: "finished",
+ status: obj.status,
+ error: obj.error,
+ summary: obj.summary,
+ runAtMs: obj.runAtMs,
+ durationMs: obj.durationMs,
+ nextRunAtMs: obj.nextRunAtMs,
+ };
+ if (typeof obj.sessionId === "string" && obj.sessionId.trim()) {
+ entry.sessionId = obj.sessionId;
+ }
+ if (typeof obj.sessionKey === "string" && obj.sessionKey.trim()) {
+ entry.sessionKey = obj.sessionKey;
+ }
+ parsed.push(entry);
+ } catch {
+ // skip malformed lines
+ }
+ }
+ return parsed.toReversed();
+ } catch {
+ return [];
+ }
+}
+
+/** GET /api/cron/jobs/[jobId]/runs -- list run log entries for a cron job */
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ jobId: string }> },
+) {
+ const { jobId } = await params;
+ if (!jobId) {
+ return Response.json({ error: "Job ID required" }, { status: 400 });
+ }
+
+ const url = new URL(request.url);
+ const limitParam = url.searchParams.get("limit");
+ const limit = Math.max(1, Math.min(500, Number(limitParam) || 100));
+
+ const logPath = join(CRON_DIR, "runs", `${jobId}.jsonl`);
+ const entries = readRunLog(logPath, limit);
+
+ return Response.json({ entries });
+}
diff --git a/apps/web/app/api/cron/jobs/route.ts b/apps/web/app/api/cron/jobs/route.ts
new file mode 100644
index 00000000000..0a77df08fb2
--- /dev/null
+++ b/apps/web/app/api/cron/jobs/route.ts
@@ -0,0 +1,99 @@
+import { readFileSync, existsSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+const CRON_DIR = join(resolveOpenClawStateDir(), "cron");
+const JOBS_FILE = join(CRON_DIR, "jobs.json");
+
+type CronStoreFile = {
+ version: 1;
+ jobs: Array>;
+};
+
+/** Read cron jobs.json, returning empty array if missing or invalid. */
+function readJobsFile(): Array> {
+ if (!existsSync(JOBS_FILE)) {return [];}
+ try {
+ const raw = readFileSync(JOBS_FILE, "utf-8");
+ const parsed = JSON.parse(raw) as CronStoreFile;
+ if (parsed && Array.isArray(parsed.jobs)) {return parsed.jobs;}
+ return [];
+ } catch {
+ return [];
+ }
+}
+
+/** Compute next wake time from job states (minimum nextRunAtMs among enabled jobs). */
+function computeNextWakeAtMs(jobs: Array>): number | null {
+ let min: number | null = null;
+ for (const job of jobs) {
+ if (job.enabled !== true) {continue;}
+ const state = job.state as Record | undefined;
+ if (!state) {continue;}
+ const next = state.nextRunAtMs;
+ if (typeof next === "number" && Number.isFinite(next)) {
+ if (min === null || next < min) {min = next;}
+ }
+ }
+ return min;
+}
+
+/** Read heartbeat config from ~/.openclaw/config.yaml (best-effort). */
+function readHeartbeatInfo(): { intervalMs: number; nextDueEstimateMs: number | null } {
+ const defaults = { intervalMs: 30 * 60_000, nextDueEstimateMs: null as number | null };
+
+ // Try to read agent session stores to estimate next heartbeat from lastRunMs
+ try {
+ const agentsDir = join(resolveOpenClawStateDir(), "agents");
+ if (!existsSync(agentsDir)) {return defaults;}
+
+ const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
+ let latestHeartbeat: number | null = null;
+
+ for (const d of agentDirs) {
+ if (!d.isDirectory()) {continue;}
+ const storePath = join(agentsDir, d.name, "sessions", "sessions.json");
+ if (!existsSync(storePath)) {continue;}
+ try {
+ const raw = readFileSync(storePath, "utf-8");
+ const store = JSON.parse(raw) as Record;
+ // Look for the main agent session (shortest key, most recently updated)
+ for (const [key, entry] of Object.entries(store)) {
+ if (key.startsWith("agent:") && !key.includes(":cron:") && entry.updatedAt) {
+ if (latestHeartbeat === null || entry.updatedAt > latestHeartbeat) {
+ latestHeartbeat = entry.updatedAt;
+ }
+ }
+ }
+ } catch {
+ // skip
+ }
+ }
+
+ if (latestHeartbeat) {
+ defaults.nextDueEstimateMs = latestHeartbeat + defaults.intervalMs;
+ }
+ } catch {
+ // ignore
+ }
+
+ return defaults;
+}
+
+/** GET /api/cron/jobs -- list all cron jobs with heartbeat & status info */
+export async function GET() {
+ const jobs = readJobsFile();
+ const heartbeat = readHeartbeatInfo();
+ const nextWakeAtMs = computeNextWakeAtMs(jobs);
+
+ return Response.json({
+ jobs,
+ heartbeat,
+ cronStatus: {
+ enabled: jobs.length > 0,
+ nextWakeAtMs,
+ },
+ });
+}
diff --git a/apps/web/app/api/cron/runs/[sessionId]/route.ts b/apps/web/app/api/cron/runs/[sessionId]/route.ts
new file mode 100644
index 00000000000..5d6369c4c1a
--- /dev/null
+++ b/apps/web/app/api/cron/runs/[sessionId]/route.ts
@@ -0,0 +1,154 @@
+import { readFileSync, readdirSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+type MessagePart =
+ | { type: "text"; text: string }
+ | { type: "thinking"; thinking: string }
+ | { type: "tool-call"; toolName: string; toolCallId: string; args?: unknown; output?: string };
+
+type ParsedMessage = {
+ id: string;
+ role: "user" | "assistant" | "system";
+ parts: MessagePart[];
+ timestamp: string;
+};
+
+/** Search agent session directories for a session file by ID. */
+function findSessionFile(sessionId: string): string | null {
+ const agentsDir = join(resolveOpenClawStateDir(), "agents");
+ if (!existsSync(agentsDir)) {return null;}
+
+ try {
+ const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
+ for (const agentDir of agentDirs) {
+ if (!agentDir.isDirectory()) {continue;}
+ const sessionFile = join(agentsDir, agentDir.name, "sessions", `${sessionId}.jsonl`);
+ if (existsSync(sessionFile)) {return sessionFile;}
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+/** Parse a JSONL session transcript into structured messages with thinking and tool calls. */
+function parseSessionTranscript(content: string): ParsedMessage[] {
+ const lines = content.trim().split("\n").filter((l) => l.trim());
+ const messages: ParsedMessage[] = [];
+
+ // Track tool calls for linking invocations with results
+ const pendingToolCalls = new Map();
+
+ for (const line of lines) {
+ try {
+ const entry = JSON.parse(line);
+
+ if (entry.type === "message" && entry.message) {
+ const msg = entry.message;
+ const role = msg.role as "user" | "assistant" | "system";
+ const parts: MessagePart[] = [];
+
+ if (Array.isArray(msg.content)) {
+ for (const part of msg.content) {
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
+ parts.push({ type: "text", text: part.text });
+ } else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) {
+ parts.push({ type: "thinking", thinking: part.thinking });
+ } else if (part.type === "tool_use" || part.type === "tool-call") {
+ const toolName = part.name ?? part.toolName ?? "unknown";
+ const toolCallId = part.id ?? part.toolCallId ?? `tool-${Date.now()}`;
+ pendingToolCalls.set(toolCallId, { toolName, args: part.input ?? part.args });
+ parts.push({
+ type: "tool-call",
+ toolName,
+ toolCallId,
+ args: part.input ?? part.args,
+ });
+ } else if (part.type === "tool_result" || part.type === "tool-result") {
+ const toolCallId = part.tool_use_id ?? part.toolCallId ?? "";
+ const pending = pendingToolCalls.get(toolCallId);
+ const outputText = typeof part.content === "string"
+ ? part.content
+ : Array.isArray(part.content)
+ ? part.content.filter((c: { type: string }) => c.type === "text").map((c: { text: string }) => c.text).join("\n")
+ : typeof part.output === "string"
+ ? part.output
+ : JSON.stringify(part.output ?? part.content ?? "");
+
+ if (pending) {
+ // Merge output into existing tool-call part
+ const existingMsg = messages[messages.length - 1];
+ if (existingMsg) {
+ const tc = existingMsg.parts.find(
+ (p) => p.type === "tool-call" && (p as { toolCallId: string }).toolCallId === toolCallId,
+ );
+ if (tc && tc.type === "tool-call") {
+ (tc as { output?: string }).output = outputText.slice(0, 5000);
+ continue;
+ }
+ }
+ parts.push({
+ type: "tool-call",
+ toolName: pending.toolName,
+ toolCallId,
+ args: pending.args,
+ output: outputText.slice(0, 5000),
+ });
+ } else {
+ parts.push({
+ type: "tool-call",
+ toolName: "tool",
+ toolCallId,
+ output: outputText.slice(0, 5000),
+ });
+ }
+ }
+ }
+ } else if (typeof msg.content === "string" && msg.content.trim()) {
+ parts.push({ type: "text", text: msg.content });
+ }
+
+ if (parts.length > 0) {
+ messages.push({
+ id: entry.id ?? `msg-${messages.length}`,
+ role,
+ parts,
+ timestamp: entry.timestamp ?? new Date(entry.ts ?? Date.now()).toISOString(),
+ });
+ }
+ }
+ } catch {
+ // skip malformed lines
+ }
+ }
+
+ return messages;
+}
+
+/** GET /api/cron/runs/[sessionId] -- get full session transcript for a cron run */
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ sessionId: string }> },
+) {
+ const { sessionId } = await params;
+ if (!sessionId) {
+ return Response.json({ error: "Session ID required" }, { status: 400 });
+ }
+
+ const sessionFile = findSessionFile(sessionId);
+ if (!sessionFile) {
+ return Response.json({ error: "Session not found" }, { status: 404 });
+ }
+
+ try {
+ const content = readFileSync(sessionFile, "utf-8");
+ const messages = parseSessionTranscript(content);
+ return Response.json({ sessionId, messages });
+ } catch (error) {
+ console.error("Error reading cron session:", error);
+ return Response.json({ error: "Failed to read session" }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/cron/runs/search-transcript/route.ts b/apps/web/app/api/cron/runs/search-transcript/route.ts
new file mode 100644
index 00000000000..2b2a91be019
--- /dev/null
+++ b/apps/web/app/api/cron/runs/search-transcript/route.ts
@@ -0,0 +1,315 @@
+import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+const AGENTS_DIR = join(resolveOpenClawStateDir(), "agents");
+
+type MessagePart =
+ | { type: "text"; text: string }
+ | { type: "thinking"; thinking: string }
+ | { type: "tool-call"; toolName: string; toolCallId: string; args?: unknown; output?: string };
+
+type ParsedMessage = {
+ id: string;
+ role: "user" | "assistant" | "system";
+ parts: MessagePart[];
+ timestamp: string;
+};
+
+/**
+ * Search for the actual agent transcript for a cron run.
+ *
+ * For main-target cron runs, the agent response lives in the main session
+ * transcript files. This endpoint searches session files for the cron payload
+ * text near the run timestamp and returns the matching conversation
+ * (user message + assistant response).
+ */
+
+/** Try to find a cron-specific session from sessions.json. */
+function findCronSessionId(jobId: string): string | null {
+ if (!existsSync(AGENTS_DIR)) {return null;}
+ try {
+ const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
+ for (const agentDir of agentDirs) {
+ if (!agentDir.isDirectory()) {continue;}
+ const sessionsJsonPath = join(AGENTS_DIR, agentDir.name, "sessions", "sessions.json");
+ if (!existsSync(sessionsJsonPath)) {continue;}
+ try {
+ const store = JSON.parse(readFileSync(sessionsJsonPath, "utf-8"));
+ // Look for cron session key matching this job
+ for (const [key, entry] of Object.entries(store)) {
+ if (key.includes(`:cron:${jobId}`) && !key.includes(":run:")) {
+ const sessionId = (entry as { sessionId?: string })?.sessionId;
+ if (typeof sessionId === "string" && sessionId.trim()) {
+ // Verify the session file actually exists
+ const sessionFile = join(AGENTS_DIR, agentDir.name, "sessions", `${sessionId}.jsonl`);
+ if (existsSync(sessionFile)) {
+ return sessionId;
+ }
+ }
+ }
+ }
+ } catch {
+ // skip malformed sessions.json
+ }
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+/** Find session files that might contain the cron run's transcript. */
+function findCandidateSessionFiles(runAtMs: number): string[] {
+ const candidates: Array<{ path: string; mtimeMs: number }> = [];
+ if (!existsSync(AGENTS_DIR)) {return [];}
+
+ try {
+ const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
+ for (const agentDir of agentDirs) {
+ if (!agentDir.isDirectory()) {continue;}
+ const sessionsDir = join(AGENTS_DIR, agentDir.name, "sessions");
+ if (!existsSync(sessionsDir)) {continue;}
+ try {
+ const files = readdirSync(sessionsDir);
+ for (const file of files) {
+ if (!file.endsWith(".jsonl")) {continue;}
+ const filePath = join(sessionsDir, file);
+ try {
+ const stat = statSync(filePath);
+ // Only consider files modified within Β±2 hours of the run
+ const windowMs = 2 * 60 * 60 * 1000;
+ if (Math.abs(stat.mtimeMs - runAtMs) < windowMs) {
+ candidates.push({ path: filePath, mtimeMs: stat.mtimeMs });
+ }
+ } catch {
+ // skip
+ }
+ }
+ } catch {
+ // skip
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ // Sort by closest modification time to runAtMs
+ candidates.sort((a, b) => Math.abs(a.mtimeMs - runAtMs) - Math.abs(b.mtimeMs - runAtMs));
+
+ // Limit to 10 most likely candidates
+ return candidates.slice(0, 10).map((c) => c.path);
+}
+
+/** Parse message entries from a JSONL transcript, optionally filtered by time range. */
+function parseMessagesInRange(
+ content: string,
+ opts?: { afterMs?: number; beforeMs?: number },
+): ParsedMessage[] {
+ const lines = content.trim().split("\n").filter((l) => l.trim());
+ const messages: ParsedMessage[] = [];
+ const pendingToolCalls = new Map();
+
+ for (const line of lines) {
+ try {
+ const entry = JSON.parse(line);
+ if (entry.type !== "message" || !entry.message) {continue;}
+
+ // Filter by timestamp if provided
+ if (opts?.afterMs || opts?.beforeMs) {
+ const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : (entry.ts ?? 0);
+ if (opts.afterMs && ts < opts.afterMs) {continue;}
+ if (opts.beforeMs && ts > opts.beforeMs) {continue;}
+ }
+
+ const msg = entry.message;
+ const role = msg.role as "user" | "assistant" | "system";
+ const parts: MessagePart[] = [];
+
+ if (Array.isArray(msg.content)) {
+ for (const part of msg.content) {
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
+ parts.push({ type: "text", text: part.text });
+ } else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) {
+ parts.push({ type: "thinking", thinking: part.thinking });
+ } else if (part.type === "tool_use" || part.type === "tool-call") {
+ const toolName = part.name ?? part.toolName ?? "unknown";
+ const toolCallId = part.id ?? part.toolCallId ?? `tool-${Date.now()}`;
+ pendingToolCalls.set(toolCallId, { toolName, args: part.input ?? part.args });
+ parts.push({ type: "tool-call", toolName, toolCallId, args: part.input ?? part.args });
+ } else if (part.type === "tool_result" || part.type === "tool-result") {
+ const toolCallId = part.tool_use_id ?? part.toolCallId ?? "";
+ const pending = pendingToolCalls.get(toolCallId);
+ const outputText = typeof part.content === "string"
+ ? part.content
+ : Array.isArray(part.content)
+ ? part.content.filter((c: { type: string }) => c.type === "text").map((c: { text: string }) => c.text).join("\n")
+ : typeof part.output === "string"
+ ? part.output
+ : JSON.stringify(part.output ?? part.content ?? "");
+
+ if (pending) {
+ const existingMsg = messages[messages.length - 1];
+ if (existingMsg) {
+ const tc = existingMsg.parts.find(
+ (p) => p.type === "tool-call" && (p as { toolCallId: string }).toolCallId === toolCallId,
+ );
+ if (tc && tc.type === "tool-call") {
+ (tc as { output?: string }).output = outputText.slice(0, 5000);
+ continue;
+ }
+ }
+ parts.push({ type: "tool-call", toolName: pending.toolName, toolCallId, args: pending.args, output: outputText.slice(0, 5000) });
+ } else {
+ parts.push({ type: "tool-call", toolName: "tool", toolCallId, output: outputText.slice(0, 5000) });
+ }
+ }
+ }
+ } else if (typeof msg.content === "string" && msg.content.trim()) {
+ parts.push({ type: "text", text: msg.content });
+ }
+
+ if (parts.length > 0) {
+ messages.push({
+ id: entry.id ?? `msg-${messages.length}`,
+ role,
+ parts,
+ timestamp: entry.timestamp ?? new Date(entry.ts ?? Date.now()).toISOString(),
+ });
+ }
+ } catch {
+ // skip malformed lines
+ }
+ }
+
+ return messages;
+}
+
+/** Extract text content from message parts. */
+function getMessageText(msg: ParsedMessage): string {
+ return msg.parts
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
+ .map((p) => p.text)
+ .join("\n");
+}
+
+/**
+ * Search session files for the cron run's conversation.
+ * Matches by finding a user message containing the summary text near runAtMs,
+ * then returns that message + all following messages until the next user message.
+ */
+function searchForRunTranscript(
+ sessionFiles: string[],
+ summary: string,
+ runAtMs: number,
+): { messages: ParsedMessage[]; sessionFile: string } | null {
+ // Use a distinctive portion of the summary for matching (first 80 chars)
+ const searchText = summary.slice(0, 80);
+ // Search window: from 5s before run to 10 minutes after (heartbeat delay)
+ const afterMs = runAtMs - 5_000;
+ const beforeMs = runAtMs + 10 * 60_000;
+
+ for (const filePath of sessionFiles) {
+ try {
+ const content = readFileSync(filePath, "utf-8");
+ if (!content.includes(searchText.slice(0, 40))) {
+ // Quick pre-check: skip files that don't contain the text at all
+ continue;
+ }
+
+ const allMessages = parseMessagesInRange(content);
+
+ // Find user messages containing the summary text within the time window
+ for (let i = 0; i < allMessages.length; i++) {
+ const msg = allMessages[i];
+ if (msg.role !== "user") {continue;}
+
+ const msgTs = new Date(msg.timestamp).getTime();
+ if (msgTs < afterMs || msgTs > beforeMs) {continue;}
+
+ const text = getMessageText(msg);
+ if (!text.includes(searchText.slice(0, 40))) {continue;}
+
+ // Found the user message! Collect it + all following messages
+ // until the next user message (the full agent turn).
+ const conversation: ParsedMessage[] = [msg];
+ for (let j = i + 1; j < allMessages.length; j++) {
+ const next = allMessages[j];
+ if (next.role === "user") {break;}
+ conversation.push(next);
+ }
+
+ return { messages: conversation, sessionFile: filePath };
+ }
+ } catch {
+ // skip unreadable files
+ }
+ }
+
+ return null;
+}
+
+/**
+ * GET /api/cron/runs/search-transcript?jobId=X&runAtMs=Y&summary=Z
+ *
+ * Search for the actual agent transcript for a cron run that doesn't have
+ * a direct sessionId. Tries:
+ * 1. Sessions.json lookup for a cron-specific session
+ * 2. Time-based search of session files near the run timestamp
+ */
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const jobId = url.searchParams.get("jobId");
+ const runAtMsStr = url.searchParams.get("runAtMs");
+ const summary = url.searchParams.get("summary");
+
+ if (!jobId || !runAtMsStr) {
+ return Response.json({ error: "jobId and runAtMs are required" }, { status: 400 });
+ }
+
+ const runAtMs = Number(runAtMsStr);
+ if (!Number.isFinite(runAtMs)) {
+ return Response.json({ error: "Invalid runAtMs" }, { status: 400 });
+ }
+
+ // Strategy 1: Look for a cron-specific session in sessions.json
+ const cronSessionId = findCronSessionId(jobId);
+ if (cronSessionId) {
+ try {
+ const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
+ for (const agentDir of agentDirs) {
+ if (!agentDir.isDirectory()) {continue;}
+ const sessionFile = join(AGENTS_DIR, agentDir.name, "sessions", `${cronSessionId}.jsonl`);
+ if (!existsSync(sessionFile)) {continue;}
+
+ const content = readFileSync(sessionFile, "utf-8");
+ const messages = parseMessagesInRange(content);
+ if (messages.length > 0) {
+ return Response.json({
+ sessionId: cronSessionId,
+ messages,
+ source: "cron-session",
+ });
+ }
+ }
+ } catch {
+ // fall through to search
+ }
+ }
+
+ // Strategy 2: Search session files near the run timestamp
+ if (summary) {
+ const candidates = findCandidateSessionFiles(runAtMs);
+ const result = searchForRunTranscript(candidates, summary, runAtMs);
+ if (result) {
+ return Response.json({
+ messages: result.messages,
+ source: "main-session-search",
+ });
+ }
+ }
+
+ return Response.json({ error: "Transcript not found" }, { status: 404 });
+}
diff --git a/apps/web/app/api/memories/route.ts b/apps/web/app/api/memories/route.ts
new file mode 100644
index 00000000000..d9b0ee2e1f6
--- /dev/null
+++ b/apps/web/app/api/memories/route.ts
@@ -0,0 +1,66 @@
+import { readFileSync, readdirSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+type MemoryFile = {
+ name: string;
+ path: string;
+ sizeBytes: number;
+};
+
+export async function GET() {
+ const stateDir = resolveOpenClawStateDir();
+ const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
+ let mainMemory: string | null = null;
+ const dailyLogs: MemoryFile[] = [];
+
+ // Read main MEMORY.md
+ for (const filename of ["MEMORY.md", "memory.md"]) {
+ const memPath = join(workspaceDir, filename);
+ if (existsSync(memPath)) {
+ try {
+ mainMemory = readFileSync(memPath, "utf-8");
+ } catch {
+ // skip unreadable
+ }
+ break;
+ }
+ }
+
+ // Scan daily log files
+ const memoryDir = join(workspaceDir, "memory");
+ if (existsSync(memoryDir)) {
+ try {
+ const entries = readdirSync(memoryDir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
+ continue;
+ }
+ const filePath = join(memoryDir, entry.name);
+ try {
+ const content = readFileSync(filePath, "utf-8");
+ dailyLogs.push({
+ name: entry.name,
+ path: filePath,
+ sizeBytes: Buffer.byteLength(content, "utf-8"),
+ });
+ } catch {
+ // skip
+ }
+ }
+ } catch {
+ // dir unreadable
+ }
+ }
+
+ // Sort daily logs by name (date-based filenames sort chronologically)
+ dailyLogs.sort((a, b) => b.name.localeCompare(a.name));
+
+ return Response.json({
+ mainMemory,
+ dailyLogs,
+ workspaceDir,
+ });
+}
diff --git a/apps/web/app/api/profiles/route.test.ts b/apps/web/app/api/profiles/route.test.ts
new file mode 100644
index 00000000000..c64108838d6
--- /dev/null
+++ b/apps/web/app/api/profiles/route.test.ts
@@ -0,0 +1,212 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import type { Dirent } from "node:fs";
+
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+}));
+
+vi.mock("node:child_process", () => ({
+ execSync: vi.fn(() => ""),
+ exec: vi.fn(
+ (
+ _cmd: string,
+ _opts: unknown,
+ cb: (err: Error | null, result: { stdout: string }) => void,
+ ) => {
+ cb(null, { stdout: "" });
+ },
+ ),
+}));
+
+vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+}));
+
+import { join } from "node:path";
+
+function makeDirent(name: string, isDir: boolean): Dirent {
+ return {
+ name,
+ isDirectory: () => isDir,
+ isFile: () => !isDir,
+ isBlockDevice: () => false,
+ isCharacterDevice: () => false,
+ isFIFO: () => false,
+ isSocket: () => false,
+ isSymbolicLink: () => false,
+ path: "",
+ parentPath: "",
+ } as Dirent;
+}
+
+describe("profiles API", () => {
+ const originalEnv = { ...process.env };
+ const STATE_DIR = join("/home/testuser", ".openclaw");
+
+ beforeEach(() => {
+ vi.resetModules();
+ vi.restoreAllMocks();
+ process.env = { ...originalEnv };
+ delete process.env.OPENCLAW_PROFILE;
+ delete process.env.OPENCLAW_HOME;
+ delete process.env.OPENCLAW_WORKSPACE;
+ delete process.env.OPENCLAW_STATE_DIR;
+
+ vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ }));
+ vi.mock("node:child_process", () => ({
+ execSync: vi.fn(() => ""),
+ exec: vi.fn(
+ (
+ _cmd: string,
+ _opts: unknown,
+ cb: (err: Error | null, result: { stdout: string }) => void,
+ ) => {
+ cb(null, { stdout: "" });
+ },
+ ),
+ }));
+ vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+ }));
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ // βββ GET /api/profiles ββββββββββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/profiles", () => {
+ async function callGet() {
+ const { GET } = await import("./route.js");
+ return GET();
+ }
+
+ it("returns profiles list with default profile", async () => {
+ const response = await callGet();
+ expect(response.status).toBe(200);
+ const json = await response.json();
+ expect(json.profiles).toBeDefined();
+ expect(json.profiles.length).toBeGreaterThanOrEqual(1);
+ expect(json.profiles[0].name).toBe("default");
+ });
+
+ it("returns activeProfile", async () => {
+ const response = await callGet();
+ const json = await response.json();
+ expect(json.activeProfile).toBe("default");
+ });
+
+ it("returns stateDir", async () => {
+ const response = await callGet();
+ const json = await response.json();
+ expect(json.stateDir).toBe(STATE_DIR);
+ });
+
+ it("discovers workspace- directories", async () => {
+ const { existsSync: es, readdirSync: rds } = await import("node:fs");
+ vi.mocked(es).mockImplementation((p) => {
+ const s = String(p);
+ return (
+ s === STATE_DIR ||
+ s === join(STATE_DIR, "workspace-dev")
+ );
+ });
+ vi.mocked(rds).mockReturnValue([
+ makeDirent("workspace-dev", true),
+ ] as unknown as Dirent[]);
+
+ const response = await callGet();
+ const json = await response.json();
+ const names = json.profiles.map((p: { name: string }) => p.name);
+ expect(names).toContain("dev");
+ });
+ });
+
+ // βββ POST /api/profiles/switch ββββββββββββββββββββββββββββββββββββ
+
+ describe("POST /api/profiles/switch", () => {
+ async function callSwitch(body: Record) {
+ const { POST } = await import("./switch/route.js");
+ const req = new Request("http://localhost/api/profiles/switch", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ return POST(req);
+ }
+
+ it("switches to named profile", async () => {
+ const { writeFileSync: wfs } = await import("node:fs");
+ const { existsSync: es } = await import("node:fs");
+ vi.mocked(es).mockReturnValue(true);
+
+ const response = await callSwitch({ profile: "work" });
+ expect(response.status).toBe(200);
+ const json = await response.json();
+ expect(json.activeProfile).toBe("work");
+
+ const writeCalls = vi.mocked(wfs).mock.calls;
+ const stateWrite = writeCalls.find((c) =>
+ (c[0] as string).includes(".ironclaw-ui-state.json"),
+ );
+ expect(stateWrite).toBeDefined();
+ });
+
+ it("'default' clears the override", async () => {
+ const { existsSync: es } = await import("node:fs");
+ vi.mocked(es).mockReturnValue(true);
+
+ const response = await callSwitch({ profile: "default" });
+ expect(response.status).toBe(200);
+ const json = await response.json();
+ expect(json.activeProfile).toBe("default");
+ });
+
+ it("rejects missing profile name", async () => {
+ const response = await callSwitch({});
+ expect(response.status).toBe(400);
+ const json = await response.json();
+ expect(json.error).toContain("Missing profile name");
+ });
+
+ it("rejects invalid profile name characters", async () => {
+ const response = await callSwitch({ profile: "bad name!" });
+ expect(response.status).toBe(400);
+ const json = await response.json();
+ expect(json.error).toContain("Invalid profile name");
+ });
+
+ it("returns workspace root after switching", async () => {
+ const { existsSync: es } = await import("node:fs");
+ const wsDir = join(STATE_DIR, "workspace-dev");
+ vi.mocked(es).mockImplementation((p) => {
+ const s = String(p);
+ return s === wsDir || s.includes(".openclaw");
+ });
+
+ const response = await callSwitch({ profile: "dev" });
+ const json = await response.json();
+ expect(json.workspaceRoot).toBeDefined();
+ });
+
+ it("returns stateDir in response", async () => {
+ const { existsSync: es } = await import("node:fs");
+ vi.mocked(es).mockReturnValue(true);
+
+ const response = await callSwitch({ profile: "test" });
+ const json = await response.json();
+ expect(json.stateDir).toBe(STATE_DIR);
+ });
+ });
+});
diff --git a/apps/web/app/api/profiles/route.ts b/apps/web/app/api/profiles/route.ts
new file mode 100644
index 00000000000..f4a55e6458f
--- /dev/null
+++ b/apps/web/app/api/profiles/route.ts
@@ -0,0 +1,16 @@
+import { discoverProfiles, getEffectiveProfile, resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function GET() {
+ const profiles = discoverProfiles();
+ const activeProfile = getEffectiveProfile();
+ const stateDir = resolveOpenClawStateDir();
+
+ return Response.json({
+ profiles,
+ activeProfile: activeProfile || "default",
+ stateDir,
+ });
+}
diff --git a/apps/web/app/api/profiles/switch/route.ts b/apps/web/app/api/profiles/switch/route.ts
new file mode 100644
index 00000000000..3180cee59aa
--- /dev/null
+++ b/apps/web/app/api/profiles/switch/route.ts
@@ -0,0 +1,34 @@
+import { setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as { profile?: string };
+ const profileName = body.profile?.trim();
+
+ if (!profileName) {
+ return Response.json({ error: "Missing profile name" }, { status: 400 });
+ }
+
+ // Validate profile name: letters, numbers, hyphens, underscores only
+ if (profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) {
+ return Response.json(
+ { error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." },
+ { status: 400 },
+ );
+ }
+
+ // "default" clears the override
+ setUIActiveProfile(profileName === "default" ? null : profileName);
+
+ const activeProfile = getEffectiveProfile();
+ const workspaceRoot = resolveWorkspaceRoot();
+ const stateDir = resolveOpenClawStateDir();
+
+ return Response.json({
+ activeProfile: activeProfile || "default",
+ workspaceRoot,
+ stateDir,
+ });
+}
diff --git a/apps/web/app/api/sessions/[sessionId]/route.ts b/apps/web/app/api/sessions/[sessionId]/route.ts
new file mode 100644
index 00000000000..39de17f10b3
--- /dev/null
+++ b/apps/web/app/api/sessions/[sessionId]/route.ts
@@ -0,0 +1,129 @@
+import { readFileSync, readdirSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+type JSONLMessage = {
+ type: string;
+ id: string;
+ parentId: string | null;
+ timestamp: string;
+ message?: {
+ role: "user" | "assistant";
+ content: Array<
+ | { type: "text"; text: string }
+ | { type: "image"; data: string }
+ | { type: "thinking"; thinking: string; thinkingSignature?: string }
+ >;
+ timestamp?: number;
+ };
+ customType?: string;
+ data?: unknown;
+};
+
+function findSessionFile(sessionId: string): string | null {
+ const openclawDir = resolveOpenClawStateDir();
+ const agentsDir = join(openclawDir, "agents");
+
+ if (!existsSync(agentsDir)) {
+ return null;
+ }
+
+ try {
+ const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
+ for (const agentDir of agentDirs) {
+ if (!agentDir.isDirectory()) {
+ continue;
+ }
+
+ const sessionFile = join(
+ agentsDir,
+ agentDir.name,
+ "sessions",
+ `${sessionId}.jsonl`
+ );
+
+ if (existsSync(sessionFile)) {
+ return sessionFile;
+ }
+ }
+ } catch {
+ // ignore errors
+ }
+
+ return null;
+}
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ sessionId: string }> }
+) {
+ const { sessionId } = await params;
+
+ if (!sessionId) {
+ return Response.json({ error: "Session ID required" }, { status: 400 });
+ }
+
+ const sessionFile = findSessionFile(sessionId);
+
+ if (!sessionFile) {
+ return Response.json({ error: "Session not found" }, { status: 404 });
+ }
+
+ try {
+ const content = readFileSync(sessionFile, "utf-8");
+ const lines = content
+ .trim()
+ .split("\n")
+ .filter((line) => line.trim());
+
+ const messages: Array<{
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ timestamp: string;
+ }> = [];
+
+ for (const line of lines) {
+ try {
+ const entry = JSON.parse(line) as JSONLMessage;
+
+ if (entry.type === "message" && entry.message) {
+ // Extract text content from the message
+ const textContent = entry.message.content
+ .filter((part) => part.type === "text" || part.type === "thinking")
+ .map((part) => {
+ if (part.type === "text") {
+ return part.text;
+ }
+ if (part.type === "thinking") {
+ return `[Thinking: ${part.thinking.slice(0, 100)}...]`;
+ }
+ return "";
+ })
+ .join("\n");
+
+ if (textContent) {
+ messages.push({
+ id: entry.id,
+ role: entry.message.role,
+ content: textContent,
+ timestamp: entry.timestamp,
+ });
+ }
+ }
+ } catch {
+ // skip malformed lines
+ }
+ }
+
+ return Response.json({ sessionId, messages });
+ } catch (error) {
+ console.error("Error reading session:", error);
+ return Response.json(
+ { error: "Failed to read session" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/api/sessions/route.ts b/apps/web/app/api/sessions/route.ts
new file mode 100644
index 00000000000..b6d84d2a02c
--- /dev/null
+++ b/apps/web/app/api/sessions/route.ts
@@ -0,0 +1,98 @@
+import { readFileSync, readdirSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+type SessionEntry = {
+ sessionId: string;
+ updatedAt: number;
+ label?: string;
+ displayName?: string;
+ channel?: string;
+ model?: string;
+ modelProvider?: string;
+ thinkingLevel?: string;
+ inputTokens?: number;
+ outputTokens?: number;
+ totalTokens?: number;
+ contextTokens?: number;
+ compactionCount?: number;
+};
+
+type SessionRow = {
+ key: string;
+ sessionId: string;
+ updatedAt: number;
+ label?: string;
+ displayName?: string;
+ channel?: string;
+ model?: string;
+ modelProvider?: string;
+ thinkingLevel?: string;
+ inputTokens?: number;
+ outputTokens?: number;
+ totalTokens?: number;
+ contextTokens?: number;
+};
+
+export async function GET() {
+ const openclawDir = resolveOpenClawStateDir();
+ const agentsDir = join(openclawDir, "agents");
+
+ if (!existsSync(agentsDir)) {
+ return Response.json({ agents: [], sessions: [] });
+ }
+
+ const allSessions: SessionRow[] = [];
+ const agentIds: string[] = [];
+
+ try {
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+ agentIds.push(entry.name);
+
+ const storePath = join(agentsDir, entry.name, "sessions", "sessions.json");
+ if (!existsSync(storePath)) {
+ continue;
+ }
+
+ try {
+ const raw = readFileSync(storePath, "utf-8");
+ const store = JSON.parse(raw) as Record;
+ for (const [key, session] of Object.entries(store)) {
+ if (!session || typeof session !== "object") {
+ continue;
+ }
+ allSessions.push({
+ key,
+ sessionId: session.sessionId,
+ updatedAt: session.updatedAt,
+ label: session.label,
+ displayName: session.displayName,
+ channel: session.channel,
+ model: session.model,
+ modelProvider: session.modelProvider,
+ thinkingLevel: session.thinkingLevel,
+ inputTokens: session.inputTokens,
+ outputTokens: session.outputTokens,
+ totalTokens: session.totalTokens,
+ contextTokens: session.contextTokens,
+ });
+ }
+ } catch {
+ // skip unreadable store files
+ }
+ }
+ } catch {
+ // agents dir unreadable
+ }
+
+ // Sort by updatedAt descending
+ allSessions.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
+
+ return Response.json({ agents: agentIds, sessions: allSessions });
+}
diff --git a/apps/web/app/api/sessions/sessions.test.ts b/apps/web/app/api/sessions/sessions.test.ts
new file mode 100644
index 00000000000..64ab449639a
--- /dev/null
+++ b/apps/web/app/api/sessions/sessions.test.ts
@@ -0,0 +1,144 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock node:fs
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
+}));
+
+// Mock node:os
+vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+}));
+
+describe("Sessions, Memories & Skills API", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
+ }));
+ vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // βββ GET /api/sessions ββββββββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/sessions", () => {
+ it("returns empty agents and sessions when no dir exists", async () => {
+ const { GET } = await import("./route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.agents).toEqual([]);
+ expect(json.sessions).toEqual([]);
+ });
+
+ it("returns sessions from agent directories", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReaddir).mockImplementation((dir) => {
+ const s = String(dir);
+ if (s.endsWith("agents")) {return ["main" as never];}
+ if (s.endsWith("sessions")) {return ["sessions.json" as never];}
+ return [];
+ });
+ const sessionsData = {
+ "s1": { label: "Chat 1", displayName: "Chat 1", channel: "webchat", updatedAt: Date.now() },
+ };
+ vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessionsData) as never);
+
+ const { GET } = await import("./route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.sessions.length).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ // βββ GET /api/sessions/[sessionId] ββββββββββββββββββββββββββββββ
+
+ describe("GET /api/sessions/[sessionId]", () => {
+ it("returns 404 when session not found", async () => {
+ const { GET } = await import("./[sessionId]/route.js");
+ const res = await GET(
+ new Request("http://localhost"),
+ { params: Promise.resolve({ sessionId: "nonexistent" }) },
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 404 for non-existent session ID", async () => {
+ const { GET } = await import("./[sessionId]/route.js");
+ const res = await GET(
+ new Request("http://localhost"),
+ { params: Promise.resolve({ sessionId: "missing-id" }) },
+ );
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // βββ GET /api/memories ββββββββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/memories", () => {
+ it("returns null mainMemory when no memory file exists", async () => {
+ const { existsSync: mockExists } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(false);
+
+ const { GET } = await import("../memories/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.mainMemory).toBeNull();
+ });
+
+ it("returns memory content when file exists", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
+ vi.mocked(mockExists).mockImplementation((p) => {
+ const s = String(p);
+ if (s.endsWith("MEMORY.md") || s.endsWith("memory.md")) {return true;}
+ return false;
+ });
+ vi.mocked(mockReadFile).mockReturnValue("# My memories\n\n- Remember X" as never);
+ vi.mocked(mockReaddir).mockReturnValue([]);
+
+ const { GET } = await import("../memories/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.mainMemory).toContain("memories");
+ });
+ });
+
+ // βββ GET /api/skills ββββββββββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/skills", () => {
+ it("returns empty skills when no skills directories exist", async () => {
+ const { GET } = await import("../skills/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.skills).toEqual([]);
+ });
+
+ it("returns skills from directory", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReaddir).mockImplementation((dir) => {
+ const s = String(dir);
+ if (s.endsWith("skills")) {return ["my-skill" as never];}
+ return [];
+ });
+ vi.mocked(mockReadFile).mockReturnValue("---\nname: My Skill\n---\n# Skill content" as never);
+
+ const { GET } = await import("../skills/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.skills.length).toBeGreaterThanOrEqual(0);
+ });
+ });
+});
diff --git a/apps/web/app/api/skills/route.ts b/apps/web/app/api/skills/route.ts
new file mode 100644
index 00000000000..d773eb81859
--- /dev/null
+++ b/apps/web/app/api/skills/route.ts
@@ -0,0 +1,85 @@
+import { readFileSync, readdirSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+type SkillEntry = {
+ name: string;
+ description: string;
+ emoji?: string;
+ source: string;
+ filePath: string;
+};
+
+/** Parse YAML frontmatter from a SKILL.md file (lightweight, no deps). */
+function parseSkillFrontmatter(content: string): {
+ name?: string;
+ description?: string;
+ emoji?: string;
+} {
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
+ if (!match) return {};
+
+ const yaml = match[1];
+ const result: Record = {};
+ for (const line of yaml.split("\n")) {
+ const kv = line.match(/^(\w+)\s*:\s*(.+)/);
+ if (kv) {
+ result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();
+ }
+ }
+ return {
+ name: result.name,
+ description: result.description,
+ emoji: result.emoji,
+ };
+}
+
+function scanSkillDir(dir: string, source: string): SkillEntry[] {
+ const skills: SkillEntry[] = [];
+ if (!existsSync(dir)) return skills;
+
+ try {
+ const entries = readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const skillMdPath = join(dir, entry.name, "SKILL.md");
+ if (!existsSync(skillMdPath)) continue;
+
+ try {
+ const content = readFileSync(skillMdPath, "utf-8");
+ const meta = parseSkillFrontmatter(content);
+ skills.push({
+ name: meta.name ?? entry.name,
+ description: meta.description ?? "",
+ emoji: meta.emoji,
+ source,
+ filePath: skillMdPath,
+ });
+ } catch {
+ // skip unreadable skill files
+ }
+ }
+ } catch {
+ // dir unreadable
+ }
+
+ return skills;
+}
+
+export async function GET() {
+ const stateDir = resolveOpenClawStateDir();
+ const workspaceRoot = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
+
+ const managedSkills = scanSkillDir(join(stateDir, "skills"), "managed");
+ const workspaceSkills = scanSkillDir(
+ join(workspaceRoot, "skills"),
+ "workspace",
+ );
+
+ const allSkills = [...workspaceSkills, ...managedSkills];
+ allSkills.sort((a, b) => a.name.localeCompare(b.name));
+
+ return Response.json({ skills: allSkills });
+}
diff --git a/apps/web/app/api/web-sessions/[id]/messages/route.ts b/apps/web/app/api/web-sessions/[id]/messages/route.ts
new file mode 100644
index 00000000000..ba0912c5173
--- /dev/null
+++ b/apps/web/app/api/web-sessions/[id]/messages/route.ts
@@ -0,0 +1,103 @@
+import {
+ readFileSync,
+ writeFileSync,
+ existsSync,
+ mkdirSync,
+} from "node:fs";
+import { join } from "node:path";
+import { resolveWebChatDir } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+
+type IndexEntry = {
+ id: string;
+ title: string;
+ createdAt: number;
+ updatedAt: number;
+ messageCount: number;
+};
+
+/**
+ * POST /api/web-sessions/[id]/messages β append or upsert messages.
+ *
+ * Uses upsert semantics: if a message with the same `id` already exists
+ * in the session JSONL, it is replaced in-place. Otherwise the message
+ * is appended. This supports both the client's post-stream save and the
+ * server-side incremental persistence from the ActiveRunManager.
+ */
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const chatDir = resolveWebChatDir();
+ const filePath = join(chatDir, `${id}.jsonl`);
+ const indexPath = join(chatDir, "index.json");
+
+ // Auto-create the session directory if it doesn't exist yet
+ if (!existsSync(chatDir)) {
+ mkdirSync(chatDir, { recursive: true });
+ }
+ if (!existsSync(filePath)) {
+ writeFileSync(filePath, "");
+ }
+
+ const { messages, title } = await request.json();
+
+ if (!Array.isArray(messages) || messages.length === 0) {
+ return Response.json({ error: "messages array required" }, { status: 400 });
+ }
+
+ // Read existing lines for upsert checks.
+ const existing = readFileSync(filePath, "utf-8");
+ const lines = existing.split("\n").filter((l) => l.trim());
+ let newCount = 0;
+
+ for (const msg of messages) {
+ const msgId = typeof msg.id === "string" ? msg.id : undefined;
+ let found = false;
+
+ if (msgId) {
+ for (let i = 0; i < lines.length; i++) {
+ try {
+ const parsed = JSON.parse(lines[i]);
+ if (parsed.id === msgId) {
+ // Replace the existing line in-place.
+ lines[i] = JSON.stringify(msg);
+ found = true;
+ break;
+ }
+ } catch {
+ /* keep malformed lines as-is */
+ }
+ }
+ }
+
+ if (!found) {
+ lines.push(JSON.stringify(msg));
+ newCount++;
+ }
+ }
+
+ writeFileSync(filePath, lines.join("\n") + "\n");
+
+ // Update index metadata
+ try {
+ if (existsSync(indexPath)) {
+ const index: IndexEntry[] = JSON.parse(
+ readFileSync(indexPath, "utf-8"),
+ );
+ const session = index.find((s) => s.id === id);
+ if (session) {
+ session.updatedAt = Date.now();
+ if (newCount > 0) {session.messageCount += newCount;}
+ if (title) {session.title = title;}
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
+ }
+ }
+ } catch {
+ // index update is best-effort
+ }
+
+ return Response.json({ ok: true });
+}
diff --git a/apps/web/app/api/web-sessions/[id]/route.ts b/apps/web/app/api/web-sessions/[id]/route.ts
new file mode 100644
index 00000000000..83f98a72bb4
--- /dev/null
+++ b/apps/web/app/api/web-sessions/[id]/route.ts
@@ -0,0 +1,94 @@
+import { readFileSync, existsSync, unlinkSync } from "node:fs";
+import { join } from "node:path";
+import { resolveWebChatDir } from "@/lib/workspace";
+import { readIndex, writeIndex } from "../shared";
+
+export const dynamic = "force-dynamic";
+
+export type ChatLine = {
+ id: string;
+ role: "user" | "assistant";
+ /** Plain text summary (always present, used for sidebar / backward compat). */
+ content: string;
+ /** Full UIMessage parts array β reasoning, tool calls, outputs, text.
+ * Present for sessions saved after the rich-persistence update;
+ * absent for older sessions (fall back to `content` as a text part). */
+ parts?: Array>;
+ timestamp: string;
+};
+
+/** GET /api/web-sessions/[id] β read all messages for a web chat session */
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const filePath = join(resolveWebChatDir(), `${id}.jsonl`);
+
+ if (!existsSync(filePath)) {
+ return Response.json({ error: "Session not found" }, { status: 404 });
+ }
+
+ const content = readFileSync(filePath, "utf-8");
+ const messages: ChatLine[] = content
+ .trim()
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((line) => {
+ try {
+ return JSON.parse(line) as ChatLine;
+ } catch {
+ return null;
+ }
+ })
+ .filter((m): m is ChatLine => m !== null);
+
+ return Response.json({ id, messages });
+}
+
+/** DELETE /api/web-sessions/[id] β delete a web chat session */
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+
+ const sessions = readIndex();
+ const idx = sessions.findIndex((s) => s.id === id);
+ if (idx === -1) {
+ return Response.json({ error: "Session not found" }, { status: 404 });
+ }
+
+ sessions.splice(idx, 1);
+ writeIndex(sessions);
+
+ const filePath = join(resolveWebChatDir(), `${id}.jsonl`);
+ if (existsSync(filePath)) {
+ unlinkSync(filePath);
+ }
+
+ return Response.json({ ok: true });
+}
+
+/** PATCH /api/web-sessions/[id] β update session metadata (e.g. rename) */
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const body = await request.json().catch(() => ({}));
+
+ const sessions = readIndex();
+ const session = sessions.find((s) => s.id === id);
+ if (!session) {
+ return Response.json({ error: "Session not found" }, { status: 404 });
+ }
+
+ if (typeof body.title === "string") {
+ session.title = body.title;
+ }
+ session.updatedAt = Date.now();
+ writeIndex(sessions);
+
+ return Response.json({ session });
+}
diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts
new file mode 100644
index 00000000000..858de33ae8a
--- /dev/null
+++ b/apps/web/app/api/web-sessions/route.ts
@@ -0,0 +1,45 @@
+import { writeFileSync } from "node:fs";
+import { randomUUID } from "node:crypto";
+import { type WebSessionMeta, ensureDir, readIndex, writeIndex } from "./shared";
+
+export { type WebSessionMeta };
+
+export const dynamic = "force-dynamic";
+
+/** GET /api/web-sessions β list web chat sessions.
+ * ?filePath=... β returns only sessions scoped to that file.
+ * No filePath β returns only global (non-file) sessions. */
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const filePath = url.searchParams.get("filePath");
+
+ const all = readIndex();
+ const sessions = filePath
+ ? all.filter((s) => s.filePath === filePath)
+ : all.filter((s) => !s.filePath);
+
+ return Response.json({ sessions });
+}
+
+/** POST /api/web-sessions β create a new web chat session */
+export async function POST(req: Request) {
+ const body = await req.json().catch(() => ({}));
+ const id = randomUUID();
+ const session: WebSessionMeta = {
+ id,
+ title: body.title || "New Chat",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ messageCount: 0,
+ ...(body.filePath ? { filePath: body.filePath } : {}),
+ };
+
+ const sessions = readIndex();
+ sessions.unshift(session);
+ writeIndex(sessions);
+
+ const dir = ensureDir();
+ writeFileSync(`${dir}/${id}.jsonl`, "");
+
+ return Response.json({ session });
+}
diff --git a/apps/web/app/api/web-sessions/shared.ts b/apps/web/app/api/web-sessions/shared.ts
new file mode 100644
index 00000000000..03073ed9c50
--- /dev/null
+++ b/apps/web/app/api/web-sessions/shared.ts
@@ -0,0 +1,88 @@
+import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
+import { join } from "node:path";
+import { resolveWebChatDir } from "@/lib/workspace";
+
+export type WebSessionMeta = {
+ id: string;
+ title: string;
+ createdAt: number;
+ updatedAt: number;
+ messageCount: number;
+ /** When set, this session is scoped to a specific workspace file. */
+ filePath?: string;
+};
+
+export function ensureDir() {
+ const dir = resolveWebChatDir();
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+ return dir;
+}
+
+/**
+ * Read the session index, auto-discovering any orphaned .jsonl files
+ * that aren't in the index (e.g. from profile switches or missing index).
+ */
+export function readIndex(): WebSessionMeta[] {
+ const dir = ensureDir();
+ const indexFile = join(dir, "index.json");
+ let index: WebSessionMeta[] = [];
+ if (existsSync(indexFile)) {
+ try {
+ index = JSON.parse(readFileSync(indexFile, "utf-8"));
+ } catch {
+ index = [];
+ }
+ }
+
+ // Scan for orphaned .jsonl files not in the index
+ try {
+ const indexed = new Set(index.map((s) => s.id));
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
+ let dirty = false;
+ for (const file of files) {
+ const id = file.replace(/\.jsonl$/, "");
+ if (indexed.has(id)) {continue;}
+
+ const fp = join(dir, file);
+ const stat = statSync(fp);
+ let title = "New Chat";
+ let messageCount = 0;
+ try {
+ const content = readFileSync(fp, "utf-8");
+ const lines = content.split("\n").filter((l) => l.trim());
+ messageCount = lines.length;
+ for (const line of lines) {
+ const parsed = JSON.parse(line);
+ if (parsed.role === "user" && parsed.content) {
+ const text = String(parsed.content);
+ title = text.length > 60 ? text.slice(0, 60) + "..." : text;
+ break;
+ }
+ }
+ } catch { /* best-effort */ }
+
+ index.push({
+ id,
+ title,
+ createdAt: stat.birthtimeMs || stat.mtimeMs,
+ updatedAt: stat.mtimeMs,
+ messageCount,
+ });
+ dirty = true;
+ }
+
+ if (dirty) {
+ index.sort((a, b) => b.updatedAt - a.updatedAt);
+ writeFileSync(indexFile, JSON.stringify(index, null, 2));
+ }
+ } catch { /* best-effort */ }
+
+ return index;
+}
+
+export function writeIndex(sessions: WebSessionMeta[]) {
+ const dir = ensureDir();
+ writeFileSync(join(dir, "index.json"), JSON.stringify(sessions, null, 2));
+}
diff --git a/apps/web/app/api/web-sessions/web-sessions.test.ts b/apps/web/app/api/web-sessions/web-sessions.test.ts
new file mode 100644
index 00000000000..1f5bec18ea3
--- /dev/null
+++ b/apps/web/app/api/web-sessions/web-sessions.test.ts
@@ -0,0 +1,304 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock node:fs
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => "[]"),
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ appendFileSync: vi.fn(),
+}));
+
+// Mock node:os
+vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+}));
+
+// Mock node:crypto
+vi.mock("node:crypto", () => ({
+ randomUUID: vi.fn(() => "test-uuid-1234"),
+}));
+
+describe("Web Sessions API", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => "[]"),
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ appendFileSync: vi.fn(),
+ }));
+ vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+ }));
+ vi.mock("node:crypto", () => ({
+ randomUUID: vi.fn(() => "test-uuid-1234"),
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // βββ GET /api/web-sessions ββββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/web-sessions", () => {
+ it("returns empty sessions when no index exists", async () => {
+ const { GET } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions");
+ const res = await GET(req);
+ const json = await res.json();
+ expect(json.sessions).toEqual([]);
+ });
+
+ it("returns global sessions when no filePath param", async () => {
+ const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ const sessions = [
+ { id: "s1", title: "Chat 1", createdAt: 1, updatedAt: 1, messageCount: 0 },
+ { id: "s2", title: "File Chat", createdAt: 2, updatedAt: 2, messageCount: 1, filePath: "doc.md" },
+ ];
+ vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessions) as never);
+
+ const { GET } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions");
+ const res = await GET(req);
+ const json = await res.json();
+ expect(json.sessions).toHaveLength(1);
+ expect(json.sessions[0].id).toBe("s1");
+ });
+
+ it("filters sessions by filePath param", async () => {
+ const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ const sessions = [
+ { id: "s1", title: "Global", createdAt: 1, updatedAt: 1, messageCount: 0 },
+ { id: "s2", title: "Doc Chat", createdAt: 2, updatedAt: 2, messageCount: 1, filePath: "doc.md" },
+ ];
+ vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessions) as never);
+
+ const { GET } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions?filePath=doc.md");
+ const res = await GET(req);
+ const json = await res.json();
+ expect(json.sessions).toHaveLength(1);
+ expect(json.sessions[0].filePath).toBe("doc.md");
+ });
+
+ it("returns empty when no matching filePath sessions", async () => {
+ const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReadFile).mockReturnValue("[]" as never);
+
+ const { GET } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions?filePath=nonexistent.md");
+ const res = await GET(req);
+ const json = await res.json();
+ expect(json.sessions).toEqual([]);
+ });
+ });
+
+ // βββ POST /api/web-sessions ββββββββββββββββββββββββββββββββββββ
+
+ describe("POST /api/web-sessions", () => {
+ it("creates a new session with default title", async () => {
+ const { writeFileSync: mockWrite } = await import("node:fs");
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await POST(req);
+ const json = await res.json();
+ expect(json.session.id).toBe("test-uuid-1234");
+ expect(json.session.title).toBe("New Chat");
+ expect(json.session.messageCount).toBe(0);
+ expect(mockWrite).toHaveBeenCalled();
+ });
+
+ it("creates session with custom title", async () => {
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "My Chat" }),
+ });
+ const res = await POST(req);
+ const json = await res.json();
+ expect(json.session.title).toBe("My Chat");
+ });
+
+ it("creates file-scoped session with filePath", async () => {
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "File Chat", filePath: "readme.md" }),
+ });
+ const res = await POST(req);
+ const json = await res.json();
+ expect(json.session.filePath).toBe("readme.md");
+ });
+
+ it("handles invalid JSON body gracefully", async () => {
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ const res = await POST(req);
+ const json = await res.json();
+ // Falls back to default title
+ expect(json.session.title).toBe("New Chat");
+ });
+
+ it("creates jsonl file for new session", async () => {
+ const { writeFileSync: mockWrite } = await import("node:fs");
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/web-sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ await POST(req);
+ // Should write at least the index.json and the empty .jsonl
+ expect(mockWrite).toHaveBeenCalled();
+ // Verify that one of the calls is to the jsonl file
+ const calls = mockWrite.mock.calls.map((c) => String(c[0]));
+ expect(calls.some((c) => c.endsWith(".jsonl"))).toBe(true);
+ });
+ });
+
+ // βββ GET /api/web-sessions/[id] ββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/web-sessions/[id]", () => {
+ it("returns session messages", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ const lines = [
+ JSON.stringify({ id: "m1", role: "user", content: "hello" }),
+ JSON.stringify({ id: "m2", role: "assistant", content: "hi" }),
+ ].join("\n");
+ vi.mocked(mockReadFile).mockReturnValue(lines as never);
+
+ const { GET } = await import("./[id]/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/web-sessions/s1"),
+ { params: Promise.resolve({ id: "s1" }) },
+ );
+ const json = await res.json();
+ expect(json.id).toBe("s1");
+ expect(json.messages).toHaveLength(2);
+ });
+
+ it("returns 404 when session file does not exist", async () => {
+ const { existsSync: mockExists } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(false);
+
+ const { GET } = await import("./[id]/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/web-sessions/nonexistent"),
+ { params: Promise.resolve({ id: "nonexistent" }) },
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("handles empty session file", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReadFile).mockReturnValue("" as never);
+
+ const { GET } = await import("./[id]/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/web-sessions/s1"),
+ { params: Promise.resolve({ id: "s1" }) },
+ );
+ const json = await res.json();
+ expect(json.messages).toEqual([]);
+ });
+ });
+
+ // βββ POST /api/web-sessions/[id]/messages ββββββββββββββββββββββ
+
+ describe("POST /api/web-sessions/[id]/messages", () => {
+ it("appends messages to session file", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: _mockWrite } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReadFile).mockImplementation((p) => {
+ const s = String(p);
+ if (s.endsWith("index.json")) {
+ return JSON.stringify([{ id: "s1", title: "Chat", createdAt: 1, updatedAt: 1, messageCount: 0 }]) as never;
+ }
+ return "" as never;
+ });
+
+ const { POST } = await import("./[id]/messages/route.js");
+ const req = new Request("http://localhost/api/web-sessions/s1/messages", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [{ id: "m1", role: "user", content: "hello" }],
+ }),
+ });
+ const res = await POST(req, { params: Promise.resolve({ id: "s1" }) });
+ const json = await res.json();
+ expect(json.ok).toBe(true);
+ });
+
+ it("auto-creates session file if missing", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: _mockWrite } = await import("node:fs");
+ vi.mocked(mockExists).mockImplementation((p) => {
+ const s = String(p);
+ if (s.endsWith(".jsonl")) {return false;}
+ return true;
+ });
+ vi.mocked(mockReadFile).mockImplementation((p) => {
+ const s = String(p);
+ if (s.endsWith("index.json")) {return "[]" as never;}
+ return "" as never;
+ });
+
+ const { POST } = await import("./[id]/messages/route.js");
+ const req = new Request("http://localhost/api/web-sessions/new-s/messages", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [{ id: "m1", role: "user", content: "first message" }],
+ }),
+ });
+ const res = await POST(req, { params: Promise.resolve({ id: "new-s" }) });
+ expect(res.status).toBe(200);
+ });
+
+ it("updates session title when provided", async () => {
+ const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReadFile).mockImplementation((p) => {
+ const s = String(p);
+ if (s.endsWith("index.json")) {
+ return JSON.stringify([{ id: "s1", title: "Old Title", createdAt: 1, updatedAt: 1, messageCount: 0 }]) as never;
+ }
+ return "" as never;
+ });
+
+ const { POST } = await import("./[id]/messages/route.js");
+ const req = new Request("http://localhost/api/web-sessions/s1/messages", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [{ id: "m1", role: "user", content: "hello" }],
+ title: "New Title",
+ }),
+ });
+ const res = await POST(req, { params: Promise.resolve({ id: "s1" }) });
+ expect(res.status).toBe(200);
+ // Verify index was written with new title
+ expect(mockWrite).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/web/app/api/workspace/assets/[...path]/route.ts b/apps/web/app/api/workspace/assets/[...path]/route.ts
new file mode 100644
index 00000000000..1c7869b0ef5
--- /dev/null
+++ b/apps/web/app/api/workspace/assets/[...path]/route.ts
@@ -0,0 +1,57 @@
+import { readFileSync, existsSync } from "node:fs";
+import { extname } from "node:path";
+import { safeResolvePath } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+const MIME_MAP: Record = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ ".bmp": "image/bmp",
+ ".ico": "image/x-icon",
+};
+
+/**
+ * GET /api/workspace/assets/
+ * Serves an image file from the workspace's assets/ directory.
+ */
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ path: string[] }> },
+) {
+ const segments = (await params).path;
+ if (!segments || segments.length === 0) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const relPath = "assets/" + segments.join("/");
+ const ext = extname(relPath).toLowerCase();
+
+ // Only serve known image types
+ const mime = MIME_MAP[ext];
+ if (!mime) {
+ return new Response("Unsupported file type", { status: 400 });
+ }
+
+ const absPath = safeResolvePath(relPath);
+ if (!absPath || !existsSync(absPath)) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ try {
+ const buffer = readFileSync(absPath);
+ return new Response(buffer, {
+ headers: {
+ "Content-Type": mime,
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+ } catch {
+ return new Response("Read error", { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/workspace/browse-file/route.ts b/apps/web/app/api/workspace/browse-file/route.ts
new file mode 100644
index 00000000000..eeed22d9857
--- /dev/null
+++ b/apps/web/app/api/workspace/browse-file/route.ts
@@ -0,0 +1,109 @@
+import { readFileSync, existsSync, statSync } from "node:fs";
+import { resolve, normalize } from "node:path";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/** MIME types for common file extensions. */
+const MIME_MAP: Record = {
+ png: "image/png",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ gif: "image/gif",
+ webp: "image/webp",
+ svg: "image/svg+xml",
+ mp4: "video/mp4",
+ webm: "video/webm",
+ mp3: "audio/mpeg",
+ wav: "audio/wav",
+ ogg: "audio/ogg",
+ pdf: "application/pdf",
+ html: "text/html",
+ htm: "text/html",
+};
+
+/** Extensions recognized as code files for syntax-highlighted viewing. */
+const CODE_EXTENSIONS = new Set([
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs",
+ "java", "kt", "swift", "c", "cpp", "h", "hpp", "cs", "css", "scss",
+ "less", "html", "htm", "xml", "json", "jsonc", "toml", "sh", "bash",
+ "zsh", "fish", "ps1", "sql", "graphql", "gql", "dockerfile", "makefile",
+ "r", "lua", "php", "vue", "svelte", "diff", "patch", "ini", "env",
+ "tf", "proto", "zig", "elixir", "ex", "erl", "hs", "scala", "clj", "dart",
+]);
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const filePath = url.searchParams.get("path");
+ const raw = url.searchParams.get("raw") === "true";
+
+ if (!filePath) {
+ return Response.json(
+ { error: "Missing 'path' query parameter" },
+ { status: 400 },
+ );
+ }
+
+ // Normalize and resolve to prevent traversal
+ const resolved = resolve(normalize(filePath));
+
+ if (!existsSync(resolved)) {
+ return Response.json(
+ { error: "File not found" },
+ { status: 404 },
+ );
+ }
+
+ try {
+ const stat = statSync(resolved);
+ if (!stat.isFile()) {
+ return Response.json(
+ { error: "Path is not a file" },
+ { status: 400 },
+ );
+ }
+ } catch {
+ return Response.json(
+ { error: "Cannot stat file" },
+ { status: 500 },
+ );
+ }
+
+ // Raw mode: return binary content with appropriate MIME type
+ if (raw) {
+ try {
+ const buffer = readFileSync(resolved);
+ const ext = resolved.split(".").pop()?.toLowerCase() ?? "";
+ const mime = MIME_MAP[ext] ?? "application/octet-stream";
+ return new Response(buffer, {
+ headers: {
+ "Content-Type": mime,
+ "Content-Length": String(buffer.length),
+ },
+ });
+ } catch {
+ return Response.json(
+ { error: "Cannot read file" },
+ { status: 500 },
+ );
+ }
+ }
+
+ // Text mode: return content and type metadata (same shape as /api/workspace/file)
+ try {
+ const content = readFileSync(resolved, "utf-8");
+ const ext = resolved.split(".").pop()?.toLowerCase();
+
+ let type: "markdown" | "yaml" | "code" | "text" = "text";
+ if (ext === "md" || ext === "mdx") {type = "markdown";}
+ else if (ext === "yaml" || ext === "yml") {type = "yaml";}
+ else if (CODE_EXTENSIONS.has(ext ?? "")) {type = "code";}
+
+ return Response.json({ content, type });
+ } catch {
+ return Response.json(
+ { error: "Cannot read file" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/browse/route.ts b/apps/web/app/api/workspace/browse/route.ts
new file mode 100644
index 00000000000..bba02cbddd7
--- /dev/null
+++ b/apps/web/app/api/workspace/browse/route.ts
@@ -0,0 +1,134 @@
+import { readdirSync, statSync, type Dirent } from "node:fs";
+import { join, dirname, resolve } from "node:path";
+import { homedir } from "node:os";
+import { resolveWorkspaceRoot } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+type BrowseNode = {
+ name: string;
+ path: string; // absolute path
+ type: "folder" | "file" | "document" | "database";
+ children?: BrowseNode[];
+ symlink?: boolean;
+};
+
+/** Directories to skip when browsing the filesystem. */
+const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
+
+/** Resolve a dirent's effective type, following symlinks to their target. */
+function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
+ if (entry.isDirectory()) {return "directory";}
+ if (entry.isFile()) {return "file";}
+ if (entry.isSymbolicLink()) {
+ try {
+ const st = statSync(absPath);
+ if (st.isDirectory()) {return "directory";}
+ if (st.isFile()) {return "file";}
+ } catch {
+ // Broken symlink
+ }
+ }
+ return null;
+}
+
+/** Build a depth-limited tree from an absolute directory. */
+function buildBrowseTree(
+ absDir: string,
+ maxDepth: number,
+ currentDepth = 0,
+ showHidden = false,
+): BrowseNode[] {
+ if (currentDepth >= maxDepth) {return [];}
+
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(absDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ const filtered = entries
+ .filter((e) => showHidden || !e.name.startsWith("."))
+ .filter((e) => {
+ const absPath = join(absDir, e.name);
+ const t = resolveEntryType(e, absPath);
+ return !(t === "directory" && SKIP_DIRS.has(e.name));
+ });
+
+ const sorted = filtered.toSorted((a, b) => {
+ const absA = join(absDir, a.name);
+ const absB = join(absDir, b.name);
+ const typeA = resolveEntryType(a, absA);
+ const typeB = resolveEntryType(b, absB);
+ const dirA = typeA === "directory";
+ const dirB = typeB === "directory";
+ if (dirA && !dirB) {return -1;}
+ if (!dirA && dirB) {return 1;}
+ return a.name.localeCompare(b.name);
+ });
+
+ const nodes: BrowseNode[] = [];
+
+ for (const entry of sorted) {
+ const absPath = join(absDir, entry.name);
+ const isSymlink = entry.isSymbolicLink();
+ const effectiveType = resolveEntryType(entry, absPath);
+
+ if (effectiveType === "directory") {
+ const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden);
+ nodes.push({
+ name: entry.name,
+ path: absPath,
+ type: "folder",
+ children: children.length > 0 ? children : undefined,
+ ...(isSymlink && { symlink: true }),
+ });
+ } else if (effectiveType === "file") {
+ const ext = entry.name.split(".").pop()?.toLowerCase();
+ const isDocument = ext === "md" || ext === "mdx";
+ const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
+
+ nodes.push({
+ name: entry.name,
+ path: absPath,
+ type: isDatabase ? "database" : isDocument ? "document" : "file",
+ ...(isSymlink && { symlink: true }),
+ });
+ }
+ }
+
+ return nodes;
+}
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ let dir = url.searchParams.get("dir");
+ const showHidden = url.searchParams.get("showHidden") === "1";
+
+ if (!dir) {
+ dir = resolveWorkspaceRoot();
+ }
+
+ if (!dir) {
+ return Response.json(
+ { entries: [], currentDir: "/", parentDir: null },
+ );
+ }
+
+ if (dir.startsWith("~")) {
+ dir = join(homedir(), dir.slice(1));
+ }
+
+ const resolved = resolve(dir);
+
+ const entries = buildBrowseTree(resolved, 3, 0, showHidden);
+ const parentDir = resolved === "/" ? null : dirname(resolved);
+
+ return Response.json({
+ entries,
+ currentDir: resolved,
+ parentDir,
+ });
+}
diff --git a/apps/web/app/api/workspace/context/route.ts b/apps/web/app/api/workspace/context/route.ts
new file mode 100644
index 00000000000..c9af98b8f19
--- /dev/null
+++ b/apps/web/app/api/workspace/context/route.ts
@@ -0,0 +1,116 @@
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { resolveWorkspaceRoot } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export type WorkspaceContext = {
+ exists: boolean;
+ organization?: {
+ id?: string;
+ name?: string;
+ slug?: string;
+ };
+ members?: Array<{
+ id: string;
+ name: string;
+ email: string;
+ role: string;
+ }>;
+ defaults?: {
+ default_view?: string;
+ date_format?: string;
+ naming_convention?: string;
+ };
+};
+
+/**
+ * Parse workspace_context.yaml with basic YAML extraction.
+ * Handles the specific structure defined by the workspace skill.
+ */
+function parseWorkspaceContext(content: string): WorkspaceContext {
+ const ctx: WorkspaceContext = { exists: true };
+
+ // Extract organization block
+ const orgMatch = content.match(
+ /organization:\s*\n((?:\s{2,}.+\n)*)/,
+ );
+ if (orgMatch) {
+ const orgBlock = orgMatch[1];
+ const org: Record = {};
+ for (const line of orgBlock.split("\n")) {
+ const kv = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
+ if (kv) {org[kv[1]] = kv[2].trim();}
+ }
+ ctx.organization = {
+ id: org.id,
+ name: org.name,
+ slug: org.slug,
+ };
+ }
+
+ // Extract members list
+ const membersMatch = content.match(
+ /members:\s*\n((?:\s{2,}.+\n)*)/,
+ );
+ if (membersMatch) {
+ const membersBlock = membersMatch[1];
+ const members: WorkspaceContext["members"] = [];
+ let current: Record = {};
+
+ for (const line of membersBlock.split("\n")) {
+ const itemStart = line.match(/^\s+-\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
+ const propLine = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
+
+ if (itemStart) {
+ if (current.id) {members.push(current as never);}
+ current = { [itemStart[1]]: itemStart[2].trim() };
+ } else if (propLine && !line.trim().startsWith("-")) {
+ current[propLine[1]] = propLine[2].trim();
+ }
+ }
+ if (current.id) {members.push(current as never);}
+ ctx.members = members;
+ }
+
+ // Extract defaults block
+ const defaultsMatch = content.match(
+ /defaults:\s*\n((?:\s{2,}.+\n)*)/,
+ );
+ if (defaultsMatch) {
+ const defaultsBlock = defaultsMatch[1];
+ const defaults: Record = {};
+ for (const line of defaultsBlock.split("\n")) {
+ const kv = line.match(/^\s+(\w[\w_]*)\s*:\s*(.+)/);
+ if (kv) {defaults[kv[1]] = kv[2].trim();}
+ }
+ ctx.defaults = {
+ default_view: defaults.default_view,
+ date_format: defaults.date_format,
+ naming_convention: defaults.naming_convention,
+ };
+ }
+
+ return ctx;
+}
+
+export async function GET() {
+ const root = resolveWorkspaceRoot();
+ if (!root) {
+ return Response.json({ exists: false } satisfies WorkspaceContext);
+ }
+
+ const ctxPath = join(root, "workspace_context.yaml");
+ if (!existsSync(ctxPath)) {
+ return Response.json({ exists: true } satisfies WorkspaceContext);
+ }
+
+ try {
+ const content = readFileSync(ctxPath, "utf-8");
+ const parsed = parseWorkspaceContext(content);
+ return Response.json(parsed);
+ } catch {
+ return Response.json({ exists: true } satisfies WorkspaceContext);
+ }
+}
diff --git a/apps/web/app/api/workspace/copy/route.ts b/apps/web/app/api/workspace/copy/route.ts
new file mode 100644
index 00000000000..6431f3eafe4
--- /dev/null
+++ b/apps/web/app/api/workspace/copy/route.ts
@@ -0,0 +1,77 @@
+import { cpSync, existsSync, statSync } from "node:fs";
+import { dirname, basename, extname } from "node:path";
+import { safeResolvePath, safeResolveNewPath } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * POST /api/workspace/copy
+ * Body: { path: string, destinationPath?: string }
+ *
+ * Duplicates a file or folder. If no destinationPath is provided,
+ * creates a copy next to the original with " copy" appended.
+ */
+export async function POST(req: Request) {
+ let body: { path?: string; destinationPath?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { path: relPath, destinationPath } = body;
+ if (!relPath || typeof relPath !== "string") {
+ return Response.json(
+ { error: "Missing 'path' field" },
+ { status: 400 },
+ );
+ }
+
+ const srcAbs = safeResolvePath(relPath);
+ if (!srcAbs) {
+ return Response.json(
+ { error: "Source not found or path traversal rejected" },
+ { status: 404 },
+ );
+ }
+
+ let destRelPath: string;
+ if (destinationPath && typeof destinationPath === "string") {
+ destRelPath = destinationPath;
+ } else {
+ // Auto-generate "name copy.ext" or "name copy" for folders
+ const name = basename(relPath);
+ const dir = dirname(relPath);
+ const ext = extname(name);
+ const stem = ext ? name.slice(0, -ext.length) : name;
+ const copyName = ext ? `${stem} copy${ext}` : `${stem} copy`;
+ destRelPath = dir === "." ? copyName : `${dir}/${copyName}`;
+ }
+
+ const destAbs = safeResolveNewPath(destRelPath);
+ if (!destAbs) {
+ return Response.json(
+ { error: "Invalid destination path" },
+ { status: 400 },
+ );
+ }
+
+ if (existsSync(destAbs)) {
+ return Response.json(
+ { error: "Destination already exists" },
+ { status: 409 },
+ );
+ }
+
+ try {
+ const isDir = statSync(srcAbs).isDirectory();
+ cpSync(srcAbs, destAbs, { recursive: isDir });
+ return Response.json({ ok: true, sourcePath: relPath, newPath: destRelPath });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Copy failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/db.test.ts b/apps/web/app/api/workspace/db.test.ts
new file mode 100644
index 00000000000..fe2a17cc2bf
--- /dev/null
+++ b/apps/web/app/api/workspace/db.test.ts
@@ -0,0 +1,273 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock workspace (include ALL exports used by the routes)
+vi.mock("@/lib/workspace", () => ({
+ safeResolvePath: vi.fn(() => null),
+ resolveWorkspaceRoot: vi.fn(() => null),
+ resolveDuckdbBin: vi.fn(() => null),
+ duckdbPath: vi.fn(() => null),
+ duckdbQuery: vi.fn(() => []),
+ duckdbQueryAsync: vi.fn(async () => []),
+ duckdbQueryOnFile: vi.fn(() => []),
+ duckdbQueryOnFileAsync: vi.fn(async () => []),
+ duckdbExecOnFile: vi.fn(() => true),
+ discoverDuckDBPaths: vi.fn(() => []),
+ isDatabaseFile: vi.fn(() => false),
+}));
+
+// Mock report-filters
+vi.mock("@/lib/report-filters", () => ({
+ buildFilterClauses: vi.fn(() => []),
+ injectFilters: vi.fn((sql: string) => sql),
+ checkSqlSafety: vi.fn(() => null),
+}));
+
+describe("Workspace DB & Reports API", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.mock("@/lib/workspace", () => ({
+ safeResolvePath: vi.fn(() => null),
+ resolveWorkspaceRoot: vi.fn(() => null),
+ resolveDuckdbBin: vi.fn(() => null),
+ duckdbPath: vi.fn(() => null),
+ duckdbQuery: vi.fn(() => []),
+ duckdbQueryAsync: vi.fn(async () => []),
+ duckdbQueryOnFile: vi.fn(() => []),
+ duckdbQueryOnFileAsync: vi.fn(async () => []),
+ duckdbExecOnFile: vi.fn(() => true),
+ discoverDuckDBPaths: vi.fn(() => []),
+ isDatabaseFile: vi.fn(() => false),
+ }));
+ vi.mock("@/lib/report-filters", () => ({
+ buildFilterClauses: vi.fn(() => []),
+ injectFilters: vi.fn((sql: string) => sql),
+ checkSqlSafety: vi.fn(() => null),
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // βββ POST /api/workspace/db/query βββββββββββββββββββββββββββββββ
+
+ describe("POST /api/workspace/db/query", () => {
+ it("returns 400 for missing sql", async () => {
+ const { POST } = await import("./db/query/route.js");
+ const req = new Request("http://localhost/api/workspace/db/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "test.duckdb" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for missing path", async () => {
+ const { POST } = await import("./db/query/route.js");
+ const req = new Request("http://localhost/api/workspace/db/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sql: "SELECT 1" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("rejects mutation queries with 403", async () => {
+ const { safeResolvePath } = await import("@/lib/workspace");
+ vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
+
+ const { POST } = await import("./db/query/route.js");
+ const req = new Request("http://localhost/api/workspace/db/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "test.duckdb", sql: "DROP TABLE users" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(403);
+ });
+
+ it("executes query and returns rows", async () => {
+ const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace");
+ vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
+ vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: 1, name: "test" }]);
+
+ const { POST } = await import("./db/query/route.js");
+ const req = new Request("http://localhost/api/workspace/db/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "test.duckdb", sql: "SELECT * FROM t" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.rows).toEqual([{ id: 1, name: "test" }]);
+ });
+
+ it("returns empty rows for empty result", async () => {
+ const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace");
+ vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
+ vi.mocked(duckdbQueryOnFile).mockReturnValue([]);
+
+ const { POST } = await import("./db/query/route.js");
+ const req = new Request("http://localhost/api/workspace/db/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "test.duckdb", sql: "SELECT * FROM empty" }),
+ });
+ const res = await POST(req);
+ const json = await res.json();
+ expect(json.rows).toEqual([]);
+ });
+ });
+
+ // βββ GET /api/workspace/db/introspect βββββββββββββββββββββββββββ
+
+ describe("GET /api/workspace/db/introspect", () => {
+ it("returns 400 for missing path", async () => {
+ const { GET } = await import("./db/introspect/route.js");
+ const req = new Request("http://localhost/api/workspace/db/introspect");
+ const res = await GET(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when file not found", async () => {
+ const { safeResolvePath } = await import("@/lib/workspace");
+ vi.mocked(safeResolvePath).mockReturnValue(null);
+
+ const { GET } = await import("./db/introspect/route.js");
+ const req = new Request("http://localhost/api/workspace/db/introspect?path=missing.duckdb");
+ const res = await GET(req);
+ expect(res.status).toBe(404);
+ });
+
+ it("returns schema when database exists", async () => {
+ const { safeResolvePath, resolveDuckdbBin, duckdbQueryOnFile } = await import("@/lib/workspace");
+ vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
+ vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
+ vi.mocked(duckdbQueryOnFile).mockReturnValue([
+ { table_name: "users", column_name: "id", data_type: "INTEGER", is_nullable: "NO" },
+ ]);
+
+ const { GET } = await import("./db/introspect/route.js");
+ const req = new Request("http://localhost/api/workspace/db/introspect?path=test.duckdb");
+ const res = await GET(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.tables).toBeDefined();
+ });
+ });
+
+ // βββ POST /api/workspace/reports/execute ββββββββββββββββββββββββ
+
+ describe("POST /api/workspace/reports/execute", () => {
+ it("returns 400 for missing sql", async () => {
+ const { POST } = await import("./reports/execute/route.js");
+ const req = new Request("http://localhost/api/workspace/reports/execute", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("rejects mutation SQL with 403", async () => {
+ const { checkSqlSafety } = await import("@/lib/report-filters");
+ vi.mocked(checkSqlSafety).mockReturnValue("Only SELECT queries allowed");
+
+ const { POST } = await import("./reports/execute/route.js");
+ const req = new Request("http://localhost/api/workspace/reports/execute", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sql: "DROP TABLE users" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(403);
+ });
+
+ it("executes report query successfully", async () => {
+ const { checkSqlSafety } = await import("@/lib/report-filters");
+ vi.mocked(checkSqlSafety).mockReturnValue(null);
+ const { duckdbQuery } = await import("@/lib/workspace");
+ vi.mocked(duckdbQuery).mockReturnValue([{ count: 42 }]);
+
+ const { POST } = await import("./reports/execute/route.js");
+ const req = new Request("http://localhost/api/workspace/reports/execute", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sql: "SELECT COUNT(*) as count FROM v_deals" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.rows).toEqual([{ count: 42 }]);
+ });
+
+ it("applies filters to SQL", async () => {
+ const { checkSqlSafety, buildFilterClauses, injectFilters } = await import("@/lib/report-filters");
+ vi.mocked(checkSqlSafety).mockReturnValue(null);
+ vi.mocked(buildFilterClauses).mockReturnValue(['"Status" = \'Active\'']);
+ vi.mocked(injectFilters).mockReturnValue("SELECT * FROM filtered");
+ const { duckdbQuery } = await import("@/lib/workspace");
+ vi.mocked(duckdbQuery).mockReturnValue([{ count: 10 }]);
+
+ const { POST } = await import("./reports/execute/route.js");
+ const req = new Request("http://localhost/api/workspace/reports/execute", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sql: "SELECT * FROM v_deals",
+ filters: [{ id: "s", column: "Status", value: { type: "select", value: "Active" } }],
+ }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(buildFilterClauses).toHaveBeenCalled();
+ expect(injectFilters).toHaveBeenCalled();
+ });
+ });
+
+ // βββ POST /api/workspace/query βββββββββββββββββββββββββββββββββ
+
+ describe("POST /api/workspace/query", () => {
+ it("returns 400 for missing sql", async () => {
+ const { POST } = await import("./query/route.js");
+ const req = new Request("http://localhost/api/workspace/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("executes query and returns rows", async () => {
+ const { duckdbQuery } = await import("@/lib/workspace");
+ vi.mocked(duckdbQuery).mockReturnValue([{ id: 1 }]);
+
+ const { POST } = await import("./query/route.js");
+ const req = new Request("http://localhost/api/workspace/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sql: "SELECT 1 as id" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.rows).toEqual([{ id: 1 }]);
+ });
+
+ it("rejects mutation SQL with 403", async () => {
+ const { POST } = await import("./query/route.js");
+ const req = new Request("http://localhost/api/workspace/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sql: "DELETE FROM users" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(403);
+ });
+ });
+});
diff --git a/apps/web/app/api/workspace/db/introspect/route.ts b/apps/web/app/api/workspace/db/introspect/route.ts
new file mode 100644
index 00000000000..409c3fb3e96
--- /dev/null
+++ b/apps/web/app/api/workspace/db/introspect/route.ts
@@ -0,0 +1,98 @@
+import { safeResolvePath, duckdbQueryOnFile, resolveDuckdbBin } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+type TableInfo = {
+ table_name: string;
+ column_count: number;
+ estimated_row_count: number;
+ columns: Array<{
+ name: string;
+ type: string;
+ is_nullable: boolean;
+ }>;
+};
+
+/**
+ * GET /api/workspace/db/introspect?path=
+ *
+ * Introspects a DuckDB / SQLite / generic DB file using the duckdb CLI.
+ * Returns the list of tables with their columns and approximate row counts.
+ */
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const relPath = searchParams.get("path");
+
+ if (!relPath) {
+ return Response.json(
+ { error: "Missing required `path` query parameter" },
+ { status: 400 },
+ );
+ }
+
+ const absPath = safeResolvePath(relPath);
+ if (!absPath) {
+ return Response.json(
+ { error: "File not found or path traversal rejected" },
+ { status: 404 },
+ );
+ }
+
+ // Check if DuckDB CLI binary is available
+ if (!resolveDuckdbBin()) {
+ return Response.json({ tables: [], path: relPath, duckdb_available: false });
+ }
+
+ // Get all user tables (skip internal DuckDB catalogs)
+ const rawTables = duckdbQueryOnFile<{
+ table_name: string;
+ table_type: string;
+ }>(
+ absPath,
+ "SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name",
+ );
+
+ if (rawTables.length === 0) {
+ return Response.json({ tables: [], path: relPath });
+ }
+
+ const tables: TableInfo[] = [];
+
+ for (const t of rawTables) {
+ // Fetch columns for this table
+ const cols = duckdbQueryOnFile<{
+ column_name: string;
+ data_type: string;
+ is_nullable: string;
+ }>(
+ absPath,
+ `SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'main' AND table_name = '${t.table_name.replace(/'/g, "''")}' ORDER BY ordinal_position`,
+ );
+
+ // Get approximate row count
+ let rowCount = 0;
+ try {
+ const countResult = duckdbQueryOnFile<{ cnt: number }>(
+ absPath,
+ `SELECT count(*) as cnt FROM "${t.table_name.replace(/"/g, '""')}"`,
+ );
+ rowCount = countResult[0]?.cnt ?? 0;
+ } catch {
+ // skip if we can't count
+ }
+
+ tables.push({
+ table_name: t.table_name,
+ column_count: cols.length,
+ estimated_row_count: rowCount,
+ columns: cols.map((c) => ({
+ name: c.column_name,
+ type: c.data_type,
+ is_nullable: c.is_nullable === "YES",
+ })),
+ });
+ }
+
+ return Response.json({ tables, path: relPath });
+}
diff --git a/apps/web/app/api/workspace/db/query/route.ts b/apps/web/app/api/workspace/db/query/route.ts
new file mode 100644
index 00000000000..69c7011af79
--- /dev/null
+++ b/apps/web/app/api/workspace/db/query/route.ts
@@ -0,0 +1,56 @@
+import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * POST /api/workspace/db/query
+ * Body: { path: string, sql: string }
+ *
+ * Executes a read-only SQL query against a database file and returns JSON rows.
+ * Only SELECT statements are allowed for safety.
+ */
+export async function POST(request: Request) {
+ let body: { path?: string; sql?: string };
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { path: relPath, sql } = body;
+
+ if (!relPath || !sql) {
+ return Response.json(
+ { error: "Missing required `path` and `sql` fields" },
+ { status: 400 },
+ );
+ }
+
+ // Basic safety: only allow SELECT-like statements
+ const trimmedSql = sql.trim().toUpperCase();
+ if (
+ !trimmedSql.startsWith("SELECT") &&
+ !trimmedSql.startsWith("PRAGMA") &&
+ !trimmedSql.startsWith("DESCRIBE") &&
+ !trimmedSql.startsWith("SHOW") &&
+ !trimmedSql.startsWith("EXPLAIN") &&
+ !trimmedSql.startsWith("WITH")
+ ) {
+ return Response.json(
+ { error: "Only read-only queries (SELECT, DESCRIBE, SHOW, EXPLAIN, WITH) are allowed" },
+ { status: 403 },
+ );
+ }
+
+ const absPath = safeResolvePath(relPath);
+ if (!absPath) {
+ return Response.json(
+ { error: "File not found or path traversal rejected" },
+ { status: 404 },
+ );
+ }
+
+ const rows = duckdbQueryOnFile(absPath, sql);
+ return Response.json({ rows, sql });
+}
diff --git a/apps/web/app/api/workspace/file-ops.test.ts b/apps/web/app/api/workspace/file-ops.test.ts
new file mode 100644
index 00000000000..5a6bcdb94d6
--- /dev/null
+++ b/apps/web/app/api/workspace/file-ops.test.ts
@@ -0,0 +1,292 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock node:fs
+vi.mock("node:fs", () => ({
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ rmSync: vi.fn(),
+ statSync: vi.fn(() => ({ isDirectory: () => false })),
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ renameSync: vi.fn(),
+ cpSync: vi.fn(),
+ copyFileSync: vi.fn(),
+}));
+
+// Mock workspace utilities
+vi.mock("@/lib/workspace", () => ({
+ readWorkspaceFile: vi.fn(),
+ safeResolvePath: vi.fn(),
+ safeResolveNewPath: vi.fn(),
+ isSystemFile: vi.fn(() => false),
+ resolveWorkspaceRoot: vi.fn(() => "/ws"),
+}));
+
+describe("Workspace File Operations API", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.mock("node:fs", () => ({
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ rmSync: vi.fn(),
+ statSync: vi.fn(() => ({ isDirectory: () => false })),
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ renameSync: vi.fn(),
+ cpSync: vi.fn(),
+ copyFileSync: vi.fn(),
+ }));
+ vi.mock("@/lib/workspace", () => ({
+ readWorkspaceFile: vi.fn(),
+ safeResolvePath: vi.fn(),
+ safeResolveNewPath: vi.fn(),
+ isSystemFile: vi.fn(() => false),
+ resolveWorkspaceRoot: vi.fn(() => "/ws"),
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // βββ GET /api/workspace/file ββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/workspace/file", () => {
+ it("returns 400 when path param is missing", async () => {
+ const { GET } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file");
+ const res = await GET(req);
+ expect(res.status).toBe(400);
+ const json = await res.json();
+ expect(json.error).toContain("path");
+ });
+
+ it("returns file content when found", async () => {
+ const { readWorkspaceFile } = await import("@/lib/workspace");
+ vi.mocked(readWorkspaceFile).mockReturnValue({ content: "# Hello", type: "markdown" });
+
+ const { GET } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file?path=doc.md");
+ const res = await GET(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.content).toBe("# Hello");
+ expect(json.type).toBe("markdown");
+ });
+
+ it("returns 404 when file not found", async () => {
+ const { readWorkspaceFile } = await import("@/lib/workspace");
+ vi.mocked(readWorkspaceFile).mockReturnValue(null);
+
+ const { GET } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file?path=missing.md");
+ const res = await GET(req);
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // βββ POST /api/workspace/file βββββββββββββββββββββββββββββββββββ
+
+ describe("POST /api/workspace/file", () => {
+ it("writes file content successfully", async () => {
+ const { safeResolveNewPath } = await import("@/lib/workspace");
+ vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md");
+ const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
+
+ const { POST } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "doc.md", content: "# Hello" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.ok).toBe(true);
+ expect(mockMkdir).toHaveBeenCalled();
+ expect(mockWrite).toHaveBeenCalled();
+ });
+
+ it("returns 400 for missing path", async () => {
+ const { POST } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ content: "text" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for missing content", async () => {
+ const { POST } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "doc.md" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for path traversal", async () => {
+ const { safeResolveNewPath } = await import("@/lib/workspace");
+ vi.mocked(safeResolveNewPath).mockReturnValue(null);
+
+ const { POST } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "../etc/passwd", content: "hack" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const { POST } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 500 on write error", async () => {
+ const { safeResolveNewPath } = await import("@/lib/workspace");
+ vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md");
+ const { writeFileSync: mockWrite } = await import("node:fs");
+ vi.mocked(mockWrite).mockImplementation(() => { throw new Error("EACCES"); });
+
+ const { POST } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "doc.md", content: "text" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(500);
+ });
+ });
+
+ // βββ DELETE /api/workspace/file βββββββββββββββββββββββββββββββββ
+
+ describe("DELETE /api/workspace/file", () => {
+ it("deletes file successfully", async () => {
+ const { safeResolvePath, isSystemFile } = await import("@/lib/workspace");
+ vi.mocked(safeResolvePath).mockReturnValue("/ws/file.txt");
+ vi.mocked(isSystemFile).mockReturnValue(false);
+
+ const { DELETE } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "file.txt" }),
+ });
+ const res = await DELETE(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.ok).toBe(true);
+ });
+
+ it("returns 403 for system file", async () => {
+ const { isSystemFile } = await import("@/lib/workspace");
+ vi.mocked(isSystemFile).mockReturnValue(true);
+
+ const { DELETE } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: ".object.yaml" }),
+ });
+ const res = await DELETE(req);
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 404 when file not found", async () => {
+ const { safeResolvePath, isSystemFile } = await import("@/lib/workspace");
+ vi.mocked(isSystemFile).mockReturnValue(false);
+ vi.mocked(safeResolvePath).mockReturnValue(null);
+
+ const { DELETE } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "nonexistent.txt" }),
+ });
+ const res = await DELETE(req);
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 400 for missing path", async () => {
+ const { DELETE } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await DELETE(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const { DELETE } = await import("./file/route.js");
+ const req = new Request("http://localhost/api/workspace/file", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ const res = await DELETE(req);
+ expect(res.status).toBe(400);
+ });
+ });
+
+ // βββ POST /api/workspace/mkdir ββββββββββββββββββββββββββββββββββ
+
+ describe("POST /api/workspace/mkdir", () => {
+ it("creates directory successfully", async () => {
+ const { safeResolveNewPath } = await import("@/lib/workspace");
+ vi.mocked(safeResolveNewPath).mockReturnValue("/ws/new-folder");
+
+ const { POST } = await import("./mkdir/route.js");
+ const req = new Request("http://localhost/api/workspace/mkdir", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "new-folder" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.ok).toBe(true);
+ });
+
+ it("returns 400 for missing path", async () => {
+ const { POST } = await import("./mkdir/route.js");
+ const req = new Request("http://localhost/api/workspace/mkdir", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for traversal attempt", async () => {
+ const { safeResolveNewPath } = await import("@/lib/workspace");
+ vi.mocked(safeResolveNewPath).mockReturnValue(null);
+
+ const { POST } = await import("./mkdir/route.js");
+ const req = new Request("http://localhost/api/workspace/mkdir", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "../../etc" }),
+ });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+ });
+});
diff --git a/apps/web/app/api/workspace/file/route.ts b/apps/web/app/api/workspace/file/route.ts
new file mode 100644
index 00000000000..a096c66f9b7
--- /dev/null
+++ b/apps/web/app/api/workspace/file/route.ts
@@ -0,0 +1,121 @@
+import { writeFileSync, mkdirSync, rmSync, statSync } from "node:fs";
+import { dirname } from "node:path";
+import { readWorkspaceFile, safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path");
+
+ if (!path) {
+ return Response.json(
+ { error: "Missing 'path' query parameter" },
+ { status: 400 },
+ );
+ }
+
+ const file = readWorkspaceFile(path);
+ if (!file) {
+ return Response.json(
+ { error: "File not found or access denied" },
+ { status: 404 },
+ );
+ }
+
+ return Response.json(file);
+}
+
+/**
+ * POST /api/workspace/file
+ * Body: { path: string, content: string }
+ *
+ * Writes a file to the workspace. Creates parent directories as needed.
+ */
+export async function POST(req: Request) {
+ let body: { path?: string; content?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { path: relPath, content } = body;
+ if (!relPath || typeof relPath !== "string" || typeof content !== "string") {
+ return Response.json(
+ { error: "Missing 'path' and 'content' fields" },
+ { status: 400 },
+ );
+ }
+
+ // Use safeResolveNewPath (not safeResolvePath) because the file may not exist yet
+ const absPath = safeResolveNewPath(relPath);
+ if (!absPath) {
+ return Response.json(
+ { error: "Invalid path or path traversal rejected" },
+ { status: 400 },
+ );
+ }
+
+ try {
+ mkdirSync(dirname(absPath), { recursive: true });
+ writeFileSync(absPath, content, "utf-8");
+ return Response.json({ ok: true, path: relPath });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Write failed" },
+ { status: 500 },
+ );
+ }
+}
+
+/**
+ * DELETE /api/workspace/file
+ * Body: { path: string }
+ *
+ * Deletes a file or folder from the workspace.
+ * System files (.object.yaml, workspace.duckdb, etc.) are protected.
+ */
+export async function DELETE(req: Request) {
+ let body: { path?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { path: relPath } = body;
+ if (!relPath || typeof relPath !== "string") {
+ return Response.json(
+ { error: "Missing 'path' field" },
+ { status: 400 },
+ );
+ }
+
+ if (isSystemFile(relPath)) {
+ return Response.json(
+ { error: "Cannot delete system file" },
+ { status: 403 },
+ );
+ }
+
+ const absPath = safeResolvePath(relPath);
+ if (!absPath) {
+ return Response.json(
+ { error: "File not found or path traversal rejected" },
+ { status: 404 },
+ );
+ }
+
+ try {
+ const stat = statSync(absPath);
+ rmSync(absPath, { recursive: stat.isDirectory() });
+ return Response.json({ ok: true, path: relPath });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Delete failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/init/route.test.ts b/apps/web/app/api/workspace/init/route.test.ts
new file mode 100644
index 00000000000..4829de2cfbc
--- /dev/null
+++ b/apps/web/app/api/workspace/init/route.test.ts
@@ -0,0 +1,219 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ copyFileSync: vi.fn(),
+}));
+
+vi.mock("node:child_process", () => ({
+ execSync: vi.fn(() => ""),
+ exec: vi.fn(
+ (
+ _cmd: string,
+ _opts: unknown,
+ cb: (err: Error | null, result: { stdout: string }) => void,
+ ) => {
+ cb(null, { stdout: "" });
+ },
+ ),
+}));
+
+vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+}));
+
+import { existsSync, mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+describe("POST /api/workspace/init", () => {
+ const originalEnv = { ...process.env };
+ const STATE_DIR = join("/home/testuser", ".openclaw");
+
+ beforeEach(() => {
+ vi.resetModules();
+ vi.restoreAllMocks();
+ process.env = { ...originalEnv };
+ delete process.env.OPENCLAW_PROFILE;
+ delete process.env.OPENCLAW_HOME;
+ delete process.env.OPENCLAW_WORKSPACE;
+ delete process.env.OPENCLAW_STATE_DIR;
+
+ vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+ readFileSync: vi.fn(() => ""),
+ readdirSync: vi.fn(() => []),
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ copyFileSync: vi.fn(),
+ }));
+ vi.mock("node:child_process", () => ({
+ execSync: vi.fn(() => ""),
+ exec: vi.fn(
+ (
+ _cmd: string,
+ _opts: unknown,
+ cb: (err: Error | null, result: { stdout: string }) => void,
+ ) => {
+ cb(null, { stdout: "" });
+ },
+ ),
+ }));
+ vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+ }));
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ async function callInit(body: Record) {
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/workspace/init", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ return POST(req);
+ }
+
+ it("creates default workspace directory", async () => {
+ const mockMkdir = vi.mocked(mkdirSync);
+ const response = await callInit({});
+ expect(response.status).toBe(200);
+ expect(mockMkdir).toHaveBeenCalledWith(
+ join(STATE_DIR, "workspace"),
+ { recursive: true },
+ );
+ const json = await response.json();
+ expect(json.profile).toBe("default");
+ expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace"));
+ });
+
+ it("creates profile-specific workspace directory", async () => {
+ const mockMkdir = vi.mocked(mkdirSync);
+ const response = await callInit({ profile: "work" });
+ expect(response.status).toBe(200);
+ expect(mockMkdir).toHaveBeenCalledWith(
+ join(STATE_DIR, "workspace-work"),
+ { recursive: true },
+ );
+ const json = await response.json();
+ expect(json.profile).toBe("work");
+ });
+
+ it("rejects invalid profile names", async () => {
+ const response = await callInit({ profile: "invalid profile!" });
+ expect(response.status).toBe(400);
+ const json = await response.json();
+ expect(json.error).toContain("Invalid profile name");
+ });
+
+ it("allows alphanumeric, hyphens, and underscores in profile names", async () => {
+ const response = await callInit({ profile: "my-work_1" });
+ expect(response.status).toBe(200);
+ const json = await response.json();
+ expect(json.profile).toBe("my-work_1");
+ });
+
+ it("accepts 'default' as profile name", async () => {
+ const response = await callInit({ profile: "default" });
+ expect(response.status).toBe(200);
+ const json = await response.json();
+ expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace"));
+ });
+
+ it("seeds bootstrap files when seedBootstrap is not false", async () => {
+ const mockWrite = vi.mocked(writeFileSync);
+ await callInit({});
+ const writtenPaths = mockWrite.mock.calls.map((c) => c[0] as string);
+ const bootstrapFiles = writtenPaths.filter(
+ (p) =>
+ p.endsWith("AGENTS.md") ||
+ p.endsWith("SOUL.md") ||
+ p.endsWith("TOOLS.md") ||
+ p.endsWith("IDENTITY.md") ||
+ p.endsWith("USER.md") ||
+ p.endsWith("HEARTBEAT.md") ||
+ p.endsWith("BOOTSTRAP.md"),
+ );
+ expect(bootstrapFiles.length).toBeGreaterThan(0);
+ });
+
+ it("returns seeded files list", async () => {
+ const response = await callInit({});
+ const json = await response.json();
+ expect(Array.isArray(json.seededFiles)).toBe(true);
+ });
+
+ it("skips bootstrap seeding when seedBootstrap is false", async () => {
+ const mockWrite = vi.mocked(writeFileSync);
+ const callsBefore = mockWrite.mock.calls.length;
+ await callInit({ seedBootstrap: false });
+ const bootstrapWrites = mockWrite.mock.calls
+ .slice(callsBefore)
+ .filter((c) => {
+ const p = c[0] as string;
+ return p.endsWith(".md") && !p.endsWith("workspace-state.json");
+ });
+ expect(bootstrapWrites).toHaveLength(0);
+ });
+
+ it("does not overwrite existing bootstrap files (idempotent)", async () => {
+ const mockExist = vi.mocked(existsSync);
+ const wsDir = join(STATE_DIR, "workspace");
+ mockExist.mockImplementation((p) => {
+ const s = String(p);
+ return s === join(wsDir, "AGENTS.md") || s === join(wsDir, "SOUL.md");
+ });
+
+ const response = await callInit({});
+ const json = await response.json();
+ expect(json.seededFiles).not.toContain("AGENTS.md");
+ expect(json.seededFiles).not.toContain("SOUL.md");
+ });
+
+ it("handles custom workspace path", async () => {
+ const mockMkdir = vi.mocked(mkdirSync);
+ const response = await callInit({
+ profile: "custom",
+ path: "/my/custom/workspace",
+ });
+ expect(response.status).toBe(200);
+ expect(mockMkdir).toHaveBeenCalledWith("/my/custom/workspace", {
+ recursive: true,
+ });
+ const json = await response.json();
+ expect(json.workspaceDir).toBe("/my/custom/workspace");
+ });
+
+ it("resolves tilde in custom path", async () => {
+ const mockMkdir = vi.mocked(mkdirSync);
+ await callInit({ profile: "tilde", path: "~/my-workspace" });
+ expect(mockMkdir).toHaveBeenCalledWith(
+ join("/home/testuser", "my-workspace"),
+ { recursive: true },
+ );
+ });
+
+ it("auto-switches to new profile after creation", async () => {
+ const response = await callInit({ profile: "newprofile" });
+ const json = await response.json();
+ expect(json.activeProfile).toBe("newprofile");
+ });
+
+ it("handles mkdir failure with 500", async () => {
+ const mockMkdir = vi.mocked(mkdirSync);
+ mockMkdir.mockImplementation(() => {
+ throw new Error("EACCES: permission denied");
+ });
+ const response = await callInit({ profile: "fail" });
+ expect(response.status).toBe(500);
+ const json = await response.json();
+ expect(json.error).toContain("Failed to create workspace directory");
+ });
+});
diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts
new file mode 100644
index 00000000000..eef98d4c744
--- /dev/null
+++ b/apps/web/app/api/workspace/init/route.ts
@@ -0,0 +1,332 @@
+import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { homedir } from "node:os";
+import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+// ---------------------------------------------------------------------------
+// Bootstrap file names (must match src/agents/workspace.ts)
+// ---------------------------------------------------------------------------
+
+const BOOTSTRAP_FILENAMES = [
+ "AGENTS.md",
+ "SOUL.md",
+ "TOOLS.md",
+ "IDENTITY.md",
+ "USER.md",
+ "HEARTBEAT.md",
+ "BOOTSTRAP.md",
+] as const;
+
+// Minimal fallback content used when templates can't be loaded from disk
+const FALLBACK_CONTENT: Record = {
+ "AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n",
+ "SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n",
+ "TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n",
+ "IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n",
+ "USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n",
+ "HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n",
+ "BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n",
+};
+
+// ---------------------------------------------------------------------------
+// CRM seed objects (mirrors src/agents/workspace-seed.ts)
+// ---------------------------------------------------------------------------
+
+type SeedField = {
+ name: string;
+ type: string;
+ required?: boolean;
+ enumValues?: string[];
+};
+
+type SeedObject = {
+ id: string;
+ name: string;
+ description: string;
+ icon: string;
+ defaultView: string;
+ entryCount: number;
+ fields: SeedField[];
+};
+
+const SEED_OBJECTS: SeedObject[] = [
+ {
+ id: "seed_obj_people_00000000000000",
+ name: "people",
+ description: "Contact management",
+ icon: "users",
+ defaultView: "table",
+ entryCount: 5,
+ fields: [
+ { name: "Full Name", type: "text", required: true },
+ { name: "Email Address", type: "email", required: true },
+ { name: "Phone Number", type: "phone" },
+ { name: "Company", type: "text" },
+ { name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] },
+ { name: "Notes", type: "richtext" },
+ ],
+ },
+ {
+ id: "seed_obj_company_0000000000000",
+ name: "company",
+ description: "Company tracking",
+ icon: "building-2",
+ defaultView: "table",
+ entryCount: 3,
+ fields: [
+ { name: "Company Name", type: "text", required: true },
+ {
+ name: "Industry",
+ type: "enum",
+ enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"],
+ },
+ { name: "Website", type: "text" },
+ { name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] },
+ { name: "Notes", type: "richtext" },
+ ],
+ },
+ {
+ id: "seed_obj_task_000000000000000",
+ name: "task",
+ description: "Task tracking board",
+ icon: "check-square",
+ defaultView: "kanban",
+ entryCount: 5,
+ fields: [
+ { name: "Title", type: "text", required: true },
+ { name: "Description", type: "text" },
+ { name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] },
+ { name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] },
+ { name: "Due Date", type: "date" },
+ { name: "Notes", type: "richtext" },
+ ],
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function stripFrontMatter(content: string): string {
+ if (!content.startsWith("---")) {return content;}
+ const endIndex = content.indexOf("\n---", 3);
+ if (endIndex === -1) {return content;}
+ return content.slice(endIndex + "\n---".length).replace(/^\s+/, "");
+}
+
+/** Try multiple candidate paths to find the monorepo root. */
+function resolveProjectRoot(): string | null {
+ const marker = join("docs", "reference", "templates", "AGENTS.md");
+ const cwd = process.cwd();
+
+ // CWD is the repo root (standalone builds)
+ if (existsSync(join(cwd, marker))) {return cwd;}
+
+ // CWD is apps/web/ (dev mode)
+ const fromApps = resolve(cwd, "..", "..");
+ if (existsSync(join(fromApps, marker))) {return fromApps;}
+
+ return null;
+}
+
+function loadTemplateContent(filename: string, projectRoot: string | null): string {
+ if (projectRoot) {
+ const templatePath = join(projectRoot, "docs", "reference", "templates", filename);
+ try {
+ const raw = readFileSync(templatePath, "utf-8");
+ return stripFrontMatter(raw);
+ } catch {
+ // fall through to fallback
+ }
+ }
+ return FALLBACK_CONTENT[filename] ?? "";
+}
+
+function generateObjectYaml(obj: SeedObject): string {
+ const lines: string[] = [
+ `id: "${obj.id}"`,
+ `name: "${obj.name}"`,
+ `description: "${obj.description}"`,
+ `icon: "${obj.icon}"`,
+ `default_view: "${obj.defaultView}"`,
+ `entry_count: ${obj.entryCount}`,
+ "fields:",
+ ];
+
+ for (const field of obj.fields) {
+ lines.push(` - name: "${field.name}"`);
+ lines.push(` type: ${field.type}`);
+ if (field.required) {lines.push(" required: true");}
+ if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);}
+ }
+
+ return lines.join("\n") + "\n";
+}
+
+function generateWorkspaceMd(objects: SeedObject[]): string {
+ const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""];
+ for (const obj of objects) {
+ lines.push(`## ${obj.name}`, "");
+ lines.push(`- **Description**: ${obj.description}`);
+ lines.push(`- **View**: \`${obj.defaultView}\``);
+ lines.push(`- **Entries**: ${obj.entryCount}`);
+ lines.push("- **Fields**:");
+ for (const field of obj.fields) {
+ const req = field.required ? " (required)" : "";
+ const vals = field.enumValues ? ` β ${field.enumValues.join(", ")}` : "";
+ lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`);
+ }
+ lines.push("");
+ }
+ return lines.join("\n");
+}
+
+function writeIfMissing(filePath: string, content: string): boolean {
+ if (existsSync(filePath)) {return false;}
+ try {
+ writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean {
+ const destPath = join(workspaceDir, "workspace.duckdb");
+ if (existsSync(destPath)) {return false;}
+
+ if (!projectRoot) {return false;}
+
+ const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb");
+ if (!existsSync(seedDb)) {return false;}
+
+ try {
+ copyFileSync(seedDb, destPath);
+ } catch {
+ return false;
+ }
+
+ // Create filesystem projections for CRM objects
+ for (const obj of SEED_OBJECTS) {
+ const objDir = join(workspaceDir, obj.name);
+ mkdirSync(objDir, { recursive: true });
+ writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj));
+ }
+
+ writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS));
+
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Route handler
+// ---------------------------------------------------------------------------
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as {
+ profile?: string;
+ path?: string;
+ seedBootstrap?: boolean;
+ };
+
+ const profileName = body.profile?.trim() || null;
+
+ if (profileName && profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) {
+ return Response.json(
+ { error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." },
+ { status: 400 },
+ );
+ }
+
+ // Determine workspace directory
+ let workspaceDir: string;
+ if (body.path?.trim()) {
+ workspaceDir = body.path.trim();
+ if (workspaceDir.startsWith("~")) {
+ workspaceDir = join(homedir(), workspaceDir.slice(1));
+ }
+ workspaceDir = resolve(workspaceDir);
+ } else {
+ const stateDir = resolveOpenClawStateDir();
+ if (profileName && profileName !== "default") {
+ workspaceDir = join(stateDir, `workspace-${profileName}`);
+ } else {
+ workspaceDir = join(stateDir, "workspace");
+ }
+ }
+
+ try {
+ mkdirSync(workspaceDir, { recursive: true });
+ } catch (err) {
+ return Response.json(
+ { error: `Failed to create workspace directory: ${(err as Error).message}` },
+ { status: 500 },
+ );
+ }
+
+ const seedBootstrap = body.seedBootstrap !== false;
+ const seeded: string[] = [];
+
+ if (seedBootstrap) {
+ const projectRoot = resolveProjectRoot();
+
+ // Seed all bootstrap files from templates
+ for (const filename of BOOTSTRAP_FILENAMES) {
+ const filePath = join(workspaceDir, filename);
+ if (!existsSync(filePath)) {
+ const content = loadTemplateContent(filename, projectRoot);
+ if (writeIfMissing(filePath, content)) {
+ seeded.push(filename);
+ }
+ }
+ }
+
+ // Seed DuckDB + CRM object projections
+ if (seedDuckDB(workspaceDir, projectRoot)) {
+ seeded.push("workspace.duckdb");
+ for (const obj of SEED_OBJECTS) {
+ seeded.push(`${obj.name}/.object.yaml`);
+ }
+ }
+
+ // Write workspace state so the gateway knows seeding was done
+ const stateDir = join(workspaceDir, ".openclaw");
+ const statePath = join(stateDir, "workspace-state.json");
+ if (!existsSync(statePath)) {
+ try {
+ mkdirSync(stateDir, { recursive: true });
+ const state = {
+ version: 1,
+ bootstrapSeededAt: new Date().toISOString(),
+ duckdbSeededAt: existsSync(join(workspaceDir, "workspace.duckdb"))
+ ? new Date().toISOString()
+ : undefined,
+ };
+ writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
+ } catch {
+ // Best-effort state tracking
+ }
+ }
+ }
+
+ // Remember custom-path workspaces in the registry
+ if (body.path?.trim() && profileName) {
+ registerWorkspacePath(profileName, workspaceDir);
+ }
+
+ // Switch to the new profile
+ if (profileName) {
+ setUIActiveProfile(profileName === "default" ? null : profileName);
+ }
+
+ return Response.json({
+ workspaceDir,
+ profile: profileName || "default",
+ activeProfile: getEffectiveProfile() || "default",
+ seededFiles: seeded,
+ workspaceRoot: resolveWorkspaceRoot(),
+ });
+}
diff --git a/apps/web/app/api/workspace/mkdir/route.ts b/apps/web/app/api/workspace/mkdir/route.ts
new file mode 100644
index 00000000000..fcbe54acb73
--- /dev/null
+++ b/apps/web/app/api/workspace/mkdir/route.ts
@@ -0,0 +1,71 @@
+import { mkdirSync, existsSync } from "node:fs";
+import { resolve, normalize } from "node:path";
+import { safeResolveNewPath } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * POST /api/workspace/mkdir
+ * Body: { path: string; absolute?: boolean }
+ *
+ * Creates a new directory. By default paths are resolved relative to the
+ * workspace root. When `absolute` is true the path is treated as a
+ * filesystem-absolute path (used by the directory picker for workspace
+ * creation outside the current workspace).
+ */
+export async function POST(req: Request) {
+ let body: { path?: string; absolute?: boolean };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { path: rawPath, absolute: useAbsolute } = body;
+ if (!rawPath || typeof rawPath !== "string") {
+ return Response.json(
+ { error: "Missing 'path' field" },
+ { status: 400 },
+ );
+ }
+
+ let absPath: string | null;
+
+ if (useAbsolute) {
+ const normalized = normalize(rawPath);
+ if (normalized.includes("/../") || normalized.includes("/..")) {
+ return Response.json(
+ { error: "Path traversal rejected" },
+ { status: 400 },
+ );
+ }
+ absPath = resolve(normalized);
+ } else {
+ absPath = safeResolveNewPath(rawPath);
+ }
+
+ if (!absPath) {
+ return Response.json(
+ { error: "Invalid path or path traversal rejected" },
+ { status: 400 },
+ );
+ }
+
+ if (existsSync(absPath)) {
+ return Response.json(
+ { error: "Directory already exists" },
+ { status: 409 },
+ );
+ }
+
+ try {
+ mkdirSync(absPath, { recursive: true });
+ return Response.json({ ok: true, path: absPath });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "mkdir failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/move/route.ts b/apps/web/app/api/workspace/move/route.ts
new file mode 100644
index 00000000000..a2670a60240
--- /dev/null
+++ b/apps/web/app/api/workspace/move/route.ts
@@ -0,0 +1,93 @@
+import { renameSync, existsSync, statSync } from "node:fs";
+import { join, basename } from "node:path";
+import { safeResolvePath, isSystemFile } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * POST /api/workspace/move
+ * Body: { sourcePath: string, destinationDir: string }
+ *
+ * Moves a file or folder into a different directory.
+ * System files are protected from moving.
+ */
+export async function POST(req: Request) {
+ let body: { sourcePath?: string; destinationDir?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { sourcePath, destinationDir } = body;
+ if (!sourcePath || typeof sourcePath !== "string" || !destinationDir || typeof destinationDir !== "string") {
+ return Response.json(
+ { error: "Missing 'sourcePath' and 'destinationDir' fields" },
+ { status: 400 },
+ );
+ }
+
+ if (isSystemFile(sourcePath)) {
+ return Response.json(
+ { error: "Cannot move system file" },
+ { status: 403 },
+ );
+ }
+
+ const srcAbs = safeResolvePath(sourcePath);
+ if (!srcAbs) {
+ return Response.json(
+ { error: "Source not found or path traversal rejected" },
+ { status: 404 },
+ );
+ }
+
+ const destDirAbs = safeResolvePath(destinationDir);
+ if (!destDirAbs) {
+ return Response.json(
+ { error: "Destination not found or path traversal rejected" },
+ { status: 404 },
+ );
+ }
+
+ // Destination must be a directory
+ if (!statSync(destDirAbs).isDirectory()) {
+ return Response.json(
+ { error: "Destination is not a directory" },
+ { status: 400 },
+ );
+ }
+
+ // Prevent moving a folder into itself or its children
+ const srcAbsNorm = srcAbs + "/";
+ if (destDirAbs.startsWith(srcAbsNorm) || destDirAbs === srcAbs) {
+ return Response.json(
+ { error: "Cannot move a folder into itself" },
+ { status: 400 },
+ );
+ }
+
+ const itemName = basename(srcAbs);
+ const destAbs = join(destDirAbs, itemName);
+
+ if (existsSync(destAbs)) {
+ return Response.json(
+ { error: `'${itemName}' already exists in destination` },
+ { status: 409 },
+ );
+ }
+
+ // Build new relative path
+ const newRelPath = destinationDir === "." ? itemName : `${destinationDir}/${itemName}`;
+
+ try {
+ renameSync(srcAbs, destAbs);
+ return Response.json({ ok: true, oldPath: sourcePath, newPath: newRelPath });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Move failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/objects.test.ts b/apps/web/app/api/workspace/objects.test.ts
new file mode 100644
index 00000000000..ce2da6a62cf
--- /dev/null
+++ b/apps/web/app/api/workspace/objects.test.ts
@@ -0,0 +1,392 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock node:child_process
+vi.mock("node:child_process", () => ({
+ execSync: vi.fn(() => ""),
+}));
+
+// Mock workspace
+vi.mock("@/lib/workspace", () => ({
+ duckdbPath: vi.fn(() => null),
+ duckdbQueryOnFile: vi.fn(() => []),
+ duckdbExecOnFile: vi.fn(() => true),
+ findDuckDBForObject: vi.fn(() => null),
+ parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])),
+ resolveDuckdbBin: vi.fn(() => null),
+ discoverDuckDBPaths: vi.fn(() => []),
+}));
+
+describe("Workspace Objects API", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.mock("node:child_process", () => ({
+ execSync: vi.fn(() => ""),
+ }));
+ vi.mock("@/lib/workspace", () => ({
+ duckdbPath: vi.fn(() => null),
+ duckdbQueryOnFile: vi.fn(() => []),
+ duckdbExecOnFile: vi.fn(() => true),
+ findDuckDBForObject: vi.fn(() => null),
+ parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])),
+ resolveDuckdbBin: vi.fn(() => null),
+ discoverDuckDBPaths: vi.fn(() => []),
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // βββ GET /api/workspace/objects/[name] ββββββββββββββββββββββββββ
+
+ describe("GET /api/workspace/objects/[name]", () => {
+ it("returns 503 when DuckDB CLI not installed", async () => {
+ const { resolveDuckdbBin } = await import("@/lib/workspace");
+ vi.mocked(resolveDuckdbBin).mockReturnValue(null);
+
+ const { GET } = await import("./objects/[name]/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/workspace/objects/bad-name!"),
+ { params: Promise.resolve({ name: "bad-name!" }) },
+ );
+ expect(res.status).toBe(503);
+ });
+
+ it("returns 400 for invalid object name (when duckdb available)", async () => {
+ const { resolveDuckdbBin } = await import("@/lib/workspace");
+ vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
+
+ const { GET } = await import("./objects/[name]/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/workspace/objects/bad!name"),
+ { params: Promise.resolve({ name: "bad!name" }) },
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when object not found", async () => {
+ const { findDuckDBForObject, resolveDuckdbBin, duckdbPath: mockDuckdbPath } = await import("@/lib/workspace");
+ vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
+ vi.mocked(findDuckDBForObject).mockReturnValue(null);
+ vi.mocked(mockDuckdbPath).mockReturnValue(null);
+
+ const { GET } = await import("./objects/[name]/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/workspace/objects/nonexistent"),
+ { params: Promise.resolve({ name: "nonexistent" }) },
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("returns object schema and entries when found", async () => {
+ const { findDuckDBForObject, duckdbQueryOnFile, resolveDuckdbBin, discoverDuckDBPaths } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
+ vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
+ vi.mocked(discoverDuckDBPaths).mockReturnValue(["/ws/workspace.duckdb"]);
+
+ // Mock different queries with a call counter
+ let queryCall = 0;
+ vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
+ queryCall++;
+ if (queryCall === 1) {
+ // Object row
+ return [{ id: "obj1", name: "leads", description: "Leads object", icon: "star" }];
+ }
+ if (queryCall === 2) {
+ // Fields
+ return [
+ { id: "f1", name: "name", type: "text", sort_order: 0 },
+ { id: "f2", name: "status", type: "enum", sort_order: 1, enum_values: '["New","Active"]' },
+ ];
+ }
+ if (queryCall === 3) {
+ // Statuses
+ return [];
+ }
+ // Entries and subsequent queries
+ return [];
+ });
+
+ const { GET } = await import("./objects/[name]/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/workspace/objects/leads"),
+ { params: Promise.resolve({ name: "leads" }) },
+ );
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.object).toBeDefined();
+ expect(json.fields).toBeDefined();
+ });
+
+ it("accepts underscored names", async () => {
+ const { findDuckDBForObject } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue(null);
+
+ const { GET } = await import("./objects/[name]/route.js");
+ const res = await GET(
+ new Request("http://localhost/api/workspace/objects/my_object"),
+ { params: Promise.resolve({ name: "my_object" }) },
+ );
+ // 404 because findDuckDBForObject returns null, but name validation passes
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // βββ POST /api/workspace/objects/[name]/entries βββββββββββββββββ
+
+ describe("POST /api/workspace/objects/[name]/entries", () => {
+ it("returns 400 for invalid object name", async () => {
+ const { POST } = await import("./objects/[name]/entries/route.js");
+ const req = new Request("http://localhost/api/workspace/objects/bad!/entries", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await POST(req, { params: Promise.resolve({ name: "bad!" }) });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when DuckDB not found", async () => {
+ const { findDuckDBForObject } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue(null);
+
+ const { POST } = await import("./objects/[name]/entries/route.js");
+ const req = new Request("http://localhost/api/workspace/objects/leads/entries", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
+ expect(res.status).toBe(404);
+ });
+
+ it("creates entry successfully", async () => {
+ const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
+
+ let queryCall = 0;
+ vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
+ queryCall++;
+ if (queryCall === 1) {return [{ id: "obj1" }];} // object lookup
+ if (queryCall === 2) {return [{ id: "new-entry-uuid" }];} // uuid generation
+ return [];
+ });
+ vi.mocked(duckdbExecOnFile).mockReturnValue(true);
+
+ const { POST } = await import("./objects/[name]/entries/route.js");
+ const req = new Request("http://localhost/api/workspace/objects/leads/entries", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fields: { name: "Acme Corp" } }),
+ });
+ const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
+ expect(res.status).toBe(201);
+ const json = await res.json();
+ expect(json.ok).toBe(true);
+ expect(json.entryId).toBeDefined();
+ });
+
+ it("returns 404 when object not found in DB", async () => {
+ const { findDuckDBForObject, duckdbQueryOnFile } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
+ vi.mocked(duckdbQueryOnFile).mockReturnValue([]); // object not found
+
+ const { POST } = await import("./objects/[name]/entries/route.js");
+ const req = new Request("http://localhost/api/workspace/objects/missing/entries", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const res = await POST(req, { params: Promise.resolve({ name: "missing" }) });
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // βββ GET /api/workspace/objects/[name]/entries/[id] βββββββββββββ
+
+ describe("GET /api/workspace/objects/[name]/entries/[id]", () => {
+ it("returns 400 for invalid object name", async () => {
+ const { GET } = await import("./objects/[name]/entries/[id]/route.js");
+ const res = await GET(
+ new Request("http://localhost"),
+ { params: Promise.resolve({ name: "bad!", id: "123" }) },
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when DuckDB not found", async () => {
+ const { findDuckDBForObject } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue(null);
+
+ const { GET } = await import("./objects/[name]/entries/[id]/route.js");
+ const res = await GET(
+ new Request("http://localhost"),
+ { params: Promise.resolve({ name: "leads", id: "123" }) },
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("returns entry details when found", async () => {
+ const { findDuckDBForObject, duckdbQueryOnFile } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
+
+ let queryCall = 0;
+ vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
+ queryCall++;
+ if (queryCall === 1) {return [{ id: "obj1" }];} // object
+ if (queryCall === 2) {return [{ id: "f1", name: "name", type: "text" }];} // fields
+ if (queryCall === 3) {return [{ entry_id: "e1", field_name: "name", value: "Acme", created_at: "2025-01-01", updated_at: "2025-01-01" }];} // EAV
+ return [];
+ });
+
+ const { GET } = await import("./objects/[name]/entries/[id]/route.js");
+ const res = await GET(
+ new Request("http://localhost"),
+ { params: Promise.resolve({ name: "leads", id: "e1" }) },
+ );
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.entry).toBeDefined();
+ });
+ });
+
+ // βββ PATCH /api/workspace/objects/[name]/entries/[id] βββββββββββ
+
+ describe("PATCH /api/workspace/objects/[name]/entries/[id]", () => {
+ it("returns 400 for invalid object name", async () => {
+ const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
+ const req = new Request("http://localhost", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fields: {} }),
+ });
+ const res = await PATCH(req, { params: Promise.resolve({ name: "bad!", id: "123" }) });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when DuckDB not found", async () => {
+ const { findDuckDBForObject } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue(null);
+
+ const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
+ const req = new Request("http://localhost", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fields: { name: "Updated" } }),
+ });
+ const res = await PATCH(req, { params: Promise.resolve({ name: "leads", id: "e1" }) });
+ expect(res.status).toBe(404);
+ });
+
+ it("updates entry fields", async () => {
+ const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
+
+ let queryCall = 0;
+ vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
+ queryCall++;
+ if (queryCall === 1) {return [{ id: "obj1" }];} // object
+ if (queryCall === 2) {return [{ id: "f1", name: "name", type: "text" }];} // fields
+ return [];
+ });
+ vi.mocked(duckdbExecOnFile).mockReturnValue(true);
+
+ const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
+ const req = new Request("http://localhost", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fields: { name: "Updated Corp" } }),
+ });
+ const res = await PATCH(req, { params: Promise.resolve({ name: "leads", id: "e1" }) });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.ok).toBe(true);
+ });
+ });
+
+ // βββ DELETE /api/workspace/objects/[name]/entries/[id] ββββββββββ
+
+ describe("DELETE /api/workspace/objects/[name]/entries/[id]", () => {
+ it("returns 400 for invalid object name", async () => {
+ const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
+ const res = await DELETE(
+ new Request("http://localhost", { method: "DELETE" }),
+ { params: Promise.resolve({ name: "bad!", id: "123" }) },
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when DuckDB not found", async () => {
+ const { findDuckDBForObject } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue(null);
+
+ const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
+ const res = await DELETE(
+ new Request("http://localhost", { method: "DELETE" }),
+ { params: Promise.resolve({ name: "leads", id: "e1" }) },
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("deletes entry successfully", async () => {
+ const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
+ vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: "obj1" }]);
+ vi.mocked(duckdbExecOnFile).mockReturnValue(true);
+
+ const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
+ const res = await DELETE(
+ new Request("http://localhost", { method: "DELETE" }),
+ { params: Promise.resolve({ name: "leads", id: "e1" }) },
+ );
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.ok).toBe(true);
+ });
+ });
+
+ // βββ POST /api/workspace/objects/[name]/entries/bulk-delete βββββ
+
+ describe("POST /api/workspace/objects/[name]/entries/bulk-delete", () => {
+ it("returns 400 for invalid object name", async () => {
+ const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
+ const req = new Request("http://localhost", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: ["e1"] }),
+ });
+ const res = await POST(req, { params: Promise.resolve({ name: "bad!" }) });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for empty entryIds", async () => {
+ const { findDuckDBForObject } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
+
+ const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
+ const req = new Request("http://localhost", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ entryIds: [] }),
+ });
+ const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
+ expect(res.status).toBe(400);
+ });
+
+ it("deletes multiple entries", async () => {
+ const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
+ vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
+ vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: "obj1" }]);
+ vi.mocked(duckdbExecOnFile).mockReturnValue(true);
+
+ const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
+ const req = new Request("http://localhost", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ entryIds: ["e1", "e2", "e3"] }),
+ });
+ const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
+ expect(res.status).toBe(200);
+ });
+ });
+});
diff --git a/apps/web/app/api/workspace/objects/[name]/display-field/route.ts b/apps/web/app/api/workspace/objects/[name]/display-field/route.ts
new file mode 100644
index 00000000000..4e0e8b34e1e
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/display-field/route.ts
@@ -0,0 +1,83 @@
+import { duckdbQueryOnFile, duckdbExecOnFile, findDuckDBForObject } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * PATCH /api/workspace/objects/[name]/display-field
+ * Set which field is used as the display label for entries of this object.
+ * Body: { displayField: string }
+ */
+export async function PATCH(
+ req: Request,
+ { params }: { params: Promise<{ name: string }> },
+) {
+ const { name } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB database not found" },
+ { status: 404 },
+ );
+ }
+
+ const body = await req.json();
+ const { displayField } = body;
+
+ if (typeof displayField !== "string" || !displayField.trim()) {
+ return Response.json(
+ { error: "displayField must be a non-empty string" },
+ { status: 400 },
+ );
+ }
+
+ // Ensure display_field column exists
+ duckdbExecOnFile(dbFile,
+ "ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR",
+ );
+
+ // Verify the object exists
+ const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
+ `SELECT id FROM objects WHERE name = '${name}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+
+ // Verify the field exists on this object
+ const escapedField = displayField.replace(/'/g, "''");
+ const fieldCheck = duckdbQueryOnFile<{ id: string }>(dbFile,
+ `SELECT id FROM fields WHERE object_id = '${objects[0].id}' AND name = '${escapedField}' LIMIT 1`,
+ );
+ if (fieldCheck.length === 0) {
+ return Response.json(
+ { error: `Field '${displayField}' not found on object '${name}'` },
+ { status: 400 },
+ );
+ }
+
+ // Update the display_field
+ const success = duckdbExecOnFile(dbFile,
+ `UPDATE objects SET display_field = '${escapedField}', updated_at = now() WHERE name = '${name}'`,
+ );
+
+ if (!success) {
+ return Response.json(
+ { error: "Failed to update display field" },
+ { status: 500 },
+ );
+ }
+
+ return Response.json({ ok: true, displayField });
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts
new file mode 100644
index 00000000000..6a6ee61cb36
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts
@@ -0,0 +1,515 @@
+import {
+ duckdbQueryOnFile,
+ duckdbExecOnFile,
+ findDuckDBForObject,
+ discoverDuckDBPaths,
+ parseRelationValue,
+} from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+// --- Types ---
+
+type ObjectRow = {
+ id: string;
+ name: string;
+ description?: string;
+ icon?: string;
+ default_view?: string;
+ display_field?: string;
+};
+
+type FieldRow = {
+ id: string;
+ name: string;
+ type: string;
+ description?: string;
+ required?: boolean;
+ enum_values?: string;
+ enum_colors?: string;
+ enum_multiple?: boolean;
+ related_object_id?: string;
+ relationship_type?: string;
+ sort_order?: number;
+};
+
+// --- Helpers ---
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+function tryParseJson(value: unknown): unknown {
+ if (typeof value !== "string") {
+ return value;
+ }
+ try {
+ return JSON.parse(value);
+ } catch {
+ return value;
+ }
+}
+
+function resolveDisplayField(
+ obj: ObjectRow,
+ fields: FieldRow[],
+): string {
+ if (obj.display_field) {
+ return obj.display_field;
+ }
+ const nameField = fields.find(
+ (f) =>
+ /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
+ );
+ if (nameField) {
+ return nameField.name;
+ }
+ const textField = fields.find((f) => f.type === "text");
+ if (textField) {
+ return textField.name;
+ }
+ return fields[0]?.name ?? "id";
+}
+
+/** Scoped query shorthand. */
+function q>(db: string, sql: string): T[] {
+ return duckdbQueryOnFile(db, sql);
+}
+
+// --- Route handlers ---
+
+/**
+ * GET /api/workspace/objects/[name]/entries/[id]
+ * Returns a single entry with all field values, relation labels, and reverse relations.
+ */
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ name: string; id: string }> },
+) {
+ const { name, id } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+ if (!id || id.length > 64) {
+ return Response.json(
+ { error: "Invalid entry ID" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB not found" },
+ { status: 404 },
+ );
+ }
+
+ // Fetch object
+ const objects = q(dbFile,
+ `SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+ const obj = objects[0];
+
+ // Fetch fields
+ const fields = q(dbFile,
+ `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
+ );
+
+ // Fetch entry field values
+ const entryRows = q<{
+ entry_id: string;
+ created_at: string;
+ updated_at: string;
+ field_name: string;
+ value: string | null;
+ }>(dbFile,
+ `SELECT e.id as entry_id, e.created_at, e.updated_at,
+ f.name as field_name, ef.value
+ FROM entries e
+ JOIN entry_fields ef ON ef.entry_id = e.id
+ JOIN fields f ON f.id = ef.field_id
+ WHERE e.id = '${sqlEscape(id)}'
+ AND e.object_id = '${sqlEscape(obj.id)}'`,
+ );
+
+ if (entryRows.length === 0) {
+ const exists = q<{ cnt: number }>(dbFile,
+ `SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(obj.id)}'`,
+ );
+ if (!exists[0] || exists[0].cnt === 0) {
+ return Response.json(
+ { error: "Entry not found" },
+ { status: 404 },
+ );
+ }
+ }
+
+ // Pivot into a single record
+ const entry: Record = { entry_id: id };
+ for (const row of entryRows) {
+ entry.created_at ??= row.created_at;
+ entry.updated_at ??= row.updated_at;
+ if (row.field_name) {
+ entry[row.field_name] = row.value;
+ }
+ }
+
+ // Parse enum JSON strings in fields
+ const parsedFields = fields.map((f) => ({
+ ...f,
+ enum_values: f.enum_values
+ ? tryParseJson(f.enum_values)
+ : undefined,
+ enum_colors: f.enum_colors
+ ? tryParseJson(f.enum_colors)
+ : undefined,
+ }));
+
+ // Resolve relation labels for this entry
+ const relationLabels: Record> = {};
+ const relatedObjectNames: Record = {};
+
+ const relationFields = fields.filter(
+ (f) => f.type === "relation" && f.related_object_id,
+ );
+
+ for (const rf of relationFields) {
+ const relatedObjs = q(dbFile,
+ `SELECT * FROM objects WHERE id = '${sqlEscape(rf.related_object_id!)}' LIMIT 1`,
+ );
+ if (relatedObjs.length === 0) {
+ continue;
+ }
+ const relObj = relatedObjs[0];
+ relatedObjectNames[rf.name] = relObj.name;
+
+ const val = entry[rf.name];
+ if (val == null || val === "") {
+ relationLabels[rf.name] = {};
+ continue;
+ }
+
+ const valStr =
+ typeof val === "object" && val !== null
+ ? JSON.stringify(val)
+ : typeof val === "string"
+ ? val
+ : typeof val === "number" || typeof val === "boolean"
+ ? String(val)
+ : "";
+ const ids = parseRelationValue(valStr);
+ if (ids.length === 0) {
+ relationLabels[rf.name] = {};
+ continue;
+ }
+
+ const relFields = q(dbFile,
+ `SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`,
+ );
+ const displayFieldName = resolveDisplayField(relObj, relFields);
+
+ const idList = ids
+ .map((i) => `'${sqlEscape(i)}'`)
+ .join(",");
+ const displayRows = q<{ entry_id: string; value: string }>(dbFile,
+ `SELECT e.id as entry_id, ef.value
+ FROM entries e
+ JOIN entry_fields ef ON ef.entry_id = e.id
+ JOIN fields f ON f.id = ef.field_id
+ WHERE e.id IN (${idList})
+ AND f.object_id = '${sqlEscape(relObj.id)}'
+ AND f.name = '${sqlEscape(displayFieldName)}'`,
+ );
+
+ const labelMap: Record = {};
+ for (const row of displayRows) {
+ labelMap[row.entry_id] = row.value || row.entry_id;
+ }
+ for (const i of ids) {
+ if (!labelMap[i]) {
+ labelMap[i] = i;
+ }
+ }
+ relationLabels[rf.name] = labelMap;
+ }
+
+ // Enrich fields with related object names
+ const enrichedFields = parsedFields.map((f) => ({
+ ...f,
+ related_object_name:
+ f.type === "relation"
+ ? relatedObjectNames[f.name]
+ : undefined,
+ }));
+
+ // Find reverse relations for this entry (search across all DBs)
+ const reverseRelations = findReverseRelationsForEntry(obj.id, id);
+
+ const effectiveDisplayField = resolveDisplayField(obj, fields);
+
+ return Response.json({
+ object: obj,
+ fields: enrichedFields,
+ entry,
+ relationLabels,
+ reverseRelations,
+ effectiveDisplayField,
+ });
+}
+
+/**
+ * PATCH /api/workspace/objects/[name]/entries/[id]
+ * Update field values for an entry.
+ * Body: { fields: { [fieldName]: newValue } }
+ */
+export async function PATCH(
+ req: Request,
+ { params }: { params: Promise<{ name: string; id: string }> },
+) {
+ const { name, id } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB not found" },
+ { status: 404 },
+ );
+ }
+
+ // Find object
+ const objects = q<{ id: string }>(dbFile,
+ `SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+ const objectId = objects[0].id;
+
+ // Verify entry exists
+ const exists = q<{ cnt: number }>(dbFile,
+ `SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(objectId)}'`,
+ );
+ if (!exists[0] || exists[0].cnt === 0) {
+ return Response.json(
+ { error: "Entry not found" },
+ { status: 404 },
+ );
+ }
+
+ const body = await req.json();
+ const fieldUpdates: Record = body.fields ?? {};
+
+ // Get field IDs by name
+ const dbFields = q<{ id: string; name: string }>(dbFile,
+ `SELECT id, name FROM fields WHERE object_id = '${sqlEscape(objectId)}'`,
+ );
+ const fieldMap = new Map(dbFields.map((f) => [f.name, f.id]));
+
+ let updatedCount = 0;
+ for (const [fieldName, value] of Object.entries(fieldUpdates)) {
+ const fieldId = fieldMap.get(fieldName);
+ if (!fieldId) {continue;}
+
+ const escapedValue =
+ value == null ? "NULL" : `'${sqlEscape(String(value))}'`;
+
+ // Try update first, then insert if no rows affected
+ const existingRows = q<{ cnt: number }>(dbFile,
+ `SELECT COUNT(*) as cnt FROM entry_fields WHERE entry_id = '${sqlEscape(id)}' AND field_id = '${sqlEscape(fieldId)}'`,
+ );
+
+ if (existingRows[0]?.cnt > 0) {
+ duckdbExecOnFile(dbFile,
+ `UPDATE entry_fields SET value = ${escapedValue} WHERE entry_id = '${sqlEscape(id)}' AND field_id = '${sqlEscape(fieldId)}'`,
+ );
+ } else {
+ duckdbExecOnFile(dbFile,
+ `INSERT INTO entry_fields (entry_id, field_id, value) VALUES ('${sqlEscape(id)}', '${sqlEscape(fieldId)}', ${escapedValue})`,
+ );
+ }
+ updatedCount++;
+ }
+
+ // Touch updated_at on the entry
+ const now = new Date().toISOString();
+ duckdbExecOnFile(dbFile,
+ `UPDATE entries SET updated_at = '${now}' WHERE id = '${sqlEscape(id)}'`,
+ );
+
+ return Response.json({ ok: true, updatedCount });
+}
+
+/**
+ * DELETE /api/workspace/objects/[name]/entries/[id]
+ * Delete a single entry and its field values.
+ */
+export async function DELETE(
+ _req: Request,
+ { params }: { params: Promise<{ name: string; id: string }> },
+) {
+ const { name, id } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB not found" },
+ { status: 404 },
+ );
+ }
+
+ // Find object
+ const objects = q<{ id: string }>(dbFile,
+ `SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+ const objectId = objects[0].id;
+
+ // Delete field values first, then entry
+ duckdbExecOnFile(dbFile,
+ `DELETE FROM entry_fields WHERE entry_id = '${sqlEscape(id)}'`,
+ );
+ duckdbExecOnFile(dbFile,
+ `DELETE FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(objectId)}'`,
+ );
+
+ return Response.json({ ok: true });
+}
+
+// --- Reverse relations for a single entry ---
+
+type ReverseRelation = {
+ fieldName: string;
+ sourceObjectName: string;
+ sourceObjectId: string;
+ displayField: string;
+ links: Array<{ id: string; label: string }>;
+};
+
+/**
+ * Find reverse relations for a single entry, searching across ALL discovered databases.
+ */
+function findReverseRelationsForEntry(
+ objectId: string,
+ entryId: string,
+): ReverseRelation[] {
+ const dbPaths = discoverDuckDBPaths();
+ const result: ReverseRelation[] = [];
+
+ for (const db of dbPaths) {
+ const reverseFields = q<{
+ id: string;
+ name: string;
+ object_id: string;
+ source_object_name: string;
+ }>(db,
+ `SELECT f.id, f.name, f.object_id, o.name as source_object_name
+ FROM fields f
+ JOIN objects o ON o.id = f.object_id
+ WHERE f.type = 'relation'
+ AND f.related_object_id = '${sqlEscape(objectId)}'`,
+ );
+
+ for (const rrf of reverseFields) {
+ const refRows = q<{
+ source_entry_id: string;
+ target_value: string;
+ }>(db,
+ `SELECT ef.entry_id as source_entry_id, ef.value as target_value
+ FROM entry_fields ef
+ WHERE ef.field_id = '${sqlEscape(rrf.id)}'
+ AND ef.value IS NOT NULL
+ AND ef.value != ''`,
+ );
+
+ const matchingSourceIds: string[] = [];
+ for (const row of refRows) {
+ const targetIds = parseRelationValue(row.target_value);
+ if (targetIds.includes(entryId)) {
+ matchingSourceIds.push(row.source_entry_id);
+ }
+ }
+
+ if (matchingSourceIds.length === 0) {
+ continue;
+ }
+
+ const sourceObj = q(db,
+ `SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`,
+ );
+ if (sourceObj.length === 0) {
+ continue;
+ }
+
+ const sourceFields = q(db,
+ `SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.object_id)}' ORDER BY sort_order`,
+ );
+ const displayFieldName = resolveDisplayField(sourceObj[0], sourceFields);
+
+ const idList = matchingSourceIds
+ .map((i) => `'${sqlEscape(i)}'`)
+ .join(",");
+ const displayRows = q<{ entry_id: string; value: string }>(db,
+ `SELECT ef.entry_id, ef.value
+ FROM entry_fields ef
+ JOIN fields f ON f.id = ef.field_id
+ WHERE ef.entry_id IN (${idList})
+ AND f.name = '${sqlEscape(displayFieldName)}'
+ AND f.object_id = '${sqlEscape(rrf.object_id)}'`,
+ );
+
+ const displayMap: Record = {};
+ for (const row of displayRows) {
+ displayMap[row.entry_id] = row.value || row.entry_id;
+ }
+
+ const links = matchingSourceIds.map((sid) => ({
+ id: sid,
+ label: displayMap[sid] || sid,
+ }));
+
+ result.push({
+ fieldName: rrf.name,
+ sourceObjectName: rrf.source_object_name,
+ sourceObjectId: rrf.object_id,
+ displayField: displayFieldName,
+ links,
+ });
+ }
+ }
+
+ return result;
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/entries/bulk-delete/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/bulk-delete/route.ts
new file mode 100644
index 00000000000..26a99459867
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/entries/bulk-delete/route.ts
@@ -0,0 +1,74 @@
+import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+/**
+ * POST /api/workspace/objects/[name]/entries/bulk-delete
+ * Delete multiple entries at once.
+ * Body: { entryIds: string[] }
+ */
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ name: string }> },
+) {
+ const { name } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB not found" },
+ { status: 404 },
+ );
+ }
+
+ const body = await req.json();
+ const entryIds: string[] = body.entryIds;
+
+ if (!Array.isArray(entryIds) || entryIds.length === 0) {
+ return Response.json(
+ { error: "entryIds must be a non-empty array" },
+ { status: 400 },
+ );
+ }
+
+ // Validate object exists
+ const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
+ `SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+ const objectId = objects[0].id;
+
+ const idList = entryIds
+ .map((id) => `'${sqlEscape(id)}'`)
+ .join(",");
+
+ // Delete field values first, then entries
+ duckdbExecOnFile(dbFile,
+ `DELETE FROM entry_fields WHERE entry_id IN (${idList})`,
+ );
+ duckdbExecOnFile(dbFile,
+ `DELETE FROM entries WHERE id IN (${idList}) AND object_id = '${sqlEscape(objectId)}'`,
+ );
+
+ return Response.json({
+ ok: true,
+ deletedCount: entryIds.length,
+ });
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/entries/options/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/options/route.ts
new file mode 100644
index 00000000000..5f332e31e62
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/entries/options/route.ts
@@ -0,0 +1,91 @@
+import { duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+type ObjectRow = {
+ id: string;
+ name: string;
+ display_field?: string;
+};
+
+type FieldRow = {
+ id: string;
+ name: string;
+ type: string;
+};
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+function resolveDisplayField(
+ obj: ObjectRow,
+ fields: FieldRow[],
+): string {
+ if (obj.display_field) {return obj.display_field;}
+ const nameField = fields.find(
+ (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
+ );
+ if (nameField) {return nameField.name;}
+ const textField = fields.find((f) => f.type === "text");
+ if (textField) {return textField.name;}
+ return fields[0]?.name ?? "id";
+}
+
+/**
+ * GET /api/workspace/objects/[name]/entries/options
+ * Returns lightweight { options: [{ id, label }] } for relation dropdowns.
+ * Supports optional ?q= search parameter.
+ */
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ name: string }> },
+) {
+ const { name } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json({ error: "Invalid object name" }, { status: 400 });
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json({ error: "DuckDB not found" }, { status: 404 });
+ }
+
+ const objects = duckdbQueryOnFile(dbFile,
+ `SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json({ error: `Object '${name}' not found` }, { status: 404 });
+ }
+ const obj = objects[0];
+
+ const fields = duckdbQueryOnFile(dbFile,
+ `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
+ );
+ const displayFieldName = resolveDisplayField(obj, fields);
+
+ // Optional search filter
+ const url = new URL(req.url);
+ const query = url.searchParams.get("q")?.trim() ?? "";
+
+ // Fetch entries with their display field value
+ const rows = duckdbQueryOnFile<{ entry_id: string; label: string | null }>(dbFile,
+ `SELECT e.id as entry_id, ef.value as label
+ FROM entries e
+ LEFT JOIN entry_fields ef ON ef.entry_id = e.id
+ LEFT JOIN fields f ON f.id = ef.field_id AND f.name = '${sqlEscape(displayFieldName)}'
+ WHERE e.object_id = '${sqlEscape(obj.id)}'
+ ${query ? `AND (ef.value IS NOT NULL AND LOWER(ef.value) LIKE '%${sqlEscape(query.toLowerCase())}%')` : ""}
+ ORDER BY ef.value ASC NULLS LAST
+ LIMIT 200`,
+ );
+
+ const options = rows.map((r) => ({
+ id: r.entry_id,
+ label: r.label || r.entry_id,
+ }));
+
+ return Response.json({ options, displayField: displayFieldName });
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/entries/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/route.ts
new file mode 100644
index 00000000000..3cf5c962526
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/entries/route.ts
@@ -0,0 +1,97 @@
+import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+/**
+ * POST /api/workspace/objects/[name]/entries
+ * Create a new entry with optional field values.
+ * Body: { fields?: Record }
+ */
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ name: string }> },
+) {
+ const { name } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB not found" },
+ { status: 404 },
+ );
+ }
+
+ // Find object
+ const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
+ `SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+ const objectId = objects[0].id;
+
+ // Generate UUID for the new entry
+ const idRows = duckdbQueryOnFile<{ id: string }>(dbFile,
+ "SELECT uuid()::VARCHAR as id",
+ );
+ const entryId = idRows[0]?.id;
+ if (!entryId) {
+ return Response.json(
+ { error: "Failed to generate UUID" },
+ { status: 500 },
+ );
+ }
+
+ // Create entry
+ const now = new Date().toISOString();
+ const ok = duckdbExecOnFile(dbFile,
+ `INSERT INTO entries (id, object_id, created_at, updated_at) VALUES ('${sqlEscape(entryId)}', '${sqlEscape(objectId)}', '${now}', '${now}')`,
+ );
+ if (!ok) {
+ return Response.json(
+ { error: "Failed to create entry" },
+ { status: 500 },
+ );
+ }
+
+ // Insert field values if provided
+ let body: { fields?: Record } = {};
+ try {
+ body = await req.json();
+ } catch {
+ // no body is fine
+ }
+
+ if (body.fields && typeof body.fields === "object") {
+ // Get field IDs by name
+ const dbFields = duckdbQueryOnFile<{ id: string; name: string }>(dbFile,
+ `SELECT id, name FROM fields WHERE object_id = '${sqlEscape(objectId)}'`,
+ );
+ const fieldMap = new Map(dbFields.map((f) => [f.name, f.id]));
+
+ for (const [fieldName, value] of Object.entries(body.fields)) {
+ const fieldId = fieldMap.get(fieldName);
+ if (!fieldId || value == null) {continue;}
+ duckdbExecOnFile(dbFile,
+ `INSERT INTO entry_fields (entry_id, field_id, value) VALUES ('${sqlEscape(entryId)}', '${sqlEscape(fieldId)}', '${sqlEscape(String(value))}')`,
+ );
+ }
+ }
+
+ return Response.json({ entryId, ok: true }, { status: 201 });
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/enum-rename/route.ts b/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/enum-rename/route.ts
new file mode 100644
index 00000000000..b5d07c14a22
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/enum-rename/route.ts
@@ -0,0 +1,116 @@
+import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+/**
+ * PATCH /api/workspace/objects/[name]/fields/[fieldId]/enum-rename
+ * Rename an enum value across the field definition and all entries.
+ * Body: { oldValue: string, newValue: string }
+ */
+export async function PATCH(
+ req: Request,
+ {
+ params,
+ }: { params: Promise<{ name: string; fieldId: string }> },
+) {
+ const { name, fieldId } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB not found" },
+ { status: 404 },
+ );
+ }
+
+ const body = await req.json();
+ const oldValue: string = body.oldValue;
+ const newValue: string = body.newValue;
+
+ if (!oldValue || !newValue || typeof oldValue !== "string" || typeof newValue !== "string") {
+ return Response.json(
+ { error: "oldValue and newValue are required" },
+ { status: 400 },
+ );
+ }
+ if (oldValue.trim() === newValue.trim()) {
+ return Response.json({ ok: true, changed: 0 });
+ }
+
+ // Validate object exists
+ const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
+ `SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+ const objectId = objects[0].id;
+
+ // Validate field exists and is an enum
+ const fields = duckdbQueryOnFile<{ id: string; enum_values: string | null; enum_colors: string | null }>(dbFile,
+ `SELECT id, enum_values, enum_colors FROM fields WHERE id = '${sqlEscape(fieldId)}' AND object_id = '${sqlEscape(objectId)}'`,
+ );
+ if (fields.length === 0) {
+ return Response.json(
+ { error: "Field not found" },
+ { status: 404 },
+ );
+ }
+
+ const field = fields[0];
+ let enumValues: string[];
+ try {
+ enumValues = field.enum_values ? JSON.parse(field.enum_values) : [];
+ } catch {
+ return Response.json(
+ { error: "Invalid enum_values in field" },
+ { status: 500 },
+ );
+ }
+
+ const idx = enumValues.indexOf(oldValue.trim());
+ if (idx === -1) {
+ return Response.json(
+ { error: `Enum value '${oldValue}' not found` },
+ { status: 404 },
+ );
+ }
+
+ // Check for duplicate
+ if (enumValues.includes(newValue.trim())) {
+ return Response.json(
+ { error: `Enum value '${newValue}' already exists` },
+ { status: 409 },
+ );
+ }
+
+ // Update enum_values array
+ enumValues[idx] = newValue.trim();
+ const newEnumJson = JSON.stringify(enumValues);
+
+ duckdbExecOnFile(dbFile,
+ `UPDATE fields SET enum_values = '${sqlEscape(newEnumJson)}' WHERE id = '${sqlEscape(fieldId)}'`,
+ );
+
+ // Update all entry_fields with the old value to the new value
+ const updatedEntries = duckdbExecOnFile(dbFile,
+ `UPDATE entry_fields SET value = '${sqlEscape(newValue.trim())}' WHERE field_id = '${sqlEscape(fieldId)}' AND value = '${sqlEscape(oldValue.trim())}'`,
+ );
+
+ return Response.json({ ok: true, updated: updatedEntries });
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/route.ts b/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/route.ts
new file mode 100644
index 00000000000..595a1c11b1e
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/route.ts
@@ -0,0 +1,98 @@
+import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+/**
+ * PATCH /api/workspace/objects/[name]/fields/[fieldId]
+ * Rename a field.
+ * Body: { name: string }
+ */
+export async function PATCH(
+ req: Request,
+ {
+ params,
+ }: { params: Promise<{ name: string; fieldId: string }> },
+) {
+ const { name, fieldId } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB not found" },
+ { status: 404 },
+ );
+ }
+
+ const body = await req.json();
+ const newName: string = body.name;
+
+ if (
+ !newName ||
+ typeof newName !== "string" ||
+ newName.trim().length === 0
+ ) {
+ return Response.json(
+ { error: "Name is required" },
+ { status: 400 },
+ );
+ }
+
+ // Validate object exists
+ const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
+ `SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+ const objectId = objects[0].id;
+
+ // Validate field exists and belongs to this object
+ const fieldExists = duckdbQueryOnFile<{ cnt: number }>(dbFile,
+ `SELECT COUNT(*) as cnt FROM fields WHERE id = '${sqlEscape(fieldId)}' AND object_id = '${sqlEscape(objectId)}'`,
+ );
+ if (!fieldExists[0] || fieldExists[0].cnt === 0) {
+ return Response.json(
+ { error: "Field not found" },
+ { status: 404 },
+ );
+ }
+
+ // Check for duplicate name
+ const duplicateCheck = duckdbQueryOnFile<{ cnt: number }>(dbFile,
+ `SELECT COUNT(*) as cnt FROM fields WHERE object_id = '${sqlEscape(objectId)}' AND name = '${sqlEscape(newName.trim())}' AND id != '${sqlEscape(fieldId)}'`,
+ );
+ if (duplicateCheck[0]?.cnt > 0) {
+ return Response.json(
+ { error: "A field with that name already exists" },
+ { status: 409 },
+ );
+ }
+
+ const ok = duckdbExecOnFile(dbFile,
+ `UPDATE fields SET name = '${sqlEscape(newName.trim())}' WHERE id = '${sqlEscape(fieldId)}'`,
+ );
+
+ if (!ok) {
+ return Response.json(
+ { error: "Failed to rename field" },
+ { status: 500 },
+ );
+ }
+
+ return Response.json({ ok: true });
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/fields/reorder/route.ts b/apps/web/app/api/workspace/objects/[name]/fields/reorder/route.ts
new file mode 100644
index 00000000000..f5818aec715
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/fields/reorder/route.ts
@@ -0,0 +1,66 @@
+import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+/**
+ * PATCH /api/workspace/objects/[name]/fields/reorder
+ * Reorder fields by updating sort_order.
+ * Body: { fieldOrder: string[] } β array of field IDs in desired order
+ */
+export async function PATCH(
+ req: Request,
+ { params }: { params: Promise<{ name: string }> },
+) {
+ const { name } = await params;
+
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ return Response.json(
+ { error: "DuckDB not found" },
+ { status: 404 },
+ );
+ }
+
+ const body = await req.json();
+ const fieldOrder: string[] = body.fieldOrder;
+
+ if (!Array.isArray(fieldOrder) || fieldOrder.length === 0) {
+ return Response.json(
+ { error: "fieldOrder must be a non-empty array" },
+ { status: 400 },
+ );
+ }
+
+ // Validate object exists
+ const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
+ `SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
+ );
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+ const objectId = objects[0].id;
+
+ // Update sort_order for each field
+ for (let i = 0; i < fieldOrder.length; i++) {
+ duckdbExecOnFile(dbFile,
+ `UPDATE fields SET sort_order = ${i} WHERE id = '${sqlEscape(fieldOrder[i])}' AND object_id = '${sqlEscape(objectId)}'`,
+ );
+ }
+
+ return Response.json({ ok: true });
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/route.ts b/apps/web/app/api/workspace/objects/[name]/route.ts
new file mode 100644
index 00000000000..d5ffd5619b8
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/route.ts
@@ -0,0 +1,491 @@
+import { duckdbPath, parseRelationValue, resolveDuckdbBin, findDuckDBForObject, duckdbQueryOnFile, discoverDuckDBPaths, getObjectViews } from "@/lib/workspace";
+import { deserializeFilters, buildWhereClause, buildOrderByClause, type FieldMeta } from "@/lib/object-filters";
+import { execSync } from "node:child_process";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+type ObjectRow = {
+ id: string;
+ name: string;
+ description?: string;
+ icon?: string;
+ default_view?: string;
+ display_field?: string;
+ immutable?: boolean;
+ created_at?: string;
+ updated_at?: string;
+};
+
+type FieldRow = {
+ id: string;
+ name: string;
+ type: string;
+ description?: string;
+ required?: boolean;
+ enum_values?: string;
+ enum_colors?: string;
+ enum_multiple?: boolean;
+ related_object_id?: string;
+ relationship_type?: string;
+ sort_order?: number;
+};
+
+type StatusRow = {
+ id: string;
+ name: string;
+ color?: string;
+ sort_order?: number;
+ is_default?: boolean;
+};
+
+type EavRow = {
+ entry_id: string;
+ created_at: string;
+ updated_at: string;
+ field_name: string;
+ value: string | null;
+};
+
+// --- Schema migration (idempotent, runs once per process) ---
+
+const migratedDbs = new Set();
+
+/** Ensure the display_field column exists on a specific DB file. */
+function ensureDisplayFieldColumn(dbFile: string) {
+ if (migratedDbs.has(dbFile)) {return;}
+ const bin = resolveDuckdbBin();
+ if (!bin) {return;}
+ try {
+ execSync(
+ `'${bin}' '${dbFile}' 'ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR'`,
+ { encoding: "utf-8", timeout: 5_000, shell: "/bin/sh" },
+ );
+ } catch {
+ // migration might fail on DBs that don't have the objects table β skip
+ }
+ migratedDbs.add(dbFile);
+}
+
+// --- Helpers ---
+
+/** Scoped query helper: queries a specific DB file. */
+function q>(dbFile: string, sql: string): T[] {
+ return duckdbQueryOnFile(dbFile, sql);
+}
+
+/**
+ * Pivot raw EAV rows into one object per entry with field names as keys.
+ */
+function pivotEavRows(rows: EavRow[]): Record[] {
+ const grouped = new Map>();
+
+ for (const row of rows) {
+ let entry = grouped.get(row.entry_id);
+ if (!entry) {
+ entry = {
+ entry_id: row.entry_id,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ };
+ grouped.set(row.entry_id, entry);
+ }
+ if (row.field_name) {
+ entry[row.field_name] = row.value;
+ }
+ }
+
+ return Array.from(grouped.values());
+}
+
+function tryParseJson(value: unknown): unknown {
+ if (typeof value !== "string") {return value;}
+ try {
+ return JSON.parse(value);
+ } catch {
+ return value;
+ }
+}
+
+/** SQL-escape a string (double single-quotes). */
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+/**
+ * Determine the display field for an object.
+ * Priority: explicit display_field > heuristic (name/title) > first text field > first field.
+ */
+function resolveDisplayField(
+ obj: ObjectRow,
+ objFields: FieldRow[],
+): string {
+ if (obj.display_field) {return obj.display_field;}
+
+ // Heuristic: look for name/title fields
+ const nameField = objFields.find(
+ (f) =>
+ /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
+ );
+ if (nameField) {return nameField.name;}
+
+ // Fallback: first text field
+ const textField = objFields.find((f) => f.type === "text");
+ if (textField) {return textField.name;}
+
+ // Ultimate fallback: first field
+ return objFields[0]?.name ?? "id";
+}
+
+/**
+ * Resolve relation field values to human-readable display labels.
+ * All queries target the same DB file where the object lives.
+ */
+function resolveRelationLabels(
+ dbFile: string,
+ fields: FieldRow[],
+ entries: Record[],
+): {
+ labels: Record>;
+ relatedObjectNames: Record;
+} {
+ const labels: Record> = {};
+ const relatedObjectNames: Record = {};
+
+ const relationFields = fields.filter(
+ (f) => f.type === "relation" && f.related_object_id,
+ );
+
+ for (const rf of relationFields) {
+ const relatedObjs = q(dbFile,
+ `SELECT * FROM objects WHERE id = '${sqlEscape(rf.related_object_id!)}' LIMIT 1`,
+ );
+ if (relatedObjs.length === 0) {continue;}
+ const relObj = relatedObjs[0];
+ relatedObjectNames[rf.name] = relObj.name;
+
+ const relFields = q(dbFile,
+ `SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`,
+ );
+ const displayFieldName = resolveDisplayField(relObj, relFields);
+
+ const entryIds = new Set();
+ for (const entry of entries) {
+ const val = entry[rf.name];
+ if (val == null || val === "") {
+ continue;
+ }
+ const valStr =
+ typeof val === "object" && val !== null
+ ? JSON.stringify(val)
+ : typeof val === "string"
+ ? val
+ : typeof val === "number" || typeof val === "boolean"
+ ? String(val)
+ : "";
+ for (const id of parseRelationValue(valStr)) {
+ entryIds.add(id);
+ }
+ }
+
+ if (entryIds.size === 0) {
+ labels[rf.name] = {};
+ continue;
+ }
+
+ const idList = Array.from(entryIds)
+ .map((id) => `'${sqlEscape(id)}'`)
+ .join(",");
+ const displayRows = q<{ entry_id: string; value: string }>(dbFile,
+ `SELECT e.id as entry_id, ef.value
+ FROM entries e
+ JOIN entry_fields ef ON ef.entry_id = e.id
+ JOIN fields f ON f.id = ef.field_id
+ WHERE e.id IN (${idList})
+ AND f.object_id = '${sqlEscape(relObj.id)}'
+ AND f.name = '${sqlEscape(displayFieldName)}'`,
+ );
+
+ const labelMap: Record = {};
+ for (const row of displayRows) {
+ labelMap[row.entry_id] = row.value || row.entry_id;
+ }
+ for (const id of entryIds) {
+ if (!labelMap[id]) {labelMap[id] = id;}
+ }
+
+ labels[rf.name] = labelMap;
+ }
+
+ return { labels, relatedObjectNames };
+}
+
+type ReverseRelation = {
+ fieldName: string;
+ sourceObjectName: string;
+ sourceObjectId: string;
+ displayField: string;
+ entries: Record>;
+};
+
+/**
+ * Find reverse relations: other objects with relation fields pointing TO this object.
+ * Searches across ALL discovered databases to catch cross-DB relations.
+ */
+function findReverseRelations(objectId: string): ReverseRelation[] {
+ const dbPaths = discoverDuckDBPaths();
+ const result: ReverseRelation[] = [];
+
+ for (const db of dbPaths) {
+ const reverseFields = q<
+ FieldRow & { source_object_id: string; source_object_name: string }
+ >(db,
+ `SELECT f.*, f.object_id as source_object_id, o.name as source_object_name
+ FROM fields f
+ JOIN objects o ON o.id = f.object_id
+ WHERE f.type = 'relation'
+ AND f.related_object_id = '${sqlEscape(objectId)}'`,
+ );
+
+ for (const rrf of reverseFields) {
+ const sourceObjs = q(db,
+ `SELECT * FROM objects WHERE id = '${sqlEscape(rrf.source_object_id)}' LIMIT 1`,
+ );
+ if (sourceObjs.length === 0) {continue;}
+
+ const sourceFields = q(db,
+ `SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.source_object_id)}' ORDER BY sort_order`,
+ );
+ const displayFieldName = resolveDisplayField(sourceObjs[0], sourceFields);
+
+ const refRows = q<{ source_entry_id: string; target_value: string }>(db,
+ `SELECT ef.entry_id as source_entry_id, ef.value as target_value
+ FROM entry_fields ef
+ WHERE ef.field_id = '${sqlEscape(rrf.id)}'
+ AND ef.value IS NOT NULL
+ AND ef.value != ''`,
+ );
+
+ if (refRows.length === 0) {continue;}
+
+ const sourceEntryIds = [...new Set(refRows.map((r) => r.source_entry_id))];
+ const idList = sourceEntryIds.map((id) => `'${sqlEscape(id)}'`).join(",");
+ const displayRows = q<{ entry_id: string; value: string }>(db,
+ `SELECT ef.entry_id, ef.value
+ FROM entry_fields ef
+ JOIN fields f ON f.id = ef.field_id
+ WHERE ef.entry_id IN (${idList})
+ AND f.name = '${sqlEscape(displayFieldName)}'
+ AND f.object_id = '${sqlEscape(rrf.source_object_id)}'`,
+ );
+
+ const displayMap: Record = {};
+ for (const row of displayRows) {
+ displayMap[row.entry_id] = row.value || row.entry_id;
+ }
+
+ const entriesMap: Record> = {};
+ for (const row of refRows) {
+ const targetIds = parseRelationValue(row.target_value);
+ for (const targetId of targetIds) {
+ if (!entriesMap[targetId]) {entriesMap[targetId] = [];}
+ entriesMap[targetId].push({
+ id: row.source_entry_id,
+ label: displayMap[row.source_entry_id] || row.source_entry_id,
+ });
+ }
+ }
+
+ result.push({
+ fieldName: rrf.name,
+ sourceObjectName: rrf.source_object_name,
+ sourceObjectId: rrf.source_object_id,
+ displayField: displayFieldName,
+ entries: entriesMap,
+ });
+ }
+ }
+
+ return result;
+}
+
+// --- Route handler ---
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ name: string }> },
+) {
+ const { name } = await params;
+
+ if (!resolveDuckdbBin()) {
+ return Response.json(
+ { error: "DuckDB CLI is not installed", code: "DUCKDB_NOT_INSTALLED" },
+ { status: 503 },
+ );
+ }
+
+ // Sanitize name to prevent injection (only allow alphanumeric + underscore + hyphen)
+ if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
+ return Response.json(
+ { error: "Invalid object name" },
+ { status: 400 },
+ );
+ }
+
+ // Find which DuckDB file contains this object (searches all discovered DBs)
+ const dbFile = findDuckDBForObject(name);
+ if (!dbFile) {
+ // Fall back to primary DB check for a friendlier error message
+ if (!duckdbPath()) {
+ return Response.json(
+ { error: "DuckDB database not found" },
+ { status: 404 },
+ );
+ }
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+
+ // Ensure display_field column exists on this specific DB
+ ensureDisplayFieldColumn(dbFile);
+
+ // All queries below target the specific DB that owns this object
+ const objects = q(dbFile,
+ `SELECT * FROM objects WHERE name = '${name}' LIMIT 1`,
+ );
+
+ if (objects.length === 0) {
+ return Response.json(
+ { error: `Object '${name}' not found` },
+ { status: 404 },
+ );
+ }
+
+ const obj = objects[0];
+
+ const fields = q(dbFile,
+ `SELECT * FROM fields WHERE object_id = '${obj.id}' ORDER BY sort_order`,
+ );
+
+ const statuses = q(dbFile,
+ `SELECT * FROM statuses WHERE object_id = '${obj.id}' ORDER BY sort_order`,
+ );
+
+ // --- Parse filter/sort/pagination query params ---
+ const url = new URL(_req.url);
+ const filtersParam = url.searchParams.get("filters");
+ const sortParam = url.searchParams.get("sort");
+ const searchParam = url.searchParams.get("search");
+ const pageParam = url.searchParams.get("page");
+ const pageSizeParam = url.searchParams.get("pageSize");
+
+ const filterGroup = filtersParam ? deserializeFilters(filtersParam) : undefined;
+ const fieldsMeta: FieldMeta[] = fields.map((f) => ({ name: f.name, type: f.type }));
+
+ // Build WHERE clause from filters
+ let whereClause = "";
+ if (filterGroup) {
+ const where = buildWhereClause(filterGroup, fieldsMeta);
+ if (where) {whereClause = ` WHERE ${where}`;}
+ }
+
+ // Build ORDER BY clause
+ let orderByClause = " ORDER BY created_at DESC";
+ if (sortParam) {
+ try {
+ const sortRules = JSON.parse(sortParam);
+ const orderBy = buildOrderByClause(sortRules);
+ if (orderBy) {orderByClause = ` ORDER BY ${orderBy}`;}
+ } catch {
+ // keep default sort
+ }
+ }
+
+ // Pagination
+ const page = Math.max(1, Number(pageParam) || 1);
+ const pageSize = Math.min(5000, Math.max(1, Number(pageSizeParam) || 100));
+ const offset = (page - 1) * pageSize;
+ const limitClause = ` LIMIT ${pageSize} OFFSET ${offset}`;
+
+ // Full-text search across text fields
+ if (searchParam && searchParam.trim()) {
+ const textFields = fields.filter((f) => ["text", "richtext", "email"].includes(f.type));
+ if (textFields.length > 0) {
+ const searchConditions = textFields
+ .map((f) => `LOWER(CAST("${f.name.replace(/"/g, '""')}" AS VARCHAR)) LIKE '%${sqlEscape(searchParam.toLowerCase())}%'`)
+ .join(" OR ");
+ whereClause = whereClause
+ ? `${whereClause} AND (${searchConditions})`
+ : ` WHERE (${searchConditions})`;
+ }
+ }
+
+ // Try the PIVOT view first, then fall back to raw EAV query + client-side pivot
+ let entries: Record[] = [];
+ let totalCount = 0;
+
+ try {
+ // Get total count with same WHERE clause but no LIMIT/OFFSET
+ const countResult = q<{ cnt: number }>(dbFile,
+ `SELECT COUNT(*) as cnt FROM v_${name}${whereClause}`,
+ );
+ totalCount = countResult[0]?.cnt ?? 0;
+
+ const pivotEntries = q(dbFile,
+ `SELECT * FROM v_${name}${whereClause}${orderByClause}${limitClause}`,
+ );
+ entries = pivotEntries;
+ } catch {
+ // Pivot view might not exist or filter SQL may not apply; fall back
+ const rawRows = q(dbFile,
+ `SELECT e.id as entry_id, e.created_at, e.updated_at,
+ f.name as field_name, ef.value
+ FROM entries e
+ JOIN entry_fields ef ON ef.entry_id = e.id
+ JOIN fields f ON f.id = ef.field_id
+ WHERE e.object_id = '${obj.id}'
+ ORDER BY e.created_at DESC
+ LIMIT 5000`,
+ );
+ entries = pivotEavRows(rawRows);
+ }
+
+ const parsedFields = fields.map((f) => ({
+ ...f,
+ enum_values: f.enum_values ? tryParseJson(f.enum_values) : undefined,
+ enum_colors: f.enum_colors ? tryParseJson(f.enum_colors) : undefined,
+ }));
+
+ const { labels: relationLabels, relatedObjectNames } =
+ resolveRelationLabels(dbFile, fields, entries);
+
+ const enrichedFields = parsedFields.map((f) => ({
+ ...f,
+ related_object_name:
+ f.type === "relation" ? relatedObjectNames[f.name] : undefined,
+ }));
+
+ const reverseRelations = findReverseRelations(obj.id);
+
+ const effectiveDisplayField = resolveDisplayField(obj, fields);
+
+ // Include saved views from .object.yaml
+ const { views: savedViews, activeView } = getObjectViews(name);
+
+ return Response.json({
+ object: obj,
+ fields: enrichedFields,
+ statuses,
+ entries,
+ relationLabels,
+ reverseRelations,
+ effectiveDisplayField,
+ savedViews,
+ activeView,
+ totalCount,
+ page,
+ pageSize,
+ });
+}
diff --git a/apps/web/app/api/workspace/objects/[name]/views/route.ts b/apps/web/app/api/workspace/objects/[name]/views/route.ts
new file mode 100644
index 00000000000..32e6ae53b0b
--- /dev/null
+++ b/apps/web/app/api/workspace/objects/[name]/views/route.ts
@@ -0,0 +1,61 @@
+import { NextResponse } from "next/server";
+import { getObjectViews, saveObjectViews } from "@/lib/workspace";
+import type { SavedView } from "@/lib/object-filters";
+
+type Params = { params: Promise<{ name: string }> };
+
+/**
+ * GET /api/workspace/objects/[name]/views
+ *
+ * Returns saved views and active_view from the object's .object.yaml.
+ */
+export async function GET(_req: Request, ctx: Params) {
+ const { name } = await ctx.params;
+ const objectName = decodeURIComponent(name);
+
+ try {
+ const { views, activeView } = getObjectViews(objectName);
+ return NextResponse.json({ views, activeView });
+ } catch (err) {
+ return NextResponse.json(
+ { error: `Failed to read views: ${err instanceof Error ? err.message : String(err)}` },
+ { status: 500 },
+ );
+ }
+}
+
+/**
+ * PUT /api/workspace/objects/[name]/views
+ *
+ * Save views and active_view to the object's .object.yaml.
+ * Body: { views: SavedView[], activeView?: string }
+ */
+export async function PUT(req: Request, ctx: Params) {
+ const { name } = await ctx.params;
+ const objectName = decodeURIComponent(name);
+
+ try {
+ const body = (await req.json()) as {
+ views?: SavedView[];
+ activeView?: string;
+ };
+
+ const views = body.views ?? [];
+ const activeView = body.activeView;
+
+ const ok = saveObjectViews(objectName, views, activeView);
+ if (!ok) {
+ return NextResponse.json(
+ { error: "Object directory not found" },
+ { status: 404 },
+ );
+ }
+
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return NextResponse.json(
+ { error: `Failed to save views: ${err instanceof Error ? err.message : String(err)}` },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/open-file/route.ts b/apps/web/app/api/workspace/open-file/route.ts
new file mode 100644
index 00000000000..402f0e2f527
--- /dev/null
+++ b/apps/web/app/api/workspace/open-file/route.ts
@@ -0,0 +1,97 @@
+import { exec } from "node:child_process";
+import { existsSync } from "node:fs";
+import { resolve, normalize } from "node:path";
+import { homedir } from "node:os";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * POST /api/workspace/open-file
+ * Opens a file or directory using the system's default application.
+ * On macOS this uses `open`, on Linux `xdg-open`.
+ */
+export async function POST(req: Request) {
+ let body: { path?: string; reveal?: boolean };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json(
+ { error: "Invalid JSON body" },
+ { status: 400 },
+ );
+ }
+
+ const rawPath = body.path;
+ if (!rawPath || typeof rawPath !== "string") {
+ return Response.json(
+ { error: "Missing 'path' in request body" },
+ { status: 400 },
+ );
+ }
+
+ // Expand ~ to home directory
+ const expanded = rawPath.startsWith("~/")
+ ? rawPath.replace(/^~/, homedir())
+ : rawPath;
+
+ let resolved = resolve(normalize(expanded));
+
+ // If the file doesn't exist and looks like a bare filename, try to locate it
+ // using macOS Spotlight (mdfind).
+ if (!existsSync(resolved) && !rawPath.includes("/")) {
+ const found = await new Promise((res) => {
+ exec(
+ `mdfind -name ${JSON.stringify(rawPath)} | head -1`,
+ (err, stdout) => {
+ if (err || !stdout.trim()) {res(null);}
+ else {res(stdout.trim().split("\n")[0]);}
+ },
+ );
+ });
+ if (found && existsSync(found)) {
+ resolved = found;
+ }
+ }
+
+ if (!existsSync(resolved)) {
+ return Response.json(
+ { error: "File not found", path: resolved },
+ { status: 404 },
+ );
+ }
+
+ const platform = process.platform;
+ const reveal = body.reveal === true;
+
+ let cmd: string;
+ if (platform === "darwin") {
+ // macOS: use `open` β `-R` reveals in Finder instead of opening
+ cmd = reveal
+ ? `open -R ${JSON.stringify(resolved)}`
+ : `open ${JSON.stringify(resolved)}`;
+ } else if (platform === "linux") {
+ // Linux: xdg-open (no reveal equivalent)
+ cmd = `xdg-open ${JSON.stringify(resolved)}`;
+ } else {
+ return Response.json(
+ { error: `Unsupported platform: ${platform}` },
+ { status: 400 },
+ );
+ }
+
+ return new Promise((res) => {
+ exec(cmd, (error) => {
+ if (error) {
+ res(
+ Response.json(
+ { error: `Failed to open file: ${error.message}` },
+ { status: 500 },
+ ),
+ );
+ } else {
+ res(Response.json({ ok: true, path: resolved }));
+ }
+ });
+ });
+}
diff --git a/apps/web/app/api/workspace/path-info/route.ts b/apps/web/app/api/workspace/path-info/route.ts
new file mode 100644
index 00000000000..e06c0e60469
--- /dev/null
+++ b/apps/web/app/api/workspace/path-info/route.ts
@@ -0,0 +1,88 @@
+import { exec } from "node:child_process";
+import { existsSync, statSync } from "node:fs";
+import { homedir } from "node:os";
+import { basename, normalize, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * GET /api/workspace/path-info?path=...
+ * Resolves and inspects a filesystem path for in-app preview routing.
+ */
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const rawPath = url.searchParams.get("path");
+
+ if (!rawPath) {
+ return Response.json(
+ { error: "Missing 'path' query parameter" },
+ { status: 400 },
+ );
+ }
+
+ let candidatePath = rawPath;
+
+ // Convert file:// URLs into local paths first.
+ if (candidatePath.startsWith("file://")) {
+ try {
+ candidatePath = fileURLToPath(candidatePath);
+ } catch {
+ return Response.json(
+ { error: "Invalid file URL" },
+ { status: 400 },
+ );
+ }
+ }
+
+ // Expand "~/..." to the current user's home directory.
+ const expandedPath = candidatePath.startsWith("~/")
+ ? candidatePath.replace(/^~/, homedir())
+ : candidatePath;
+ let resolvedPath = resolve(normalize(expandedPath));
+
+ // If the path doesn't exist and looks like a bare filename, try to locate it
+ // using macOS Spotlight (mdfind).
+ if (!existsSync(resolvedPath) && !rawPath.includes("/")) {
+ const found = await new Promise((res) => {
+ exec(
+ `mdfind -name ${JSON.stringify(rawPath)} | head -1`,
+ (err, stdout) => {
+ if (err || !stdout.trim()) {res(null);}
+ else {res(stdout.trim().split("\n")[0]);}
+ },
+ );
+ });
+ if (found && existsSync(found)) {
+ resolvedPath = found;
+ }
+ }
+
+ if (!existsSync(resolvedPath)) {
+ return Response.json(
+ { error: "Path not found", path: resolvedPath },
+ { status: 404 },
+ );
+ }
+
+ try {
+ const stat = statSync(resolvedPath);
+ const type = stat.isDirectory()
+ ? "directory"
+ : stat.isFile()
+ ? "file"
+ : "other";
+
+ return Response.json({
+ path: resolvedPath,
+ name: basename(resolvedPath) || resolvedPath,
+ type,
+ });
+ } catch {
+ return Response.json(
+ { error: "Cannot stat path", path: resolvedPath },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/query/route.ts b/apps/web/app/api/workspace/query/route.ts
new file mode 100644
index 00000000000..2df1dcc2962
--- /dev/null
+++ b/apps/web/app/api/workspace/query/route.ts
@@ -0,0 +1,43 @@
+import { duckdbQuery } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function POST(req: Request) {
+ let body: { sql?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json(
+ { error: "Invalid JSON body" },
+ { status: 400 },
+ );
+ }
+
+ const { sql } = body;
+ if (!sql || typeof sql !== "string") {
+ return Response.json(
+ { error: "Missing 'sql' field in request body" },
+ { status: 400 },
+ );
+ }
+
+ // Basic SQL safety: reject obviously dangerous statements
+ const upper = sql.toUpperCase().trim();
+ if (
+ upper.startsWith("DROP") ||
+ upper.startsWith("DELETE") ||
+ upper.startsWith("INSERT") ||
+ upper.startsWith("UPDATE") ||
+ upper.startsWith("ALTER") ||
+ upper.startsWith("CREATE")
+ ) {
+ return Response.json(
+ { error: "Only SELECT queries are allowed" },
+ { status: 403 },
+ );
+ }
+
+ const rows = duckdbQuery(sql);
+ return Response.json({ rows });
+}
diff --git a/apps/web/app/api/workspace/raw-file/route.ts b/apps/web/app/api/workspace/raw-file/route.ts
new file mode 100644
index 00000000000..25c0084fe13
--- /dev/null
+++ b/apps/web/app/api/workspace/raw-file/route.ts
@@ -0,0 +1,124 @@
+import { existsSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { safeResolvePath, resolveWorkspaceRoot } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+const MIME_MAP: Record = {
+ // Images
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ png: "image/png",
+ gif: "image/gif",
+ webp: "image/webp",
+ svg: "image/svg+xml",
+ ico: "image/x-icon",
+ bmp: "image/bmp",
+ tiff: "image/tiff",
+ tif: "image/tiff",
+ avif: "image/avif",
+ heic: "image/heic",
+ heif: "image/heif",
+ // Video
+ mp4: "video/mp4",
+ webm: "video/webm",
+ mov: "video/quicktime",
+ avi: "video/x-msvideo",
+ mkv: "video/x-matroska",
+ // Audio
+ mp3: "audio/mpeg",
+ wav: "audio/wav",
+ ogg: "audio/ogg",
+ m4a: "audio/mp4",
+ // Documents
+ pdf: "application/pdf",
+ html: "text/html",
+ htm: "text/html",
+};
+
+/**
+ * Resolve a file path, trying multiple strategies:
+ * 1. Absolute path β the agent may read files from anywhere on the local machine
+ * (Photos library, Downloads, etc.), so we serve any readable absolute path.
+ * 2. Workspace-relative via safeResolvePath
+ * 3. Bare filename β search common workspace subdirectories
+ *
+ * Security note: this is a local-only dev server; it never runs in production.
+ */
+function resolveFile(path: string): string | null {
+ // 1. Absolute path β serve directly if it exists on disk
+ if (path.startsWith("/")) {
+ const abs = resolve(path);
+ if (existsSync(abs)) {return abs;}
+ // Fall through to workspace-relative in case the leading / is accidental
+ }
+
+ // 2. Standard workspace-relative resolution
+ const resolved = safeResolvePath(path);
+ if (resolved) {return resolved;}
+
+ // 3. Try common subdirectories in case the path is a bare filename
+ const root = resolveWorkspaceRoot();
+ if (!root) {return null;}
+ const rootAbs = resolve(root);
+ const basename = path.split("/").pop() ?? path;
+ if (basename === path) {
+ const subdirs = [
+ "assets",
+ "knowledge",
+ "manufacturing",
+ "uploads",
+ "files",
+ "images",
+ "media",
+ "reports",
+ "exports",
+ ];
+ for (const sub of subdirs) {
+ const candidate = resolve(root, sub, basename);
+ if (
+ candidate.startsWith(rootAbs) &&
+ existsSync(candidate)
+ ) {
+ return candidate;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * GET /api/workspace/raw-file?path=...
+ * Serves a workspace file with the correct Content-Type for inline display.
+ * Used by the chain-of-thought component to render images, videos, and PDFs.
+ */
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path");
+
+ if (!path) {
+ return new Response("Missing path", { status: 400 });
+ }
+
+ const absolute = resolveFile(path);
+ if (!absolute) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
+ const contentType = MIME_MAP[ext] ?? "application/octet-stream";
+
+ try {
+ const buffer = readFileSync(absolute);
+ return new Response(buffer, {
+ headers: {
+ "Content-Type": contentType,
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+ } catch {
+ return new Response("Read error", { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/workspace/rename/route.ts b/apps/web/app/api/workspace/rename/route.ts
new file mode 100644
index 00000000000..43066d2c316
--- /dev/null
+++ b/apps/web/app/api/workspace/rename/route.ts
@@ -0,0 +1,84 @@
+import { renameSync, existsSync } from "node:fs";
+import { join, dirname } from "node:path";
+import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * POST /api/workspace/rename
+ * Body: { path: string, newName: string }
+ *
+ * Renames a file or folder within the same directory.
+ * System files are protected from renaming.
+ */
+export async function POST(req: Request) {
+ let body: { path?: string; newName?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { path: relPath, newName } = body;
+ if (!relPath || typeof relPath !== "string" || !newName || typeof newName !== "string") {
+ return Response.json(
+ { error: "Missing 'path' and 'newName' fields" },
+ { status: 400 },
+ );
+ }
+
+ if (isSystemFile(relPath)) {
+ return Response.json(
+ { error: "Cannot rename system file" },
+ { status: 403 },
+ );
+ }
+
+ // Validate newName: no slashes, no empty, no traversal
+ if (newName.includes("/") || newName.includes("\\") || newName.trim() === "") {
+ return Response.json(
+ { error: "Invalid file name" },
+ { status: 400 },
+ );
+ }
+
+ const absPath = safeResolvePath(relPath);
+ if (!absPath) {
+ return Response.json(
+ { error: "Source not found or path traversal rejected" },
+ { status: 404 },
+ );
+ }
+
+ const parentDir = dirname(absPath);
+ const newAbsPath = join(parentDir, newName);
+
+ // Ensure the new path stays within workspace
+ const parentRel = dirname(relPath);
+ const newRelPath = parentRel === "." ? newName : `${parentRel}/${newName}`;
+ const validated = safeResolveNewPath(newRelPath);
+ if (!validated) {
+ return Response.json(
+ { error: "Invalid destination path" },
+ { status: 400 },
+ );
+ }
+
+ if (existsSync(newAbsPath)) {
+ return Response.json(
+ { error: `A file named '${newName}' already exists` },
+ { status: 409 },
+ );
+ }
+
+ try {
+ renameSync(absPath, newAbsPath);
+ return Response.json({ ok: true, oldPath: relPath, newPath: newRelPath });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Rename failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/reports/execute/route.ts b/apps/web/app/api/workspace/reports/execute/route.ts
new file mode 100644
index 00000000000..fb6b8ed23a7
--- /dev/null
+++ b/apps/web/app/api/workspace/reports/execute/route.ts
@@ -0,0 +1,54 @@
+import { duckdbQuery } from "@/lib/workspace";
+import { buildFilterClauses, injectFilters, checkSqlSafety } from "@/lib/report-filters";
+import type { FilterEntry } from "@/lib/report-filters";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * POST /api/workspace/reports/execute
+ *
+ * Body: { sql: string, filters?: FilterEntry[] }
+ *
+ * Executes a report panel's SQL query with optional filter injection.
+ * Only SELECT-compatible queries are allowed.
+ */
+export async function POST(req: Request) {
+ let body: {
+ sql?: string;
+ filters?: FilterEntry[];
+ };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { sql, filters } = body;
+ if (!sql || typeof sql !== "string") {
+ return Response.json(
+ { error: "Missing 'sql' field in request body" },
+ { status: 400 },
+ );
+ }
+
+ // Basic SQL safety: reject mutation statements
+ const safetyError = checkSqlSafety(sql);
+ if (safetyError) {
+ return Response.json({ error: safetyError }, { status: 403 });
+ }
+
+ // Build filter clauses and inject into SQL
+ const filterClauses = buildFilterClauses(filters);
+ const finalSql = injectFilters(sql, filterClauses);
+
+ try {
+ const rows = duckdbQuery(finalSql);
+ return Response.json({ rows, sql: finalSql });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Query execution failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/search-index/route.ts b/apps/web/app/api/workspace/search-index/route.ts
new file mode 100644
index 00000000000..41902b942fa
--- /dev/null
+++ b/apps/web/app/api/workspace/search-index/route.ts
@@ -0,0 +1,289 @@
+import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
+import { join } from "node:path";
+import {
+ resolveWorkspaceRoot,
+ parseSimpleYaml,
+ duckdbQueryAllAsync,
+ discoverDuckDBPaths,
+ duckdbQueryOnFileAsync,
+ isDatabaseFile,
+} from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/** Safely convert an unknown DB value to a display string. */
+function dbStr(val: unknown): string {
+ if (val == null) {return "";}
+ if (typeof val === "object") {return JSON.stringify(val);}
+ return String(val as string | number | boolean);
+}
+
+// --- Types ---
+
+export type SearchIndexItem = {
+ /** Unique key: relative path for files, entryId for entries */
+ id: string;
+ /** Primary display text (filename or display-field value) */
+ label: string;
+ /** Secondary text (path for files, object name for entries) */
+ sublabel?: string;
+ /** Item kind for grouping and icons */
+ kind: "file" | "object" | "entry";
+ /** Icon hint */
+ icon?: string;
+
+ // Entry-specific
+ objectName?: string;
+ entryId?: string;
+ /** First few field key-value pairs for search and preview */
+ fields?: Record;
+
+ // File/object-specific
+ path?: string;
+ nodeType?: "document" | "folder" | "file" | "report" | "database";
+};
+
+// --- DB types ---
+
+type ObjectRow = {
+ id: string;
+ name: string;
+ description?: string;
+ icon?: string;
+ default_view?: string;
+ display_field?: string;
+};
+
+type FieldRow = {
+ id: string;
+ name: string;
+ type: string;
+ sort_order?: number;
+};
+
+type EavRow = {
+ entry_id: string;
+ created_at: string;
+ updated_at: string;
+ field_name: string;
+ value: string | null;
+};
+
+// --- Helpers ---
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+/** Determine the display field (same heuristic as the objects route). */
+function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string {
+ if (obj.display_field) {return obj.display_field;}
+
+ const nameField = fields.find(
+ (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
+ );
+ if (nameField) {return nameField.name;}
+
+ const textField = fields.find((f) => f.type === "text");
+ if (textField) {return textField.name;}
+
+ return fields[0]?.name ?? "id";
+}
+
+/** Flatten a tree recursively to produce file/object search items. */
+function flattenTree(
+ absDir: string,
+ relBase: string,
+ dbObjects: Map,
+ items: SearchIndexItem[],
+) {
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(absDir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+
+ for (const entry of entries) {
+ if (entry.name.startsWith(".")) {continue;}
+
+ const absPath = join(absDir, entry.name);
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
+
+ if (entry.isDirectory()) {
+ const dbObj = dbObjects.get(entry.name);
+ // Check for .object.yaml
+ const yamlPath = join(absPath, ".object.yaml");
+ const hasYaml = existsSync(yamlPath);
+
+ if (dbObj || hasYaml) {
+ let icon: string | undefined;
+ if (hasYaml) {
+ try {
+ const parsed = parseSimpleYaml(
+ readFileSync(yamlPath, "utf-8"),
+ );
+ icon = parsed.icon as string | undefined;
+ } catch {
+ // ignore
+ }
+ }
+
+ items.push({
+ id: relPath,
+ label: entry.name,
+ sublabel: relPath,
+ kind: "object",
+ icon: icon ?? dbObj?.icon,
+ path: relPath,
+ nodeType: undefined,
+ });
+ } else {
+ // Regular folder -- don't add as item, but recurse
+ }
+
+ flattenTree(absPath, relPath, dbObjects, items);
+ } else if (entry.isFile()) {
+ const isReport = entry.name.endsWith(".report.json");
+ const ext = entry.name.split(".").pop()?.toLowerCase();
+ const isDocument = ext === "md" || ext === "mdx";
+ const isDatabase = isDatabaseFile(entry.name);
+
+ items.push({
+ id: relPath,
+ label: entry.name.replace(/\.md$/, ""),
+ sublabel: relPath,
+ kind: "file",
+ path: relPath,
+ nodeType: isReport
+ ? "report"
+ : isDatabase
+ ? "database"
+ : isDocument
+ ? "document"
+ : "file",
+ });
+ }
+ }
+}
+
+/**
+ * Fetch all entries from all objects across ALL discovered DuckDB files.
+ * Deduplicates objects by name (shallower DBs win).
+ */
+async function buildEntryItems(): Promise {
+ const items: SearchIndexItem[] = [];
+ const dbPaths = discoverDuckDBPaths();
+ if (dbPaths.length === 0) {return [];}
+
+ // Collect all objects across DBs, deduplicating by name (shallowest wins)
+ const seenNames = new Set();
+ const objectsWithDb: Array<{ obj: ObjectRow; dbPath: string }> = [];
+
+ for (const dbPath of dbPaths) {
+ const objs = await duckdbQueryOnFileAsync(dbPath,
+ "SELECT * FROM objects ORDER BY name",
+ );
+ for (const obj of objs) {
+ if (seenNames.has(obj.name)) {continue;}
+ seenNames.add(obj.name);
+ objectsWithDb.push({ obj, dbPath });
+ }
+ }
+
+ for (const { obj, dbPath } of objectsWithDb) {
+ const fields = await duckdbQueryOnFileAsync(dbPath,
+ `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
+ );
+ const displayField = resolveDisplayField(obj, fields);
+ const previewFields = fields
+ .filter((f) => !["relation", "richtext"].includes(f.type))
+ .slice(0, 4);
+
+ // Try PIVOT view first, then raw EAV (on the same DB)
+ let entries: Record[] = await duckdbQueryOnFileAsync(dbPath,
+ `SELECT * FROM v_${obj.name} ORDER BY created_at DESC LIMIT 500`,
+ );
+
+ if (entries.length === 0) {
+ const rawRows = await duckdbQueryOnFileAsync(dbPath,
+ `SELECT e.id as entry_id, e.created_at, e.updated_at,
+ f.name as field_name, ef.value
+ FROM entries e
+ JOIN entry_fields ef ON ef.entry_id = e.id
+ JOIN fields f ON f.id = ef.field_id
+ WHERE e.object_id = '${sqlEscape(obj.id)}'
+ ORDER BY e.created_at DESC
+ LIMIT 2500`,
+ );
+
+ const grouped = new Map>();
+ for (const row of rawRows) {
+ let entry = grouped.get(row.entry_id);
+ if (!entry) {
+ entry = { entry_id: row.entry_id };
+ grouped.set(row.entry_id, entry);
+ }
+ if (row.field_name) {entry[row.field_name] = row.value;}
+ }
+ entries = Array.from(grouped.values());
+ }
+
+ for (const entry of entries) {
+ const entryId = dbStr(entry.entry_id);
+ if (!entryId) {continue;}
+
+ const displayValue = dbStr(entry[displayField]);
+ const fieldPreview: Record = {};
+ for (const f of previewFields) {
+ const val = entry[f.name];
+ if (val != null && val !== "") {
+ fieldPreview[f.name] = dbStr(val);
+ }
+ }
+
+ items.push({
+ id: `entry:${obj.name}:${entryId}`,
+ label: displayValue || `(${obj.name} entry)`,
+ sublabel: obj.name,
+ kind: "entry",
+ icon: obj.icon,
+ objectName: obj.name,
+ entryId,
+ fields: Object.keys(fieldPreview).length > 0 ? fieldPreview : undefined,
+ });
+ }
+ }
+
+ return items;
+}
+
+// --- Route handler ---
+
+export async function GET() {
+ const items: SearchIndexItem[] = [];
+
+ // 1. Files + objects from tree
+ const root = resolveWorkspaceRoot();
+ if (root) {
+ // Aggregate objects from ALL discovered DuckDB files (shallower wins)
+ const dbObjects = new Map();
+ const objs = await duckdbQueryAllAsync(
+ "SELECT * FROM objects",
+ "name",
+ );
+ for (const o of objs) {dbObjects.set(o.name, o);}
+
+ // Scan workspace root (the workspace folder IS the knowledge base)
+ flattenTree(root, "", dbObjects, items);
+ }
+
+ // 2. Entries from all objects across all discovered DBs
+ const dbPaths = discoverDuckDBPaths();
+ if (dbPaths.length > 0) {
+ items.push(...await buildEntryItems());
+ }
+
+ return Response.json({ items });
+}
diff --git a/apps/web/app/api/workspace/suggest-files/route.ts b/apps/web/app/api/workspace/suggest-files/route.ts
new file mode 100644
index 00000000000..57bd1dfd45b
--- /dev/null
+++ b/apps/web/app/api/workspace/suggest-files/route.ts
@@ -0,0 +1,434 @@
+import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
+import { join, dirname, resolve, basename } from "node:path";
+import { homedir } from "node:os";
+import {
+ resolveWorkspaceRoot,
+ duckdbQueryAllAsync,
+ discoverDuckDBPaths,
+ duckdbQueryOnFileAsync,
+ parseSimpleYaml,
+} from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+type SuggestItem = {
+ name: string;
+ path: string;
+ type: "folder" | "file" | "document" | "database" | "object" | "entry";
+ /** Icon hint (emoji) for objects/entries */
+ icon?: string;
+ /** Object name that owns this entry */
+ objectName?: string;
+ /** DB entry ID */
+ entryId?: string;
+};
+
+const SKIP_DIRS = new Set([
+ "node_modules",
+ ".git",
+ ".Trash",
+ "__pycache__",
+ ".cache",
+ ".DS_Store",
+]);
+
+/** List entries in a directory, sorted folders-first then alphabetically. */
+function listDir(absDir: string, filter?: string): SuggestItem[] {
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(absDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ const lowerFilter = filter?.toLowerCase();
+
+ const sorted = entries
+ .filter((e) => !e.name.startsWith("."))
+ .filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)))
+ .filter((e) => !lowerFilter || e.name.toLowerCase().includes(lowerFilter))
+ .toSorted((a, b) => {
+ if (a.isDirectory() && !b.isDirectory()) {return -1;}
+ if (!a.isDirectory() && b.isDirectory()) {return 1;}
+ return a.name.localeCompare(b.name);
+ });
+
+ const items: SuggestItem[] = [];
+ for (const entry of sorted) {
+ if (items.length >= 30) {break;}
+ const absPath = join(absDir, entry.name);
+
+ if (entry.isDirectory()) {
+ items.push({ name: entry.name, path: absPath, type: "folder" });
+ } else if (entry.isFile()) {
+ const ext = entry.name.split(".").pop()?.toLowerCase();
+ const isDocument = ext === "md" || ext === "mdx";
+ const isDatabase =
+ ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
+ items.push({
+ name: entry.name,
+ path: absPath,
+ type: isDatabase ? "database" : isDocument ? "document" : "file",
+ });
+ }
+ }
+ return items;
+}
+
+/** Recursively search for files matching a query, up to a limit. */
+function searchFiles(
+ absDir: string,
+ query: string,
+ results: SuggestItem[],
+ maxResults: number,
+ depth = 0,
+): void {
+ if (depth > 6 || results.length >= maxResults) {return;}
+
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(absDir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+
+ const lowerQuery = query.toLowerCase();
+
+ for (const entry of entries) {
+ if (results.length >= maxResults) {return;}
+ if (entry.name.startsWith(".")) {continue;}
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) {continue;}
+
+ const absPath = join(absDir, entry.name);
+
+ if (entry.isFile() && entry.name.toLowerCase().includes(lowerQuery)) {
+ const ext = entry.name.split(".").pop()?.toLowerCase();
+ const isDocument = ext === "md" || ext === "mdx";
+ const isDatabase =
+ ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
+ results.push({
+ name: entry.name,
+ path: absPath,
+ type: isDatabase ? "database" : isDocument ? "document" : "file",
+ });
+ } else if (
+ entry.isDirectory() &&
+ entry.name.toLowerCase().includes(lowerQuery)
+ ) {
+ results.push({ name: entry.name, path: absPath, type: "folder" });
+ }
+
+ if (entry.isDirectory()) {
+ searchFiles(absPath, query, results, maxResults, depth + 1);
+ }
+ }
+}
+
+/**
+ * Resolve a user-typed path query into a directory to list and an optional filter.
+ *
+ * Examples:
+ * "../" β list parent of workspace root
+ * "/" β list filesystem root
+ * "~/" β list home dir
+ * "~/Doc" β list home dir, filter "Doc"
+ * "src/utils" β list /src, filter "utils"
+ * "foo.ts" β search by filename
+ */
+function resolvePath(
+ raw: string,
+ workspaceRoot: string,
+): { dir: string; filter?: string } | null {
+ const home = homedir();
+
+ if (raw.startsWith("~/")) {
+ const rest = raw.slice(2);
+ if (!rest || rest.endsWith("/")) {
+ // List the directory
+ const dir = rest ? resolve(home, rest) : home;
+ return { dir };
+ }
+ // Has a trailing segment β list parent, filter by segment
+ const dir = resolve(home, dirname(rest));
+ return { dir, filter: basename(rest) };
+ }
+
+ if (raw.startsWith("/")) {
+ if (raw === "/") {return { dir: "/" };}
+ if (raw.endsWith("/")) {
+ return { dir: resolve(raw) };
+ }
+ const dir = dirname(resolve(raw));
+ return { dir, filter: basename(raw) };
+ }
+
+ if (raw.startsWith("../") || raw === "..") {
+ const resolved = resolve(workspaceRoot, raw);
+ if (raw.endsWith("/") || raw === "..") {
+ return { dir: resolved };
+ }
+ return { dir: dirname(resolved), filter: basename(resolved) };
+ }
+
+ if (raw.startsWith("./")) {
+ const rest = raw.slice(2);
+ if (!rest || rest.endsWith("/")) {
+ const dir = rest ? resolve(workspaceRoot, rest) : workspaceRoot;
+ return { dir };
+ }
+ const dir = resolve(workspaceRoot, dirname(rest));
+ return { dir, filter: basename(rest) };
+ }
+
+ // Contains a slash β treat as relative path from workspace
+ if (raw.includes("/")) {
+ if (raw.endsWith("/")) {
+ return { dir: resolve(workspaceRoot, raw) };
+ }
+ const dir = resolve(workspaceRoot, dirname(raw));
+ return { dir, filter: basename(raw) };
+ }
+
+ // No path separator β this is a filename search
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// DuckDB object & entry search
+// ---------------------------------------------------------------------------
+
+type ObjectRow = {
+ id: string;
+ name: string;
+ description?: string;
+ icon?: string;
+ display_field?: string;
+};
+
+type FieldRow = {
+ id: string;
+ name: string;
+ type: string;
+ sort_order?: number;
+};
+
+function sqlEscape(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string {
+ if (obj.display_field) {return obj.display_field;}
+ const nameField = fields.find(
+ (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
+ );
+ if (nameField) {return nameField.name;}
+ const textField = fields.find((f) => f.type === "text");
+ if (textField) {return textField.name;}
+ return fields[0]?.name ?? "id";
+}
+
+/** Read icon from .object.yaml if present. */
+function readObjectIcon(workspaceRoot: string, objName: string): string | undefined {
+ // Walk workspace to find a folder matching objName that has .object.yaml
+ function walk(dir: string, depth: number): string | undefined {
+ if (depth > 4) {return undefined;}
+ try {
+ const entries = readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {continue;}
+ if (entry.name === objName) {
+ const yamlPath = join(dir, entry.name, ".object.yaml");
+ if (existsSync(yamlPath)) {
+ const parsed = parseSimpleYaml(readFileSync(yamlPath, "utf-8"));
+ if (parsed.icon) {return dbStr(parsed.icon);}
+ }
+ }
+ const found = walk(join(dir, entry.name), depth + 1);
+ if (found) {return found;}
+ }
+ } catch { /* skip */ }
+ return undefined;
+ }
+ return walk(workspaceRoot, 0);
+}
+
+/** Search objects by name (case-insensitive substring). */
+async function searchObjects(
+ query: string,
+ workspaceRoot: string,
+ max: number,
+): Promise {
+ const sql = query
+ ? `SELECT * FROM objects WHERE LOWER(name) LIKE LOWER('%${sqlEscape(query)}%') ORDER BY name LIMIT ${max}`
+ : `SELECT * FROM objects ORDER BY name LIMIT ${max}`;
+ const objects = await duckdbQueryAllAsync(sql, "name");
+
+ const items: SuggestItem[] = [];
+ for (const obj of objects) {
+ const yamlIcon = readObjectIcon(workspaceRoot, obj.name);
+ items.push({
+ name: obj.name,
+ path: `workspace:object:${obj.name}`,
+ type: "object",
+ icon: yamlIcon ?? obj.icon,
+ });
+ }
+ return items;
+}
+
+/** Safely convert an unknown DB value to a display string. */
+function dbStr(val: unknown): string {
+ if (val == null) {return "";}
+ if (typeof val === "object") {return JSON.stringify(val);}
+ return String(val as string | number | boolean);
+}
+
+/**
+ * Search entries across all objects using a single UNION ALL query per DB.
+ * Each object's pivot view (v_) is searched by display field with ILIKE.
+ * This avoids spawning N DuckDB CLI processes per object.
+ */
+async function searchEntries(
+ query: string,
+ max: number,
+): Promise {
+ const dbPaths = discoverDuckDBPaths();
+ if (dbPaths.length === 0 || !query) {return [];}
+
+ const items: SuggestItem[] = [];
+ const seenObjects = new Set();
+ const likePattern = `%${sqlEscape(query)}%`;
+
+ for (const dbPath of dbPaths) {
+ if (items.length >= max) {break;}
+
+ // Step 1: get objects + display fields in a single query
+ type ObjFieldRow = ObjectRow & { field_name: string; field_type: string };
+ const objFields = await duckdbQueryOnFileAsync(
+ dbPath,
+ `SELECT o.*, f.name as field_name, f.type as field_type
+ FROM objects o
+ LEFT JOIN fields f ON f.object_id = o.id
+ ORDER BY o.name, f.sort_order`,
+ );
+
+ // Group fields by object and resolve display fields
+ const objectMap = new Map();
+ const fieldsByObj = new Map();
+ for (const row of objFields) {
+ if (seenObjects.has(row.name)) {continue;}
+ if (!fieldsByObj.has(row.id)) {fieldsByObj.set(row.id, []);}
+ if (row.field_name) {
+ fieldsByObj.get(row.id)!.push({
+ id: row.id,
+ name: row.field_name,
+ type: row.field_type,
+ });
+ }
+ if (!objectMap.has(row.name)) {
+ const fields = fieldsByObj.get(row.id) ?? [];
+ objectMap.set(row.name, {
+ obj: row,
+ displayField: resolveDisplayField(row, fields),
+ });
+ }
+ }
+
+ // Re-resolve display fields now that all fields are collected
+ for (const [name, entry] of objectMap) {
+ const fields = fieldsByObj.get(entry.obj.id) ?? [];
+ entry.displayField = resolveDisplayField(entry.obj, fields);
+ seenObjects.add(name);
+ }
+
+ if (objectMap.size === 0) {continue;}
+
+ // Step 2: build a single UNION ALL query searching all pivot views
+ // Wrap each SELECT in parens so per-view LIMIT is valid DuckDB syntax
+ const unionParts: string[] = [];
+ for (const [name, { displayField }] of objectMap) {
+ const safeDisplay = sqlEscape(displayField);
+ unionParts.push(
+ `(SELECT '${sqlEscape(name)}' as _obj_name, entry_id, "${safeDisplay}" as _display
+ FROM v_${name}
+ WHERE LOWER(CAST("${safeDisplay}" AS VARCHAR)) LIKE LOWER('${likePattern}')
+ LIMIT ${max})`,
+ );
+ }
+
+ if (unionParts.length === 0) {continue;}
+
+ type EntryHit = { _obj_name: string; entry_id: string; _display: string };
+ const hits = await duckdbQueryOnFileAsync(
+ dbPath,
+ `${unionParts.join(" UNION ALL ")} LIMIT ${max}`,
+ );
+
+ for (const hit of hits) {
+ if (items.length >= max) {return items;}
+ if (!hit.entry_id || !hit._display) {continue;}
+ const objInfo = objectMap.get(hit._obj_name);
+ items.push({
+ name: String(hit._display),
+ path: `workspace:entry:${hit._obj_name}:${hit.entry_id}`,
+ type: "entry",
+ icon: objInfo?.obj.icon,
+ objectName: hit._obj_name,
+ entryId: hit.entry_id,
+ });
+ }
+ }
+
+ return items;
+}
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const pathQuery = url.searchParams.get("path");
+ const searchQuery = url.searchParams.get("q");
+ const workspaceRoot = resolveWorkspaceRoot() ?? homedir();
+
+ // Search mode: find files, objects, and entries by name
+ if (searchQuery) {
+ // File search: workspace only (skip expensive home dir traversal)
+ const fileResults: SuggestItem[] = [];
+ searchFiles(workspaceRoot, searchQuery, fileResults, 15);
+
+ // DuckDB search: objects and entries (sequential to avoid lock contention)
+ const objectResults = await searchObjects(searchQuery, workspaceRoot, 10);
+ const entryResults = await searchEntries(searchQuery, 15);
+
+ // Deduplicate: if an object matches, remove the duplicate folder
+ const objectNames = new Set(objectResults.map((o) => o.name));
+ const dedupedFiles = fileResults.filter(
+ (f) => !(f.type === "folder" && objectNames.has(f.name)),
+ );
+
+ // Merge: objects first, then entries, then files
+ const items = [...objectResults, ...entryResults, ...dedupedFiles].slice(0, 30);
+ return Response.json({ items });
+ }
+
+ // Browse mode: resolve path and list directory
+ if (pathQuery) {
+ const resolved = resolvePath(pathQuery, workspaceRoot);
+ if (!resolved) {
+ const results: SuggestItem[] = [];
+ searchFiles(workspaceRoot, pathQuery, results, 20);
+ return Response.json({ items: results });
+ }
+ const items = listDir(resolved.dir, resolved.filter);
+ return Response.json({ items });
+ }
+
+ // Default: list workspace root + all objects
+ const fileItems = listDir(workspaceRoot);
+ const objectItems = await searchObjects("", workspaceRoot, 20);
+ // Deduplicate: if an object also appears as a folder, keep the object version
+ const objectNames = new Set(objectItems.map((o) => o.name));
+ const dedupedFiles = fileItems.filter(
+ (f) => !(f.type === "folder" && objectNames.has(f.name)),
+ );
+ return Response.json({ items: [...objectItems, ...dedupedFiles] });
+}
diff --git a/apps/web/app/api/workspace/thumbnail/route.ts b/apps/web/app/api/workspace/thumbnail/route.ts
new file mode 100644
index 00000000000..22b298ead14
--- /dev/null
+++ b/apps/web/app/api/workspace/thumbnail/route.ts
@@ -0,0 +1,69 @@
+import { existsSync, readFileSync, mkdirSync } from "node:fs";
+import { execSync } from "node:child_process";
+import { join, basename } from "node:path";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import { safeResolvePath } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+const THUMB_DIR = join(tmpdir(), "ironclaw-thumbs");
+mkdirSync(THUMB_DIR, { recursive: true });
+
+/**
+ * Resolve a file path β supports absolute paths and workspace-relative paths.
+ */
+function resolveFile(path: string): string | null {
+ if (path.startsWith("/")) {
+ const abs = resolve(path);
+ if (existsSync(abs)) {return abs;}
+ }
+ return safeResolvePath(path) ?? null;
+}
+
+/**
+ * GET /api/workspace/thumbnail?path=...&size=200
+ * Uses macOS Quick Look (qlmanage) to generate a thumbnail image.
+ * Returns the thumbnail as image/png.
+ */
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path");
+ const size = url.searchParams.get("size") ?? "200";
+
+ if (!path) {
+ return new Response("Missing path", { status: 400 });
+ }
+
+ const absolute = resolveFile(path);
+ if (!absolute) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ // The thumbnail output filename is .png
+ const thumbName = `${basename(absolute)}.png`;
+ const thumbPath = join(THUMB_DIR, thumbName);
+
+ try {
+ // Generate thumbnail using macOS Quick Look
+ execSync(
+ `qlmanage -t -s ${parseInt(size, 10)} -o "${THUMB_DIR}" "${absolute}" 2>/dev/null`,
+ { timeout: 5000 },
+ );
+
+ if (!existsSync(thumbPath)) {
+ return new Response("Thumbnail generation failed", { status: 500 });
+ }
+
+ const buffer = readFileSync(thumbPath);
+ return new Response(buffer, {
+ headers: {
+ "Content-Type": "image/png",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+ } catch {
+ return new Response("Thumbnail generation failed", { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/workspace/tree-browse.test.ts b/apps/web/app/api/workspace/tree-browse.test.ts
new file mode 100644
index 00000000000..3d908100e8e
--- /dev/null
+++ b/apps/web/app/api/workspace/tree-browse.test.ts
@@ -0,0 +1,233 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import type { Dirent } from "node:fs";
+
+// Mock node:fs
+vi.mock("node:fs", () => ({
+ readdirSync: vi.fn(() => []),
+ readFileSync: vi.fn(() => ""),
+ existsSync: vi.fn(() => false),
+ statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })),
+}));
+
+// Mock node:os
+vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+}));
+
+// Mock workspace
+vi.mock("@/lib/workspace", () => ({
+ resolveWorkspaceRoot: vi.fn(() => null),
+ parseSimpleYaml: vi.fn(() => ({})),
+ duckdbQueryAll: vi.fn(() => []),
+ duckdbQueryAllAsync: vi.fn(async () => []),
+ isDatabaseFile: vi.fn(() => false),
+ discoverDuckDBPaths: vi.fn(() => []),
+ resolveDuckdbBin: vi.fn(() => null),
+ safeResolvePath: vi.fn(() => null),
+}));
+
+function makeDirent(name: string, isDir: boolean): Dirent {
+ return {
+ name,
+ isDirectory: () => isDir,
+ isFile: () => !isDir,
+ isBlockDevice: () => false,
+ isCharacterDevice: () => false,
+ isFIFO: () => false,
+ isSocket: () => false,
+ isSymbolicLink: () => false,
+ path: "",
+ parentPath: "",
+ } as Dirent;
+}
+
+describe("Workspace Tree & Browse API", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.mock("node:fs", () => ({
+ readdirSync: vi.fn(() => []),
+ readFileSync: vi.fn(() => ""),
+ existsSync: vi.fn(() => false),
+ statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })),
+ }));
+ vi.mock("node:os", () => ({
+ homedir: vi.fn(() => "/home/testuser"),
+ }));
+ vi.mock("@/lib/workspace", () => ({
+ resolveWorkspaceRoot: vi.fn(() => null),
+ parseSimpleYaml: vi.fn(() => ({})),
+ duckdbQueryAll: vi.fn(() => []),
+ duckdbQueryAllAsync: vi.fn(async () => []),
+ isDatabaseFile: vi.fn(() => false),
+ discoverDuckDBPaths: vi.fn(() => []),
+ resolveDuckdbBin: vi.fn(() => null),
+ safeResolvePath: vi.fn(() => null),
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // βββ GET /api/workspace/tree ββββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/workspace/tree", () => {
+ it("returns tree with exists=false when no workspace root", async () => {
+ const { GET } = await import("./tree/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.exists).toBe(false);
+ expect(json.tree).toEqual([]);
+ });
+
+ it("returns tree with workspace files", async () => {
+ const { resolveWorkspaceRoot } = await import("@/lib/workspace");
+ vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
+ const { readdirSync: mockReaddir, existsSync: mockExists } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReaddir).mockImplementation((dir) => {
+ if (String(dir) === "/ws") {
+ return [
+ makeDirent("knowledge", true),
+ makeDirent("readme.md", false),
+ ] as unknown as Dirent[];
+ }
+ return [] as unknown as Dirent[];
+ });
+
+ const { GET } = await import("./tree/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.exists).toBe(true);
+ expect(json.tree.length).toBeGreaterThan(0);
+ });
+
+ it("includes workspaceRoot in response", async () => {
+ const { resolveWorkspaceRoot } = await import("@/lib/workspace");
+ vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
+ const { existsSync: mockExists } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+
+ const { GET } = await import("./tree/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.workspaceRoot).toBe("/ws");
+ });
+ });
+
+ // βββ GET /api/workspace/browse ββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/workspace/browse", () => {
+ it("returns directory listing", async () => {
+ const { existsSync: mockExists, readdirSync: mockReaddir, statSync: mockStat } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReaddir).mockReturnValue([
+ makeDirent("file.txt", false),
+ makeDirent("subfolder", true),
+ ] as unknown as Dirent[]);
+ vi.mocked(mockStat).mockReturnValue({ isDirectory: () => false, size: 100 } as never);
+
+ const { GET } = await import("./browse/route.js");
+ const req = new Request("http://localhost/api/workspace/browse?dir=/tmp/test");
+ const res = await GET(req);
+ const json = await res.json();
+ expect(json.entries).toBeDefined();
+ expect(json.currentDir).toBeDefined();
+ });
+
+ it("returns parentDir for nested directories", async () => {
+ const { existsSync: mockExists, readdirSync: mockReaddir, statSync: mockStat } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReaddir).mockReturnValue([]);
+ vi.mocked(mockStat).mockReturnValue({ isDirectory: () => true, size: 0 } as never);
+
+ const { GET } = await import("./browse/route.js");
+ const req = new Request("http://localhost/api/workspace/browse?dir=/tmp/test/sub");
+ const res = await GET(req);
+ const json = await res.json();
+ expect(json.parentDir).toBeDefined();
+ });
+ });
+
+ // βββ GET /api/workspace/suggest-files ββββββββββββββββββββββββββββ
+
+ describe("GET /api/workspace/suggest-files", () => {
+ it("returns suggestions when workspace exists", async () => {
+ const { resolveWorkspaceRoot } = await import("@/lib/workspace");
+ vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
+ const { existsSync: mockExists, readdirSync: mockReaddir } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReaddir).mockReturnValue([
+ makeDirent("doc.md", false),
+ ] as unknown as Dirent[]);
+
+ const { GET } = await import("./suggest-files/route.js");
+ const req = new Request("http://localhost/api/workspace/suggest-files?q=doc");
+ const res = await GET(req);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.items).toBeDefined();
+ });
+ });
+
+ // βββ GET /api/workspace/context ββββββββββββββββββββββββββββββββββ
+
+ describe("GET /api/workspace/context", () => {
+ it("returns exists=false when no workspace root", async () => {
+ const { resolveWorkspaceRoot } = await import("@/lib/workspace");
+ vi.mocked(resolveWorkspaceRoot).mockReturnValue(null);
+
+ const { GET } = await import("./context/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.exists).toBe(false);
+ });
+
+ it("returns context when workspace_context.yaml exists", async () => {
+ const { resolveWorkspaceRoot, parseSimpleYaml } = await import("@/lib/workspace");
+ vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
+ vi.mocked(parseSimpleYaml).mockReturnValue({ org_name: "Acme", org_slug: "acme" });
+ const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReadFile).mockReturnValue("org_name: Acme" as never);
+
+ const { GET } = await import("./context/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.exists).toBe(true);
+ });
+ });
+
+ // βββ GET /api/workspace/search-index βββββββββββββββββββββββββββββ
+
+ describe("GET /api/workspace/search-index", () => {
+ it("returns empty items when no workspace", async () => {
+ const { resolveWorkspaceRoot } = await import("@/lib/workspace");
+ vi.mocked(resolveWorkspaceRoot).mockReturnValue(null);
+
+ const { GET } = await import("./search-index/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.items).toEqual([]);
+ });
+
+ it("returns file items from workspace tree", async () => {
+ const { resolveWorkspaceRoot, duckdbQueryAll } = await import("@/lib/workspace");
+ vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
+ vi.mocked(duckdbQueryAll).mockReturnValue([]);
+ const { existsSync: mockExists, readdirSync: mockReaddir } = await import("node:fs");
+ vi.mocked(mockExists).mockReturnValue(true);
+ vi.mocked(mockReaddir).mockImplementation((dir) => {
+ if (String(dir) === "/ws") {
+ return [makeDirent("readme.md", false)] as unknown as Dirent[];
+ }
+ return [] as unknown as Dirent[];
+ });
+
+ const { GET } = await import("./search-index/route.js");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.items.length).toBeGreaterThanOrEqual(0);
+ });
+ });
+});
diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts
new file mode 100644
index 00000000000..c603d38b4eb
--- /dev/null
+++ b/apps/web/app/api/workspace/tree/route.ts
@@ -0,0 +1,264 @@
+import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs";
+import { join } from "node:path";
+import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export type TreeNode = {
+ name: string;
+ path: string; // relative to workspace root (or ~skills/ for virtual nodes)
+ type: "object" | "document" | "folder" | "file" | "database" | "report";
+ icon?: string;
+ defaultView?: "table" | "kanban";
+ children?: TreeNode[];
+ /** Virtual nodes live outside the main workspace (e.g. Skills, Memories). */
+ virtual?: boolean;
+ /** True when the entry is a symbolic link. */
+ symlink?: boolean;
+};
+
+type DbObject = {
+ name: string;
+ icon?: string;
+ default_view?: string;
+};
+
+/** Read .object.yaml metadata from a directory if it exists. */
+function readObjectMeta(
+ dirPath: string,
+): { icon?: string; defaultView?: string } | null {
+ const yamlPath = join(dirPath, ".object.yaml");
+ if (!existsSync(yamlPath)) {return null;}
+
+ try {
+ const content = readFileSync(yamlPath, "utf-8");
+ const parsed = parseSimpleYaml(content);
+ return {
+ icon: parsed.icon as string | undefined,
+ defaultView: parsed.default_view as string | undefined,
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Query ALL discovered DuckDB files for objects so we can identify object
+ * directories even when .object.yaml files are missing.
+ * Shallower databases win on name conflicts (parent priority).
+ */
+function loadDbObjects(): Map {
+ const map = new Map();
+ const rows = duckdbQueryAll(
+ "SELECT name, icon, default_view FROM objects",
+ "name",
+ );
+ for (const row of rows) {
+ map.set(row.name, row);
+ }
+ return map;
+}
+
+/** Resolve a dirent's effective type, following symlinks to their target. */
+function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
+ if (entry.isDirectory()) {return "directory";}
+ if (entry.isFile()) {return "file";}
+ if (entry.isSymbolicLink()) {
+ try {
+ const st = statSync(absPath);
+ if (st.isDirectory()) {return "directory";}
+ if (st.isFile()) {return "file";}
+ } catch {
+ // Broken symlink -- skip
+ }
+ }
+ return null;
+}
+
+/** Recursively build a tree from a workspace directory. */
+function buildTree(
+ absDir: string,
+ relativeBase: string,
+ dbObjects: Map,
+ showHidden = false,
+): TreeNode[] {
+ const nodes: TreeNode[] = [];
+
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(absDir, { withFileTypes: true });
+ } catch {
+ return nodes;
+ }
+
+ const filtered = entries.filter((e) => {
+ // .object.yaml is always needed for metadata; also shown as a node when showHidden is on
+ if (e.name === ".object.yaml") {return true;}
+ if (e.name.startsWith(".")) {return showHidden;}
+ return true;
+ });
+
+ // Sort: directories first, then files, alphabetical within each group
+ const sorted = filtered.toSorted((a, b) => {
+ const absA = join(absDir, a.name);
+ const absB = join(absDir, b.name);
+ const typeA = resolveEntryType(a, absA);
+ const typeB = resolveEntryType(b, absB);
+ const dirA = typeA === "directory";
+ const dirB = typeB === "directory";
+ if (dirA && !dirB) {return -1;}
+ if (!dirA && dirB) {return 1;}
+ return a.name.localeCompare(b.name);
+ });
+
+ for (const entry of sorted) {
+ // .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files
+ if (entry.name === ".object.yaml" && !showHidden) {continue;}
+
+ const absPath = join(absDir, entry.name);
+ const relPath = relativeBase
+ ? `${relativeBase}/${entry.name}`
+ : entry.name;
+
+ const isSymlink = entry.isSymbolicLink();
+ const effectiveType = resolveEntryType(entry, absPath);
+
+ if (effectiveType === "directory") {
+ const objectMeta = readObjectMeta(absPath);
+ const dbObject = dbObjects.get(entry.name);
+ const children = buildTree(absPath, relPath, dbObjects, showHidden);
+
+ if (objectMeta || dbObject) {
+ nodes.push({
+ name: entry.name,
+ path: relPath,
+ type: "object",
+ icon: objectMeta?.icon ?? dbObject?.icon,
+ defaultView:
+ ((objectMeta?.defaultView ?? dbObject?.default_view) as
+ | "table"
+ | "kanban") ?? "table",
+ children: children.length > 0 ? children : undefined,
+ ...(isSymlink && { symlink: true }),
+ });
+ } else {
+ nodes.push({
+ name: entry.name,
+ path: relPath,
+ type: "folder",
+ children: children.length > 0 ? children : undefined,
+ ...(isSymlink && { symlink: true }),
+ });
+ }
+ } else if (effectiveType === "file") {
+ const ext = entry.name.split(".").pop()?.toLowerCase();
+ const isReport = entry.name.endsWith(".report.json");
+ const isDocument = ext === "md" || ext === "mdx";
+ const isDatabase = isDatabaseFile(entry.name);
+
+ nodes.push({
+ name: entry.name,
+ path: relPath,
+ type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
+ ...(isSymlink && { symlink: true }),
+ });
+ }
+ }
+
+ return nodes;
+}
+
+// --- Virtual folder builders ---
+
+/** Parse YAML frontmatter from a SKILL.md file (lightweight). */
+function parseSkillFrontmatter(content: string): { name?: string; emoji?: string } {
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
+ if (!match) {return {};}
+ const yaml = match[1];
+ const result: Record = {};
+ for (const line of yaml.split("\n")) {
+ const kv = line.match(/^(\w+)\s*:\s*(.+)/);
+ if (kv) {result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();}
+ }
+ return { name: result.name, emoji: result.emoji };
+}
+
+/** Build a virtual "Skills" folder from /skills/. */
+function buildSkillsVirtualFolder(): TreeNode | null {
+ const stateDir = resolveOpenClawStateDir();
+ const dirs = [
+ join(stateDir, "skills"),
+ ];
+
+ const children: TreeNode[] = [];
+ const seen = new Set();
+
+ for (const dir of dirs) {
+ if (!existsSync(dir)) {continue;}
+ try {
+ const entries = readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isDirectory() || seen.has(entry.name)) {continue;}
+ const skillMdPath = join(dir, entry.name, "SKILL.md");
+ if (!existsSync(skillMdPath)) {continue;}
+
+ seen.add(entry.name);
+ let displayName = entry.name;
+ try {
+ const content = readFileSync(skillMdPath, "utf-8");
+ const meta = parseSkillFrontmatter(content);
+ if (meta.name) {displayName = meta.name;}
+ if (meta.emoji) {displayName = `${meta.emoji} ${displayName}`;}
+ } catch {
+ // skip
+ }
+
+ children.push({
+ name: displayName,
+ path: `~skills/${entry.name}/SKILL.md`,
+ type: "document",
+ virtual: true,
+ });
+ }
+ } catch {
+ // dir unreadable
+ }
+ }
+
+ if (children.length === 0) {return null;}
+ children.sort((a, b) => a.name.localeCompare(b.name));
+
+ return {
+ name: "Skills",
+ path: "~skills",
+ type: "folder",
+ virtual: true,
+ children,
+ };
+}
+
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const showHidden = url.searchParams.get("showHidden") === "1";
+
+ const openclawDir = resolveOpenClawStateDir();
+ const profile = getEffectiveProfile();
+ const root = resolveWorkspaceRoot();
+ if (!root) {
+ const tree: TreeNode[] = [];
+ const skillsFolder = buildSkillsVirtualFolder();
+ if (skillsFolder) {tree.push(skillsFolder);}
+ return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile });
+ }
+
+ const dbObjects = loadDbObjects();
+
+ const tree = buildTree(root, "", dbObjects, showHidden);
+
+ const skillsFolder = buildSkillsVirtualFolder();
+ if (skillsFolder) {tree.push(skillsFolder);}
+
+ return Response.json({ tree, exists: true, workspaceRoot: root, openclawDir, profile });
+}
diff --git a/apps/web/app/api/workspace/upload/route.ts b/apps/web/app/api/workspace/upload/route.ts
new file mode 100644
index 00000000000..04909bf2e62
--- /dev/null
+++ b/apps/web/app/api/workspace/upload/route.ts
@@ -0,0 +1,73 @@
+import { writeFileSync, mkdirSync } from "node:fs";
+import { join, dirname } from "node:path";
+import { resolveWorkspaceRoot, safeResolveNewPath } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+const MAX_SIZE = 25 * 1024 * 1024; // 25 MB
+
+/**
+ * POST /api/workspace/upload
+ * Accepts multipart form data with a "file" field.
+ * Saves to assets/- inside the workspace.
+ * Returns { ok, path } where path is workspace-relative.
+ */
+export async function POST(req: Request) {
+ const root = resolveWorkspaceRoot();
+ if (!root) {
+ return Response.json(
+ { error: "Workspace not found" },
+ { status: 500 },
+ );
+ }
+
+ let formData: FormData;
+ try {
+ formData = await req.formData();
+ } catch {
+ return Response.json({ error: "Invalid form data" }, { status: 400 });
+ }
+
+ const file = formData.get("file");
+ if (!file || !(file instanceof File)) {
+ return Response.json(
+ { error: "Missing 'file' field" },
+ { status: 400 },
+ );
+ }
+
+ // Validate size
+ if (file.size > MAX_SIZE) {
+ return Response.json(
+ { error: "File is too large (max 25 MB)" },
+ { status: 400 },
+ );
+ }
+
+ // Build a safe filename: timestamp + sanitized original name
+ const safeName = file.name
+ .replace(/[^a-zA-Z0-9._-]/g, "_")
+ .replace(/_{2,}/g, "_");
+ const relPath = join("assets", `${Date.now()}-${safeName}`);
+
+ const absPath = safeResolveNewPath(relPath);
+ if (!absPath) {
+ return Response.json(
+ { error: "Invalid path" },
+ { status: 400 },
+ );
+ }
+
+ try {
+ mkdirSync(dirname(absPath), { recursive: true });
+ const buffer = Buffer.from(await file.arrayBuffer());
+ writeFileSync(absPath, buffer);
+ return Response.json({ ok: true, path: relPath });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Upload failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/virtual-file/route.ts b/apps/web/app/api/workspace/virtual-file/route.ts
new file mode 100644
index 00000000000..33b17f0ae6c
--- /dev/null
+++ b/apps/web/app/api/workspace/virtual-file/route.ts
@@ -0,0 +1,170 @@
+import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
+import { join, dirname, resolve, normalize } from "node:path";
+import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * Resolve a virtual path (~skills/... or ~memories/...) to an absolute filesystem path.
+ * Returns null if the path is invalid or tries to escape.
+ */
+function resolveVirtualPath(virtualPath: string): string | null {
+ const stateDir = resolveOpenClawStateDir();
+ const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
+
+ if (virtualPath.startsWith("~skills/")) {
+ // ~skills//SKILL.md
+ const rest = virtualPath.slice("~skills/".length);
+ // Validate: must be /SKILL.md
+ const parts = rest.split("/");
+ if (parts.length !== 2 || parts[1] !== "SKILL.md" || !parts[0]) {
+ return null;
+ }
+ const skillName = parts[0];
+ // Prevent path traversal
+ if (skillName.includes("..") || skillName.includes("/")) {
+ return null;
+ }
+
+ // Check workspace skills first, then managed skills
+ const candidates = [
+ join(workspaceDir, "skills", skillName, "SKILL.md"),
+ join(stateDir, "skills", skillName, "SKILL.md"),
+ ];
+ for (const candidate of candidates) {
+ if (existsSync(candidate)) {
+ return candidate;
+ }
+ }
+ // Default to workspace skills dir for new files
+ return candidates[0];
+ }
+
+ if (virtualPath.startsWith("~memories/")) {
+ const rest = virtualPath.slice("~memories/".length);
+ // Prevent path traversal
+ if (rest.includes("..") || rest.includes("/")) {
+ return null;
+ }
+
+ if (rest === "MEMORY.md") {
+ // Check both casing
+ for (const filename of ["MEMORY.md", "memory.md"]) {
+ const candidate = join(workspaceDir, filename);
+ if (existsSync(candidate)) {
+ return candidate;
+ }
+ }
+ // Default to MEMORY.md for new files
+ return join(workspaceDir, "MEMORY.md");
+ }
+
+ // Daily log: must be a .md file in the memory/ subdirectory
+ if (!rest.endsWith(".md")) {
+ return null;
+ }
+ return join(workspaceDir, "memory", rest);
+ }
+
+ if (virtualPath.startsWith("~workspace/")) {
+ const rest = virtualPath.slice("~workspace/".length);
+ // Only allow direct filenames (no subdirectories, no traversal)
+ if (!rest || rest.includes("..") || rest.includes("/")) {
+ return null;
+ }
+ return join(workspaceDir, rest);
+ }
+
+ return null;
+}
+
+/**
+ * Double-check that the resolved path stays within expected directories.
+ */
+function isSafePath(absPath: string): boolean {
+ const stateDir = resolveOpenClawStateDir();
+ const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
+ const normalized = normalize(resolve(absPath));
+ const allowed = [
+ normalize(join(stateDir, "skills")),
+ normalize(join(workspaceDir, "skills")),
+ normalize(workspaceDir),
+ ];
+ return allowed.some((dir) => normalized.startsWith(dir));
+}
+
+/** Extensions recognized as code files for syntax-highlighted viewing. */
+const VIRTUAL_CODE_EXTENSIONS = new Set([
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs",
+ "java", "kt", "swift", "c", "cpp", "h", "hpp", "cs", "css", "scss",
+ "less", "html", "htm", "xml", "json", "jsonc", "toml", "sh", "bash",
+ "zsh", "fish", "ps1", "sql", "graphql", "gql", "diff", "patch",
+ "ini", "env", "tf", "proto", "zig", "lua", "php",
+]);
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path");
+
+ if (!path) {
+ return Response.json({ error: "Missing 'path' query parameter" }, { status: 400 });
+ }
+
+ const absPath = resolveVirtualPath(path);
+ if (!absPath || !isSafePath(absPath)) {
+ return Response.json({ error: "Invalid virtual path" }, { status: 400 });
+ }
+
+ if (!existsSync(absPath)) {
+ return Response.json({ error: "File not found" }, { status: 404 });
+ }
+
+ try {
+ const content = readFileSync(absPath, "utf-8");
+ const ext = absPath.split(".").pop()?.toLowerCase();
+ let type: "markdown" | "yaml" | "code" | "text" = "text";
+ if (ext === "md" || ext === "mdx") {type = "markdown";}
+ else if (ext === "yaml" || ext === "yml") {type = "yaml";}
+ else if (VIRTUAL_CODE_EXTENSIONS.has(ext ?? "")) {type = "code";}
+ return Response.json({ content, type });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Read failed" },
+ { status: 500 },
+ );
+ }
+}
+
+export async function POST(req: Request) {
+ let body: { path?: string; content?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { path: virtualPath, content } = body;
+ if (!virtualPath || typeof virtualPath !== "string" || typeof content !== "string") {
+ return Response.json(
+ { error: "Missing 'path' and 'content' fields" },
+ { status: 400 },
+ );
+ }
+
+ const absPath = resolveVirtualPath(virtualPath);
+ if (!absPath || !isSafePath(absPath)) {
+ return Response.json({ error: "Invalid virtual path" }, { status: 400 });
+ }
+
+ try {
+ mkdirSync(dirname(absPath), { recursive: true });
+ writeFileSync(absPath, content, "utf-8");
+ return Response.json({ ok: true, path: virtualPath });
+ } catch (err) {
+ return Response.json(
+ { error: err instanceof Error ? err.message : "Write failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/workspace/watch/route.ts b/apps/web/app/api/workspace/watch/route.ts
new file mode 100644
index 00000000000..9bb11bb50ef
--- /dev/null
+++ b/apps/web/app/api/workspace/watch/route.ts
@@ -0,0 +1,148 @@
+import { resolveWorkspaceRoot } from "@/lib/workspace";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+// ---------------------------------------------------------------------------
+// Singleton watcher: one chokidar instance shared across all SSE connections.
+// Uses polling (no native fs.watch FDs) so it doesn't compete with Next.js's
+// own dev watcher for the macOS per-process file-descriptor limit.
+// ---------------------------------------------------------------------------
+
+type Listener = (type: string, relPath: string) => void;
+
+let listeners = new Set();
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let sharedWatcher: any = null;
+let sharedRoot: string | null = null;
+let _watcherReady = false;
+
+async function ensureWatcher(root: string) {
+ if (sharedWatcher && sharedRoot === root) {return;}
+
+ // Root changed (e.g. profile switch) -- close the old watcher first.
+ if (sharedWatcher) {
+ await sharedWatcher.close();
+ sharedWatcher = null;
+ sharedRoot = null;
+ _watcherReady = false;
+ }
+
+ try {
+ const chokidar = await import("chokidar");
+ sharedRoot = root;
+ sharedWatcher = chokidar.watch(root, {
+ ignoreInitial: true,
+ usePolling: true,
+ interval: 1500,
+ binaryInterval: 3000,
+ ignored: [
+ /(^|[\\/])node_modules([\\/]|$)/,
+ /(^|[\\/])\.git([\\/]|$)/,
+ /(^|[\\/])\.next([\\/]|$)/,
+ /(^|[\\/])dist([\\/]|$)/,
+ /\.duckdb\.wal$/,
+ /\.duckdb\.tmp$/,
+ ],
+ depth: 5,
+ });
+
+ sharedWatcher.on("all", (eventType: string, filePath: string) => {
+ const rel = filePath.startsWith(root)
+ ? filePath.slice(root.length + 1)
+ : filePath;
+ for (const fn of listeners) {fn(eventType, rel);}
+ });
+
+ sharedWatcher.once("ready", () => {_watcherReady = true;});
+
+ sharedWatcher.on("error", () => {
+ // Swallow; polling mode shouldn't hit EMFILE but be safe.
+ });
+ } catch {
+ // chokidar unavailable -- listeners simply won't fire.
+ }
+}
+
+function stopWatcherIfIdle() {
+ if (listeners.size > 0 || !sharedWatcher) {return;}
+ sharedWatcher.close();
+ sharedWatcher = null;
+ sharedRoot = null;
+ _watcherReady = false;
+}
+
+/**
+ * GET /api/workspace/watch
+ *
+ * Server-Sent Events endpoint that watches the workspace for file changes.
+ * Falls back gracefully if chokidar is unavailable.
+ */
+export async function GET(req: Request) {
+ const root = resolveWorkspaceRoot();
+ if (!root) {
+ return new Response("Workspace not found", { status: 404 });
+ }
+
+ const encoder = new TextEncoder();
+ let closed = false;
+ let heartbeat: ReturnType | null = null;
+ let debounceTimer: ReturnType | null = null;
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ controller.enqueue(encoder.encode("event: connected\ndata: {}\n\n"));
+
+ const listener: Listener = (_type, _rel) => {
+ if (closed) {return;}
+ if (debounceTimer) {clearTimeout(debounceTimer);}
+ debounceTimer = setTimeout(() => {
+ if (closed) {return;}
+ try {
+ const data = JSON.stringify({ type: _type, path: _rel });
+ controller.enqueue(encoder.encode(`event: change\ndata: ${data}\n\n`));
+ } catch { /* stream closed */ }
+ }, 300);
+ };
+
+ heartbeat = setInterval(() => {
+ if (closed) {return;}
+ try {
+ controller.enqueue(encoder.encode(": heartbeat\n\n"));
+ } catch { /* closed */ }
+ }, 30_000);
+
+ function teardown() {
+ if (closed) {return;}
+ closed = true;
+ listeners.delete(listener);
+ if (heartbeat) {clearInterval(heartbeat);}
+ if (debounceTimer) {clearTimeout(debounceTimer);}
+ stopWatcherIfIdle();
+ }
+
+ req.signal.addEventListener("abort", teardown, { once: true });
+
+ listeners.add(listener);
+ await ensureWatcher(root);
+
+ if (!sharedWatcher) {
+ controller.enqueue(
+ encoder.encode("event: error\ndata: {\"error\":\"File watching unavailable\"}\n\n"),
+ );
+ }
+ },
+ cancel() {
+ closed = true;
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ });
+}
diff --git a/apps/web/app/components/chain-of-thought.tsx b/apps/web/app/components/chain-of-thought.tsx
new file mode 100644
index 00000000000..5f7fa9d32fe
--- /dev/null
+++ b/apps/web/app/components/chain-of-thought.tsx
@@ -0,0 +1,1726 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { DiffCard } from "./diff-viewer";
+
+/* βββ Diff synthesis from edit tool args βββ */
+
+/**
+ * Build a unified diff string from old_string/new_string pairs.
+ * This provides a visual diff even when the tool result doesn't include one.
+ */
+function buildSyntheticDiff(filePath: string, oldStr: string, newStr: string): string {
+ const oldLines = oldStr.split("\n");
+ const newLines = newStr.split("\n");
+ const lines: string[] = [
+ `--- a/${filePath}`,
+ `+++ b/${filePath}`,
+ `@@ -1,${oldLines.length} +1,${newLines.length} @@`,
+ ];
+ for (const line of oldLines) {
+ lines.push(`-${line}`);
+ }
+ for (const line of newLines) {
+ lines.push(`+${line}`);
+ }
+ return lines.join("\n");
+}
+
+/* βββ Public types βββ */
+
+export type ChainPart =
+ | { kind: "reasoning"; text: string; isStreaming: boolean }
+ | {
+ kind: "tool";
+ toolName: string;
+ toolCallId: string;
+ status: "running" | "done" | "error";
+ args?: Record;
+ output?: Record;
+ errorText?: string;
+ }
+;
+
+/* βββ Media / file type helpers βββ */
+
+const IMAGE_EXTS = new Set([
+ "jpg",
+ "jpeg",
+ "png",
+ "gif",
+ "webp",
+ "svg",
+ "bmp",
+ "avif",
+ "heic",
+ "heif",
+ "tiff",
+ "tif",
+ "ico",
+]);
+const VIDEO_EXTS = new Set([
+ "mp4",
+ "webm",
+ "mov",
+ "avi",
+ "mkv",
+]);
+const PDF_EXTS = new Set(["pdf"]);
+const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "m4a"]);
+
+type MediaKind = "image" | "video" | "pdf" | "audio" | null;
+
+function getFileExt(path: string): string {
+ return (path.split(".").pop() ?? "").toLowerCase();
+}
+
+function detectMedia(path: string): MediaKind {
+ const ext = getFileExt(path);
+ if (IMAGE_EXTS.has(ext)) {return "image";}
+ if (VIDEO_EXTS.has(ext)) {return "video";}
+ if (PDF_EXTS.has(ext)) {return "pdf";}
+ if (AUDIO_EXTS.has(ext)) {return "audio";}
+ return null;
+}
+
+function rawFileUrl(path: string): string {
+ return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
+}
+
+/** Resolve a media URL β use raw URL directly if it's already HTTP */
+function resolveMediaUrl(path: string): string {
+ if (path.startsWith("http://") || path.startsWith("https://")) {
+ return path;
+ }
+ return rawFileUrl(path);
+}
+
+/** Regex to find file paths with media extensions in free text */
+const MEDIA_FILE_RE =
+ /(?:^|[\s"'(=])(((?:\/|\.\/)?[\w.\-/\\]+)\.(?:jpe?g|png|gif|webp|svg|bmp|avif|heic|heif|tiff?|ico|mp4|webm|mov|avi|mkv|mp3|wav|ogg|m4a|pdf))\b/i;
+
+const PATH_KEYS = [
+ "path",
+ "file",
+ "file_path",
+ "filePath",
+ "filename",
+ "url",
+ "src",
+ "name",
+ "target",
+];
+
+/**
+ * Extract the file path from tool args and/or output.
+ * Searches standard keys, then all string values, then output text.
+ */
+function getFilePath(
+ args?: Record,
+ output?: Record,
+): string | null {
+ // 1. Check standard keys in args
+ if (args) {
+ for (const key of PATH_KEYS) {
+ const v = args[key];
+ if (typeof v === "string" && v.length > 0) {return v;}
+ }
+ }
+
+ // 2. Check standard keys in output
+ if (output) {
+ for (const key of PATH_KEYS) {
+ const v = output[key];
+ if (typeof v === "string" && v.length > 0 && looksLikePath(v))
+ {return v;}
+ }
+ }
+
+ // 3. Scan all string values in args for file-like paths
+ if (args) {
+ const found = findPathInValues(args);
+ if (found) {return found;}
+ }
+
+ // 4. Extract from output text
+ if (output?.text && typeof output.text === "string") {
+ const m = output.text.match(MEDIA_FILE_RE);
+ if (m) {return m[1];}
+ }
+
+ // 5. Scan output values too
+ if (output) {
+ const found = findPathInValues(output);
+ if (found) {return found;}
+ }
+
+ return null;
+}
+
+/** Check if a string looks like a file path (has an extension, no spaces) */
+function looksLikePath(s: string): boolean {
+ return (
+ s.length > 2 &&
+ s.length < 500 &&
+ /\.\w{1,5}$/.test(s) &&
+ !s.includes(" ")
+ );
+}
+
+/** Search all string values in an object for a path-like string */
+function findPathInValues(obj: Record): string | null {
+ for (const val of Object.values(obj)) {
+ if (typeof val === "string" && looksLikePath(val)) {
+ return val;
+ }
+ }
+ return null;
+}
+
+/* βββ Domain / URL extraction helpers βββ */
+
+const URL_RE = /https?:\/\/[^\s"'<>,;)}\]]+/gi;
+
+function extractDomains(text: string): string[] {
+ const urls = text.match(URL_RE) ?? [];
+ const domains = new Set();
+ for (const url of urls) {
+ try {
+ const hostname = new URL(url).hostname;
+ if (hostname && !hostname.includes("localhost")) {
+ domains.add(hostname);
+ }
+ } catch {
+ /* skip */
+ }
+ }
+ return [...domains].slice(0, 8);
+}
+
+function faviconUrl(domain: string): string {
+ return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
+}
+
+/* βββ Format tool args for display βββ */
+
+/** Render tool arguments as a compact readable string. */
+function formatArgs(args: Record): string {
+ const lines: string[] = [];
+ for (const [key, value] of Object.entries(args)) {
+ if (value === undefined || value === null) {continue;}
+ const str =
+ typeof value === "string"
+ ? value
+ : JSON.stringify(value, null, 2);
+ lines.push(`${key}: ${str}`);
+ }
+ const joined = lines.join("\n");
+ return joined.length > 2000 ? joined.slice(0, 2000) + "\n..." : joined;
+}
+
+/* βββ Classify tool steps βββ */
+
+type StepKind =
+ | "search"
+ | "fetch"
+ | "read"
+ | "exec"
+ | "write"
+ | "image"
+ | "generic";
+
+function classifyTool(
+ name: string,
+ args?: Record,
+): StepKind {
+ const n = name.toLowerCase().replace(/[_-]/g, "");
+ if (
+ [
+ "websearch",
+ "search",
+ "googlesearch",
+ "bingsearch",
+ "browsersearch",
+ "tavily",
+ ].some((k) => n.includes(k))
+ )
+ {return "search";}
+
+ // Browser tool β classify based on the action being performed
+ if (n === "browser") {
+ const action =
+ typeof args?.action === "string"
+ ? args.action.toLowerCase()
+ : "";
+ if (action === "open" || action === "navigate") {return "fetch";}
+ if (action === "screenshot") {return "image";}
+ return "generic"; // act/snapshot/status etc. have no URL
+ }
+
+ if (
+ ["fetchurl", "fetch", "browse", "browseurl", "webfetch"].some(
+ (k) => n.includes(k),
+ )
+ )
+ {return "fetch";}
+ if (
+ ["read", "file", "readfile", "getfile"].some(
+ (k) => n.includes(k),
+ )
+ )
+ {return "read";}
+ if (
+ [
+ "bash",
+ "shell",
+ "execute",
+ "exec",
+ "terminal",
+ "command",
+ "run",
+ ].some((k) => n.includes(k))
+ )
+ {return "exec";}
+ if (
+ [
+ "write",
+ "create",
+ "edit",
+ "str_replace",
+ "save",
+ "patch",
+ ].some((k) => n.includes(k))
+ )
+ {return "write";}
+ if (
+ [
+ "image",
+ "screenshot",
+ "photo",
+ "picture",
+ "dalle",
+ "generateimage",
+ ].some((k) => n.includes(k))
+ )
+ {return "image";}
+ return "generic";
+}
+
+function buildStepLabel(
+ kind: StepKind,
+ toolName: string,
+ args?: Record,
+ output?: Record,
+): string {
+ const strVal = (key: string) => {
+ const v = args?.[key];
+ return typeof v === "string" && v.length > 0 ? v : null;
+ };
+
+ switch (kind) {
+ case "search": {
+ const q =
+ strVal("query") ??
+ strVal("search_query") ??
+ strVal("search") ??
+ strVal("q");
+ return q ? `Searching for ${q}` : "Searching...";
+ }
+ case "fetch": {
+ const u =
+ strVal("url") ??
+ strVal("targetUrl") ??
+ strVal("path") ??
+ strVal("src");
+ if (u) {
+ try {
+ return `Fetching ${new URL(u).hostname}`;
+ } catch {
+ return `Fetching ${u}`;
+ }
+ }
+ // Fallback: check output for the URL (web_fetch results include url/finalUrl)
+ const outUrl =
+ (typeof output?.finalUrl === "string" && output.finalUrl) ||
+ (typeof output?.url === "string" && output.url);
+ if (outUrl) {
+ try {
+ return `Fetched ${new URL(outUrl).hostname}`;
+ } catch {
+ return `Fetched ${outUrl}`;
+ }
+ }
+ return "Fetching page";
+ }
+ case "read": {
+ const p = getFilePath(args, output);
+ if (p) {
+ const short = p.split("/").pop() ?? p;
+ return short.startsWith("http")
+ ? `Fetching ${short}`
+ : `Reading ${short}`;
+ }
+ return "Reading file";
+ }
+ case "exec": {
+ const cmd = strVal("command") ?? strVal("cmd");
+ if (cmd) {return `Running: ${cmd}`;}
+ return "Running command";
+ }
+ case "write": {
+ const p = strVal("path") ?? strVal("file") ?? strVal("file_path");
+ if (p) {
+ const short = p.split("/").pop() ?? p;
+ return `Editing ${short}`;
+ }
+ return "Editing file";
+ }
+ case "image":
+ return strVal("description")
+ ? `Generating image: ${strVal("description")!}`
+ : "Generating image";
+ default: {
+ // For generic/unknown tools, build a descriptive label from args
+ const name = toolName
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase())
+ .trim();
+ if (args) {
+ // Try common arg patterns for a meaningful summary
+ const desc =
+ strVal("command") ??
+ strVal("cmd") ??
+ strVal("query") ??
+ strVal("path") ??
+ strVal("url") ??
+ strVal("message") ??
+ strVal("description") ??
+ strVal("input") ??
+ strVal("text");
+ if (desc) {return `${name}: ${desc}`;}
+ }
+ return name || "Tool";
+ }
+ }
+}
+
+/** Extract domains from tool output for search steps */
+function getSearchDomains(
+ output?: Record,
+): string[] {
+ if (!output) {return [];}
+ const text = typeof output.text === "string" ? output.text : "";
+ const results = output.results;
+ const citations = output.citations;
+ let combined = text;
+ if (Array.isArray(results)) {
+ for (const r of results) {
+ if (typeof r === "string") {
+ combined += ` ${r}`;
+ } else if (typeof r === "object" && r !== null) {
+ const obj = r as Record;
+ if (typeof obj.url === "string")
+ {combined += ` ${obj.url}`;}
+ if (typeof obj.link === "string")
+ {combined += ` ${obj.link}`;}
+ }
+ }
+ }
+ if (Array.isArray(citations)) {
+ for (const c of citations) {
+ // Citations can be plain URL strings or objects with a url field
+ if (typeof c === "string") {
+ combined += ` ${c}`;
+ } else if (typeof c === "object" && c !== null) {
+ const obj = c as Record;
+ if (typeof obj.url === "string")
+ {combined += ` ${obj.url}`;}
+ }
+ }
+ }
+ // Scan all remaining string values in the output for URLs we may have missed
+ for (const val of Object.values(output)) {
+ if (typeof val === "string" && val !== text && val.includes("http")) {
+ combined += ` ${val}`;
+ }
+ }
+ return extractDomains(combined);
+}
+
+/** Extract domain(s) from fetch/browser tool args and/or output */
+function getFetchDomains(
+ args?: Record,
+ output?: Record,
+): string[] {
+ const domains = new Set();
+ // Check args for URL (web_fetch uses "url", browser tool uses "targetUrl")
+ for (const key of ["url", "targetUrl", "path", "src"]) {
+ const v = args?.[key];
+ if (typeof v === "string" && v.startsWith("http")) {
+ try {
+ const hostname = new URL(v).hostname;
+ if (hostname && !hostname.includes("localhost")) {
+ domains.add(hostname);
+ }
+ } catch {
+ /* skip */
+ }
+ }
+ }
+ // Check output for URL / finalUrl
+ for (const key of ["url", "finalUrl", "targetUrl"]) {
+ const v = output?.[key];
+ if (typeof v === "string" && v.startsWith("http")) {
+ try {
+ const hostname = new URL(v).hostname;
+ if (hostname && !hostname.includes("localhost")) {
+ domains.add(hostname);
+ }
+ } catch {
+ /* skip */
+ }
+ }
+ }
+ return [...domains].slice(0, 4);
+}
+
+/* βββ Group consecutive media reads βββ */
+
+type ToolPart = Extract;
+
+type VisualItem =
+ | { type: "tool"; tool: ToolPart }
+ | {
+ type: "media-group";
+ mediaKind: "image" | "video" | "pdf" | "audio";
+ items: Array<{ path: string; tool: ToolPart }>;
+ }
+ | {
+ type: "fetch-group";
+ items: ToolPart[];
+ };
+
+function groupToolSteps(tools: ToolPart[]): VisualItem[] {
+ const result: VisualItem[] = [];
+ let i = 0;
+ while (i < tools.length) {
+ const tool = tools[i];
+ const kind = classifyTool(tool.toolName, tool.args);
+ // Check both args AND output for the file path
+ const filePath = getFilePath(tool.args, tool.output);
+ const media = filePath ? detectMedia(filePath) : null;
+
+ // If this is a media read, look for consecutive media reads of the same kind
+ if (kind === "read" && media && filePath) {
+ const group: Array<{ path: string; tool: ToolPart }> = [
+ { path: filePath, tool },
+ ];
+ let j = i + 1;
+ while (j < tools.length) {
+ const next = tools[j];
+ const nextKind = classifyTool(next.toolName, next.args);
+ const nextPath = getFilePath(next.args, next.output);
+ const nextMedia = nextPath ? detectMedia(nextPath) : null;
+ if (nextKind === "read" && nextMedia === media && nextPath) {
+ group.push({ path: nextPath, tool: next });
+ j++;
+ } else {
+ break;
+ }
+ }
+ result.push({
+ type: "media-group",
+ mediaKind: media,
+ items: group,
+ });
+ i = j;
+ } else if (kind === "fetch") {
+ // Group consecutive fetch tools into a single compact card
+ const group: ToolPart[] = [tool];
+ let j = i + 1;
+ while (j < tools.length) {
+ const next = tools[j];
+ const nextKind = classifyTool(next.toolName, next.args);
+ if (nextKind === "fetch") {
+ group.push(next);
+ j++;
+ } else {
+ break;
+ }
+ }
+ if (group.length > 1) {
+ result.push({ type: "fetch-group", items: group });
+ } else {
+ result.push({ type: "tool", tool });
+ }
+ i = j;
+ } else {
+ result.push({ type: "tool", tool });
+ i++;
+ }
+ }
+ return result;
+}
+
+/* βββ Main component βββ */
+
+export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isStreaming?: boolean }) {
+ const [isOpen, setIsOpen] = useState(!!isStreaming);
+
+ const isActive = parts.some(
+ (p) =>
+ (p.kind === "reasoning" && p.isStreaming) ||
+ (p.kind === "tool" && p.status === "running"),
+ );
+
+ /* βββ Live elapsed-time tracking βββ */
+ const startRef = useRef(null);
+ const [elapsed, setElapsed] = useState(0);
+
+ useEffect(() => {
+ if (isActive && startRef.current === null) {
+ startRef.current = Date.now();
+ }
+ }, [isActive]);
+
+ useEffect(() => {
+ if (!isActive) {return;}
+ const tick = () => {
+ if (startRef.current !== null) {
+ setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
+ }
+ };
+ tick();
+ const id = setInterval(tick, 1000);
+ return () => clearInterval(id);
+ }, [isActive]);
+
+ const formatDuration = useCallback((s: number) => {
+ if (s < 60) {return `${s}s`;}
+ const m = Math.floor(s / 60);
+ const rem = s % 60;
+ return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
+ }, []);
+
+ // Collapse only when the parent stream truly ends β not on intermediate
+ // isActive flickers (e.g. gap between reasoning end and tool start).
+ const wasStreamingRef = useRef(false);
+ useEffect(() => {
+ if (isStreaming) {
+ wasStreamingRef.current = true;
+ } else if (wasStreamingRef.current && parts.length > 0) {
+ wasStreamingRef.current = false;
+ setIsOpen(false);
+ }
+ }, [isStreaming, parts.length]);
+
+ const reasoningText = parts
+ .filter(
+ (p): p is Extract =>
+ p.kind === "reasoning",
+ )
+ .map((p) => p.text)
+ .join("");
+ const isReasoningStreaming = parts.some(
+ (p) => p.kind === "reasoning" && p.isStreaming,
+ );
+
+ const tools = parts.filter(
+ (p): p is ToolPart => p.kind === "tool",
+ );
+ const visualItems = groupToolSteps(tools);
+
+ const headerLabel = isActive
+ ? elapsed > 0
+ ? `Thinking for ${formatDuration(elapsed)}`
+ : "Thinking..."
+ : elapsed > 0
+ ? `Thought for ${formatDuration(elapsed)}`
+ : "Thought";
+
+ return (
+
+ {/* Header trigger */}
+
+
+ {/* Collapsible content */}
+
+ {isOpen && (
+
+
+ {/* Timeline connector line */}
+
+
+ {reasoningText && (
+
+
+
+
+
+
+
+
+ )}
+ {visualItems.map((item, idx) => {
+ if (item.type === "media-group") {
+ return (
+
+
+
+ );
+ }
+ if (item.type === "fetch-group") {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+ );
+}
+
+/* βββ Reasoning block βββ */
+
+function ReasoningBlock({
+ text,
+ isStreaming: _isStreaming,
+}: {
+ text: string;
+ isStreaming: boolean;
+}) {
+ return (
+
+
+ {text}
+
+
+ );
+}
+
+/* βββ Fetch group (consecutive web fetches in one compact card) βββ */
+
+function FetchGroup({ items }: { items: ToolPart[] }) {
+ const anyRunning = items.some((t) => t.status === "running");
+ const doneCount = items.filter((t) => t.status === "done").length;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {anyRunning
+ ? `Fetching ${items.length} sources...`
+ : `Fetched ${items.length} sources`}
+
+ {!anyRunning && (
+
+ {doneCount} {doneCount === 1 ? "result" : "results"}
+
+ )}
+
+
+ {items.map((tool) => {
+ const { domain, url } = getFetchDomainAndUrl(tool.args, tool.output);
+ return (
+ { e.currentTarget.style.background = "var(--color-surface-hover)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
+ onClick={url ? undefined : (e) => e.preventDefault()}
+ >
+ {domain ? (
+ /* eslint-disable-next-line @next/next/no-img-element */
+
+ ) : (
+
+ )}
+
+ {domain?.replace(/^www\./, "") ?? url ?? "Fetching..."}
+
+ {tool.status === "running" ? (
+
+ ) : url ? (
+
+ {url}
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ );
+}
+
+/** Extract domain and full URL from fetch tool args/output */
+function getFetchDomainAndUrl(
+ args?: Record,
+ output?: Record,
+): { domain: string | null; url: string | null } {
+ for (const key of ["url", "targetUrl", "path", "src"]) {
+ const v = args?.[key];
+ if (typeof v === "string" && v.startsWith("http")) {
+ try {
+ return { domain: new URL(v).hostname, url: v };
+ } catch {
+ /* skip */
+ }
+ }
+ }
+ for (const key of ["url", "finalUrl", "targetUrl"]) {
+ const v = output?.[key];
+ if (typeof v === "string" && v.startsWith("http")) {
+ try {
+ return { domain: new URL(v).hostname, url: v };
+ } catch {
+ /* skip */
+ }
+ }
+ }
+ return { domain: null, url: null };
+}
+
+/* βββ Media group (images, videos, PDFs, audio) βββ */
+
+function MediaGroup({
+ mediaKind,
+ items,
+}: {
+ mediaKind: "image" | "video" | "pdf" | "audio";
+ items: Array<{ path: string; tool: ToolPart }>;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const anyRunning = items.some(
+ (i) => i.tool.status === "running",
+ );
+
+ // Show completed items progressively β don't wait for allDone
+ const completedItems = items.filter(
+ (i) => i.tool.status === "done",
+ );
+ const doneCount = completedItems.length;
+
+ const label = anyRunning
+ ? `Reading ${items.length} ${mediaKind}${items.length > 1 ? "s" : ""}...`
+ : mediaKind === "image"
+ ? items.length === 1
+ ? `Read 1 image`
+ : `Read ${items.length} images`
+ : mediaKind === "video"
+ ? items.length === 1
+ ? `Read 1 video`
+ : `Read ${items.length} videos`
+ : mediaKind === "pdf"
+ ? items.length === 1
+ ? `Read 1 PDF`
+ : `Read ${items.length} PDFs`
+ : items.length === 1
+ ? `Read 1 audio file`
+ : `Read ${items.length} audio files`;
+
+ // Show up to 6 thumbnails by default, expandable
+ const PREVIEW_COUNT = 6;
+ const displayItems = expanded
+ ? completedItems
+ : completedItems.slice(0, PREVIEW_COUNT);
+ const hasMore =
+ completedItems.length > PREVIEW_COUNT && !expanded;
+
+ return (
+
+
+
+
+
+
+
+
+ {label}
+
+
+ {/* Image thumbnail grid β show progressively as items complete */}
+ {doneCount > 0 && mediaKind === "image" && (
+
+ {displayItems.map((item) => (
+
+ ))}
+ {anyRunning && (
+
+
+
+
+
+ )}
+ {hasMore && (
+
+ )}
+
+ )}
+
+ {/* Video inline */}
+ {doneCount > 0 && mediaKind === "video" && (
+
+ {displayItems.map((item) => (
+
+ ))}
+
+ )}
+
+ {/* PDF links */}
+ {doneCount > 0 && mediaKind === "pdf" && (
+
+ {displayItems.map((item) => {
+ const filename =
+ item.path.split("/").pop() ??
+ item.path;
+ return (
+
+
+
+ {filename}
+
+
+ );
+ })}
+
+ )}
+
+ {/* Audio inline */}
+ {doneCount > 0 && mediaKind === "audio" && (
+
+ {displayItems.map((item) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+/** Image thumbnail with error fallback */
+function MediaThumb({
+ path,
+ single,
+}: {
+ path: string;
+ single: boolean;
+}) {
+ const [error, setError] = useState(false);
+ const filename = path.split("/").pop() ?? path;
+ const url = resolveMediaUrl(path);
+ const w = single ? 200 : 80;
+ const h = single ? 150 : 80;
+
+ if (error) {
+ return (
+
+ {filename}
+
+ );
+ }
+
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
setError(true)}
+ />
+
+ );
+}
+
+/* βββ Tool step (non-media) βββ */
+
+function ToolStep({
+ toolName,
+ status,
+ args,
+ output,
+ errorText,
+}: {
+ toolName: string;
+ status: "running" | "done" | "error";
+ args?: Record;
+ output?: Record;
+ errorText?: string;
+}) {
+ const kind = classifyTool(toolName, args);
+ // Show output by default for exec/command tools β these are the most
+ // useful to see inline. Other tools default to collapsed.
+ const [showOutput, setShowOutput] = useState(kind === "exec" || kind === "generic");
+ // Auto-expand diffs for write tool steps
+ const [showDiff, setShowDiff] = useState(true);
+ const label = buildStepLabel(kind, toolName, args, output);
+ const domains =
+ kind === "search"
+ ? getSearchDomains(output)
+ : kind === "fetch"
+ ? getFetchDomains(args, output)
+ : [];
+ const outputText =
+ typeof output?.text === "string" ? output.text : undefined;
+
+ // Detect diff data from edit/write tool results.
+ // Priority: output.diff (from edit tool), then synthesize from args.
+ const diffText = (() => {
+ if (kind !== "write" || status !== "done") {return undefined;}
+ // 1. Direct diff from tool result (edit tool returns this)
+ if (typeof output?.diff === "string") {return output.diff;}
+ // 2. Synthesize from edit args (old_string/new_string or oldText/newText)
+ const oldStr =
+ typeof args?.old_string === "string" ? args.old_string :
+ typeof args?.oldText === "string" ? args.oldText : null;
+ const newStr =
+ typeof args?.new_string === "string" ? args.new_string :
+ typeof args?.newText === "string" ? args.newText : null;
+ if (oldStr !== null && newStr !== null) {
+ const path = typeof args?.path === "string" ? args.path :
+ typeof args?.file_path === "string" ? args.file_path : "file";
+ return buildSyntheticDiff(path, oldStr, newStr);
+ }
+ return undefined;
+ })();
+
+ // For single-file reads that are media, render inline preview
+ const filePath = getFilePath(args, output);
+ const media = filePath ? detectMedia(filePath) : null;
+ const isSingleMedia = kind === "read" && media && status === "done";
+
+ return (
+
+
+ {status === "error" ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {label}
+ {/* Exit code badge for exec tools */}
+ {kind === "exec" && status === "done" && output?.exitCode !== undefined && (
+
+ exit {typeof output.exitCode === "object" && output.exitCode != null ? JSON.stringify(output.exitCode) : typeof output.exitCode === "number" ? String(output.exitCode) : typeof output.exitCode === "string" ? output.exitCode : ""}
+
+ )}
+
+
+ {/* Inline diff for edit/write tool steps */}
+ {diffText && status === "done" && (
+
+
+ {showDiff && (
+
+ )}
+
+ )}
+
+ {/* Single media inline preview (when not grouped) */}
+ {isSingleMedia && filePath && media === "image" && (
+
+
+
+ )}
+
+ {isSingleMedia && filePath && media === "video" && (
+
+ )}
+
+ {isSingleMedia && filePath && media === "pdf" && (
+
+
+
+ {filePath.split("/").pop() ?? filePath}
+
+
+ )}
+
+ {isSingleMedia && filePath && media === "audio" && (
+
+ )}
+
+ {/* Domain badges (search results / fetched page) β skip when running, the running section handles its own */}
+ {domains.length > 0 && status !== "running" && (
+
+ {domains.map((domain) => (
+
+ ))}
+
+ )}
+
+ {(kind === "search" || kind === "fetch") &&
+ status === "running" &&
+ args && (
+
+ {/* Show favicon badges for known domains while running */}
+ {domains.length > 0
+ ? domains.map((domain) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {domain.replace(
+ /^www\./,
+ "",
+ )}
+
+
+ ))
+ : (
+
+
+ {kind === "fetch"
+ ? "Fetching..."
+ : "Searching..."}
+
+ )}
+
+ )}
+
+ {status === "error" && errorText && (
+
+ {errorText}
+
+ )}
+
+ {/* Args summary β show for tools with no output/diff so they're never opaque */}
+ {!outputText &&
+ !diffText &&
+ !isSingleMedia &&
+ status === "done" &&
+ args &&
+ Object.keys(args).length > 0 && (
+
+ {formatArgs(args)}
+
+ )}
+
+ {/* Output toggle β skip for media files and diffs only */}
+ {outputText &&
+ status === "done" &&
+ !isSingleMedia &&
+ !diffText && (
+
+
+ {showOutput && (
+
+ {outputText.length > 10000
+ ? outputText.slice(0, 10000) +
+ "\n... (truncated)"
+ : outputText}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+/* βββ Domain badge with favicon βββ */
+
+function DomainBadge({ domain }: { domain: string }) {
+ const short = domain.replace(/^www\./, "");
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {short}
+
+ );
+}
+
+/* βββ Step icons βββ */
+
+function StepIcon({ kind }: { kind: StepKind }) {
+ const color = "var(--color-text-muted)";
+ const size = 16;
+
+ switch (kind) {
+ case "search":
+ return (
+
+ );
+ case "fetch":
+ return (
+
+ );
+ case "read":
+ return (
+
+ );
+ case "exec":
+ return (
+
+ );
+ case "write":
+ return (
+
+ );
+ case "image":
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+}
+
+function ErrorCircleIcon() {
+ return (
+
+ );
+}
+
+function PdfIcon() {
+ return (
+
+ );
+}
+
+/* βββ Header icons βββ */
+
+function ThinkingIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function ChevronIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/web/app/components/charts/chart-panel.tsx b/apps/web/app/components/charts/chart-panel.tsx
new file mode 100644
index 00000000000..ecba8e87a20
--- /dev/null
+++ b/apps/web/app/components/charts/chart-panel.tsx
@@ -0,0 +1,426 @@
+"use client";
+
+import { useMemo } from "react";
+import {
+ BarChart,
+ Bar,
+ LineChart,
+ Line,
+ AreaChart,
+ Area,
+ PieChart,
+ Pie,
+ Cell,
+ RadarChart,
+ Radar,
+ PolarGrid,
+ PolarAngleAxis,
+ PolarRadiusAxis,
+ ScatterChart,
+ Scatter,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+ FunnelChart,
+ Funnel,
+ LabelList,
+} from "recharts";
+import type { PanelConfig } from "./types";
+
+// --- Color palette derived from CSS variables + accessible defaults ---
+
+const CHART_PALETTE = [
+ "#2563eb", // accent
+ "#60a5fa", // blue
+ "#22c55e", // green
+ "#f59e0b", // amber
+ "#c084fc", // purple
+ "#fb923c", // orange
+ "#14b8a6", // teal
+ "#f43f5e", // rose
+ "#a78bfa", // violet
+ "#38bdf8", // sky
+];
+
+type ChartPanelProps = {
+ config: PanelConfig;
+ data: Record[];
+ /** Compact mode for inline chat cards */
+ compact?: boolean;
+};
+
+// --- Shared tooltip/axis styles ---
+
+const axisStyle = {
+ fontSize: 11,
+ fill: "var(--color-text-muted)",
+};
+
+const gridStyle = {
+ stroke: "var(--color-border-strong)",
+ strokeDasharray: "3 3",
+};
+
+function tooltipStyle() {
+ return {
+ contentStyle: {
+ background: "var(--color-surface)",
+ border: "1px solid var(--color-border)",
+ borderRadius: 8,
+ fontSize: 12,
+ color: "var(--color-text)",
+ },
+ itemStyle: { color: "var(--color-text)" },
+ labelStyle: { color: "var(--color-text-muted)", marginBottom: 4 },
+ };
+}
+
+// --- Formatters ---
+
+/** Safe string conversion for chart values (handles objects via JSON.stringify). */
+function toDisplayStr(val: unknown): string {
+ if (val == null) {return "";}
+ if (typeof val === "object") {return JSON.stringify(val);}
+ if (typeof val === "string") {return val;}
+ if (typeof val === "number" || typeof val === "boolean") {return String(val);}
+ // symbol, bigint, function β val is narrowed (object already handled above)
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
+ return String(val);
+}
+
+function formatValue(val: unknown): string {
+ if (val === null || val === undefined) {return "";}
+ if (typeof val === "object") {return JSON.stringify(val);}
+ if (typeof val === "number") {
+ if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;}
+ if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;}
+ return Number.isInteger(val) ? String(val) : val.toFixed(2);
+ }
+ return toDisplayStr(val);
+}
+
+function formatLabel(val: unknown): string {
+ if (val === null || val === undefined) {return "";}
+ const str = toDisplayStr(val);
+ // Truncate long date strings
+ if (str.length > 16 && !isNaN(Date.parse(str))) {
+ return str.slice(0, 10);
+ }
+ // Truncate long labels
+ if (str.length > 20) {return str.slice(0, 18) + "...";}
+ return str;
+}
+
+// --- Chart renderers ---
+
+function CartesianChart({
+ config,
+ data,
+ compact,
+ ChartComponent,
+ SeriesComponent,
+ areaProps,
+}: {
+ config: PanelConfig;
+ data: Record[];
+ compact?: boolean;
+ ChartComponent: typeof BarChart ;
+ SeriesComponent: typeof Bar | typeof Line | typeof Area;
+ areaProps?: Record;
+}) {
+ const { mapping } = config;
+ const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x";
+ const yKeys = mapping.yAxis ?? Object.keys(data[0] ?? {}).filter((k) => k !== xKey);
+ const colors = mapping.colors ?? CHART_PALETTE;
+ const height = compact ? 200 : 320;
+ const ttStyle = tooltipStyle();
+
+ return (
+
+
+
+
+
+
+ {yKeys.length > 1 && !compact && }
+ {yKeys.map((key, i) => {
+ const color = colors[i % colors.length];
+ const props: Record = {
+ key,
+ dataKey: key,
+ fill: color,
+ stroke: color,
+ name: key,
+ ...areaProps,
+ };
+ if (SeriesComponent === Bar) {
+ props.radius = [4, 4, 0, 0];
+ props.maxBarSize = 48;
+ }
+ if (SeriesComponent === Line) {
+ props.strokeWidth = 2;
+ props.dot = { r: 3, fill: color };
+ props.activeDot = { r: 5 };
+ }
+ if (SeriesComponent === Area) {
+ props.fillOpacity = 0.15;
+ props.strokeWidth = 2;
+ }
+ // @ts-expect-error - dynamic component props
+ return ;
+ })}
+
+
+ );
+}
+
+function PieDonutChart({
+ config,
+ data,
+ compact,
+}: {
+ config: PanelConfig;
+ data: Record[];
+ compact?: boolean;
+}) {
+ const { mapping, type } = config;
+ const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
+ const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value";
+ const colors = mapping.colors ?? CHART_PALETTE;
+ const height = compact ? 200 : 320;
+ const ttStyle = tooltipStyle();
+ const innerRadius = type === "donut" ? "50%" : 0;
+
+ return (
+
+
+ {
+ const p = props as Record;
+ const name = p.name;
+ const percent = typeof p.percent === "number" ? p.percent : 0;
+ return `${formatLabel(name)} ${(percent * 100).toFixed(0)}%`;
+ }) as never}
+ labelLine={!compact}
+ style={{ fontSize: 11 }}
+ >
+ {data.map((_, i) => (
+ |
+ ))}
+
+
+ {!compact && }
+
+
+ );
+}
+
+function RadarChartPanel({
+ config,
+ data,
+ compact,
+}: {
+ config: PanelConfig;
+ data: Record[];
+ compact?: boolean;
+}) {
+ const { mapping } = config;
+ const nameKey = mapping.xAxis ?? mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
+ const valueKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "value"];
+ const colors = mapping.colors ?? CHART_PALETTE;
+ const height = compact ? 200 : 320;
+ const ttStyle = tooltipStyle();
+
+ return (
+
+
+
+
+
+ {valueKeys.map((key, i) => (
+
+ ))}
+
+ {!compact && valueKeys.length > 1 && }
+
+
+ );
+}
+
+function ScatterChartPanel({
+ config,
+ data,
+ compact,
+}: {
+ config: PanelConfig;
+ data: Record[];
+ compact?: boolean;
+}) {
+ const { mapping } = config;
+ const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x";
+ const yKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "y"];
+ const colors = mapping.colors ?? CHART_PALETTE;
+ const height = compact ? 200 : 320;
+ const ttStyle = tooltipStyle();
+
+ return (
+
+
+
+
+
+
+ {yKeys.map((key, i) => (
+
+ ))}
+ {!compact && yKeys.length > 1 && }
+
+
+ );
+}
+
+function FunnelChartPanel({
+ config,
+ data,
+ compact,
+}: {
+ config: PanelConfig;
+ data: Record[];
+ compact?: boolean;
+}) {
+ const { mapping } = config;
+ const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
+ const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value";
+ const colors = mapping.colors ?? CHART_PALETTE;
+ const height = compact ? 200 : 320;
+ const ttStyle = tooltipStyle();
+
+ // Funnel expects data with fill colors
+ const funnelData = data.map((row, i) => ({
+ ...row,
+ fill: colors[i % colors.length],
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+// --- Main ChartPanel component ---
+
+export function ChartPanel({ config, data, compact }: ChartPanelProps) {
+ // Coerce numeric values for Recharts
+ const processedData = useMemo(() => {
+ if (!data || data.length === 0) {return [];}
+ const { mapping } = config;
+ const numericKeys = new Set([
+ ...(mapping.yAxis ?? []),
+ ...(mapping.valueKey ? [mapping.valueKey] : []),
+ ]);
+
+ return data.map((row) => {
+ const out: Record = { ...row };
+ for (const key of numericKeys) {
+ if (key in out) {
+ const v = out[key];
+ if (typeof v === "string" && v !== "" && !isNaN(Number(v))) {
+ out[key] = Number(v);
+ }
+ }
+ }
+ return out;
+ });
+ }, [data, config]);
+
+ if (processedData.length === 0) {
+ return (
+
+ No data
+
+ );
+ }
+
+ switch (config.type) {
+ case "bar":
+ return ;
+ case "line":
+ return ;
+ case "area":
+ return ;
+ case "pie":
+ return ;
+ case "donut":
+ return ;
+ case "radar":
+ case "radialBar":
+ return ;
+ case "scatter":
+ return ;
+ case "funnel":
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/apps/web/app/components/charts/filter-bar.tsx b/apps/web/app/components/charts/filter-bar.tsx
new file mode 100644
index 00000000000..c1d8a13715b
--- /dev/null
+++ b/apps/web/app/components/charts/filter-bar.tsx
@@ -0,0 +1,349 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import type { FilterConfig, FilterState, FilterValue } from "./types";
+
+type FilterBarProps = {
+ filters: FilterConfig[];
+ value: FilterState;
+ onChange: (state: FilterState) => void;
+};
+
+// --- Icons ---
+
+function FilterIcon() {
+ return (
+
+ );
+}
+
+function XIcon() {
+ return (
+
+ );
+}
+
+// --- Individual filter components ---
+
+function DateRangeFilter({
+ filter,
+ value,
+ onChange,
+}: {
+ filter: FilterConfig;
+ value: FilterValue | undefined;
+ onChange: (v: FilterValue) => void;
+}) {
+ const current = value?.type === "dateRange" ? value : { type: "dateRange" as const };
+
+ return (
+
+
+ onChange({ ...current, from: e.target.value || undefined })}
+ className="px-2 py-1 rounded-md text-[11px] outline-none"
+ style={{
+ background: "var(--color-bg)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text)",
+ colorScheme: "dark",
+ }}
+ />
+ to
+ onChange({ ...current, to: e.target.value || undefined })}
+ className="px-2 py-1 rounded-md text-[11px] outline-none"
+ style={{
+ background: "var(--color-bg)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text)",
+ colorScheme: "dark",
+ }}
+ />
+
+ );
+}
+
+function SelectFilter({
+ filter,
+ value,
+ onChange,
+ options,
+}: {
+ filter: FilterConfig;
+ value: FilterValue | undefined;
+ onChange: (v: FilterValue) => void;
+ options: string[];
+}) {
+ const current = value?.type === "select" ? value.value : undefined;
+
+ return (
+
+
+
+
+ );
+}
+
+function MultiSelectFilter({
+ filter,
+ value,
+ onChange,
+ options,
+}: {
+ filter: FilterConfig;
+ value: FilterValue | undefined;
+ onChange: (v: FilterValue) => void;
+ options: string[];
+}) {
+ const current = value?.type === "multiSelect" ? (value.values ?? []) : [];
+
+ const toggleOption = (opt: string) => {
+ const next = current.includes(opt)
+ ? current.filter((v) => v !== opt)
+ : [...current, opt];
+ onChange({ type: "multiSelect", values: next.length > 0 ? next : undefined });
+ };
+
+ return (
+
+
+
+ {options.map((opt) => {
+ const selected = current.includes(opt);
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+function NumberFilter({
+ filter,
+ value,
+ onChange,
+}: {
+ filter: FilterConfig;
+ value: FilterValue | undefined;
+ onChange: (v: FilterValue) => void;
+}) {
+ const current = value?.type === "number" ? value : { type: "number" as const };
+
+ return (
+
+
+ onChange({ ...current, min: e.target.value ? Number(e.target.value) : undefined })}
+ className="px-2 py-1 rounded-md text-[11px] outline-none w-20"
+ style={{
+ background: "var(--color-bg)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text)",
+ }}
+ />
+ to
+ onChange({ ...current, max: e.target.value ? Number(e.target.value) : undefined })}
+ className="px-2 py-1 rounded-md text-[11px] outline-none w-20"
+ style={{
+ background: "var(--color-bg)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text)",
+ }}
+ />
+
+ );
+}
+
+// --- Main FilterBar ---
+
+export function FilterBar({ filters, value, onChange }: FilterBarProps) {
+ // Fetch options for select/multiSelect filters
+ const [optionsMap, setOptionsMap] = useState>({});
+
+ const fetchOptions = useCallback(async () => {
+ const toFetch = filters.filter(
+ (f) => (f.type === "select" || f.type === "multiSelect") && f.sql,
+ );
+ if (toFetch.length === 0) {return;}
+
+ const results: Record = {};
+ await Promise.all(
+ toFetch.map(async (f) => {
+ try {
+ const res = await fetch("/api/workspace/reports/execute", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sql: f.sql }),
+ });
+ if (!res.ok) {return;}
+ const data = await res.json();
+ const rows: Record[] = data.rows ?? [];
+ // Extract the first column's values as options
+ const opts = rows
+ .map((r) => {
+ const vals = Object.values(r);
+ const v = vals[0];
+ if (v == null) {return null;}
+ if (typeof v === "object") {return JSON.stringify(v);}
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- v narrowed, object handled above
+ return typeof v === "string" ? v : (typeof v === "number" || typeof v === "boolean" ? String(v) : String(v));
+ })
+ .filter((v): v is string => v !== null);
+ results[f.id] = opts;
+ } catch {
+ // skip failed option fetches
+ }
+ }),
+ );
+ setOptionsMap(results);
+ }, [filters]);
+
+ useEffect(() => {
+ void fetchOptions();
+ }, [fetchOptions]);
+
+ const handleFilterChange = useCallback(
+ (filterId: string, v: FilterValue) => {
+ onChange({ ...value, [filterId]: v });
+ },
+ [value, onChange],
+ );
+
+ const hasActiveFilters = Object.values(value).some((v) => {
+ if (!v) {return false;}
+ if (v.type === "dateRange") {return v.from || v.to;}
+ if (v.type === "select") {return v.value;}
+ if (v.type === "multiSelect") {return v.values && v.values.length > 0;}
+ if (v.type === "number") {return v.min !== undefined || v.max !== undefined;}
+ return false;
+ });
+
+ const clearFilters = () => onChange({});
+
+ if (filters.length === 0) {return null;}
+
+ return (
+
+
+
+ Filters
+
+
+ {filters.map((filter) => {
+ const fv = value[filter.id];
+ switch (filter.type) {
+ case "dateRange":
+ return (
+ handleFilterChange(filter.id, v)}
+ />
+ );
+ case "select":
+ return (
+ handleFilterChange(filter.id, v)}
+ options={optionsMap[filter.id] ?? []}
+ />
+ );
+ case "multiSelect":
+ return (
+ handleFilterChange(filter.id, v)}
+ options={optionsMap[filter.id] ?? []}
+ />
+ );
+ case "number":
+ return (
+ handleFilterChange(filter.id, v)}
+ />
+ );
+ default:
+ return null;
+ }
+ })}
+
+ {hasActiveFilters && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/app/components/charts/report-card.tsx b/apps/web/app/components/charts/report-card.tsx
new file mode 100644
index 00000000000..12e4d7a33a0
--- /dev/null
+++ b/apps/web/app/components/charts/report-card.tsx
@@ -0,0 +1,464 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { ChartPanel } from "./chart-panel";
+import type { ReportConfig, PanelConfig } from "./types";
+
+type ReportCardProps = {
+ config: ReportConfig;
+};
+
+// --- Icons ---
+
+function ChartBarIcon() {
+ return (
+
+ );
+}
+
+function ExpandIcon() {
+ return (
+
+ );
+}
+
+function CollapseIcon() {
+ return (
+
+ );
+}
+
+function PinIcon() {
+ return (
+
+ );
+}
+
+function RefreshIcon() {
+ return (
+
+ );
+}
+
+// --- Panel data state ---
+
+type PanelData = {
+ rows: Record[];
+ loading: boolean;
+ error?: string;
+};
+
+// --- Grid size helpers ---
+
+function panelColSpan(size?: string): string {
+ switch (size) {
+ case "full":
+ return "col-span-6";
+ case "third":
+ return "col-span-2";
+ case "half":
+ default:
+ return "col-span-3";
+ }
+}
+
+// --- Main ReportCard ---
+
+export function ReportCard({ config }: ReportCardProps) {
+ const [panelData, setPanelData] = useState>({});
+ const [pinning, setPinning] = useState(false);
+ const [pinned, setPinned] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+
+ // In compact mode show at most 2 panels; expanded shows all
+ const visiblePanels = expanded ? config.panels : config.panels.slice(0, 2);
+ const hasMore = config.panels.length > 2;
+
+ // Execute panel SQL queries
+ const executePanels = useCallback(async (panels: PanelConfig[]) => {
+ const initial: Record = {};
+ for (const panel of panels) {
+ initial[panel.id] = { rows: [], loading: true };
+ }
+ setPanelData((prev) => ({ ...prev, ...initial }));
+
+ await Promise.all(
+ panels.map(async (panel) => {
+ try {
+ const res = await fetch("/api/workspace/reports/execute", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sql: panel.sql }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setPanelData((prev) => ({
+ ...prev,
+ [panel.id]: { rows: [], loading: false, error: data.error || `HTTP ${res.status}` },
+ }));
+ return;
+ }
+ const data = await res.json();
+ setPanelData((prev) => ({
+ ...prev,
+ [panel.id]: { rows: data.rows ?? [], loading: false },
+ }));
+ } catch (err) {
+ setPanelData((prev) => ({
+ ...prev,
+ [panel.id]: { rows: [], loading: false, error: err instanceof Error ? err.message : "Failed" },
+ }));
+ }
+ }),
+ );
+ }, []);
+
+ // Load initial compact panels
+ useEffect(() => {
+ void executePanels(config.panels.slice(0, 2));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // When expanding, fetch any panels not yet loaded
+ const handleToggleExpand = useCallback(() => {
+ setExpanded((prev) => {
+ const next = !prev;
+ if (next && hasMore) {
+ const unloaded = config.panels.filter((p) => !panelData[p.id]);
+ if (unloaded.length > 0) {
+ void executePanels(unloaded);
+ }
+ }
+ return next;
+ });
+ }, [hasMore, config.panels, panelData, executePanels]);
+
+ // Refresh all visible panels
+ const handleRefresh = useCallback(() => {
+ void executePanels(expanded ? config.panels : config.panels.slice(0, 2));
+ }, [expanded, config.panels, executePanels]);
+
+ // Pin report to workspace /reports directory
+ const handlePin = async () => {
+ setPinning(true);
+ try {
+ const slug = config.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 40);
+ const filename = `${slug}.report.json`;
+
+ await fetch("/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ path: `reports/${filename}`,
+ content: JSON.stringify(config, null, 2),
+ }),
+ });
+ setPinned(true);
+ } catch {
+ // silently fail
+ } finally {
+ setPinning(false);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ {config.title}
+
+
+ {config.panels.length} chart{config.panels.length !== 1 ? "s" : ""}
+
+
+
+
+ {expanded && (
+
+ )}
+ {!pinned ? (
+
+ ) : (
+
+
+ Pinned
+
+ )}
+
+
+
+
+ {/* Description */}
+ {config.description && (
+
+
+ {config.description}
+
+
+ )}
+
+
+ {expanded ? (
+ /* ββ Expanded: full grid with all panels ββ */
+
+
+ {config.panels.map((panel) => (
+
+ ))}
+
+
+ ) : (
+ /* ββ Compact: max 2 panels ββ */
+
+ 1 ? "grid-cols-2" : "grid-cols-1"}`}>
+ {visiblePanels.map((panel) => (
+
+ ))}
+
+
+ {/* More panels indicator */}
+ {hasMore && (
+
+ )}
+
+ )}
+
+
+ );
+}
+
+// --- Compact panel card for inline rendering ---
+
+function CompactPanelCard({
+ panel,
+ data,
+}: {
+ panel: PanelConfig;
+ data?: PanelData;
+}) {
+ return (
+
+
+
+ {panel.title}
+
+
+
+ {data?.loading ? (
+
+
+
+ ) : data?.error ? (
+
+
+ {data.error}
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+// --- Expanded panel card for full report view ---
+
+function ExpandedPanelCard({
+ panel,
+ data,
+}: {
+ panel: PanelConfig;
+ data?: PanelData;
+}) {
+ const colSpan = panelColSpan(panel.size);
+
+ return (
+
+
+
+ {panel.title}
+
+ {data && !data.loading && !data.error && (
+
+ {data.rows.length} rows
+
+ )}
+
+
+ {data?.loading ? (
+
+
+
+ ) : data?.error ? (
+
+
+ Query error
+
+
+ {data.error}
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/components/charts/report-viewer.tsx b/apps/web/app/components/charts/report-viewer.tsx
new file mode 100644
index 00000000000..72690e76e1d
--- /dev/null
+++ b/apps/web/app/components/charts/report-viewer.tsx
@@ -0,0 +1,407 @@
+"use client";
+
+import { useEffect, useState, useCallback, useMemo } from "react";
+import { ChartPanel } from "./chart-panel";
+import { FilterBar } from "./filter-bar";
+import type { ReportConfig, FilterState, PanelConfig, FilterConfig } from "./types";
+
+type ReportViewerProps = {
+ /** Report config object (inline or loaded) */
+ config?: ReportConfig;
+ /** Path to load report config from filesystem */
+ reportPath?: string;
+};
+
+// --- Icons ---
+
+function ChartBarIcon({ size = 20 }: { size?: number }) {
+ return (
+
+ );
+}
+
+function RefreshIcon() {
+ return (
+
+ );
+}
+
+// --- Helpers ---
+
+type PanelData = {
+ panelId: string;
+ rows: Record[];
+ loading: boolean;
+ error?: string;
+};
+
+/** Build filter entries for the API from active filter state + filter configs. */
+function buildFilterEntries(
+ filterState: FilterState,
+ filterConfigs: FilterConfig[],
+): Array<{ id: string; column: string; value: FilterState[string] }> {
+ const entries: Array<{ id: string; column: string; value: FilterState[string] }> = [];
+ for (const fc of filterConfigs) {
+ const v = filterState[fc.id];
+ if (!v) {continue;}
+ // Only include if the filter has an active value
+ const hasValue =
+ (v.type === "dateRange" && (v.from || v.to)) ||
+ (v.type === "select" && v.value) ||
+ (v.type === "multiSelect" && v.values && v.values.length > 0) ||
+ (v.type === "number" && (v.min !== undefined || v.max !== undefined));
+ if (hasValue) {
+ entries.push({ id: fc.id, column: fc.column, value: v });
+ }
+ }
+ return entries;
+}
+
+// --- Grid size helpers ---
+
+function panelColSpan(size?: string): string {
+ switch (size) {
+ case "full":
+ return "col-span-6";
+ case "third":
+ return "col-span-2";
+ case "half":
+ default:
+ return "col-span-3";
+ }
+}
+
+// --- Main ReportViewer ---
+
+export function ReportViewer({ config: propConfig, reportPath }: ReportViewerProps) {
+ const [config, setConfig] = useState(propConfig ?? null);
+ const [configLoading, setConfigLoading] = useState(!propConfig && !!reportPath);
+ const [configError, setConfigError] = useState(null);
+ const [panelData, setPanelData] = useState>({});
+ const [filterState, setFilterState] = useState({});
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ // Load report config from filesystem if path provided
+ useEffect(() => {
+ if (propConfig) {
+ setConfig(propConfig);
+ return;
+ }
+ if (!reportPath) {return;}
+
+ let cancelled = false;
+ setConfigLoading(true);
+ setConfigError(null);
+
+ fetch(`/api/workspace/file?path=${encodeURIComponent(reportPath)}`)
+ .then(async (res) => {
+ if (!res.ok) {throw new Error(`Failed to load report: HTTP ${res.status}`);}
+ const data = await res.json();
+ if (cancelled) {return;}
+ try {
+ const parsed = JSON.parse(data.content) as ReportConfig;
+ setConfig(parsed);
+ } catch {
+ throw new Error("Invalid report JSON");
+ }
+ })
+ .catch((err) => {
+ if (!cancelled) {
+ setConfigError(err instanceof Error ? err.message : "Failed to load report");
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {setConfigLoading(false);}
+ });
+
+ return () => { cancelled = true; };
+ }, [propConfig, reportPath]);
+
+ // Execute all panel SQL queries when config or filters change
+ const executeAllPanels = useCallback(async () => {
+ if (!config) {return;}
+
+ const filterEntries = buildFilterEntries(filterState, config.filters ?? []);
+
+ // Mark all panels as loading
+ const initialState: Record = {};
+ for (const panel of config.panels) {
+ initialState[panel.id] = { panelId: panel.id, rows: [], loading: true };
+ }
+ setPanelData(initialState);
+
+ // Execute all panels in parallel
+ await Promise.all(
+ config.panels.map(async (panel) => {
+ try {
+ const res = await fetch("/api/workspace/reports/execute", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sql: panel.sql,
+ filters: filterEntries.length > 0 ? filterEntries : undefined,
+ }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setPanelData((prev) => ({
+ ...prev,
+ [panel.id]: {
+ panelId: panel.id,
+ rows: [],
+ loading: false,
+ error: data.error || `HTTP ${res.status}`,
+ },
+ }));
+ return;
+ }
+
+ const data = await res.json();
+ setPanelData((prev) => ({
+ ...prev,
+ [panel.id]: {
+ panelId: panel.id,
+ rows: data.rows ?? [],
+ loading: false,
+ },
+ }));
+ } catch (err) {
+ setPanelData((prev) => ({
+ ...prev,
+ [panel.id]: {
+ panelId: panel.id,
+ rows: [],
+ loading: false,
+ error: err instanceof Error ? err.message : "Query failed",
+ },
+ }));
+ }
+ }),
+ );
+ }, [config, filterState]);
+
+ // Re-execute when config, filters, or refresh key changes
+ useEffect(() => {
+ void executeAllPanels();
+ }, [executeAllPanels, refreshKey]);
+
+ const totalRows = useMemo(() => {
+ return Object.values(panelData).reduce((sum, pd) => sum + pd.rows.length, 0);
+ }, [panelData]);
+
+ // --- Loading state ---
+ if (configLoading) {
+ return (
+
+
+
+ Loading report...
+
+
+ );
+ }
+
+ // --- Error state ---
+ if (configError) {
+ return (
+
+
+
+ Failed to load report
+
+
+ {configError}
+
+
+ );
+ }
+
+ if (!config) {
+ return (
+
+
+ No report configuration found
+
+
+ );
+ }
+
+ return (
+
+ {/* Report header */}
+
+
+
+
+
+
+
+
+ {config.title}
+
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
+
+
+ {config.panels.length} panel{config.panels.length !== 1 ? "s" : ""}
+
+
+ {totalRows} rows
+
+
+
+
+
+
+ {/* Filters */}
+ {config.filters && config.filters.length > 0 && (
+
+ )}
+
+ {/* Panel grid */}
+
+
+ {config.panels.map((panel) => (
+
+ ))}
+
+
+
+ );
+}
+
+// --- Individual panel card ---
+
+function PanelCard({
+ panel,
+ data,
+}: {
+ panel: PanelConfig;
+ data?: PanelData;
+}) {
+ const colSpan = panelColSpan(panel.size);
+
+ return (
+
+ {/* Panel header */}
+
+
+ {panel.title}
+
+ {data && !data.loading && !data.error && (
+
+ {data.rows.length} rows
+
+ )}
+
+
+ {/* Chart area */}
+
+ {data?.loading ? (
+
+
+
+ ) : data?.error ? (
+
+
+ Query error
+
+
+ {data.error}
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/components/charts/types.ts b/apps/web/app/components/charts/types.ts
new file mode 100644
index 00000000000..150dfdc3d36
--- /dev/null
+++ b/apps/web/app/components/charts/types.ts
@@ -0,0 +1,64 @@
+/** Shared types for the report/analytics system. */
+
+export type ChartType =
+ | "bar"
+ | "line"
+ | "area"
+ | "pie"
+ | "donut"
+ | "radar"
+ | "radialBar"
+ | "scatter"
+ | "funnel";
+
+export type PanelSize = "full" | "half" | "third";
+
+export type PanelMapping = {
+ /** Key for x-axis or category axis */
+ xAxis?: string;
+ /** One or more keys for y-axis values (supports stacked/multi-series) */
+ yAxis?: string[];
+ /** Key used as label for pie/donut/funnel */
+ nameKey?: string;
+ /** Key used as value for pie/donut/funnel */
+ valueKey?: string;
+ /** Custom colors for series (hex). Falls back to palette. */
+ colors?: string[];
+};
+
+export type PanelConfig = {
+ id: string;
+ title: string;
+ type: ChartType;
+ sql: string;
+ mapping: PanelMapping;
+ size?: PanelSize;
+};
+
+export type FilterType = "dateRange" | "select" | "multiSelect" | "number";
+
+export type FilterConfig = {
+ id: string;
+ type: FilterType;
+ label: string;
+ column: string;
+ /** SQL to fetch available options (for select/multiSelect) */
+ sql?: string;
+};
+
+export type ReportConfig = {
+ version: number;
+ title: string;
+ description?: string;
+ panels: PanelConfig[];
+ filters?: FilterConfig[];
+};
+
+/** Active filter values keyed by filter ID */
+export type FilterState = Record;
+
+export type FilterValue =
+ | { type: "dateRange"; from?: string; to?: string }
+ | { type: "select"; value?: string }
+ | { type: "multiSelect"; values?: string[] }
+ | { type: "number"; min?: number; max?: number };
diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx
new file mode 100644
index 00000000000..b8b55e2028e
--- /dev/null
+++ b/apps/web/app/components/chat-message.tsx
@@ -0,0 +1,940 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import type { UIMessage } from "ai";
+import { memo, useMemo, useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import type { Components } from "react-markdown";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { ChainOfThought, type ChainPart } from "./chain-of-thought";
+import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
+import { splitDiffBlocks, hasDiffBlocks } from "@/lib/diff-blocks";
+import type { ReportConfig } from "./charts/types";
+import { DiffCard } from "./diff-viewer";
+import { SyntaxBlock } from "./syntax-block";
+
+// Lazy-load ReportCard (uses Recharts which is heavy)
+const ReportCard = dynamic(
+ () =>
+ import("./charts/report-card").then((m) => ({
+ default: m.ReportCard,
+ })),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
+
+/* βββ Silent-reply leak filter βββ */
+
+const _SILENT_TOKEN = "NO_REPLY";
+
+function isLeakedSilentToken(text: string): boolean {
+ const t = text.trim();
+ if (!t) {return false;}
+ if (new RegExp(`^${_SILENT_TOKEN}\\W*$`).test(t)) {return true;}
+ if (_SILENT_TOKEN.startsWith(t) && t.length >= 2 && t.length < _SILENT_TOKEN.length) {return true;}
+ return false;
+}
+
+/* βββ Part grouping βββ */
+
+type MessageSegment =
+ | { type: "text"; text: string }
+ | { type: "chain"; parts: ChainPart[] }
+ | { type: "report-artifact"; config: ReportConfig }
+ | { type: "diff-artifact"; diff: string }
+ | { type: "subagent-card"; task: string; label?: string; status: "running" | "done" | "error" };
+
+/** Map AI SDK tool state string to a simplified status */
+function toolStatus(state: string): "running" | "done" | "error" {
+ if (state === "output-available") {
+ return "done";
+ }
+ if (state === "error") {
+ return "error";
+ }
+ return "running";
+}
+
+/**
+ * Group consecutive non-text parts (reasoning + tools) into chain-of-thought
+ * blocks, with text parts standing alone between them.
+ */
+function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
+ const segments: MessageSegment[] = [];
+ let chain: ChainPart[] = [];
+
+ const flush = (textFollows?: boolean) => {
+ if (chain.length > 0) {
+ // If text content follows this chain, all tools must have
+ // completed β force any stuck "running" tools to "done".
+ if (textFollows) {
+ for (const cp of chain) {
+ if (cp.kind === "tool" && cp.status === "running") {
+ cp.status = "done";
+ }
+ }
+ }
+ segments.push({ type: "chain", parts: [...chain] });
+ chain = [];
+ }
+ };
+
+ for (const part of parts) {
+ if (part.type === "text") {
+ const text = (part as { type: "text"; text: string }).text;
+ if (isLeakedSilentToken(text)) { continue; }
+ flush(true);
+ if (hasReportBlocks(text)) {
+ segments.push(
+ ...(splitReportBlocks(text) as MessageSegment[]),
+ );
+ } else if (hasDiffBlocks(text)) {
+ for (const seg of splitDiffBlocks(text)) {
+ if (seg.type === "diff-artifact") {
+ segments.push({ type: "diff-artifact", diff: seg.diff });
+ } else {
+ segments.push({ type: "text", text: seg.text });
+ }
+ }
+ } else {
+ segments.push({ type: "text", text });
+ }
+ } else if (part.type === "reasoning") {
+ const rp = part as {
+ type: "reasoning";
+ text: string;
+ state?: string;
+ };
+ // Skip lifecycle/compaction status labels β they add noise
+ // (e.g. "Preparing response...", "Optimizing session context...")
+ const statusLabels = [
+ "Preparing response...",
+ "Optimizing session context...",
+ ];
+ const isStatus = statusLabels.some((l) =>
+ rp.text.startsWith(l),
+ );
+ if (!isStatus) {
+ chain.push({
+ kind: "reasoning",
+ text: rp.text,
+ isStreaming: rp.state === "streaming",
+ });
+ }
+ } else if (part.type === "dynamic-tool") {
+ const tp = part as {
+ type: "dynamic-tool";
+ toolName: string;
+ toolCallId: string;
+ state: string;
+ input?: unknown;
+ output?: unknown;
+ };
+ if (tp.toolName === "sessions_spawn") {
+ flush(true);
+ const args = asRecord(tp.input);
+ const task = typeof args?.task === "string" ? args.task : "Subagent task";
+ const label = typeof args?.label === "string" ? args.label : undefined;
+ segments.push({ type: "subagent-card", task, label, status: toolStatus(tp.state) });
+ } else {
+ chain.push({
+ kind: "tool",
+ toolName: tp.toolName,
+ toolCallId: tp.toolCallId,
+ status: toolStatus(tp.state),
+ args: asRecord(tp.input),
+ output: asRecord(tp.output),
+ });
+ }
+ } else if (part.type.startsWith("tool-")) {
+ // Handles both live SSE parts (input/output fields) and
+ // persisted JSONL parts (args/result fields from tool-invocation)
+ const tp = part as {
+ type: string;
+ toolCallId: string;
+ toolName?: string;
+ state?: string;
+ title?: string;
+ input?: unknown;
+ output?: unknown;
+ // Persisted JSONL format uses args/result instead
+ args?: unknown;
+ result?: unknown;
+ errorText?: string;
+ };
+ const resolvedToolName = tp.title ?? tp.toolName ?? part.type.replace("tool-", "");
+ if (resolvedToolName === "sessions_spawn") {
+ flush(true);
+ const args = asRecord(tp.input) ?? asRecord(tp.args);
+ const task = typeof args?.task === "string" ? args.task : "Subagent task";
+ const label = typeof args?.label === "string" ? args.label : undefined;
+ const resolvedState =
+ tp.state ??
+ (tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available");
+ segments.push({ type: "subagent-card", task, label, status: toolStatus(resolvedState) });
+ } else {
+ // Persisted tool-invocation parts have no state field but
+ // include result/output/errorText to indicate completion.
+ const resolvedState =
+ tp.state ??
+ (tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available");
+ chain.push({
+ kind: "tool",
+ toolName: resolvedToolName,
+ toolCallId: tp.toolCallId,
+ status: toolStatus(resolvedState),
+ args: asRecord(tp.input) ?? asRecord(tp.args),
+ output: asRecord(tp.output) ?? asRecord(tp.result),
+ });
+ }
+ }
+ }
+
+ flush();
+ return segments;
+}
+
+/** Safely cast unknown to Record if it's a non-null object */
+function asRecord(
+ val: unknown,
+): Record | undefined {
+ if (val && typeof val === "object" && !Array.isArray(val)) {
+ return val as Record;
+ }
+ return undefined;
+}
+
+/* βββ Attachment parsing for sent messages βββ */
+
+function parseAttachments(
+ text: string,
+): { paths: string[]; message: string } | null {
+ const match = text.match(/\[Attached files: (.+?)\]/);
+ if (!match) {return null;}
+ const afterIdx = (match.index ?? 0) + match[0].length;
+ const message = text.slice(afterIdx).trim();
+ const paths = match[1]
+ .split(", ")
+ .map((p) => p.trim())
+ .filter(Boolean);
+ return { paths, message };
+}
+
+function getCategoryFromPath(
+ filePath: string,
+): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" {
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
+ if (
+ [
+ "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp",
+ "ico", "tiff", "heic",
+ ].includes(ext)
+ )
+ {return "image";}
+ if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
+ {return "video";}
+ if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
+ {return "audio";}
+ if (ext === "pdf") {return "pdf";}
+ if (
+ [
+ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs",
+ "java", "cpp", "c", "h", "css", "html", "json",
+ "yaml", "yml", "toml", "md", "sh", "bash", "sql",
+ "swift", "kt",
+ ].includes(ext)
+ )
+ {return "code";}
+ if (
+ [
+ "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
+ "rtf", "csv", "pages", "numbers", "key",
+ ].includes(ext)
+ )
+ {return "document";}
+ return "other";
+}
+
+function _shortenPath(path: string): string {
+ return path
+ .replace(/^\/Users\/[^/]+/, "~")
+ .replace(/^\/home\/[^/]+/, "~")
+ .replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
+}
+
+const _attachCategoryMeta: Record = {
+ image: { bg: "rgba(16, 185, 129, 0.15)", fg: "#10b981" },
+ video: { bg: "rgba(139, 92, 246, 0.15)", fg: "#8b5cf6" },
+ audio: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" },
+ pdf: { bg: "rgba(239, 68, 68, 0.15)", fg: "#ef4444" },
+ code: { bg: "rgba(59, 130, 246, 0.15)", fg: "#3b82f6" },
+ document: { bg: "rgba(107, 114, 128, 0.15)", fg: "#6b7280" },
+ other: { bg: "rgba(107, 114, 128, 0.10)", fg: "#9ca3af" },
+};
+
+function _AttachFileIcon({ category }: { category: string }) {
+ const props = {
+ width: 14,
+ height: 14,
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 2,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+ };
+ switch (category) {
+ case "image":
+ return (
+
+ );
+ case "video":
+ return (
+
+ );
+ case "audio":
+ return (
+
+ );
+ case "pdf":
+ return (
+
+ );
+ case "code":
+ return (
+
+ );
+ case "document":
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+}
+
+function AttachedFilesCard({ paths }: { paths: string[] }) {
+ return (
+
+ {paths.map((filePath, i) => {
+ const category = getCategoryFromPath(filePath);
+ const src = category === "image"
+ ? `/api/workspace/raw-file?path=${encodeURIComponent(filePath)}`
+ : `/api/workspace/thumbnail?path=${encodeURIComponent(filePath)}&size=200`;
+ const ext = filePath.split(".").pop()?.toUpperCase() ?? "";
+
+ return (
+
+
{ (e.currentTarget as HTMLImageElement).style.display = "none"; }}
+ />
+ {category !== "image" && (
+
+ {ext}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+/* βββ File path detection for clickable inline code βββ */
+
+/**
+ * Detect whether an inline code string looks like a local file/directory path.
+ * Matches anything starting with:
+ * ~/ (home-relative)
+ * / (absolute)
+ * ./ (current-dir-relative)
+ * ../ (parent-dir-relative)
+ * Must contain at least one `/` separator to distinguish from plain commands.
+ */
+function looksLikeFilePath(text: string): boolean {
+ const t = text.trim();
+ if (!t || t.length < 3 || t.length > 500) {return false;}
+ // Full path prefix
+ if (t.startsWith("~/") || t.startsWith("/") || t.startsWith("./") || t.startsWith("../")) {
+ const afterPrefix = t.startsWith("~/") ? t.slice(2) :
+ t.startsWith("../") ? t.slice(3) :
+ t.startsWith("./") ? t.slice(2) :
+ t.slice(1);
+ return afterPrefix.includes("/") || afterPrefix.includes(".");
+ }
+ // Bare filename with a known extension (e.g. "Rachapoom-Passport.pdf")
+ const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i;
+ if (fileExtPattern.test(t) && !t.includes(" ")) {
+ return true;
+ }
+ return false;
+}
+
+/** Check if text looks like a filename (allows spaces, used for bold text). */
+function looksLikeFileName(text: string): boolean {
+ const t = text.trim();
+ if (!t || t.length < 3 || t.length > 300) {return false;}
+ const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i;
+ return fileExtPattern.test(t);
+}
+
+/** Open a file path using the system default application. */
+async function openFilePath(path: string, reveal = false) {
+ try {
+ const res = await fetch("/api/workspace/open-file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path, reveal }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ console.error("Failed to open file:", data);
+ }
+ } catch (err) {
+ console.error("Failed to open file:", err);
+ }
+}
+
+type FilePathClickHandler = (
+ path: string,
+) => Promise | boolean | void;
+
+/** Convert file:// URLs to local paths for in-app preview routing. */
+function normalizePathReference(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed.startsWith("file://")) {
+ return trimmed;
+ }
+ try {
+ const url = new URL(trimmed);
+ if (url.protocol !== "file:") {
+ return trimmed;
+ }
+ const decoded = decodeURIComponent(url.pathname);
+ // Windows file URLs are /C:/... in URL form
+ if (/^\/[A-Za-z]:\//.test(decoded)) {
+ return decoded.slice(1);
+ }
+ return decoded;
+ } catch {
+ return trimmed;
+ }
+}
+
+/** Clickable file path inline code element */
+function FilePathCode({
+ path,
+ children,
+ onFilePathClick,
+}: {
+ path: string;
+ children: React.ReactNode;
+ onFilePathClick?: FilePathClickHandler;
+}) {
+ const [status, setStatus] = useState<"idle" | "opening" | "error">("idle");
+
+ const handleClick = async (e: React.MouseEvent) => {
+ e.preventDefault();
+ setStatus("opening");
+ try {
+ if (onFilePathClick) {
+ const handled = await onFilePathClick(path);
+ if (handled === false) {
+ setStatus("error");
+ setTimeout(() => setStatus("idle"), 2000);
+ return;
+ }
+ setStatus("idle");
+ } else {
+ const res = await fetch("/api/workspace/open-file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path }),
+ });
+ if (!res.ok) {
+ setStatus("error");
+ setTimeout(() => setStatus("idle"), 2000);
+ } else {
+ setStatus("idle");
+ }
+ }
+ } catch {
+ setStatus("error");
+ setTimeout(() => setStatus("idle"), 2000);
+ }
+ };
+
+ const handleContextMenu = async (e: React.MouseEvent) => {
+ // Right-click reveals in Finder instead of opening
+ e.preventDefault();
+ await openFilePath(path, true);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+/* βββ Markdown component overrides for chat βββ */
+
+function createMarkdownComponents(
+ onFilePathClick?: FilePathClickHandler,
+): Components {
+ return {
+ // Open external links in new tab; intercept local file-path links
+ a: ({ href, children, ...props }) => {
+ const rawHref = typeof href === "string" ? href : "";
+ const normalizedHref = normalizePathReference(rawHref);
+ const isExternal =
+ rawHref && (rawHref.startsWith("http://") || rawHref.startsWith("https://") || rawHref.startsWith("//"));
+ const isWorkspaceAppLink = rawHref.startsWith("/workspace");
+ const isLocalPathLink =
+ !isWorkspaceAppLink &&
+ (Boolean(rawHref.startsWith("file://")) ||
+ looksLikeFilePath(normalizedHref));
+ return (
+ {
+ if (!isLocalPathLink || !onFilePathClick) {return;}
+ e.preventDefault();
+ void onFilePathClick(normalizedHref);
+ }}
+ >
+ {children}
+
+ );
+ },
+ // Route local image paths through raw-file API so workspace images render
+ img: ({ src, alt, ...props }) => {
+ const resolvedSrc = typeof src === "string" && !src.startsWith("http://") && !src.startsWith("https://") && !src.startsWith("data:")
+ ? `/api/workspace/raw-file?path=${encodeURIComponent(src)}`
+ : src;
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+ },
+ // Syntax-highlighted fenced code blocks
+ pre: ({ children, ...props }) => {
+ const child = Array.isArray(children) ? children[0] : children;
+ if (
+ child &&
+ typeof child === "object" &&
+ "type" in child &&
+ (child as { type?: string }).type === "code"
+ ) {
+ const codeEl = child as {
+ props?: {
+ className?: string;
+ children?: string;
+ };
+ };
+ const className = codeEl.props?.className ?? "";
+ const langMatch = className.match(/language-(\w+)/);
+ const lang = langMatch?.[1] ?? "";
+ const code =
+ typeof codeEl.props?.children === "string"
+ ? codeEl.props.children.replace(/\n$/, "")
+ : "";
+
+ // Diff language: render as DiffCard
+ if (lang === "diff") {
+ return ;
+ }
+
+ // Known language: syntax-highlight with shiki
+ if (lang) {
+ return (
+
+
+ {lang}
+
+
+
+ );
+ }
+ }
+ // Fallback: default pre rendering
+ return {children};
+ },
+ // Inline code β detect file paths and make them clickable
+ code: ({ children, className, ...props }) => {
+ // If this code has a language class, it's inside a and
+ // will be handled by the pre override above. Just return raw.
+ if (className?.startsWith("language-")) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ // Check if the inline code content looks like a file path
+ const text = typeof children === "string" ? children : "";
+ const normalizedText = normalizePathReference(text);
+ if (normalizedText && looksLikeFilePath(normalizedText)) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ // Regular inline code
+ return {children};
+ },
+ // Bold text β detect filenames and make them clickable
+ strong: ({ children, ...props }) => {
+ const text = typeof children === "string" ? children
+ : Array.isArray(children) ? children.filter((c) => typeof c === "string").join("")
+ : "";
+ if (text && looksLikeFileName(text)) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+ return {children};
+ },
+ };
+}
+
+/* βββ Chat message βββ */
+
+export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick, onFilePathClick }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void; onFilePathClick?: FilePathClickHandler }) {
+ const isUser = message.role === "user";
+ const segments = groupParts(message.parts);
+ const markdownComponents = useMemo(
+ () => createMarkdownComponents(onFilePathClick),
+ [onFilePathClick],
+ );
+
+ if (isUser) {
+ // User: right-aligned subtle pill
+ const textContent = segments
+ .filter(
+ (s): s is { type: "text"; text: string } =>
+ s.type === "text",
+ )
+ .map((s) => s.text)
+ .join("\n");
+
+ // Parse attachment prefix from sent messages
+ const attachmentInfo = parseAttachments(textContent);
+
+ if (attachmentInfo) {
+ return (
+
+ {/* Attachment previews β standalone above the text bubble */}
+
+ {/* Text bubble */}
+ {attachmentInfo.message && (
+
+
+ {attachmentInfo.message}
+
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+ {textContent}
+
+
+
+ );
+ }
+
+ // Find the last text segment index for streaming optimization
+ const lastTextIdx = isStreaming
+ ? segments.reduce((acc, s, i) => (s.type === "text" ? i : acc), -1)
+ : -1;
+
+ // Assistant: free-flowing text, left-aligned, NO bubble
+ return (
+
+
+ {segments.map((segment, index) => {
+ if (segment.type === "text") {
+ // Detect agent error messages
+ const errorMatch = segment.text.match(
+ /^\[error\]\s*([\s\S]*)$/,
+ );
+ if (errorMatch) {
+ return (
+
+
+
+ {errorMatch[1].trim()}
+
+
+ );
+ }
+
+ // During streaming, render the active text as plain text
+ // to avoid expensive ReactMarkdown re-parses on every token.
+ // Switch to full markdown once streaming ends.
+ if (index === lastTextIdx) {
+ return (
+
+ {segment.text}
+
+ );
+ }
+
+ return (
+
+
+ {segment.text}
+
+
+ );
+ }
+ if (segment.type === "report-artifact") {
+ return (
+
+
+
+ );
+ }
+ if (segment.type === "diff-artifact") {
+ return (
+
+
+
+ );
+ }
+ if (segment.type === "subagent-card") {
+ const truncatedTask = segment.task.length > 80 ? segment.task.slice(0, 80) + "..." : segment.task;
+ const isRunning = segment.status === "running";
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+});
diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx
new file mode 100644
index 00000000000..06c6e26ea99
--- /dev/null
+++ b/apps/web/app/components/chat-panel.tsx
@@ -0,0 +1,2146 @@
+"use client";
+
+import { useChat } from "@ai-sdk/react";
+import { DefaultChatTransport, type UIMessage } from "ai";
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { ChatMessage } from "./chat-message";
+import {
+ FilePickerModal,
+ type SelectedFile,
+} from "./file-picker-modal";
+import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { UnicodeSpinner } from "./unicode-spinner";
+
+// ββ Attachment types & helpers ββ
+
+type AttachedFile = {
+ id: string;
+ name: string;
+ path: string;
+ /** True while the file is still uploading to the server. */
+ uploading?: boolean;
+ /** Local blob URL for instant preview before upload completes. */
+ localUrl?: string;
+};
+
+function getFileCategory(
+ name: string,
+): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" {
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
+ if (
+ [
+ "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp",
+ "ico", "tiff", "heic",
+ ].includes(ext)
+ )
+ {return "image";}
+ if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
+ {return "video";}
+ if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
+ {return "audio";}
+ if (ext === "pdf") {return "pdf";}
+ if (
+ [
+ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
+ "cpp", "c", "h", "css", "html", "json", "yaml", "yml",
+ "toml", "md", "sh", "bash", "sql", "swift", "kt",
+ ].includes(ext)
+ )
+ {return "code";}
+ if (
+ [
+ "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
+ "rtf", "csv", "pages", "numbers", "key",
+ ].includes(ext)
+ )
+ {return "document";}
+ return "other";
+}
+
+function shortenPath(path: string): string {
+ return path
+ .replace(/^\/Users\/[^/]+/, "~")
+ .replace(/^\/home\/[^/]+/, "~")
+ .replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
+}
+
+const categoryMeta: Record = {
+ image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
+ video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
+ audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
+ pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
+ code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
+ document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
+ other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
+};
+
+function FileTypeIcon({ category }: { category: string }) {
+ const props = {
+ width: 16,
+ height: 16,
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 2,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+ };
+ switch (category) {
+ case "image":
+ return (
+
+ );
+ case "video":
+ return (
+
+ );
+ case "audio":
+ return (
+
+ );
+ case "pdf":
+ return (
+
+ );
+ case "code":
+ return (
+
+ );
+ case "document":
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+}
+
+function QueueItem({
+ msg,
+ idx,
+ onEdit,
+ onSendNow,
+ onRemove,
+}: {
+ msg: QueuedMessage;
+ idx: number;
+ onEdit: (id: string, text: string) => void;
+ onSendNow: (id: string) => void;
+ onRemove: (id: string) => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(msg.text);
+ const inputRef = useRef(null);
+
+ const autoResize = () => {
+ const el = inputRef.current;
+ if (!el) {return;}
+ el.style.height = "auto";
+ el.style.height = `${el.scrollHeight}px`;
+ };
+
+ useEffect(() => {
+ if (editing) {
+ inputRef.current?.focus();
+ const len = inputRef.current?.value.length ?? 0;
+ inputRef.current?.setSelectionRange(len, len);
+ autoResize();
+ }
+ }, [editing]);
+
+ const commitEdit = () => {
+ const trimmed = draft.trim();
+ if (trimmed && trimmed !== msg.text) {
+ onEdit(msg.id, trimmed);
+ } else {
+ setDraft(msg.text);
+ }
+ setEditing(false);
+ };
+
+ return (
+ 0 ? "border-t" : ""}`}
+ style={idx > 0 ? { borderColor: "var(--color-border)" } : undefined}
+ >
+
+ {idx + 1}
+
+ {editing ? (
+
+ );
+}
+
+function AttachmentStrip({
+ files,
+ compact,
+ onRemove,
+ onClearAll: _onClearAll,
+}: {
+ files: AttachedFile[];
+ compact?: boolean;
+ onRemove: (id: string) => void;
+ onClearAll: () => void;
+}) {
+ if (files.length === 0) {return null;}
+
+ return (
+
+
+ {files.map((af) => {
+ const category = getFileCategory(
+ af.name,
+ );
+ const meta =
+ categoryMeta[category] ??
+ categoryMeta.other;
+ const short = shortenPath(af.path);
+
+ return (
+
+ {/* Remove button */}
+
+
+ {category === "image" ? (
+ /* Image thumbnail β no filename */
+
{
+ (e.currentTarget as HTMLImageElement).style.display = "none";
+ }}
+ />
+ ) : category === "pdf" && af.path ? (
+ /* PDF thumbnail via Quick Look */
+
{
+ (e.currentTarget as HTMLImageElement).style.display = "none";
+ }}
+ />
+ ) : (
+
+
+
+
+
+
+ {af.name}
+
+
+ {af.uploading ? "Uploading..." : short}
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+// ββ SSE stream parser for reconnection ββ
+// Converts raw SSE events (AI SDK v6 wire format) into UIMessage parts.
+
+type ParsedPart =
+ | { type: "text"; text: string }
+ | { type: "user-message"; id?: string; text: string }
+ | { type: "reasoning"; text: string; state?: string }
+ | {
+ type: "dynamic-tool";
+ toolName: string;
+ toolCallId: string;
+ state: string;
+ input?: Record;
+ output?: Record;
+ };
+
+export function createStreamParser() {
+ const parts: ParsedPart[] = [];
+ let currentTextIdx = -1;
+ let currentReasoningIdx = -1;
+
+ function processEvent(event: Record) {
+ const t = event.type as string;
+
+ switch (t) {
+ case "user-message":
+ currentTextIdx = -1;
+ currentReasoningIdx = -1;
+ parts.push({
+ type: "user-message",
+ id: event.id as string | undefined,
+ text: (event.text as string) ?? "",
+ });
+ break;
+ case "reasoning-start":
+ parts.push({
+ type: "reasoning",
+ text: "",
+ state: "streaming",
+ });
+ currentReasoningIdx = parts.length - 1;
+ break;
+ case "reasoning-delta": {
+ if (currentReasoningIdx >= 0) {
+ const p = parts[currentReasoningIdx] as {
+ type: "reasoning";
+ text: string;
+ };
+ p.text += event.delta as string;
+ }
+ break;
+ }
+ case "reasoning-end":
+ if (currentReasoningIdx >= 0) {
+ const p = parts[currentReasoningIdx] as {
+ type: "reasoning";
+ state?: string;
+ };
+ delete p.state;
+ }
+ currentReasoningIdx = -1;
+ break;
+ case "text-start":
+ parts.push({ type: "text", text: "" });
+ currentTextIdx = parts.length - 1;
+ break;
+ case "text-delta": {
+ if (currentTextIdx >= 0) {
+ const p = parts[currentTextIdx] as {
+ type: "text";
+ text: string;
+ };
+ p.text += event.delta as string;
+ }
+ break;
+ }
+ case "text-end":
+ currentTextIdx = -1;
+ break;
+ case "tool-input-start":
+ parts.push({
+ type: "dynamic-tool",
+ toolCallId: event.toolCallId as string,
+ toolName: event.toolName as string,
+ state: "input-available",
+ input: {},
+ });
+ break;
+ case "tool-input-available":
+ for (let i = parts.length - 1; i >= 0; i--) {
+ const p = parts[i];
+ if (
+ p.type === "dynamic-tool" &&
+ p.toolCallId === event.toolCallId
+ ) {
+ p.input =
+ (event.input as Record) ??
+ {};
+ break;
+ }
+ }
+ break;
+ case "tool-output-available":
+ for (let i = parts.length - 1; i >= 0; i--) {
+ const p = parts[i];
+ if (
+ p.type === "dynamic-tool" &&
+ p.toolCallId === event.toolCallId
+ ) {
+ p.state = "output-available";
+ p.output =
+ (event.output as Record<
+ string,
+ unknown
+ >) ?? {};
+ break;
+ }
+ }
+ break;
+ case "tool-output-error":
+ for (let i = parts.length - 1; i >= 0; i--) {
+ const p = parts[i];
+ if (
+ p.type === "dynamic-tool" &&
+ p.toolCallId === event.toolCallId
+ ) {
+ p.state = "error";
+ p.output = {
+ error: event.errorText as string,
+ };
+ break;
+ }
+ }
+ break;
+ }
+ }
+
+ return {
+ processEvent,
+ getParts: (): ParsedPart[] => parts.map((p) => ({ ...p })),
+ };
+}
+
+/** Imperative handle for parent-driven session control (main page). */
+export type ChatPanelHandle = {
+ loadSession: (sessionId: string) => Promise;
+ newSession: () => Promise;
+ /** Create a new session and immediately send a message. */
+ sendNewMessage: (text: string) => Promise;
+ /** Insert a file mention into the chat editor (e.g. from sidebar drag). */
+ insertFileMention?: (name: string, path: string) => void;
+};
+
+export type FileContext = {
+ path: string;
+ filename: string;
+ /** When true the path refers to a directory rather than a file. */
+ isDirectory?: boolean;
+};
+
+type FileScopedSession = {
+ id: string;
+ title: string;
+ createdAt: number;
+ updatedAt: number;
+ messageCount: number;
+};
+
+/** A message waiting to be sent after the current agent run finishes. */
+type QueuedMessage = {
+ id: string;
+ text: string;
+ mentionedFiles: Array<{ name: string; path: string }>;
+ attachedFiles: AttachedFile[];
+ createdAt: number;
+};
+
+export type SubagentSpawnInfo = {
+ childSessionKey: string;
+ runId: string;
+ task: string;
+ label?: string;
+ parentSessionId: string;
+ status?: "running" | "completed" | "error";
+};
+
+type ChatPanelProps = {
+ /** When set, scopes sessions to this file and prepends content as context. */
+ fileContext?: FileContext;
+ /** Compact mode for workspace sidebar (smaller UI, built-in session tabs). */
+ compact?: boolean;
+ /** Override the header title when a session is active (e.g. show the session's actual title). */
+ sessionTitle?: string;
+ /** Session ID to auto-load on mount (for non-file panels that remount after navigation). */
+ initialSessionId?: string;
+ /** Called when file content may have changed after agent edits. */
+ onFileChanged?: (newContent: string) => void;
+ /** Called when active session changes (for external sidebar highlighting). */
+ onActiveSessionChange?: (sessionId: string | null) => void;
+ /** Called when session list needs refresh (for external sidebar). */
+ onSessionsChange?: () => void;
+ /** Called when the agent spawns a subagent. */
+ onSubagentSpawned?: (info: SubagentSpawnInfo) => void;
+ /** Called when user clicks a subagent card in the chat to view its output. */
+ onSubagentClick?: (task: string) => void;
+ /** Called when user clicks an inline file path in chat output. */
+ onFilePathClick?: (path: string) => Promise | boolean | void;
+ /** Called when user deletes the current session (e.g. from header menu). */
+ onDeleteSession?: (sessionId: string) => void;
+ /** Called when user renames the current session. */
+ onRenameSession?: (sessionId: string, newTitle: string) => void;
+};
+
+export const ChatPanel = forwardRef(
+ function ChatPanelInner(
+ {
+ fileContext,
+ compact,
+ sessionTitle,
+ initialSessionId,
+ onFileChanged,
+ onActiveSessionChange,
+ onSessionsChange,
+ onSubagentSpawned,
+ onSubagentClick,
+ onFilePathClick,
+ onDeleteSession,
+ onRenameSession: _onRenameSession,
+ },
+ ref,
+ ) {
+ const editorRef = useRef(null);
+ const [editorEmpty, setEditorEmpty] = useState(true);
+ const [currentSessionId, setCurrentSessionId] = useState<
+ string | null
+ >(null);
+ const [loadingSession, setLoadingSession] = useState(false);
+ const messagesEndRef = useRef(null);
+
+ // ββ Attachment state ββ
+ const [attachedFiles, setAttachedFiles] = useState<
+ AttachedFile[]
+ >([]);
+ const [showFilePicker, setShowFilePicker] =
+ useState(false);
+
+ // ββ Reconnection state ββ
+ const [isReconnecting, setIsReconnecting] = useState(false);
+ const reconnectAbortRef = useRef(null);
+
+ // Track persisted messages to avoid double-saves
+ const savedMessageIdsRef = useRef>(new Set());
+ // Set when /new or + triggers a new session
+ const newSessionPendingRef = useRef(false);
+ // Whether the next message should include file context
+ const isFirstFileMessageRef = useRef(true);
+
+ // File-scoped session list (compact mode only)
+ const [fileSessions, setFileSessions] = useState<
+ FileScopedSession[]
+ >([]);
+
+ // ββ Message queue (messages to send after current run completes) ββ
+ const [queuedMessages, setQueuedMessages] = useState([]);
+ const [rawView, _setRawView] = useState(false);
+
+ const filePath = fileContext?.path ?? null;
+
+ // ββ Ref-based session ID for transport ββ
+ const sessionIdRef = useRef(null);
+ useEffect(() => {
+ sessionIdRef.current = currentSessionId;
+ }, [currentSessionId]);
+
+ // ββ Transport (per-instance) ββ
+ const transport = useMemo(
+ () =>
+ new DefaultChatTransport({
+ api: "/api/chat",
+ body: () => {
+ const sid = sessionIdRef.current;
+ return sid ? { sessionId: sid } : {};
+ },
+ }),
+ [],
+ );
+
+ const { messages, sendMessage, status, stop, error, setMessages } =
+ useChat({ transport });
+
+ const isStreaming =
+ status === "streaming" ||
+ status === "submitted" ||
+ isReconnecting;
+
+ // Auto-scroll to bottom on new messages, but only when the user
+ // is already near the bottom. If the user scrolls up during
+ // streaming, we stop auto-scrolling until they return to the
+ // bottom (or a new user message is sent).
+ const scrollContainerRef = useRef(null);
+ const userScrolledAwayRef = useRef(false);
+ const scrollRafRef = useRef(0);
+
+ // Detect when the user scrolls away from the bottom.
+ useEffect(() => {
+ const el = scrollContainerRef.current;
+ if (!el) {return;}
+
+ const onScroll = () => {
+ const distanceFromBottom =
+ el.scrollHeight - el.scrollTop - el.clientHeight;
+ // Threshold: if within 80px of the bottom, consider "at bottom"
+ userScrolledAwayRef.current = distanceFromBottom > 80;
+ };
+
+ el.addEventListener("scroll", onScroll, { passive: true });
+ return () => el.removeEventListener("scroll", onScroll);
+ }, []);
+
+ // Auto-scroll effect β skips when user has scrolled away.
+ useEffect(() => {
+ if (userScrolledAwayRef.current) {return;}
+ if (scrollRafRef.current) {return;}
+ scrollRafRef.current = requestAnimationFrame(() => {
+ scrollRafRef.current = 0;
+ messagesEndRef.current?.scrollIntoView({
+ behavior: "smooth",
+ });
+ });
+ }, [messages]);
+
+ // ββ Session persistence helpers ββ
+
+ const createSession = useCallback(
+ async (title: string): Promise => {
+ const body: Record = { title };
+ if (filePath) {
+ body.filePath = filePath;
+ }
+ const res = await fetch("/api/web-sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ return data.session.id;
+ },
+ [filePath],
+ );
+
+ // ββ Stream reconnection ββ
+ // Attempts to reconnect to an active agent run for the given session.
+ // Replays buffered SSE events and streams live updates.
+ const attemptReconnect = useCallback(
+ async (
+ sessionId: string,
+ baseMessages: Array<{
+ id: string;
+ role: "user" | "assistant" | "system";
+ parts: UIMessage["parts"];
+ }>,
+ ): Promise => {
+ const abort = new AbortController();
+ reconnectAbortRef.current = abort;
+
+ try {
+ const res = await fetch(
+ `/api/chat/stream?sessionId=${encodeURIComponent(sessionId)}`,
+ { signal: abort.signal },
+ );
+ if (!res.ok || !res.body) {
+ return false; // No active run
+ }
+
+ // If the run already completed (still in the grace
+ // period), skip the expensive SSE replay -- the
+ // persisted messages we already loaded are final.
+ if (res.headers.get("X-Run-Active") === "false") {
+ void res.body.cancel();
+ return false;
+ }
+
+ setIsReconnecting(true);
+
+ const parser = createStreamParser();
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ const reconnectMsgId = `reconnect-${sessionId}`;
+ let buffer = "";
+ let frameRequested = false;
+
+ const updateUI = () => {
+ // Guard: if the session was switched while a
+ // rAF was pending, don't overwrite the new
+ // session's messages with stale data.
+ if (abort.signal.aborted) {return;}
+ const assistantMsg = {
+ id: reconnectMsgId,
+ role: "assistant" as const,
+ parts: parser.getParts() as UIMessage["parts"],
+ };
+ setMessages([
+ ...baseMessages,
+ assistantMsg,
+ ]);
+ };
+
+ // Read the SSE stream
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- loop reads until done
+ while (true) {
+ const { done, value } =
+ await reader.read();
+ if (done) {break;}
+
+ buffer += decoder.decode(value, {
+ stream: true,
+ });
+
+ // Parse SSE events (data: \n\n)
+ let idx;
+ while (
+ (idx = buffer.indexOf("\n\n")) !== -1
+ ) {
+ const chunk = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 2);
+
+ if (chunk.startsWith("data: ")) {
+ try {
+ const event = JSON.parse(
+ chunk.slice(6),
+ );
+ parser.processEvent(event);
+ } catch {
+ /* skip malformed events */
+ }
+ }
+ }
+
+ // Batch UI updates to animation frames
+ if (!frameRequested) {
+ frameRequested = true;
+ requestAnimationFrame(() => {
+ frameRequested = false;
+ updateUI();
+ });
+ }
+ }
+
+ // Final update after stream ends
+ updateUI();
+
+ // Mark all messages as saved (server persisted them)
+ if (!abort.signal.aborted) {
+ for (const m of baseMessages) {
+ savedMessageIdsRef.current.add(m.id);
+ }
+ savedMessageIdsRef.current.add(reconnectMsgId);
+ }
+
+ setIsReconnecting(false);
+ reconnectAbortRef.current = null;
+ return true;
+ } catch (err) {
+ if (
+ (err as Error).name !== "AbortError"
+ ) {
+ console.error(
+ "Reconnection error:",
+ err,
+ );
+ }
+ setIsReconnecting(false);
+ reconnectAbortRef.current = null;
+ return false;
+ }
+ },
+ [setMessages],
+ );
+
+ // ββ File-scoped session initialization ββ
+ const fetchFileSessionsRef = useRef<
+ (() => Promise) | null
+ >(null);
+
+ fetchFileSessionsRef.current = async () => {
+ if (!filePath) {
+ return [];
+ }
+ try {
+ const res = await fetch(
+ `/api/web-sessions?filePath=${encodeURIComponent(filePath)}`,
+ );
+ const data = await res.json();
+ return (data.sessions || []) as FileScopedSession[];
+ } catch {
+ return [];
+ }
+ };
+
+ useEffect(() => {
+ if (!filePath) {
+ return;
+ }
+ let cancelled = false;
+
+ sessionIdRef.current = null;
+ setCurrentSessionId(null);
+ onActiveSessionChange?.(null);
+ setMessages([]);
+ savedMessageIdsRef.current.clear();
+ isFirstFileMessageRef.current = true;
+
+ void (async () => {
+ const sessions =
+ (await fetchFileSessionsRef.current?.()) ?? [];
+ if (cancelled) {
+ return;
+ }
+ setFileSessions(sessions);
+
+ if (sessions.length > 0) {
+ const latest = sessions[0];
+ setCurrentSessionId(latest.id);
+ sessionIdRef.current = latest.id;
+ onActiveSessionChange?.(latest.id);
+ isFirstFileMessageRef.current = false;
+
+ try {
+ const msgRes = await fetch(
+ `/api/web-sessions/${latest.id}`,
+ );
+ if (cancelled) {
+ return;
+ }
+ const msgData = await msgRes.json();
+ const sessionMessages: Array<{
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ parts?: Array>;
+ _streaming?: boolean;
+ }> = msgData.messages || [];
+
+ // Filter out in-progress streaming messages
+ // (will be rebuilt from the live SSE stream)
+ const hasStreaming = sessionMessages.some(
+ (m) => m._streaming,
+ );
+ const completedMessages = hasStreaming
+ ? sessionMessages.filter(
+ (m) => !m._streaming,
+ )
+ : sessionMessages;
+
+ const uiMessages = completedMessages.map(
+ (msg) => {
+ savedMessageIdsRef.current.add(msg.id);
+ return {
+ id: msg.id,
+ role: msg.role,
+ parts: (msg.parts ?? [
+ {
+ type: "text" as const,
+ text: msg.content,
+ },
+ ]) as UIMessage["parts"],
+ };
+ },
+ );
+ if (!cancelled) {
+ setMessages(uiMessages);
+ }
+
+ // Always try to reconnect to a potentially
+ // active agent run. The stream endpoint returns
+ // 404 gracefully if no run exists, avoiding the
+ // 2-second persistence timing gap for _streaming.
+ if (!cancelled) {
+ await attemptReconnect(
+ latest.id,
+ uiMessages,
+ );
+ }
+ } catch {
+ // ignore
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters
+ }, [filePath, attemptReconnect]);
+
+ // ββ Non-file panel: auto-restore session on mount ββ
+ // When the main ChatPanel remounts after navigation (e.g. user viewed
+ // a file then returned to chat), re-load the previously active session
+ // and reconnect to any active stream.
+ const initialSessionHandled = useRef(false);
+ useEffect(() => {
+ if (filePath || !initialSessionId || initialSessionHandled.current) {
+ return;
+ }
+ initialSessionHandled.current = true;
+ void handleSessionSelect(initialSessionId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount
+ }, []);
+
+ // ββ Poll for subagent spawns during active streaming ββ
+ const [hasRunningSubagents, setHasRunningSubagents] = useState(false);
+
+ useEffect(() => {
+ if (!currentSessionId || !onSubagentSpawned) {return;}
+ let cancelled = false;
+
+ const poll = async () => {
+ try {
+ const res = await fetch(
+ `/api/chat/subagents?sessionId=${encodeURIComponent(currentSessionId)}`,
+ );
+ if (cancelled || !res.ok) {return;}
+ const data = await res.json();
+ const subagents: Array<{
+ sessionKey: string;
+ runId: string;
+ task: string;
+ label?: string;
+ status: "running" | "completed" | "error";
+ }> = data.subagents ?? [];
+ let anyRunning = false;
+ for (const sa of subagents) {
+ if (sa.status === "running") {anyRunning = true;}
+ onSubagentSpawned({
+ childSessionKey: sa.sessionKey,
+ runId: sa.runId,
+ task: sa.task,
+ label: sa.label,
+ parentSessionId: currentSessionId,
+ status: sa.status,
+ });
+ }
+ if (!cancelled) {setHasRunningSubagents(anyRunning);}
+ } catch { /* ignore */ }
+ };
+
+ void poll();
+ const id = setInterval(poll, 3_000);
+ return () => { cancelled = true; clearInterval(id); };
+ }, [currentSessionId, onSubagentSpawned]);
+
+ // ββ Post-stream side-effects (file-reload, session refresh) ββ
+ // Message persistence is handled server-side by ActiveRunManager,
+ // so we only refresh the file sessions list and notify the parent
+ // when the file content may have changed.
+ const prevStatusRef = useRef(status);
+ useEffect(() => {
+ const wasStreaming =
+ prevStatusRef.current === "streaming" ||
+ prevStatusRef.current === "submitted";
+ const isNowReady = status === "ready";
+
+ if (wasStreaming && isNowReady && currentSessionId) {
+ // Mark all current messages as saved β the server
+ // already persisted them via ActiveRunManager.
+ for (const m of messages) {
+ savedMessageIdsRef.current.add(m.id);
+ }
+
+ if (filePath) {
+ void fetchFileSessionsRef.current?.().then(
+ (sessions) => {
+ setFileSessions(sessions);
+ },
+ );
+ }
+
+ if (filePath && onFileChanged) {
+ fetch(
+ `/api/workspace/file?path=${encodeURIComponent(filePath)}`,
+ )
+ .then((r) => r.json())
+ .then((data) => {
+ if (data.content) {
+ onFileChanged(data.content);
+ }
+ })
+ .catch(() => {});
+ }
+
+ onSessionsChange?.();
+ }
+ prevStatusRef.current = status;
+ }, [
+ status,
+ messages,
+ currentSessionId,
+ filePath,
+ onFileChanged,
+ onSessionsChange,
+ ]);
+
+ // ββ Actions ββ
+
+ // Ref for handleNewSession so handleEditorSubmit doesn't depend on the hook order
+ const handleNewSessionRef = useRef<() => void>(() => {});
+
+ /** Submit from the Tiptap editor (called on Enter or send button).
+ * `overrideAttachments` is used by the queue system to pass saved attachments directly. */
+ const handleEditorSubmit = useCallback(
+ async (
+ text: string,
+ mentionedFiles: Array<{ name: string; path: string }>,
+ overrideAttachments?: AttachedFile[],
+ ) => {
+ const hasText = text.trim().length > 0;
+ const hasMentions = mentionedFiles.length > 0;
+ // Use override attachments (from queue) or current state
+ const readyFiles = overrideAttachments
+ ? overrideAttachments.filter((f) => !f.uploading && f.path)
+ : attachedFiles.filter((f) => !f.uploading && f.path);
+ const hasFiles = readyFiles.length > 0;
+ if (!hasText && !hasMentions && !hasFiles) {
+ return;
+ }
+
+ const userText = text.trim();
+ const currentAttachments = [...readyFiles];
+
+ if (userText.toLowerCase() === "/new") {
+ // Revoke blob URLs before clearing
+ for (const f of attachedFiles) {
+ if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
+ }
+ setAttachedFiles([]);
+ handleNewSessionRef.current();
+ return;
+ }
+
+ // Queue the message if the agent is still running.
+ if (isStreaming) {
+ // Clear attachment strip but keep blob URLs alive for queue thumbnails
+ if (!overrideAttachments) {
+ setAttachedFiles([]);
+ }
+ setQueuedMessages((prev) => [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ text: userText,
+ mentionedFiles,
+ attachedFiles: currentAttachments,
+ createdAt: Date.now(),
+ },
+ ]);
+ return;
+ }
+
+ // Clear attachments (revoke blob URLs to free memory)
+ if (!overrideAttachments && currentAttachments.length > 0) {
+ for (const f of attachedFiles) {
+ if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
+ }
+ setAttachedFiles([]);
+ }
+
+ let sessionId = currentSessionId;
+ if (!sessionId) {
+ const titleSource =
+ userText || "File attachment";
+ const title =
+ titleSource.length > 60
+ ? titleSource.slice(0, 60) + "..."
+ : titleSource;
+ sessionId = await createSession(title);
+ setCurrentSessionId(sessionId);
+ sessionIdRef.current = sessionId;
+ onActiveSessionChange?.(sessionId);
+ onSessionsChange?.();
+
+ if (filePath) {
+ void fetchFileSessionsRef.current?.().then(
+ (sessions) => {
+ setFileSessions(sessions);
+ },
+ );
+ }
+ }
+
+ // Build message with optional attachment prefix
+ let messageText = userText;
+
+ // Merge mention paths and attachment paths
+ const allFilePaths = [
+ ...mentionedFiles.map((f) => f.path),
+ ...currentAttachments.map((f) => f.path),
+ ];
+ if (allFilePaths.length > 0) {
+ const prefix = `[Attached files: ${allFilePaths.join(", ")}]`;
+ messageText = messageText
+ ? `${prefix}\n\n${messageText}`
+ : prefix;
+ }
+
+ if (fileContext && isFirstFileMessageRef.current) {
+ const label = fileContext.isDirectory ? "directory" : "file";
+ messageText = `[Context: workspace ${label} '${fileContext.path}']\n\n${messageText}`;
+ isFirstFileMessageRef.current = false;
+ }
+
+ // Reset scroll lock so we auto-scroll to the new user message
+ userScrolledAwayRef.current = false;
+ void sendMessage({ text: messageText });
+ },
+ [
+ attachedFiles,
+ isStreaming,
+ currentSessionId,
+ createSession,
+ onActiveSessionChange,
+ onSessionsChange,
+ filePath,
+ fileContext,
+ sendMessage,
+ ],
+ );
+
+ // ββ Queue flush: send the next queued message once the stream finishes ββ
+ const prevFlushStatusRef = useRef(status);
+ useEffect(() => {
+ const wasStreaming =
+ prevFlushStatusRef.current === "streaming" ||
+ prevFlushStatusRef.current === "submitted";
+ const isNowReady = status === "ready";
+ prevFlushStatusRef.current = status;
+
+ if (wasStreaming && isNowReady && queuedMessages.length > 0) {
+ const [next, ...rest] = queuedMessages;
+ setQueuedMessages(rest);
+ // Revoke blob URLs from queued attachments (no longer needed for thumbnails)
+ for (const f of next.attachedFiles) {
+ if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
+ }
+ // Use a microtask so React can settle the status update first.
+ queueMicrotask(() => {
+ void handleEditorSubmit(next.text, next.mentionedFiles, next.attachedFiles);
+ });
+ }
+ }, [status, queuedMessages, handleEditorSubmit]);
+
+ const handleSessionSelect = useCallback(
+ async (sessionId: string) => {
+ if (sessionId === currentSessionId) {
+ return;
+ }
+
+ // Stop any active stream/reconnection for the old session.
+ reconnectAbortRef.current?.abort();
+ void stop();
+
+ setLoadingSession(true);
+ setCurrentSessionId(sessionId);
+ sessionIdRef.current = sessionId;
+ onActiveSessionChange?.(sessionId);
+ savedMessageIdsRef.current.clear();
+ isFirstFileMessageRef.current = false;
+ setQueuedMessages([]);
+
+ try {
+ const response = await fetch(
+ `/api/web-sessions/${sessionId}`,
+ );
+ if (!response.ok) {
+ throw new Error("Failed to load session");
+ }
+
+ const data = await response.json();
+ const sessionMessages: Array<{
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ parts?: Array>;
+ _streaming?: boolean;
+ }> = data.messages || [];
+
+ const hasStreaming = sessionMessages.some(
+ (m) => m._streaming,
+ );
+ const completedMessages = hasStreaming
+ ? sessionMessages.filter(
+ (m) => !m._streaming,
+ )
+ : sessionMessages;
+
+ const uiMessages = completedMessages.map(
+ (msg) => {
+ savedMessageIdsRef.current.add(msg.id);
+ return {
+ id: msg.id,
+ role: msg.role,
+ parts: (msg.parts ?? [
+ {
+ type: "text" as const,
+ text: msg.content,
+ },
+ ]) as UIMessage["parts"],
+ };
+ },
+ );
+
+ setMessages(uiMessages);
+
+ // Clear loading state *before* reconnecting β the
+ // persisted messages are now visible. attemptReconnect
+ // manages its own `isReconnecting` state which shows
+ // "Resuming stream..." instead of "Loading session...".
+ setLoadingSession(false);
+
+ // Always try to reconnect -- the stream endpoint
+ // returns 404 gracefully if no active run exists,
+ // and this avoids missing runs whose _streaming
+ // flag hasn't been persisted yet.
+ await attemptReconnect(sessionId, uiMessages);
+ } catch (err) {
+ console.error("Error loading session:", err);
+ setLoadingSession(false);
+ }
+ },
+ [
+ currentSessionId,
+ setMessages,
+ onActiveSessionChange,
+ stop,
+ attemptReconnect,
+ ],
+ );
+
+ const handleNewSession = useCallback(() => {
+ reconnectAbortRef.current?.abort();
+ void stop();
+ setIsReconnecting(false);
+ setCurrentSessionId(null);
+ sessionIdRef.current = null;
+ onActiveSessionChange?.(null);
+ setMessages([]);
+ savedMessageIdsRef.current.clear();
+ isFirstFileMessageRef.current = true;
+ newSessionPendingRef.current = false;
+ setQueuedMessages([]);
+ // Focus the chat input after state updates so "New Chat" is ready to type.
+ requestAnimationFrame(() => {
+ editorRef.current?.focus();
+ });
+ }, [setMessages, onActiveSessionChange, stop]);
+
+ // Keep the ref in sync so handleEditorSubmit can call it
+ handleNewSessionRef.current = handleNewSession;
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ loadSession: handleSessionSelect,
+ newSession: async () => { handleNewSession(); },
+ sendNewMessage: async (text: string) => {
+ handleNewSession();
+ const title =
+ text.length > 60 ? text.slice(0, 60) + "..." : text;
+ const sessionId = await createSession(title);
+ setCurrentSessionId(sessionId);
+ sessionIdRef.current = sessionId;
+ onActiveSessionChange?.(sessionId);
+ onSessionsChange?.();
+ userScrolledAwayRef.current = false;
+ void sendMessage({ text });
+ },
+ insertFileMention: (name: string, path: string) => {
+ editorRef.current?.insertFileMention(name, path);
+ },
+ }),
+ [handleSessionSelect, handleNewSession, createSession, onActiveSessionChange, onSessionsChange, sendMessage],
+ );
+
+ // ββ Stop handler (aborts server-side run + client-side stream) ββ
+ const handleStop = useCallback(async () => {
+ // Abort reconnection stream if active (immediate visual feedback).
+ reconnectAbortRef.current?.abort();
+ setIsReconnecting(false);
+
+ // Stop the server-side agent run and wait for confirmation so the
+ // session is no longer in "running" state before we stop the
+ // client-side stream (which may trigger queued message flush).
+ if (currentSessionId) {
+ try {
+ await fetch("/api/chat/stop", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ sessionId: currentSessionId,
+ }),
+ });
+ } catch { /* ignore */ }
+ }
+
+ // Stop the useChat transport stream (transitions status β "ready").
+ void stop();
+ }, [currentSessionId, stop]);
+
+ // ββ Queue handlers ββ
+
+ const removeQueuedMessage = useCallback((id: string) => {
+ setQueuedMessages((prev) => prev.filter((m) => m.id !== id));
+ }, []);
+
+ const updateQueuedMessageText = useCallback((id: string, text: string) => {
+ setQueuedMessages((prev) => prev.map((m) => m.id === id ? { ...m, text } : m));
+ }, []);
+
+ /** Force-send: stop the agent, then immediately submit this queued message. */
+ const forceSendQueuedMessage = useCallback(
+ async (id: string) => {
+ const msg = queuedMessages.find((m) => m.id === id);
+ if (!msg) {return;}
+ // Remove it from the queue first.
+ setQueuedMessages((prev) => prev.filter((m) => m.id !== id));
+ // Stop the current agent run.
+ await handleStop();
+ // Submit the message after a short delay to let status settle.
+ setTimeout(() => {
+ void handleEditorSubmit(msg.text, msg.mentionedFiles, msg.attachedFiles);
+ }, 100);
+ },
+ [queuedMessages, handleStop, handleEditorSubmit],
+ );
+
+ // ββ Attachment handlers ββ
+
+ const handleFilesSelected = useCallback(
+ (files: SelectedFile[]) => {
+ const newFiles: AttachedFile[] = files.map(
+ (f) => ({
+ id: `${f.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ name: f.name,
+ path: f.path,
+ }),
+ );
+ setAttachedFiles((prev) => [
+ ...prev,
+ ...newFiles,
+ ]);
+ },
+ [],
+ );
+
+ const removeAttachment = useCallback((id: string) => {
+ setAttachedFiles((prev) => {
+ const removed = prev.find((f) => f.id === id);
+ if (removed?.localUrl) {URL.revokeObjectURL(removed.localUrl);}
+ return prev.filter((f) => f.id !== id);
+ });
+ }, []);
+
+ const clearAllAttachments = useCallback(() => {
+ setAttachedFiles((prev) => {
+ for (const f of prev) {
+ if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
+ }
+ return [];
+ });
+ }, []);
+
+ /** Upload native files (e.g. dropped from Finder/Desktop) and attach them.
+ * Shows files instantly with a local preview, then uploads in the background. */
+ const uploadAndAttachNativeFiles = useCallback(
+ (files: FileList) => {
+ const fileArray = Array.from(files);
+
+ // Immediately add placeholder entries with local blob URLs
+ const placeholders: AttachedFile[] = fileArray.map((file) => ({
+ id: `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ name: file.name,
+ path: "",
+ uploading: true,
+ localUrl: URL.createObjectURL(file),
+ }));
+ setAttachedFiles((prev) => [...prev, ...placeholders]);
+
+ // Upload each file in the background and update the entry
+ for (let i = 0; i < fileArray.length; i++) {
+ const file = fileArray[i];
+ const placeholderId = placeholders[i].id;
+ const localUrl = placeholders[i].localUrl;
+
+ const form = new FormData();
+ form.append("file", file);
+ fetch("/api/workspace/upload", {
+ method: "POST",
+ body: form,
+ })
+ .then((res) => res.ok ? res.json() : null)
+ .then((json: { ok?: boolean; path?: string } | null) => {
+ if (json?.ok && json.path) {
+ // Replace placeholder with the real uploaded file
+ setAttachedFiles((prev) =>
+ prev.map((f) =>
+ f.id === placeholderId
+ ? { ...f, path: json.path!, uploading: false }
+ : f,
+ ),
+ );
+ } else {
+ // Upload failed β remove the placeholder
+ setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholderId));
+ if (localUrl) {URL.revokeObjectURL(localUrl);}
+ }
+ })
+ .catch(() => {
+ setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholderId));
+ if (localUrl) {URL.revokeObjectURL(localUrl);}
+ });
+ }
+ },
+ [],
+ );
+
+ // ββ Status label ββ
+
+ const _statusLabel = loadingSession
+ ? "Loading session..."
+ : isReconnecting
+ ? "Resuming stream..."
+ : status === "ready"
+ ? "Ready"
+ : status === "submitted"
+ ? "Thinking..."
+ : status === "streaming"
+ ? (hasRunningSubagents ? "Waiting for subagents..." : "Streaming...")
+ : status === "error"
+ ? "Error"
+ : status;
+
+ // Show an inline Unicode spinner in the message flow when the AI
+ // is thinking/streaming but hasn't produced visible text yet.
+ const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
+ const lastAssistantHasText =
+ lastMsg?.role === "assistant" &&
+ lastMsg.parts.some((p) => p.type === "text" && (p as { text: string }).text.length > 0);
+ const showInlineSpinner = isStreaming && !lastAssistantHasText;
+
+ // ββ Render ββ
+
+ return (
+
+ {/* Header β sticky glass bar */}
+
+
+ {compact && fileContext ? (
+
+ Chat: {fileContext.filename}
+
+ ) : (
+
+ {currentSessionId
+ ? (sessionTitle || "Chat Session")
+ : "New Chat"}
+
+ )}
+
+
+ {currentSessionId && onDeleteSession && (
+
+
+
+
+
+ onDeleteSession(currentSessionId)}
+ >
+
+ Delete
+
+
+
+ )}
+ {compact && (
+
+ )}
+
+
+
+ {/* File-scoped session tabs (compact mode) */}
+ {compact && fileContext && fileSessions.length > 0 && (
+
+ {fileSessions.slice(0, 10).map((s) => (
+
+ ))}
+
+ )}
+
+
+ {/* Messages */}
+
+ {loadingSession ? (
+
+
+
+
+ Loading session...
+
+
+
+ ) : messages.length === 0 ? (
+
+
+ {compact ? (
+
+ Ask about this file
+
+ ) : (
+ <>
+
+ What can I help with?
+
+
+ Send a message to start a
+ conversation with your
+ agent.
+
+ >
+ )}
+
+
+ ) : (
+
+ {rawView ? (
+
+ {JSON.stringify(messages, null, 2)}
+
+ ) : messages.map((message, i) => (
+
+ ))}
+ {showInlineSpinner && (
+
+
+
+ )}
+
+
+ )}
+
+
+ {/* Transport-level error display */}
+ {error && (
+
+
+ {error.message}
+
+ )}
+
+
+ {/* Input bar at bottom */}
+
+
+ {
+ if (
+ e.dataTransfer?.types.includes("application/x-file-mention") ||
+ e.dataTransfer?.types.includes("Files")
+ ) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ // visual feedback
+ (e.currentTarget as HTMLElement).setAttribute("data-drag-hover", "");
+ }
+ }}
+ onDragLeave={(e) => {
+ // Only remove when leaving the container itself (not entering a child)
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
+ (e.currentTarget as HTMLElement).removeAttribute("data-drag-hover");
+ }
+ }}
+ onDrop={(e) => {
+ (e.currentTarget as HTMLElement).removeAttribute("data-drag-hover");
+
+ // Sidebar file mention drop
+ const data = e.dataTransfer?.getData("application/x-file-mention");
+ if (data) {
+ e.preventDefault();
+ e.stopPropagation();
+ try {
+ const { name, path } = JSON.parse(data) as { name: string; path: string };
+ if (name && path) {
+ editorRef.current?.insertFileMention(name, path);
+ }
+ } catch {
+ // ignore malformed data
+ }
+ return;
+ }
+
+ // Native file drop (from OS file manager / Desktop)
+ const files = e.dataTransfer?.files;
+ if (files && files.length > 0) {
+ e.preventDefault();
+ e.stopPropagation();
+ uploadAndAttachNativeFiles(files);
+ }
+ }}
+ >
+ {/* Queued messages indicator */}
+ {queuedMessages.length > 0 && (
+
+
+
+ Queue ({queuedMessages.length})
+
+
+ {queuedMessages.map((msg, idx) => (
+
+ ))}
+
+
+
+ )}
+
+ {/* Attachment preview strip */}
+
+
+
+ setEditorEmpty(isEmpty)
+ }
+ onNativeFileDrop={uploadAndAttachNativeFiles}
+ placeholder={
+ compact && fileContext
+ ? `Ask about ${fileContext.isDirectory ? "this folder" : fileContext.filename}...`
+ : isStreaming
+ ? "Type to queue a message..."
+ : attachedFiles.length >
+ 0
+ ? "Add a message or send files..."
+ : "Type @ to mention files..."
+ }
+ disabled={loadingSession}
+ compact={compact}
+ />
+
+ {/* Toolbar row */}
+
+
+
+
+ {/* Send / Stop / Queue buttons */}
+
+ {isStreaming && (
+
+ )}
+ {isStreaming ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {/* File picker modal */}
+
+ setShowFilePicker(false)
+ }
+ onSelect={handleFilesSelected}
+ />
+
+
+ );
+ },
+);
diff --git a/apps/web/app/components/cron/cron-dashboard.tsx b/apps/web/app/components/cron/cron-dashboard.tsx
new file mode 100644
index 00000000000..e21a5270767
--- /dev/null
+++ b/apps/web/app/components/cron/cron-dashboard.tsx
@@ -0,0 +1,444 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import type {
+ CronJob,
+ HeartbeatInfo,
+ CronStatusInfo,
+ CronJobsResponse,
+} from "../../types/cron";
+
+/* βββ Helpers βββ */
+
+function formatSchedule(schedule: CronJob["schedule"]): string {
+ switch (schedule.kind) {
+ case "cron":
+ return `cron: ${schedule.expr}${schedule.tz ? ` (${schedule.tz})` : ""}`;
+ case "every": {
+ const ms = schedule.everyMs;
+ if (ms >= 86_400_000) {return `every ${Math.round(ms / 86_400_000)}d`;}
+ if (ms >= 3_600_000) {return `every ${Math.round(ms / 3_600_000)}h`;}
+ if (ms >= 60_000) {return `every ${Math.round(ms / 60_000)}m`;}
+ return `every ${Math.round(ms / 1000)}s`;
+ }
+ case "at":
+ return `at ${schedule.at}`;
+ default:
+ return "unknown";
+ }
+}
+
+function formatCountdown(ms: number): string {
+ if (ms <= 0) {return "now";}
+ const totalSec = Math.ceil(ms / 1000);
+ if (totalSec < 60) {return `${totalSec}s`;}
+ const min = Math.floor(totalSec / 60);
+ const sec = totalSec % 60;
+ if (min < 60) {return sec > 0 ? `${min}m ${sec}s` : `${min}m`;}
+ const hr = Math.floor(min / 60);
+ const remMin = min % 60;
+ return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
+}
+
+function formatTimeAgo(ms: number): string {
+ const ago = Date.now() - ms;
+ if (ago < 60_000) {return "just now";}
+ if (ago < 3_600_000) {return `${Math.floor(ago / 60_000)}m ago`;}
+ if (ago < 86_400_000) {return `${Math.floor(ago / 3_600_000)}h ago`;}
+ return `${Math.floor(ago / 86_400_000)}d ago`;
+}
+
+function formatDuration(ms: number): string {
+ if (ms < 1000) {return `${ms}ms`;}
+ if (ms < 60_000) {return `${(ms / 1000).toFixed(1)}s`;}
+ return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
+}
+
+function jobStatusLabel(job: CronJob): string {
+ if (!job.enabled) {return "disabled";}
+ if (job.state.runningAtMs) {return "running";}
+ return job.state.lastStatus ?? "idle";
+}
+
+function jobStatusColor(status: string): string {
+ switch (status) {
+ case "ok": return "var(--color-success, #22c55e)";
+ case "running": return "var(--color-accent)";
+ case "error": return "var(--color-error, #ef4444)";
+ case "disabled": return "var(--color-text-muted)";
+ case "skipped": return "var(--color-warning, #f59e0b)";
+ default: return "var(--color-text-muted)";
+ }
+}
+
+/* βββ Countdown hook βββ */
+
+function useCountdown(targetMs: number | null | undefined): string | null {
+ const [now, setNow] = useState(Date.now());
+ useEffect(() => {
+ if (!targetMs) {return;}
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, [targetMs]);
+ if (!targetMs) {return null;}
+ return formatCountdown(targetMs - now);
+}
+
+/* βββ Main component βββ */
+
+export function CronDashboard({
+ onSelectJob,
+}: {
+ onSelectJob: (jobId: string) => void;
+}) {
+ const [jobs, setJobs] = useState([]);
+ const [heartbeat, setHeartbeat] = useState({ intervalMs: 30 * 60_000, nextDueEstimateMs: null });
+ const [cronStatus, setCronStatus] = useState({ enabled: false, nextWakeAtMs: null });
+ const [loading, setLoading] = useState(true);
+
+ const fetchData = useCallback(async () => {
+ try {
+ const res = await fetch("/api/cron/jobs");
+ const data: CronJobsResponse = await res.json();
+ setJobs(data.jobs ?? []);
+ setHeartbeat(data.heartbeat ?? { intervalMs: 30 * 60_000, nextDueEstimateMs: null });
+ setCronStatus(data.cronStatus ?? { enabled: false, nextWakeAtMs: null });
+ } catch {
+ // ignore
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void fetchData();
+ const id = setInterval(() => void fetchData(), 30_000);
+ return () => clearInterval(id);
+ }, [fetchData]);
+
+ const heartbeatCountdown = useCountdown(heartbeat.nextDueEstimateMs);
+ const cronWakeCountdown = useCountdown(cronStatus.nextWakeAtMs);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ const enabledJobs = jobs.filter((j) => j.enabled);
+ const disabledJobs = jobs.filter((j) => !j.enabled);
+
+ return (
+
+ {/* Header */}
+
+ Cron
+
+
+ Scheduled jobs and heartbeat status
+
+
+ {/* Status cards */}
+
+ {/* Heartbeat card */}
+ }
+ value={heartbeatCountdown ? `in ${heartbeatCountdown}` : "unknown"}
+ subtitle={`Interval: ${formatCountdown(heartbeat.intervalMs)}`}
+ description="The heartbeat wakes the agent periodically. Cron jobs with wakeMode=next-heartbeat piggyback on this loop."
+ />
+
+ {/* Cron scheduler card */}
+ }
+ value={cronWakeCountdown ? `next in ${cronWakeCountdown}` : jobs.length === 0 ? "no jobs" : "idle"}
+ subtitle={`${enabledJobs.length} active / ${jobs.length} total jobs`}
+ description="The cron timer fires every ~60s, checking for due jobs. Isolated jobs run independently; main-session jobs wake the heartbeat."
+ />
+
+ {/* Running card */}
+ }
+ value={`${jobs.filter((j) => j.state.runningAtMs).length}`}
+ subtitle={`${jobs.filter((j) => j.state.lastStatus === "error").length} errors`}
+ description="Jobs currently executing. Errors show consecutive failures."
+ />
+
+
+ {/* Timeline - upcoming runs in next 24h */}
+
+
+ {/* Jobs table */}
+
+
+ Jobs
+
+
+ {jobs.length === 0 ? (
+
+
+ No cron jobs configured. Use ironclaw cron add to create one.
+
+
+ ) : (
+
+
+
+
+ Name
+ Schedule
+ Status
+ Next Run
+ Last Run
+ Target
+
+
+
+ {[...enabledJobs, ...disabledJobs].map((job) => (
+ onSelectJob(job.id)} />
+ ))}
+
+
+
+ )}
+
+
+ );
+}
+
+/* βββ Status card βββ */
+
+function StatusCard({
+ title,
+ icon,
+ value,
+ subtitle,
+ description,
+}: {
+ title: string;
+ icon: React.ReactNode;
+ value: string;
+ subtitle: string;
+ description: string;
+}) {
+ return (
+
+
+ {icon}
+
+ {title}
+
+
+
+ {value}
+
+
+ {subtitle}
+
+
+ {description}
+
+
+ );
+}
+
+/* βββ Timeline βββ */
+
+function TimelineSection({ jobs }: { jobs: CronJob[] }) {
+ const [now, setNow] = useState(Date.now());
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 10_000);
+ return () => clearInterval(id);
+ }, []);
+
+ const horizon = 24 * 60 * 60 * 1000; // 24h
+ const upcoming = jobs
+ .filter((j) => j.state.nextRunAtMs && j.state.nextRunAtMs > now && j.state.nextRunAtMs < now + horizon)
+ .toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0));
+
+ if (upcoming.length === 0) {return null;}
+
+ return (
+
+
+ Upcoming (next 24h)
+
+
+
+ {/* Timeline bar */}
+
+
+ {upcoming.map((job) => {
+ const timeUntil = (job.state.nextRunAtMs ?? 0) - now;
+ return (
+
+
+
+
+
+
+ {job.name}
+
+
+ in {formatCountdown(timeUntil)}
+
+
+
+ {new Date(job.state.nextRunAtMs!).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+/* βββ Job row βββ */
+
+function JobRow({ job, onClick }: { job: CronJob; onClick: () => void }) {
+ const status = jobStatusLabel(job);
+ const statusColor = jobStatusColor(status);
+ const [now, setNow] = useState(Date.now());
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 5000);
+ return () => clearInterval(id);
+ }, []);
+
+ const nextRunStr = job.state.nextRunAtMs
+ ? job.state.nextRunAtMs > now
+ ? `in ${formatCountdown(job.state.nextRunAtMs - now)}`
+ : "overdue"
+ : "-";
+
+ const lastRunStr = job.state.lastRunAtMs
+ ? `${formatTimeAgo(job.state.lastRunAtMs)}${job.state.lastDurationMs ? ` (${formatDuration(job.state.lastDurationMs)})` : ""}`
+ : "-";
+
+ return (
+ { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
+ >
+
+ {job.name}
+ {job.description && (
+
+ {job.description}
+
+ )}
+
+
+ {formatSchedule(job.schedule)}
+
+
+
+ {status === "running" && (
+
+ )}
+ {status}
+
+
+
+ {nextRunStr}
+
+
+ {lastRunStr}
+
+
+ {job.sessionTarget}
+
+
+ );
+}
+
+/* βββ Icons βββ */
+
+function HeartbeatIcon() {
+ return (
+
+ );
+}
+
+function ClockIcon() {
+ return (
+
+ );
+}
+
+function RunningIcon() {
+ return (
+
+ );
+}
diff --git a/apps/web/app/components/cron/cron-job-detail.tsx b/apps/web/app/components/cron/cron-job-detail.tsx
new file mode 100644
index 00000000000..7a227979d0c
--- /dev/null
+++ b/apps/web/app/components/cron/cron-job-detail.tsx
@@ -0,0 +1,469 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import type { CronJob, CronRunLogEntry, CronRunsResponse } from "../../types/cron";
+import { CronRunChat, CronRunTranscriptSearch } from "./cron-run-chat";
+
+/* βββ Helpers βββ */
+
+function formatSchedule(schedule: CronJob["schedule"]): string {
+ switch (schedule.kind) {
+ case "cron":
+ return schedule.expr + (schedule.tz ? ` (${schedule.tz})` : "");
+ case "every": {
+ const ms = schedule.everyMs;
+ if (ms >= 86_400_000) {return `every ${Math.round(ms / 86_400_000)} day(s)`;}
+ if (ms >= 3_600_000) {return `every ${Math.round(ms / 3_600_000)} hour(s)`;}
+ if (ms >= 60_000) {return `every ${Math.round(ms / 60_000)} minute(s)`;}
+ return `every ${Math.round(ms / 1000)} second(s)`;
+ }
+ case "at":
+ return new Date(schedule.at).toLocaleString();
+ default:
+ return "unknown";
+ }
+}
+
+function formatCountdown(ms: number): string {
+ if (ms <= 0) {return "now";}
+ const totalSec = Math.ceil(ms / 1000);
+ if (totalSec < 60) {return `${totalSec}s`;}
+ const min = Math.floor(totalSec / 60);
+ const sec = totalSec % 60;
+ if (min < 60) {return sec > 0 ? `${min}m ${sec}s` : `${min}m`;}
+ const hr = Math.floor(min / 60);
+ const remMin = min % 60;
+ return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
+}
+
+function formatDuration(ms: number): string {
+ if (ms < 1000) {return `${ms}ms`;}
+ if (ms < 60_000) {return `${(ms / 1000).toFixed(1)}s`;}
+ return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
+}
+
+function payloadSummary(payload: CronJob["payload"]): string {
+ if (payload.kind === "systemEvent") {return payload.text.slice(0, 120);}
+ return payload.message.slice(0, 120);
+}
+
+/* βββ Main component βββ */
+
+export function CronJobDetail({
+ job,
+ onBack,
+}: {
+ job: CronJob;
+ onBack: () => void;
+}) {
+ const [runs, setRuns] = useState([]);
+ const [loadingRuns, setLoadingRuns] = useState(true);
+ const [expandedRunTs, setExpandedRunTs] = useState(null);
+
+ const fetchRuns = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/cron/jobs/${encodeURIComponent(job.id)}/runs?limit=50`);
+ const data: CronRunsResponse = await res.json();
+ setRuns(data.entries ?? []);
+ } catch {
+ // ignore
+ } finally {
+ setLoadingRuns(false);
+ }
+ }, [job.id]);
+
+ useEffect(() => {
+ void fetchRuns();
+ const id = setInterval(fetchRuns, 15_000);
+ return () => clearInterval(id);
+ }, [fetchRuns]);
+
+ const status = !job.enabled
+ ? "disabled"
+ : job.state.runningAtMs
+ ? "running"
+ : (job.state.lastStatus ?? "idle");
+
+ return (
+
+ {/* Back button + header */}
+
+
+ {/* Job header */}
+
+
+
+ {job.name}
+
+
+
+ {job.description && (
+
+ {job.description}
+
+ )}
+
+
+ {/* Config + countdown grid */}
+
+ {/* Next run countdown */}
+
+
+ {/* Job config */}
+
+
+ Configuration
+
+
+
+
+
+
+ {job.agentId && }
+ {job.delivery && }
+
+
+
+
+
+ {/* Error streak */}
+ {job.state.consecutiveErrors && job.state.consecutiveErrors > 0 && (
+
+
+
+
+ {job.state.consecutiveErrors} consecutive error{job.state.consecutiveErrors > 1 ? "s" : ""}
+
+
+ {job.state.lastError && (
+
+ {job.state.lastError}
+
+ )}
+
+ )}
+
+ {/* Run history */}
+
+
+ Run History
+
+
+ {loadingRuns ? (
+
+
+
+ ) : runs.length === 0 ? (
+
+
+ No runs recorded yet.
+
+
+ ) : (
+
+ {runs.toReversed().map((run) => (
+ setExpandedRunTs(expandedRunTs === run.ts ? null : run.ts)}
+ />
+ ))}
+
+ )}
+
+
+ );
+}
+
+/* βββ Next run countdown card βββ */
+
+function NextRunCard({ job }: { job: CronJob }) {
+ const [now, setNow] = useState(Date.now());
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, []);
+
+ const nextMs = job.state.nextRunAtMs;
+ const isRunning = !!job.state.runningAtMs;
+
+ return (
+
+
+ {isRunning ? "Currently Running" : "Next Run"}
+
+ {isRunning ? (
+
+
+
+ Running now
+
+
+ ) : nextMs ? (
+ <>
+
+ {nextMs > now ? formatCountdown(nextMs - now) : "overdue"}
+
+
+ {new Date(nextMs).toLocaleString()}
+
+ >
+ ) : (
+
+ {job.enabled ? "Not scheduled" : "Disabled"}
+
+ )}
+
+ );
+}
+
+/* βββ Run card βββ */
+
+function RunCard({
+ run,
+ isExpanded,
+ onToggle,
+}: {
+ run: CronRunLogEntry;
+ isExpanded: boolean;
+ onToggle: () => void;
+}) {
+ const statusColor = run.status === "ok"
+ ? "var(--color-success, #22c55e)"
+ : run.status === "error"
+ ? "var(--color-error, #ef4444)"
+ : "var(--color-warning, #f59e0b)";
+
+ return (
+
+ {/* Run header - clickable */}
+
+
+ {/* Expanded content */}
+ {isExpanded && (
+
+ {/* Error message */}
+ {run.error && (
+
+ {run.error}
+
+ )}
+
+ {/* Session transcript */}
+ {run.sessionId ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+/* βββ Transcript search with summary fallback βββ */
+
+function RunTranscriptOrSummary({ run }: { run: CronRunLogEntry }) {
+ const summaryFallback = run.summary ? (
+
+
+ Run Output
+
+
+
+ {run.summary}
+
+
+
+ ) : (
+
+ No output recorded for this run.
+
+ );
+
+ return (
+
+ );
+}
+
+/* βββ Subcomponents βββ */
+
+function StatusBadge({ status }: { status: string }) {
+ const color = status === "ok"
+ ? "var(--color-success, #22c55e)"
+ : status === "running"
+ ? "var(--color-accent)"
+ : status === "error"
+ ? "var(--color-error, #ef4444)"
+ : "var(--color-text-muted)";
+
+ return (
+
+ {status === "running" && (
+
+ )}
+ {status}
+
+ );
+}
+
+function ConfigRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/apps/web/app/components/cron/cron-run-chat.tsx b/apps/web/app/components/cron/cron-run-chat.tsx
new file mode 100644
index 00000000000..9a50ae79d05
--- /dev/null
+++ b/apps/web/app/components/cron/cron-run-chat.tsx
@@ -0,0 +1,445 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import type { SessionMessage, SessionMessagePart, CronRunSessionResponse } from "../../types/cron";
+
+/* βββ Main component βββ */
+
+export function CronRunChat({ sessionId }: { sessionId: string }) {
+ const [messages, setMessages] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchSession = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/cron/runs/${encodeURIComponent(sessionId)}`);
+ if (!res.ok) {
+ setError(res.status === 404 ? "Session transcript not found" : "Failed to load session");
+ return;
+ }
+ const data: CronRunSessionResponse = await res.json();
+ setMessages(data.messages ?? []);
+ } catch {
+ setError("Failed to load session");
+ } finally {
+ setLoading(false);
+ }
+ }, [sessionId]);
+
+ useEffect(() => {
+ void fetchSession();
+ }, [fetchSession]);
+
+ if (loading) {
+ return (
+
+
+ Loading session transcript...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (messages.length === 0) {
+ return (
+
+ Empty session transcript.
+
+ );
+ }
+
+ return (
+
+
+ Session Transcript
+
+ {messages.map((msg) => (
+
+ ))}
+
+ );
+}
+
+/* βββ Transcript search fallback (no sessionId) βββ */
+
+export function CronRunTranscriptSearch({
+ jobId,
+ runAtMs,
+ summary,
+ fallback,
+}: {
+ jobId: string;
+ runAtMs?: number;
+ summary?: string;
+ fallback?: React.ReactNode;
+}) {
+ const [messages, setMessages] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [notFound, setNotFound] = useState(false);
+
+ const fetchTranscript = useCallback(async () => {
+ if (!runAtMs || !summary) {
+ setLoading(false);
+ setNotFound(true);
+ return;
+ }
+ try {
+ const params = new URLSearchParams({
+ jobId,
+ runAtMs: String(runAtMs),
+ summary,
+ });
+ const res = await fetch(`/api/cron/runs/search-transcript?${params}`);
+ if (!res.ok) {
+ setNotFound(true);
+ return;
+ }
+ const data = await res.json() as { messages?: SessionMessage[] };
+ if (data.messages && data.messages.length > 0) {
+ setMessages(data.messages);
+ } else {
+ setNotFound(true);
+ }
+ } catch {
+ setNotFound(true);
+ } finally {
+ setLoading(false);
+ }
+ }, [jobId, runAtMs, summary]);
+
+ useEffect(() => {
+ void fetchTranscript();
+ }, [fetchTranscript]);
+
+ if (loading) {
+ return (
+
+
+ Searching for transcript...
+
+ );
+ }
+
+ if (notFound || messages.length === 0) {
+ return <>{fallback}>;
+ }
+
+ return (
+
+
+ Session Transcript
+
+ {messages.map((msg) => (
+
+ ))}
+
+ );
+}
+
+/* βββ Message rendering βββ */
+
+function CronChatMessage({ message }: { message: SessionMessage }) {
+ const isUser = message.role === "user";
+ const isSystem = message.role === "system";
+
+ // Group parts into segments
+ const segments = groupPartsIntoSegments(message.parts);
+
+ if (isSystem) {
+ const textContent = message.parts
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
+ .map((p) => p.text)
+ .join("\n");
+ return (
+
+ system: {textContent.slice(0, 500)}
+
+ );
+ }
+
+ if (isUser) {
+ const textContent = message.parts
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
+ .map((p) => p.text)
+ .join("\n");
+ return (
+
+
+ {textContent}
+
+
+ );
+ }
+
+ // Assistant message
+ return (
+
+ {segments.map((segment, idx) => {
+ if (segment.type === "text") {
+ return (
+
+
+ {segment.text}
+
+
+ );
+ }
+
+ if (segment.type === "thinking") {
+ return ;
+ }
+
+ if (segment.type === "tool-group") {
+ return ;
+ }
+
+ return null;
+ })}
+
+ );
+}
+
+/* βββ Part grouping βββ */
+
+type ChatSegment =
+ | { type: "text"; text: string }
+ | { type: "thinking"; thinking: string }
+ | { type: "tool-group"; tools: Array };
+
+function groupPartsIntoSegments(parts: SessionMessagePart[]): ChatSegment[] {
+ const segments: ChatSegment[] = [];
+ let toolBuffer: Array = [];
+
+ const flushTools = () => {
+ if (toolBuffer.length > 0) {
+ segments.push({ type: "tool-group", tools: [...toolBuffer] });
+ toolBuffer = [];
+ }
+ };
+
+ for (const part of parts) {
+ if (part.type === "text") {
+ flushTools();
+ segments.push({ type: "text", text: part.text });
+ } else if (part.type === "thinking") {
+ flushTools();
+ segments.push({ type: "thinking", thinking: part.thinking });
+ } else if (part.type === "tool-call") {
+ toolBuffer.push(part as SessionMessagePart & { type: "tool-call" });
+ }
+ }
+ flushTools();
+ return segments;
+}
+
+/* βββ Thinking block (always expanded for historical runs) βββ */
+
+function ThinkingBlock({ text }: { text: string }) {
+ const [expanded, setExpanded] = useState(true);
+ const isLong = text.length > 600;
+
+ return (
+
+
+ {expanded && (
+
+ {text}
+
+ )}
+
+ );
+}
+
+/* βββ Tool group βββ */
+
+function ToolGroup({ tools }: { tools: Array }) {
+ return (
+
+ {/* Timeline connector */}
+
+
+ {tools.map((tool) => (
+
+ ))}
+
+
+ );
+}
+
+/* βββ Tool call step βββ */
+
+function ToolCallStep({ tool }: { tool: SessionMessagePart & { type: "tool-call" } }) {
+ const [showOutput, setShowOutput] = useState(false);
+ const label = buildToolLabel(tool.toolName, tool.args);
+
+ return (
+
+
+
+
+
+
+ {label}
+
+ {tool.output && (
+
+
+ {showOutput && (
+
+ {tool.output.length > 3000 ? tool.output.slice(0, 3000) + "\n..." : tool.output}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+/* βββ Tool label builder βββ */
+
+function buildToolLabel(toolName: string, args?: unknown): string {
+ const a = args as Record | undefined;
+ const strVal = (key: string) => {
+ const v = a?.[key];
+ return typeof v === "string" && v.length > 0 ? v : null;
+ };
+
+ const n = toolName.toLowerCase().replace(/[_-]/g, "");
+
+ if (["websearch", "search", "googlesearch"].some((k) => n.includes(k))) {
+ const q = strVal("query") ?? strVal("search_query") ?? strVal("q");
+ return q ? `Searching: ${q.slice(0, 80)}` : "Searching...";
+ }
+ if (["fetchurl", "fetch", "webfetch"].some((k) => n.includes(k))) {
+ const u = strVal("url") ?? strVal("path");
+ return u ? `Fetching: ${u.slice(0, 60)}` : "Fetching page";
+ }
+ if (["read", "readfile", "getfile"].some((k) => n.includes(k))) {
+ const p = strVal("path") ?? strVal("file");
+ return p ? `Reading: ${p.split("/").pop()}` : "Reading file";
+ }
+ if (["bash", "shell", "execute", "exec", "terminal"].some((k) => n.includes(k))) {
+ const cmd = strVal("command") ?? strVal("cmd");
+ return cmd ? `Running: ${cmd.slice(0, 60)}` : "Running command";
+ }
+ if (["write", "create", "edit", "str_replace", "save"].some((k) => n.includes(k))) {
+ const p = strVal("path") ?? strVal("file");
+ return p ? `Editing: ${p.split("/").pop()}` : "Editing file";
+ }
+
+ return toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/* βββ Tool icon βββ */
+
+function ToolIcon({ toolName }: { toolName: string }) {
+ const color = "var(--color-text-muted)";
+ const n = toolName.toLowerCase().replace(/[_-]/g, "");
+
+ if (["search", "websearch"].some((k) => n.includes(k))) {
+ return (
+
+ );
+ }
+ if (["bash", "shell", "exec", "terminal"].some((k) => n.includes(k))) {
+ return (
+
+ );
+ }
+ if (["write", "edit", "create", "save"].some((k) => n.includes(k))) {
+ return (
+
+ );
+ }
+ // Default: file/read icon
+ return (
+
+ );
+}
diff --git a/apps/web/app/components/diff-viewer.tsx b/apps/web/app/components/diff-viewer.tsx
new file mode 100644
index 00000000000..3606af7731e
--- /dev/null
+++ b/apps/web/app/components/diff-viewer.tsx
@@ -0,0 +1,300 @@
+"use client";
+
+import { useState, useMemo } from "react";
+
+type DiffCardProps = {
+ /** Raw unified diff text (contents of a ```diff block) */
+ diff: string;
+};
+
+type DiffFile = {
+ oldPath: string;
+ newPath: string;
+ hunks: DiffHunk[];
+ additions: number;
+ deletions: number;
+};
+
+type DiffHunk = {
+ header: string;
+ lines: DiffLine[];
+};
+
+type DiffLine = {
+ type: "addition" | "deletion" | "context" | "header";
+ content: string;
+ oldLine?: number;
+ newLine?: number;
+};
+
+/** Parse unified diff text into structured file sections. */
+function parseDiff(raw: string): DiffFile[] {
+ const files: DiffFile[] = [];
+ const lines = raw.split("\n");
+ let current: DiffFile | null = null;
+ let currentHunk: DiffHunk | null = null;
+ let oldLine = 0;
+ let newLine = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // File header: --- a/path or --- /dev/null
+ if (line.startsWith("--- ")) {
+ const nextLine = lines[i + 1];
+ if (nextLine?.startsWith("+++ ")) {
+ const oldPath = line.replace(/^--- (a\/)?/, "").trim();
+ const newPath = nextLine.replace(/^\+\+\+ (b\/)?/, "").trim();
+ current = { oldPath, newPath, hunks: [], additions: 0, deletions: 0 };
+ files.push(current);
+ i++; // skip +++ line
+ continue;
+ }
+ }
+
+ // Hunk header: @@ -old,count +new,count @@
+ const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)/);
+ if (hunkMatch) {
+ oldLine = parseInt(hunkMatch[1], 10);
+ newLine = parseInt(hunkMatch[2], 10);
+ currentHunk = {
+ header: line,
+ lines: [{ type: "header", content: line }],
+ };
+ if (current) {
+ current.hunks.push(currentHunk);
+ } else {
+ // Diff without file headers -- create an implicit file
+ current = { oldPath: "", newPath: "", hunks: [currentHunk], additions: 0, deletions: 0 };
+ files.push(current);
+ }
+ continue;
+ }
+
+ if (!currentHunk || !current) {continue;}
+
+ if (line.startsWith("+")) {
+ currentHunk.lines.push({ type: "addition", content: line.slice(1), newLine });
+ current.additions++;
+ newLine++;
+ } else if (line.startsWith("-")) {
+ currentHunk.lines.push({ type: "deletion", content: line.slice(1), oldLine });
+ current.deletions++;
+ oldLine++;
+ } else if (line.startsWith(" ") || line === "") {
+ currentHunk.lines.push({ type: "context", content: line.slice(1) || "", oldLine, newLine });
+ oldLine++;
+ newLine++;
+ }
+ }
+
+ // If no structured files were found, treat the whole thing as one block
+ if (files.length === 0 && raw.trim()) {
+ const fallbackLines = raw.split("\n").map((l): DiffLine => {
+ if (l.startsWith("+")) {return { type: "addition", content: l.slice(1) };}
+ if (l.startsWith("-")) {return { type: "deletion", content: l.slice(1) };}
+ return { type: "context", content: l };
+ });
+ files.push({
+ oldPath: "",
+ newPath: "",
+ hunks: [{ header: "", lines: fallbackLines }],
+ additions: fallbackLines.filter((l) => l.type === "addition").length,
+ deletions: fallbackLines.filter((l) => l.type === "deletion").length,
+ });
+ }
+
+ return files;
+}
+
+function displayPath(file: DiffFile): string {
+ if (file.newPath && file.newPath !== "/dev/null") {return file.newPath;}
+ if (file.oldPath && file.oldPath !== "/dev/null") {return file.oldPath;}
+ return "diff";
+}
+
+/* βββ Icons βββ */
+
+function FileIcon() {
+ return (
+
+ );
+}
+
+function ChevronIcon({ expanded }: { expanded: boolean }) {
+ return (
+
+ );
+}
+
+/* βββ Single file diff βββ */
+
+function DiffFileCard({ file }: { file: DiffFile }) {
+ const [expanded, setExpanded] = useState(true);
+ const path = displayPath(file);
+
+ return (
+
+ {/* File header */}
+
+
+ {/* Diff lines */}
+ {expanded && (
+
+
+
+ {file.hunks.map((hunk, hi) =>
+ hunk.lines.map((line, li) => {
+ if (line.type === "header") {
+ return (
+
+
+ {line.content}
+
+
+ );
+ }
+
+ const bgColor =
+ line.type === "addition"
+ ? "var(--diff-add-bg)"
+ : line.type === "deletion"
+ ? "var(--diff-del-bg)"
+ : "transparent";
+ const textColor =
+ line.type === "addition"
+ ? "var(--diff-add-text)"
+ : line.type === "deletion"
+ ? "var(--diff-del-text)"
+ : "var(--color-text)";
+ const prefix =
+ line.type === "addition"
+ ? "+"
+ : line.type === "deletion"
+ ? "-"
+ : " ";
+
+ return (
+
+ {/* Old line number */}
+
+ {line.type !== "addition" ? line.oldLine : ""}
+
+ {/* New line number */}
+
+ {line.type !== "deletion" ? line.newLine : ""}
+
+ {/* Content */}
+
+
+ {prefix}
+
+ {line.content}
+
+
+ );
+ }),
+ )}
+
+
+
+ )}
+
+ );
+}
+
+/* βββ Main DiffCard βββ */
+
+export function DiffCard({ diff }: DiffCardProps) {
+ const files = useMemo(() => parseDiff(diff), [diff]);
+
+ return (
+
+ {files.map((file, i) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/components/file-picker-modal.tsx b/apps/web/app/components/file-picker-modal.tsx
new file mode 100644
index 00000000000..5bdb7862cb6
--- /dev/null
+++ b/apps/web/app/components/file-picker-modal.tsx
@@ -0,0 +1,936 @@
+"use client";
+
+import {
+ Fragment,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+// ββ Types ββ
+
+type BrowseEntry = {
+ name: string;
+ path: string;
+ type: "folder" | "file" | "document" | "database";
+ children?: BrowseEntry[];
+};
+
+export type SelectedFile = {
+ name: string;
+ path: string;
+};
+
+type FilePickerModalProps = {
+ open: boolean;
+ onClose: () => void;
+ onSelect: (files: SelectedFile[]) => void;
+};
+
+// ββ Helpers ββ
+
+function getCategoryFromName(
+ name: string,
+): "image" | "video" | "audio" | "pdf" | "code" | "document" | "folder" | "other" {
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
+ if (
+ ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
+ )
+ {return "image";}
+ if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";}
+ if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";}
+ if (ext === "pdf") {return "pdf";}
+ if (
+ [
+ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
+ "cpp", "c", "h", "css", "html", "json", "yaml", "yml",
+ "toml", "md", "sh", "bash", "sql", "swift", "kt",
+ ].includes(ext)
+ )
+ {return "code";}
+ if (
+ ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext)
+ )
+ {return "document";}
+ return "other";
+}
+
+function buildBreadcrumbs(
+ dir: string,
+): { label: string; path: string }[] {
+ const segments: { label: string; path: string }[] = [];
+ const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/);
+ const homeDir = homeMatch?.[1];
+
+ if (homeDir) {
+ segments.push({ label: "~", path: homeDir });
+ const rest = dir.slice(homeDir.length);
+ const parts = rest.split("/").filter(Boolean);
+ let currentPath = homeDir;
+ for (const part of parts) {
+ currentPath += "/" + part;
+ segments.push({ label: part, path: currentPath });
+ }
+ } else if (dir === "/") {
+ segments.push({ label: "/", path: "/" });
+ } else {
+ segments.push({ label: "/", path: "/" });
+ const parts = dir.split("/").filter(Boolean);
+ let currentPath = "";
+ for (const part of parts) {
+ currentPath += "/" + part;
+ segments.push({ label: part, path: currentPath });
+ }
+ }
+ return segments;
+}
+
+const pickerColors: Record = {
+ folder: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
+ image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
+ video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
+ audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
+ pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
+ code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
+ document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
+ other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
+};
+
+// ββ Icons ββ
+
+function PickerIcon({
+ category,
+ size = 16,
+}: {
+ category: string;
+ size?: number;
+}) {
+ const props = {
+ width: size,
+ height: size,
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 2,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+ };
+ switch (category) {
+ case "folder":
+ return (
+
+ );
+ case "image":
+ return (
+
+ );
+ case "video":
+ return (
+
+ );
+ case "audio":
+ return (
+
+ );
+ case "pdf":
+ return (
+
+ );
+ case "code":
+ return (
+
+ );
+ case "document":
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+}
+
+// ββ Main component ββ
+
+export function FilePickerModal({
+ open,
+ onClose,
+ onSelect,
+}: FilePickerModalProps) {
+ const [currentDir, setCurrentDir] = useState(null);
+ const [displayDir, setDisplayDir] = useState("");
+ const [entries, setEntries] = useState([]);
+ const [parentDir, setParentDir] = useState(null);
+ const [selected, setSelected] = useState<
+ Map
+ >(new Map());
+ const [loading, setLoading] = useState(false);
+ const [search, setSearch] = useState("");
+ const [creatingFolder, setCreatingFolder] = useState(false);
+ const [newFolderName, setNewFolderName] = useState("");
+ const [error, setError] = useState(null);
+
+ // Animation
+ const [visible, setVisible] = useState(false);
+ useEffect(() => {
+ if (open) {
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => setVisible(true)),
+ );
+ } else {
+ setVisible(false);
+ }
+ }, [open]);
+
+ // Reset transient state on close
+ useEffect(() => {
+ if (!open) {
+ setSearch("");
+ setCreatingFolder(false);
+ setNewFolderName("");
+ setError(null);
+ }
+ }, [open]);
+
+ // Search input ref for autofocus
+ const searchRef = useRef(null);
+ const newFolderRef = useRef(null);
+
+ // Fetch directory
+ const fetchDir = useCallback(async (dir: string | null) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const url = dir
+ ? `/api/workspace/browse?dir=${encodeURIComponent(dir)}`
+ : "/api/workspace/browse";
+ const res = await fetch(url);
+ if (!res.ok) {throw new Error("Failed to list directory");}
+ const data = await res.json();
+ setEntries(data.entries || []);
+ setDisplayDir(data.currentDir || "");
+ setParentDir(data.parentDir ?? null);
+ } catch {
+ setError("Could not load this directory");
+ setEntries([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Fetch on open and navigation
+ useEffect(() => {
+ if (open) { void fetchDir(currentDir); }
+ }, [open, currentDir, fetchDir]);
+
+ // Escape key
+ useEffect(() => {
+ if (!open) {return;}
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {onClose();}
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [open, onClose]);
+
+ // Handlers
+ const toggleSelect = useCallback(
+ (entry: BrowseEntry) => {
+ setSelected((prev) => {
+ const next = new Map(prev);
+ if (next.has(entry.path)) {
+ next.delete(entry.path);
+ } else {
+ next.set(entry.path, {
+ name: entry.name,
+ path: entry.path,
+ });
+ }
+ return next;
+ });
+ },
+ [],
+ );
+
+ const navigateInto = useCallback((path: string) => {
+ setCurrentDir(path);
+ setSearch("");
+ setCreatingFolder(false);
+ }, []);
+
+ const handleCreateFolder = useCallback(async () => {
+ if (!newFolderName.trim() || !displayDir) {return;}
+ const folderPath = `${displayDir}/${newFolderName.trim()}`;
+ try {
+ await fetch("/api/workspace/mkdir", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: folderPath }),
+ });
+ setCreatingFolder(false);
+ setNewFolderName("");
+ void fetchDir(currentDir);
+ } catch {
+ setError("Failed to create folder");
+ }
+ }, [newFolderName, displayDir, currentDir, fetchDir]);
+
+ const handleConfirm = useCallback(() => {
+ onSelect(Array.from(selected.values()));
+ setSelected(new Map());
+ onClose();
+ }, [selected, onSelect, onClose]);
+
+ // Filter & sort entries (folders first, then alphabetically)
+ const sorted = entries
+ .filter(
+ (e) =>
+ !search ||
+ e.name
+ .toLowerCase()
+ .includes(search.toLowerCase()),
+ )
+ .toSorted((a, b) => {
+ if (a.type === "folder" && b.type !== "folder")
+ {return -1;}
+ if (a.type !== "folder" && b.type === "folder")
+ {return 1;}
+ return a.name.localeCompare(b.name);
+ });
+
+ const breadcrumbs = displayDir
+ ? buildBreadcrumbs(displayDir)
+ : [];
+
+ if (!open) {return null;}
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Select Files
+
+
+ Browse and attach
+ files
+
+
+
+
+
+
+ {/* Breadcrumb path */}
+ {displayDir && (
+
+ {breadcrumbs.map(
+ (seg, i) => (
+
+ {i >
+ 0 && (
+
+ /
+
+ )}
+
+
+ ),
+ )}
+
+ )}
+
+ {/* Search bar + New Folder */}
+
+
+
+
+ setSearch(
+ e.target.value,
+ )
+ }
+ placeholder="Filter files..."
+ className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]"
+ style={{
+ color: "var(--color-text)",
+ }}
+ />
+
+
+
+
+ {/* File list */}
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+ {error}
+
+ ) : (
+ <>
+ {/* Parent directory row */}
+ {parentDir && (
+
+ )}
+
+ {/* New folder input */}
+ {creatingFolder && (
+
+
+
+
+
+ setNewFolderName(
+ e
+ .target
+ .value,
+ )
+ }
+ onKeyDown={(
+ e,
+ ) => {
+ if (
+ e.key ===
+ "Enter"
+ ) {
+ void handleCreateFolder();
+ }
+ if (
+ e.key ===
+ "Escape"
+ ) {
+ setCreatingFolder(
+ false,
+ );
+ setNewFolderName(
+ "",
+ );
+ }
+ }}
+ placeholder="Folder name..."
+ className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1"
+ style={{
+ color: "var(--color-text)",
+ background:
+ "var(--color-surface)",
+ border: "1px solid var(--color-accent)",
+ }}
+ />
+
+ )}
+
+ {/* Entries */}
+ {sorted.length ===
+ 0 &&
+ !parentDir && (
+
+ This
+ folder
+ is
+ empty
+
+ )}
+ {sorted.map(
+ (entry) => {
+ const isFolder =
+ entry.type ===
+ "folder";
+ const category =
+ isFolder
+ ? "folder"
+ : getCategoryFromName(
+ entry.name,
+ );
+ const colors =
+ pickerColors[
+ category
+ ] ??
+ pickerColors.other;
+ const isSelected =
+ selected.has(
+ entry.path,
+ );
+
+ return (
+ {
+ if (
+ isFolder
+ ) {
+ navigateInto(
+ entry.path,
+ );
+ } else {
+ toggleSelect(
+ entry,
+ );
+ }
+ }}
+ >
+ {/* Checkbox */}
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Name */}
+
+ {
+ entry.name
+ }
+
+
+ {/* Folder chevron */}
+ {isFolder && (
+
+ )}
+
+ );
+ },
+ )}
+ >
+ )}
+
+
+ {/* Footer */}
+
+
+ {selected.size > 0
+ ? `${selected.size} ${selected.size === 1 ? "item" : "items"} selected`
+ : "No files selected"}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/components/sidebar.tsx b/apps/web/app/components/sidebar.tsx
new file mode 100644
index 00000000000..f4575c955b4
--- /dev/null
+++ b/apps/web/app/components/sidebar.tsx
@@ -0,0 +1,533 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { FileManagerTree } from "./workspace/file-manager-tree";
+import { ProfileSwitcher } from "./workspace/profile-switcher";
+import { CreateWorkspaceDialog } from "./workspace/create-workspace-dialog";
+
+// --- Types ---
+
+type WebSession = {
+ id: string;
+ title: string;
+ createdAt: number;
+ updatedAt: number;
+ messageCount: number;
+};
+
+type SkillEntry = {
+ name: string;
+ description: string;
+ emoji?: string;
+ source: string;
+};
+
+type MemoryFile = {
+ name: string;
+ sizeBytes: number;
+};
+
+type TreeNode = {
+ name: string;
+ path: string;
+ type: "object" | "document" | "folder" | "file" | "database" | "report";
+ icon?: string;
+ defaultView?: "table" | "kanban";
+ children?: TreeNode[];
+};
+
+type SidebarSection = "chats" | "skills" | "memories" | "workspace" | "reports";
+
+type SidebarProps = {
+ onSessionSelect?: (sessionId: string) => void;
+ onNewSession?: () => void;
+ activeSessionId?: string;
+ refreshKey?: number;
+};
+
+// --- Helpers ---
+
+function timeAgo(ts: number): string {
+ const diff = Date.now() - ts;
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) {return `${seconds}s ago`;}
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) {return `${minutes}m ago`;}
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) {return `${hours}h ago`;}
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+// --- Section Components ---
+
+function ChatsSection({
+ sessions,
+ onSessionSelect,
+ activeSessionId,
+}: {
+ sessions: WebSession[];
+ onSessionSelect?: (sessionId: string) => void;
+ activeSessionId?: string;
+}) {
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const filteredSessions = sessions.filter((s) =>
+ s.title.toLowerCase().includes(searchTerm.toLowerCase()),
+ );
+
+ return (
+
+ {sessions.length > 3 && (
+
+ setSearchTerm(e.target.value)}
+ placeholder="Search chats..."
+ className="w-full px-3 py-1.5 text-xs bg-[var(--color-bg)] border border-[var(--color-border)] rounded-md text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-transparent"
+ />
+
+ )}
+
+ {filteredSessions.length === 0 ? (
+
+ {searchTerm ? "No matching chats." : "No chats yet. Send a message to start."}
+
+ ) : (
+
+ {filteredSessions.map((s) => {
+ const isActive = s.id === activeSessionId;
+ return (
+ onSessionSelect?.(s.id)}
+ className={`mx-2 px-3 py-2 rounded-lg hover:bg-[var(--color-surface-hover)] cursor-pointer transition-colors ${
+ isActive
+ ? "bg-[var(--color-surface-hover)] border-l-2 border-[var(--color-accent)]"
+ : ""
+ }`}
+ >
+
+ {s.title}
+
+ {timeAgo(s.updatedAt)}
+
+
+ {s.messageCount > 0 && (
+
+ {s.messageCount} message{s.messageCount !== 1 ? "s" : ""}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function SkillsSection({ skills }: { skills: SkillEntry[] }) {
+ if (skills.length === 0) {
+ return No skills found.
;
+ }
+
+ return (
+
+ {skills.map((skill) => (
+
+
+ {skill.emoji && {skill.emoji}}
+ {skill.name}
+ {skill.source}
+
+ {skill.description && (
+
+ {skill.description}
+
+ )}
+
+ ))}
+
+ );
+}
+
+function MemoriesSection({
+ mainMemory,
+ dailyLogs,
+}: {
+ mainMemory: string | null;
+ dailyLogs: MemoryFile[];
+}) {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+
+ {mainMemory ? (
+
+
+ {expanded && (
+
+ {mainMemory}
+
+ )}
+
+ ) : (
+ No MEMORY.md found.
+ )}
+
+ {dailyLogs.length > 0 && (
+
+
+ Daily logs ({dailyLogs.length})
+
+
+ {dailyLogs.slice(0, 10).map((log) => (
+
+ {log.name}
+ {(log.sizeBytes / 1024).toFixed(1)}kb
+
+ ))}
+ {dailyLogs.length > 10 && (
+
+ ...and {dailyLogs.length - 10} more
+
+ )}
+
+
+ )}
+
+ );
+}
+
+// --- Workspace Section (uses FileManagerTree in compact mode) ---
+
+function WorkspaceSection({ tree, onRefresh }: { tree: TreeNode[]; onRefresh: () => void }) {
+ const handleSelect = useCallback((node: TreeNode) => {
+ // Navigate to workspace page for actionable items
+ if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database" || node.type === "report") {
+ window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
+ }
+ }, []);
+
+ if (tree.length === 0) {
+ return (
+
+ No workspace data yet.
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Full workspace link */}
+
+
+ Open full workspace
+
+
+ );
+}
+
+// --- Reports Section ---
+
+function ReportsSection({ tree }: { tree: TreeNode[] }) {
+ // Collect all report nodes from the tree (recursive)
+ const reports: TreeNode[] = [];
+ function collect(nodes: TreeNode[]) {
+ for (const n of nodes) {
+ if (n.type === "report") {reports.push(n);}
+ if (n.children) {collect(n.children);}
+ }
+ }
+ collect(tree);
+
+ if (reports.length === 0) {
+ return (
+
+ No reports yet. Ask the agent to create one.
+
+ );
+ }
+
+ return (
+
+ {reports.map((report) => (
+
+
+
+
+
+ {report.name.replace(/\.report\.json$/, "")}
+
+
+ ))}
+
+ );
+}
+
+// --- Collapsible Header ---
+
+function SectionHeader({
+ title,
+ count,
+ isOpen,
+ onToggle,
+}: {
+ title: string;
+ count?: number;
+ isOpen: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+ );
+}
+
+// --- Main Sidebar ---
+
+export function Sidebar({
+ onSessionSelect,
+ onNewSession,
+ activeSessionId,
+ refreshKey,
+}: SidebarProps) {
+ const [openSections, setOpenSections] = useState>(new Set(["chats", "workspace"]));
+ const [webSessions, setWebSessions] = useState([]);
+ const [skills, setSkills] = useState([]);
+ const [mainMemory, setMainMemory] = useState(null);
+ const [dailyLogs, setDailyLogs] = useState([]);
+ const [workspaceTree, setWorkspaceTree] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
+ const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
+
+ const toggleSection = (section: SidebarSection) => {
+ setOpenSections((prev) => {
+ const next = new Set(prev);
+ if (next.has(section)) {next.delete(section);}
+ else {next.add(section);}
+ return next;
+ });
+ };
+
+ // Full sidebar re-fetch after profile switch or workspace creation
+ const handleProfileSwitch = useCallback(() => {
+ setSidebarRefreshKey((k) => k + 1);
+ }, []);
+
+ // Fetch sidebar data (re-runs when refreshKey or sidebarRefreshKey changes)
+ useEffect(() => {
+ async function load() {
+ setLoading(true);
+ try {
+ const [webSessionsRes, skillsRes, memoriesRes, workspaceRes] = await Promise.all([
+ fetch("/api/web-sessions").then((r) => r.json()),
+ fetch("/api/skills").then((r) => r.json()),
+ fetch("/api/memories").then((r) => r.json()),
+ fetch("/api/workspace/tree").then((r) => r.json()).catch(() => ({ tree: [] })),
+ ]);
+ setWebSessions(webSessionsRes.sessions ?? []);
+ setSkills(skillsRes.skills ?? []);
+ setMainMemory(memoriesRes.mainMemory ?? null);
+ setDailyLogs(memoriesRes.dailyLogs ?? []);
+ setWorkspaceTree(workspaceRes.tree ?? []);
+ } catch (err) {
+ console.error("Failed to load sidebar data:", err);
+ } finally {
+ setLoading(false);
+ }
+ }
+ void load();
+ }, [refreshKey, sidebarRefreshKey]);
+
+ const refreshWorkspace = useCallback(async () => {
+ try {
+ const res = await fetch("/api/workspace/tree");
+ const data = await res.json();
+ setWorkspaceTree(data.tree ?? []);
+ } catch {
+ // ignore
+ }
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/components/subagent-panel.tsx b/apps/web/app/components/subagent-panel.tsx
new file mode 100644
index 00000000000..0012620c8d8
--- /dev/null
+++ b/apps/web/app/components/subagent-panel.tsx
@@ -0,0 +1,424 @@
+"use client";
+
+import type { UIMessage } from "ai";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ChatMessage } from "./chat-message";
+import { createStreamParser } from "./chat-panel";
+import { UnicodeSpinner } from "./unicode-spinner";
+import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
+
+type SubagentPanelProps = {
+ sessionKey: string;
+ task: string;
+ label?: string;
+ onBack: () => void;
+};
+
+type QueuedMessage = {
+ id: string;
+ text: string;
+ mentionedFiles: Array<{ name: string; path: string }>;
+};
+
+function taskMessage(sessionKey: string, task: string): UIMessage {
+ return {
+ id: `task-${sessionKey}`,
+ role: "user",
+ parts: [{ type: "text", text: task }],
+ } as UIMessage;
+}
+
+function buildMessagesFromParsed(
+ sessionKey: string,
+ task: string,
+ parts: Array>,
+): UIMessage[] {
+ const messages: UIMessage[] = [taskMessage(sessionKey, task)];
+ let assistantParts: UIMessage["parts"] = [];
+ let assistantCount = 0;
+ let userCount = 0;
+
+ const pushAssistant = () => {
+ if (assistantParts.length === 0) {return;}
+ messages.push({
+ id: `assistant-${sessionKey}-${assistantCount++}`,
+ role: "assistant",
+ parts: assistantParts,
+ } as UIMessage);
+ assistantParts = [];
+ };
+
+ for (const part of parts) {
+ if (part.type === "user-message") {
+ pushAssistant();
+ messages.push({
+ id: (part.id as string | undefined) ?? `user-${sessionKey}-${userCount++}`,
+ role: "user",
+ parts: [{ type: "text", text: (part.text as string) ?? "" }],
+ } as UIMessage);
+ continue;
+ }
+ assistantParts.push(part as UIMessage["parts"][number]);
+ }
+ pushAssistant();
+ return messages;
+}
+
+export function SubagentPanel({ sessionKey, task, label, onBack }: SubagentPanelProps) {
+ const editorRef = useRef(null);
+ const [editorEmpty, setEditorEmpty] = useState(true);
+ const [messages, setMessages] = useState(() => [taskMessage(sessionKey, task)]);
+ const [queuedMessages, setQueuedMessages] = useState([]);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [connected, setConnected] = useState(false);
+ const [isReconnecting, setIsReconnecting] = useState(false);
+ const messagesEndRef = useRef(null);
+ const scrollContainerRef = useRef(null);
+ const userScrolledAwayRef = useRef(false);
+ const streamAbortRef = useRef(null);
+ const scrollRafRef = useRef(0);
+
+ const displayLabel = label || (task.length > 60 ? task.slice(0, 60) + "..." : task);
+
+ const streamFromResponse = useCallback(
+ async (
+ res: Response,
+ onUpdate: (parts: Array>) => void,
+ signal: AbortSignal,
+ ) => {
+ if (!res.body) {return;}
+ const parser = createStreamParser();
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let frameRequested = false;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {break;}
+ buffer += decoder.decode(value, { stream: true });
+ let idx;
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
+ const chunk = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 2);
+ if (chunk.startsWith("data: ")) {
+ try {
+ const event = JSON.parse(chunk.slice(6)) as Record;
+ parser.processEvent(event);
+ } catch {
+ // ignore malformed event
+ }
+ }
+ }
+ if (!frameRequested) {
+ frameRequested = true;
+ requestAnimationFrame(() => {
+ frameRequested = false;
+ if (!signal.aborted) {
+ onUpdate(parser.getParts() as Array>);
+ }
+ });
+ }
+ }
+ if (!signal.aborted) {
+ onUpdate(parser.getParts() as Array>);
+ }
+ },
+ [],
+ );
+
+ const reconnect = useCallback(async () => {
+ streamAbortRef.current?.abort();
+ const abort = new AbortController();
+ streamAbortRef.current = abort;
+ setIsReconnecting(true);
+ try {
+ const res = await fetch(`/api/chat/stream?sessionKey=${encodeURIComponent(sessionKey)}`, {
+ signal: abort.signal,
+ });
+ if (!res.ok || !res.body) {
+ setConnected(false);
+ setIsStreaming(false);
+ return;
+ }
+ setConnected(true);
+ setIsStreaming(res.headers.get("X-Run-Active") !== "false");
+ await streamFromResponse(
+ res,
+ (parts) => setMessages(buildMessagesFromParsed(sessionKey, task, parts)),
+ abort.signal,
+ );
+ } catch (err) {
+ if ((err as Error).name !== "AbortError") {
+ console.error("Subagent reconnect error:", err);
+ }
+ } finally {
+ setIsReconnecting(false);
+ if (!abort.signal.aborted) {
+ setIsStreaming(false);
+ streamAbortRef.current = null;
+ }
+ }
+ }, [sessionKey, task, streamFromResponse]);
+
+ const sendSubagentMessage = useCallback(
+ async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => {
+ const trimmed = text.trim();
+ const hasMentions = mentionedFiles.length > 0;
+ if (!trimmed && !hasMentions) {return;}
+
+ const allFilePaths = mentionedFiles.map((f) => f.path);
+ const payloadText = allFilePaths.length > 0
+ ? `[Attached files: ${allFilePaths.join(", ")}]\n\n${trimmed}`
+ : trimmed;
+
+ const optimisticUser: UIMessage = {
+ id: `user-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ role: "user",
+ parts: [{ type: "text", text: payloadText }],
+ } as UIMessage;
+ const baseMessages = [...messages, optimisticUser];
+ setMessages(baseMessages);
+
+ streamAbortRef.current?.abort();
+ const abort = new AbortController();
+ streamAbortRef.current = abort;
+ setIsStreaming(true);
+ setConnected(true);
+
+ try {
+ const res = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ signal: abort.signal,
+ body: JSON.stringify({
+ sessionKey,
+ messages: [optimisticUser],
+ }),
+ });
+ if (!res.ok || !res.body) {
+ setIsStreaming(false);
+ return;
+ }
+ await streamFromResponse(
+ res,
+ (parts) => {
+ const assistantMsg: UIMessage = {
+ id: `assistant-${sessionKey}-${Date.now()}`,
+ role: "assistant",
+ parts: parts as UIMessage["parts"],
+ } as UIMessage;
+ setMessages([...baseMessages, assistantMsg]);
+ },
+ abort.signal,
+ );
+ } catch (err) {
+ if ((err as Error).name !== "AbortError") {
+ console.error("Subagent send error:", err);
+ }
+ } finally {
+ if (!abort.signal.aborted) {
+ setIsStreaming(false);
+ streamAbortRef.current = null;
+ }
+ }
+ },
+ [messages, sessionKey, streamFromResponse],
+ );
+
+ const handleEditorSubmit = useCallback(
+ async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => {
+ if (isStreaming || isReconnecting) {
+ setQueuedMessages((prev) => [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ text,
+ mentionedFiles,
+ },
+ ]);
+ return;
+ }
+ await sendSubagentMessage(text, mentionedFiles);
+ },
+ [isStreaming, isReconnecting, sendSubagentMessage],
+ );
+
+ const handleStop = useCallback(async () => {
+ streamAbortRef.current?.abort();
+ streamAbortRef.current = null;
+ setIsStreaming(false);
+ setIsReconnecting(false);
+ try {
+ await fetch("/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sessionKey }),
+ });
+ } catch {
+ // ignore
+ }
+ }, [sessionKey]);
+
+ useEffect(() => {
+ void reconnect();
+ return () => {
+ streamAbortRef.current?.abort();
+ };
+ }, [reconnect]);
+
+ useEffect(() => {
+ const wasBusy = isStreaming || isReconnecting;
+ if (wasBusy || queuedMessages.length === 0) {return;}
+ const [next, ...rest] = queuedMessages;
+ setQueuedMessages(rest);
+ queueMicrotask(() => {
+ void sendSubagentMessage(next.text, next.mentionedFiles);
+ });
+ }, [isStreaming, isReconnecting, queuedMessages, sendSubagentMessage]);
+
+ useEffect(() => {
+ const el = scrollContainerRef.current;
+ if (!el) {return;}
+ const onScroll = () => {
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ userScrolledAwayRef.current = distanceFromBottom > 80;
+ };
+ el.addEventListener("scroll", onScroll, { passive: true });
+ return () => el.removeEventListener("scroll", onScroll);
+ }, []);
+
+ useEffect(() => {
+ if (userScrolledAwayRef.current) {return;}
+ if (scrollRafRef.current) {return;}
+ scrollRafRef.current = requestAnimationFrame(() => {
+ scrollRafRef.current = 0;
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ });
+ }, [messages]);
+
+ const statusLabel = useMemo(() => {
+ if (!connected && (isStreaming || isReconnecting)) {return Connecting ;}
+ if (isReconnecting) {return Resuming ;}
+ if (isStreaming) {return ;}
+ return "Completed";
+ }, [connected, isStreaming, isReconnecting]);
+
+ return (
+
+
+
+
+
+
+ {displayLabel}
+
+
+ {statusLabel}
+
+
+
+
+
+
+ {messages.map((message, i) => (
+
+ ))}
+
+
+
+
+
+
+ {queuedMessages.length > 0 && (
+
+
+
+ Queued ({queuedMessages.length})
+
+
+ {queuedMessages.map((msg) => (
+
+
+ {msg.text}
+
+
+
+ ))}
+
+
+
+ )}
+ setEditorEmpty(isEmpty)}
+ placeholder={isStreaming || isReconnecting ? "Type to queue a message..." : "Type @ to mention files..."}
+ />
+
+
+ {(isStreaming || isReconnecting) && (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/components/syntax-block.tsx b/apps/web/app/components/syntax-block.tsx
new file mode 100644
index 00000000000..ea3f0a77453
--- /dev/null
+++ b/apps/web/app/components/syntax-block.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { createHighlighter, type Highlighter } from "shiki";
+
+// Singleton highlighter (shared with code-viewer)
+let highlighterPromise: Promise | null = null;
+
+/** Languages to preload for chat code blocks. */
+const CHAT_LANGS = [
+ "typescript", "tsx", "javascript", "jsx",
+ "python", "ruby", "go", "rust", "java",
+ "c", "cpp", "csharp", "swift", "kotlin",
+ "css", "scss", "html", "xml",
+ "json", "yaml", "toml",
+ "bash", "sql", "graphql",
+ "markdown", "diff", "php", "lua",
+ "vue", "svelte", "dart", "zig",
+];
+
+function getHighlighter(): Promise {
+ if (!highlighterPromise) {
+ highlighterPromise = createHighlighter({
+ themes: ["github-dark", "github-light"],
+ langs: CHAT_LANGS,
+ });
+ }
+ return highlighterPromise;
+}
+
+type SyntaxBlockProps = {
+ code: string;
+ lang: string;
+};
+
+/**
+ * Renders a syntax-highlighted code block using shiki.
+ * Falls back to plain monospace while loading.
+ */
+export function SyntaxBlock({ code, lang }: SyntaxBlockProps) {
+ const [html, setHtml] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void getHighlighter().then((hl) => {
+ if (cancelled) {return;}
+ try {
+ const result = hl.codeToHtml(code, {
+ lang,
+ themes: {
+ dark: "github-dark",
+ light: "github-light",
+ },
+ });
+ setHtml(result);
+ } catch {
+ // If the language isn't loaded, fall back to plain text
+ try {
+ const result = hl.codeToHtml(code, {
+ lang: "text",
+ themes: {
+ dark: "github-dark",
+ light: "github-light",
+ },
+ });
+ setHtml(result);
+ } catch {
+ // Give up on highlighting
+ }
+ }
+ });
+ return () => { cancelled = true; };
+ }, [code, lang]);
+
+ if (html) {
+ return (
+
+ );
+ }
+
+ // Fallback: plain code while shiki loads
+ return (
+ {code}
+ );
+}
diff --git a/apps/web/app/components/tiptap/chat-editor.tsx b/apps/web/app/components/tiptap/chat-editor.tsx
new file mode 100644
index 00000000000..c45f692e546
--- /dev/null
+++ b/apps/web/app/components/tiptap/chat-editor.tsx
@@ -0,0 +1,493 @@
+"use client";
+
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+} from "react";
+import { useEditor, EditorContent } from "@tiptap/react";
+import type { Editor } from "@tiptap/core";
+import StarterKit from "@tiptap/starter-kit";
+import Placeholder from "@tiptap/extension-placeholder";
+import Suggestion from "@tiptap/suggestion";
+import { Extension } from "@tiptap/core";
+import { FileMentionNode, chatFileMentionPluginKey } from "./file-mention-extension";
+import {
+ createFileMentionRenderer,
+ type SuggestItem,
+} from "./file-mention-list";
+
+// ββ Types ββ
+
+export type ChatEditorHandle = {
+ /** Insert a file mention node programmatically. */
+ insertFileMention: (name: string, path: string) => void;
+ /** Clear the editor content. */
+ clear: () => void;
+ /** Focus the editor. */
+ focus: () => void;
+ /** Check if the editor is empty (no text, no mentions). */
+ isEmpty: () => boolean;
+ /** Programmatically submit the current content. */
+ submit: () => void;
+};
+
+type ChatEditorProps = {
+ /** Called when user presses Enter (without Shift). */
+ onSubmit: (text: string, mentionedFiles: Array<{ name: string; path: string }>) => void;
+ /** Called on every content change. */
+ onChange?: (isEmpty: boolean) => void;
+ /** Called when native files (e.g. from Finder/Desktop) are dropped onto the editor. */
+ onNativeFileDrop?: (files: FileList) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ compact?: boolean;
+};
+
+// ββ Helpers ββ
+
+function getFileCategory(name: string): string {
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
+ if (
+ ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
+ )
+ {return "image";}
+ if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";}
+ if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";}
+ if (ext === "pdf") {return "pdf";}
+ if (
+ [
+ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
+ "cpp", "c", "h", "css", "html", "json", "yaml", "yml",
+ "toml", "md", "sh", "bash", "sql", "swift", "kt",
+ ].includes(ext)
+ )
+ {return "code";}
+ if (["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext))
+ {return "document";}
+ return "other";
+}
+
+const categoryColors: Record = {
+ image: { bg: "rgba(16, 185, 129, 0.15)", fg: "#10b981" },
+ video: { bg: "rgba(139, 92, 246, 0.15)", fg: "#8b5cf6" },
+ audio: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" },
+ pdf: { bg: "rgba(239, 68, 68, 0.15)", fg: "#ef4444" },
+ code: { bg: "rgba(59, 130, 246, 0.15)", fg: "#3b82f6" },
+ document: { bg: "rgba(107, 114, 128, 0.15)", fg: "#6b7280" },
+ folder: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" },
+ other: { bg: "rgba(107, 114, 128, 0.10)", fg: "#9ca3af" },
+};
+
+function shortenPath(path: string): string {
+ return path
+ .replace(/^\/Users\/[^/]+/, "~")
+ .replace(/^\/home\/[^/]+/, "~")
+ .replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
+}
+
+/**
+ * Serialize the editor content to plain text with mention markers.
+ * Returns { text, mentionedFiles }.
+ * Objects serialize as `[object: name]`, entries as `[entry: objectName/label]`,
+ * and files as `[file: path]`.
+ */
+function serializeContent(editor: ReturnType): {
+ text: string;
+ mentionedFiles: Array<{ name: string; path: string }>;
+} {
+ if (!editor) {return { text: "", mentionedFiles: [] };}
+
+ const mentionedFiles: Array<{ name: string; path: string }> = [];
+ const parts: string[] = [];
+
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === "chatFileMention") {
+ const label = node.attrs.label as string;
+ const path = node.attrs.path as string;
+ const mType = node.attrs.mentionType as string;
+ const objectName = node.attrs.objectName as string;
+
+ mentionedFiles.push({ name: label, path });
+
+ if (mType === "object") {
+ parts.push(`[object: ${label}]`);
+ } else if (mType === "entry") {
+ parts.push(`[entry: ${objectName ? `${objectName}/` : ""}${label}]`);
+ } else {
+ parts.push(`[file: ${path}]`);
+ }
+ return false;
+ }
+ if (node.isText && node.text) {
+ parts.push(node.text);
+ }
+ if (node.type.name === "paragraph" && parts.length > 0) {
+ const lastPart = parts[parts.length - 1];
+ if (lastPart !== undefined && lastPart !== "\n") {
+ parts.push("\n");
+ }
+ }
+ return true;
+ });
+
+ return { text: parts.join("").trim(), mentionedFiles };
+}
+
+// ββ File mention suggestion extension (wired to the async popup) ββ
+
+function createChatFileMentionSuggestion() {
+ return Extension.create({
+ name: "chatFileMentionSuggestion",
+
+ addProseMirrorPlugins() {
+ return [
+ Suggestion({
+ editor: this.editor,
+ char: "@",
+ pluginKey: chatFileMentionPluginKey,
+ startOfLine: false,
+ allowSpaces: true,
+ command: ({
+ editor,
+ range,
+ props,
+ }: {
+ editor: Editor;
+ range: { from: number; to: number };
+ props: SuggestItem;
+ }) => {
+ // For folders: update the query text to navigate into the folder
+ if (props.type === "folder") {
+ const shortPath = shortenPath(props.path);
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .insertContent(`@${shortPath}/`)
+ .run();
+ return;
+ }
+
+ // Determine mention type for objects/entries
+ const mentionType =
+ props.type === "object" ? "object"
+ : props.type === "entry" ? "entry"
+ : "file";
+
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .insertContent([
+ {
+ type: "chatFileMention",
+ attrs: {
+ label: props.name,
+ path: props.path,
+ mentionType,
+ objectName: props.objectName ?? "",
+ },
+ },
+ { type: "text", text: " " },
+ ])
+ .run();
+ },
+ items: ({ query }: { query: string }) => {
+ // Items are fetched async by the renderer, return empty here
+ void query;
+ return [];
+ },
+ render: createFileMentionRenderer(),
+ }),
+ ];
+ },
+ });
+}
+
+// ββ Main component ββ
+
+export const ChatEditor = forwardRef(
+ function ChatEditor({ onSubmit, onChange, onNativeFileDrop, placeholder, disabled, compact }, ref) {
+ const submitRef = useRef(onSubmit);
+ submitRef.current = onSubmit;
+
+ const nativeFileDropRef = useRef(onNativeFileDrop);
+ nativeFileDropRef.current = onNativeFileDrop;
+
+ // Ref to access the TipTap editor from within ProseMirror's handleDOMEvents
+ // (the handlers are defined at useEditor() call time, before the editor exists).
+ const editorRefInternal = useRef(null);
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ extensions: [
+ StarterKit.configure({
+ heading: false,
+ codeBlock: false,
+ blockquote: false,
+ horizontalRule: false,
+ bulletList: false,
+ orderedList: false,
+ listItem: false,
+ }),
+ Placeholder.configure({
+ placeholder: placeholder ?? "Ask anything...",
+ showOnlyWhenEditable: false,
+ }),
+ FileMentionNode,
+ createChatFileMentionSuggestion(),
+ ],
+ editorProps: {
+ attributes: {
+ class: `chat-editor-content ${compact ? "chat-editor-compact" : ""}`,
+ style: `color: var(--color-text);`,
+ },
+ handleKeyDown: (_view, event) => {
+ // Enter without shift = submit
+ if (event.key === "Enter" && !event.shiftKey) {
+ // Don't submit if suggestion popup is active
+ // The suggestion plugin handles Enter in that case
+ return false;
+ }
+ return false;
+ },
+ // Handle drag-and-drop of files from the sidebar.
+ // Using handleDOMEvents ensures our handler runs BEFORE
+ // ProseMirror's built-in drop processing, which would
+ // otherwise consume the event or insert the text/plain
+ // fallback data as raw text.
+ handleDOMEvents: {
+ paste: (_view, event) => {
+ const clipboardData = event.clipboardData;
+ if (!clipboardData) {return false;}
+
+ // Collect files from clipboard (images, screenshots, etc.)
+ const pastedFiles: File[] = [];
+ if (clipboardData.items) {
+ for (const item of Array.from(clipboardData.items)) {
+ if (item.kind === "file") {
+ const file = item.getAsFile();
+ if (file) {pastedFiles.push(file);}
+ }
+ }
+ }
+
+ if (pastedFiles.length > 0) {
+ event.preventDefault();
+ const dt = new DataTransfer();
+ for (const f of pastedFiles) {dt.items.add(f);}
+ nativeFileDropRef.current?.(dt.files);
+ return true;
+ }
+
+ return false;
+ },
+ dragover: (_view, event) => {
+ const de = event;
+ if (de.dataTransfer?.types.includes("application/x-file-mention")) {
+ de.preventDefault();
+ de.dataTransfer.dropEffect = "copy";
+ return true;
+ }
+ // Accept native file drops (e.g. from Finder/Desktop)
+ if (de.dataTransfer?.types.includes("Files")) {
+ de.preventDefault();
+ de.dataTransfer.dropEffect = "copy";
+ return true;
+ }
+ return false;
+ },
+ drop: (_view, event) => {
+ const de = event;
+
+ // Sidebar file mention drop
+ const data = de.dataTransfer?.getData("application/x-file-mention");
+ if (data) {
+ de.preventDefault();
+ de.stopPropagation();
+ try {
+ const { name, path } = JSON.parse(data) as { name: string; path: string };
+ if (name && path) {
+ editorRefInternal.current
+ ?.chain()
+ .focus()
+ .insertContent([
+ {
+ type: "chatFileMention",
+ attrs: { label: name, path },
+ },
+ { type: "text", text: " " },
+ ])
+ .run();
+ }
+ } catch {
+ // ignore malformed data
+ }
+ return true;
+ }
+
+ // Native file drop (from OS file manager)
+ const files = de.dataTransfer?.files;
+ if (files && files.length > 0) {
+ de.preventDefault();
+ de.stopPropagation();
+ nativeFileDropRef.current?.(files);
+ return true;
+ }
+
+ return false;
+ },
+ },
+ },
+ onUpdate: ({ editor: ed }) => {
+ onChange?.(ed.isEmpty);
+ },
+ });
+
+ // Keep internal ref in sync so handleDOMEvents handlers can access the editor
+ useEffect(() => {
+ editorRefInternal.current = editor ?? null;
+ }, [editor]);
+
+ // Handle Enter-to-submit via a keydown listener on the editor DOM
+ useEffect(() => {
+ if (!editor) {return;}
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
+ // Check if suggestion popup is active by checking if the plugin has active state
+ const suggestState = chatFileMentionPluginKey.getState(editor.state);
+ if (suggestState?.active) {return;} // Let suggestion handle it
+
+ event.preventDefault();
+ const { text, mentionedFiles } = serializeContent(editor);
+ if (text.trim() || mentionedFiles.length > 0) {
+ submitRef.current(text, mentionedFiles);
+ editor.commands.clearContent(true);
+ }
+ }
+ };
+
+ const el = editor.view.dom;
+ el.addEventListener("keydown", handleKeyDown);
+ return () => el.removeEventListener("keydown", handleKeyDown);
+ }, [editor]);
+
+ // Disable/enable editor
+ useEffect(() => {
+ if (editor) {
+ editor.setEditable(!disabled);
+ }
+ }, [editor, disabled]);
+
+ useImperativeHandle(ref, () => ({
+ insertFileMention: (name: string, path: string) => {
+ editor
+ ?.chain()
+ .focus()
+ .insertContent([
+ {
+ type: "chatFileMention",
+ attrs: { label: name, path },
+ },
+ { type: "text", text: " " },
+ ])
+ .run();
+ },
+ clear: () => {
+ editor?.commands.clearContent(true);
+ },
+ focus: () => {
+ editor?.commands.focus();
+ },
+ isEmpty: () => {
+ return editor?.isEmpty ?? true;
+ },
+ submit: () => {
+ if (!editor) {return;}
+ const { text, mentionedFiles } = serializeContent(editor);
+ if (text.trim() || mentionedFiles.length > 0) {
+ submitRef.current(text, mentionedFiles);
+ editor.commands.clearContent(true);
+ }
+ },
+ }));
+
+ return (
+ <>
+
+
+ >
+ );
+ },
+);
+
+/**
+ * Helper to extract file mention info for styling (used by renderHTML).
+ * Returns CSS custom properties for the mention pill.
+ */
+export function getMentionStyle(label: string): React.CSSProperties {
+ const category = getFileCategory(label);
+ const colors = categoryColors[category] ?? categoryColors.other;
+ return {
+ "--mention-bg": colors.bg,
+ "--mention-fg": colors.fg,
+ } as React.CSSProperties;
+}
diff --git a/apps/web/app/components/tiptap/file-mention-extension.ts b/apps/web/app/components/tiptap/file-mention-extension.ts
new file mode 100644
index 00000000000..591e14fc931
--- /dev/null
+++ b/apps/web/app/components/tiptap/file-mention-extension.ts
@@ -0,0 +1,111 @@
+import { Node, mergeAttributes } from "@tiptap/core";
+import { type SuggestionOptions } from "@tiptap/suggestion";
+import { PluginKey } from "@tiptap/pm/state";
+
+export const chatFileMentionPluginKey = new PluginKey("chatFileMention");
+
+export type FileMentionAttrs = {
+ label: string;
+ path: string;
+ /** Distinguish between file, object, and entry mentions */
+ mentionType?: "file" | "object" | "entry";
+ /** Object name for entry mentions */
+ objectName?: string;
+};
+
+/** Resolve mention pill colors from the mention type or filename extension. */
+function mentionColors(label: string, mentionType?: string): { bg: string; fg: string } {
+ if (mentionType === "object") {return { bg: "rgba(14,165,233,0.15)", fg: "#0ea5e9" };}
+ if (mentionType === "entry") {return { bg: "rgba(34,197,94,0.15)", fg: "#22c55e" };}
+ const ext = label.split(".").pop()?.toLowerCase() ?? "";
+ if (
+ ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
+ )
+ {return { bg: "rgba(16,185,129,0.15)", fg: "#10b981" };}
+ if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
+ {return { bg: "rgba(139,92,246,0.15)", fg: "#8b5cf6" };}
+ if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
+ {return { bg: "rgba(245,158,11,0.15)", fg: "#f59e0b" };}
+ if (ext === "pdf") {return { bg: "rgba(239,68,68,0.15)", fg: "#ef4444" };}
+ if (
+ [
+ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
+ "cpp", "c", "h", "css", "html", "json", "yaml", "yml",
+ "toml", "md", "sh", "bash", "sql", "swift", "kt",
+ ].includes(ext)
+ )
+ {return { bg: "rgba(59,130,246,0.15)", fg: "#3b82f6" };}
+ if (
+ ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext)
+ )
+ {return { bg: "rgba(107,114,128,0.15)", fg: "#6b7280" };}
+ return { bg: "rgba(107,114,128,0.10)", fg: "#9ca3af" };
+}
+
+/**
+ * Inline atom node for file mentions in the chat editor.
+ * Renders as a non-editable pill: [@icon filename].
+ * Serializes to `[file: /absolute/path]` for the chat API.
+ */
+export const FileMentionNode = Node.create({
+ name: "chatFileMention",
+ group: "inline",
+ inline: true,
+ atom: true,
+ selectable: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ label: { default: "" },
+ path: { default: "" },
+ mentionType: { default: "file" },
+ objectName: { default: "" },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'span[data-chat-file-mention]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ const label = (HTMLAttributes.label as string) || "file";
+ const mType = HTMLAttributes.mentionType as string | undefined;
+ const colors = mentionColors(label, mType);
+ return [
+ "span",
+ mergeAttributes(
+ {
+ "data-chat-file-mention": "",
+ class: "chat-file-mention",
+ style: `--mention-bg: ${colors.bg}; --mention-fg: ${colors.fg};`,
+ title: HTMLAttributes.path || "",
+ },
+ HTMLAttributes,
+ ),
+ `@${label}`,
+ ];
+ },
+});
+
+/** Suggestion configuration for the @ trigger in the chat editor. */
+export type FileMentionSuggestionOptions = Omit<
+ SuggestionOptions<{ name: string; path: string; type: string }>,
+ "editor"
+>;
+
+/**
+ * Build the suggestion config for the file mention node.
+ * The actual items fetching and rendering is handled by the chat-editor component.
+ */
+export function buildFileMentionSuggestion(
+ overrides: Partial,
+): Partial {
+ return {
+ char: "@",
+ pluginKey: chatFileMentionPluginKey,
+ startOfLine: false,
+ allowSpaces: true,
+ ...overrides,
+ };
+}
diff --git a/apps/web/app/components/tiptap/file-mention-list.tsx b/apps/web/app/components/tiptap/file-mention-list.tsx
new file mode 100644
index 00000000000..aa64ff8b767
--- /dev/null
+++ b/apps/web/app/components/tiptap/file-mention-list.tsx
@@ -0,0 +1,557 @@
+"use client";
+
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { createPortal } from "react-dom";
+
+// ββ Types ββ
+
+type SuggestItem = {
+ name: string;
+ path: string;
+ type: "folder" | "file" | "document" | "database" | "object" | "entry";
+ icon?: string;
+ objectName?: string;
+ entryId?: string;
+};
+
+export type FileMentionListRef = {
+ onKeyDown: (props: { event: KeyboardEvent }) => boolean;
+};
+
+type FileMentionListProps = {
+ items: SuggestItem[];
+ command: (item: SuggestItem) => void;
+ loading?: boolean;
+};
+
+// ββ File type helpers ββ
+
+type FileCategory =
+ | "folder" | "image" | "video" | "audio" | "pdf" | "code"
+ | "document" | "database" | "object" | "entry" | "other";
+
+function getFileCategory(name: string, type: string): FileCategory {
+ if (type === "folder") {return "folder";}
+ if (type === "database") {return "database";}
+ if (type === "object") {return "object";}
+ if (type === "entry") {return "entry";}
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
+ if (
+ ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
+ )
+ {return "image";}
+ if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";}
+ if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";}
+ if (ext === "pdf") {return "pdf";}
+ if (
+ [
+ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
+ "cpp", "c", "h", "css", "html", "json", "yaml", "yml",
+ "toml", "md", "sh", "bash", "sql", "swift", "kt",
+ ].includes(ext)
+ )
+ {return "code";}
+ if (type === "document") {return "document";}
+ return "other";
+}
+
+const categoryColors: Record = {
+ folder: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
+ image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
+ video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
+ audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
+ pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
+ code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
+ document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
+ database: { bg: "rgba(168, 85, 247, 0.12)", fg: "#a855f7" },
+ object: { bg: "rgba(14, 165, 233, 0.12)", fg: "#0ea5e9" },
+ entry: { bg: "rgba(34, 197, 94, 0.12)", fg: "#22c55e" },
+ other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
+};
+
+function MiniIcon({ category }: { category: string }) {
+ const props = {
+ width: 12,
+ height: 12,
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 2,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+ };
+ switch (category) {
+ case "folder":
+ return (
+
+ );
+ case "image":
+ return (
+
+ );
+ case "video":
+ return (
+
+ );
+ case "audio":
+ return (
+
+ );
+ case "pdf":
+ return (
+
+ );
+ case "code":
+ return (
+
+ );
+ case "database":
+ return (
+
+ );
+ case "object":
+ return (
+
+ );
+ case "entry":
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+}
+
+function shortenPath(path: string): string {
+ return path
+ .replace(/^\/Users\/[^/]+/, "~")
+ .replace(/^\/home\/[^/]+/, "~")
+ .replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
+}
+
+// ββ List component ββ
+
+const FileMentionList = forwardRef(
+ ({ items, command, loading }, ref) => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const listRef = useRef(null);
+
+ useEffect(() => {
+ setSelectedIndex(0);
+ }, [items]);
+
+ useEffect(() => {
+ const el = listRef.current?.children[selectedIndex] as
+ | HTMLElement
+ | undefined;
+ el?.scrollIntoView({ block: "nearest" });
+ }, [selectedIndex]);
+
+ const selectItem = useCallback(
+ (index: number) => {
+ const item = items[index];
+ if (item) {command(item);}
+ },
+ [items, command],
+ );
+
+ useImperativeHandle(ref, () => ({
+ onKeyDown: ({ event }: { event: KeyboardEvent }) => {
+ if (event.key === "ArrowUp") {
+ setSelectedIndex((i) => (i + items.length - 1) % items.length);
+ return true;
+ }
+ if (event.key === "ArrowDown") {
+ setSelectedIndex((i) => (i + 1) % items.length);
+ return true;
+ }
+ if (event.key === "Enter" || event.key === "Tab") {
+ selectItem(selectedIndex);
+ return true;
+ }
+ return false;
+ },
+ }));
+
+ if (loading) {
+ return (
+
+
+
+
+ Searching...
+
+
+
+ );
+ }
+
+ if (items.length === 0) {
+ return (
+
+
+ No results found
+
+
+ );
+ }
+
+ return (
+
+ {items.map((item, index) => {
+ const category = getFileCategory(item.name, item.type);
+ const colors = categoryColors[category] ?? categoryColors.other;
+ const hasEmoji = item.icon && /\p{Emoji_Presentation}/u.test(item.icon);
+ const isDbItem = item.type === "object" || item.type === "entry";
+ const sublabel = item.type === "entry" && item.objectName
+ ? item.objectName
+ : isDbItem
+ ? item.type
+ : shortenPath(item.path);
+
+ return (
+
+ );
+ })}
+
+ );
+ },
+);
+
+FileMentionList.displayName = "FileMentionList";
+
+// ββ Floating portal renderer for Tiptap suggestion ββ
+
+export type MentionRendererProps = {
+ items: SuggestItem[];
+ command: (item: SuggestItem) => void;
+ clientRect: (() => DOMRect | null) | null | undefined;
+ componentRef: React.RefObject;
+ loading?: boolean;
+};
+
+export function MentionPopupRenderer({
+ items,
+ command,
+ clientRect,
+ componentRef,
+ loading,
+}: MentionRendererProps) {
+ const popupRef = useRef(null);
+
+ useLayoutEffect(() => {
+ if (!popupRef.current || !clientRect) {return;}
+ const rect = clientRect();
+ if (!rect) {return;}
+
+ const el = popupRef.current;
+ const popupHeight = el.offsetHeight || 200;
+
+ // Position above the cursor if not enough space below
+ const spaceBelow = window.innerHeight - rect.bottom;
+ if (spaceBelow < popupHeight + 8) {
+ el.style.position = "fixed";
+ el.style.left = `${rect.left}px`;
+ el.style.bottom = `${window.innerHeight - rect.top + 4}px`;
+ el.style.top = "auto";
+ } else {
+ el.style.position = "fixed";
+ el.style.left = `${rect.left}px`;
+ el.style.top = `${rect.bottom + 4}px`;
+ el.style.bottom = "auto";
+ }
+ el.style.zIndex = "100";
+ }, [clientRect, items, loading]);
+
+ return createPortal(
+
+
+ ,
+ document.body,
+ );
+}
+
+/**
+ * Creates a Tiptap suggestion render() function that fetches file suggestions
+ * from /api/workspace/suggest-files and renders them in a floating popup.
+ */
+export function createFileMentionRenderer() {
+ return () => {
+ let container: HTMLDivElement | null = null;
+ let root: ReturnType | null =
+ null;
+ const componentRef: React.RefObject = {
+ current: null,
+ };
+ let currentQuery = "";
+ let currentItems: SuggestItem[] = [];
+ let isLoading = false;
+ let debounceTimer: ReturnType | null = null;
+ let latestCommand: ((item: SuggestItem) => void) | null = null;
+ let latestClientRect: (() => DOMRect | null) | null = null;
+
+ function render() {
+ if (!root || !latestCommand) {return;}
+ root.render(
+ ,
+ );
+ }
+
+ async function fetchSuggestions(query: string) {
+ isLoading = true;
+ render();
+
+ try {
+ const hasPath =
+ query.startsWith("/") ||
+ query.startsWith("~/") ||
+ query.startsWith("../") ||
+ query.startsWith("./") ||
+ query.includes("/");
+ const param = hasPath
+ ? `path=${encodeURIComponent(query)}`
+ : query
+ ? `q=${encodeURIComponent(query)}`
+ : "";
+ const url = `/api/workspace/suggest-files${param ? `?${param}` : ""}`;
+ const res = await fetch(url);
+ const data = await res.json();
+ currentItems = data.items ?? [];
+ } catch {
+ currentItems = [];
+ }
+
+ isLoading = false;
+ render();
+ }
+
+ function debouncedFetch(query: string) {
+ if (debounceTimer) {clearTimeout(debounceTimer);}
+ debounceTimer = setTimeout(() => {
+ void fetchSuggestions(query);
+ }, 120);
+ }
+
+ return {
+ onStart: (props: {
+ query: string;
+ command: (item: SuggestItem) => void;
+ clientRect?: (() => DOMRect | null) | null;
+ }) => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ latestCommand = props.command;
+ latestClientRect = props.clientRect ?? null;
+ currentQuery = props.query;
+
+ void import("react-dom/client").then(({ createRoot }) => {
+ root = createRoot(container!);
+ debouncedFetch(currentQuery);
+ });
+ },
+
+ onUpdate: (props: {
+ query: string;
+ command: (item: SuggestItem) => void;
+ clientRect?: (() => DOMRect | null) | null;
+ }) => {
+ latestCommand = props.command;
+ latestClientRect = props.clientRect ?? null;
+ currentQuery = props.query;
+ debouncedFetch(currentQuery);
+ },
+
+ onKeyDown: (props: { event: KeyboardEvent }) => {
+ if (props.event.key === "Escape") {
+ root?.unmount();
+ container?.remove();
+ container = null;
+ root = null;
+ return true;
+ }
+ const handled = componentRef.current?.onKeyDown(props) ?? false;
+ if (handled) {
+ // Stop the chat-editor's DOM keydown listener from
+ // also firing and submitting the message. By the time
+ // that listener runs, the suggestion command has already
+ // executed and the plugin state is inactive, so the
+ // `suggestState.active` guard would not catch it.
+ props.event.stopImmediatePropagation();
+ }
+ return handled;
+ },
+
+ onExit: () => {
+ if (debounceTimer) {clearTimeout(debounceTimer);}
+ root?.unmount();
+ container?.remove();
+ container = null;
+ root = null;
+ },
+ };
+ };
+}
+
+export type { SuggestItem };
diff --git a/apps/web/app/components/ui/dropdown-menu.tsx b/apps/web/app/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000000..ff3f3d16af0
--- /dev/null
+++ b/apps/web/app/components/ui/dropdown-menu.tsx
@@ -0,0 +1,314 @@
+"use client";
+
+import * as React from "react";
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { ChevronRightIcon, CheckIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: React.ComponentProps &
+ Pick<
+ React.ComponentProps,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ );
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ onSelect,
+ onClick,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+ onSelect?: () => void;
+}) {
+ const handleClick = (e: React.MouseEvent & { preventBaseUIHandler: () => void }) => {
+ onClick?.(e);
+ onSelect?.();
+ };
+ return (
+
+ );
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return (
+