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 agent --stream-json --subscribe-session-key --after-seq ` + - same profile/workspace env wiring as `spawnAgentProcess`. + +3. **Replace parent waiting flow with subscribe child process** + +- In [apps/web/lib/active-runs.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/lib/active-runs.ts): + - replace `subscribeToSessionKey(...)` usage with a managed subscribe child, + - parse NDJSON from subscribe child and route through existing parent event processor, + - dedupe using `globalSeq` (drop stale/replayed duplicates), + - store/cleanup process handle across finalize/abort/cleanup. + +4. **Replace subagent fallback/rehydration with subscribe child process** + +- In [apps/web/lib/subagent-runs.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/lib/subagent-runs.ts): + - swap `subscribeToSessionKey(...)` for one managed subscribe child per running subagent session, + - feed NDJSON into existing `routeRawEvent`/transform path, + - use `lastGlobalSeq` dedupe and robust teardown on completion/error/cleanup. + +5. **Retire direct web WS client** + +- Remove [apps/web/lib/gateway-events.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/lib/gateway-events.ts) imports/usages from web runtime. +- Delete file only after all references are gone and typecheck passes. + +6. **Long-wait stream resilience** + +- Add lightweight SSE keepalive comments/events while run status is `waiting-for-subagents` in [apps/web/app/api/chat/stream/route.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/app/api/chat/stream/route.ts) or run subscription layer, so idle waits don’t silently time out. + +7. **Verification gates** + +- Run targeted checks for: + - parent run -> subagent spawn -> parent wait -> announcement turn -> finalize, + - page refresh during parent wait, + - page refresh during subagent live stream, + - no cross-session event bleed, + - no duplicate tool/lifecycle events after replay handoff. + +## Flow target + +```mermaid +flowchart TD + webRun[WebRunManager] --> parentCli[agent --stream-json main run] + parentCli --> ndjsonParent[Parent NDJSON events] + parentCli -->|parent exits while subagents running| waitState[waitingForSubagents] + waitState --> subscribeCliParent[agent --stream-json subscribe parentSessionKey] + subscribeCliParent --> ndjsonReplayParent[ReplayedPlusLive NDJSON] + subagentMgr[SubagentRunManager] --> subscribeCliSub[agent --stream-json subscribe subagentSessionKey] + subscribeCliSub --> ndjsonSub[Subagent NDJSON] + ndjsonReplayParent --> sse[API chat stream SSE] + ndjsonSub --> sse +``` diff --git a/.cursor/plans/interactive_subagent_panel_fb79f5b8.plan.md b/.cursor/plans/interactive_subagent_panel_fb79f5b8.plan.md new file mode 100644 index 00000000000..187efbe6561 --- /dev/null +++ b/.cursor/plans/interactive_subagent_panel_fb79f5b8.plan.md @@ -0,0 +1,132 @@ +--- +name: Interactive Subagent Panel +overview: Make subagents fully independent of the parent agent's event stream. Each subagent gets its own gateway subscription immediately on registration. Then make the subagent panel interactive with stop, send, queue matching the main chat, using unified API routes. +todos: + - id: decouple-subagent + content: "SubagentRunManager: subscribe immediately on registration, remove routeRawEvent/preRegBuffer/activateGatewayFallback" + status: pending + - id: remove-parent-routing + content: "active-runs.ts: remove subagent event routing from parent NDJSON stream" + status: pending + - id: srm-methods + content: "SubagentRunManager: add persistUserMessage(), reactivateSubagent(), abortSubagent(), spawnSubagentMessage()" + status: pending + - id: unify-chat-route + content: Extend POST /api/chat to dispatch to subagent flow when sessionKey is a subagent key + status: pending + - id: unify-stop-route + content: Extend POST /api/chat/stop to dispatch to SubagentRunManager when sessionKey is a subagent key + status: pending + - id: unify-stream-route + content: Extend GET /api/chat/stream to dispatch to SubagentRunManager when sessionKey is a subagent key + status: pending + - id: parser-turns + content: Extend createStreamParser to handle user-message events as turn boundaries + status: pending + - id: panel-rewrite + content: Rewrite SubagentPanel with ChatEditor, send/stop/queue, multi-turn conversation + status: pending +isProject: false +--- + +# Interactive Subagent Panel + +## Core Problem + +Subagent events piggyback on the parent agent's CLI NDJSON stream. When the parent finishes (spawns subagents then exits), the stream dies and subagent events stop flowing. The `activateGatewayFallback()` partially compensates but loses early events. + +The root cause is architectural: subagents are treated as appendages of the parent. They should be independent sessions. + +## Architecture Change + +A subagent is just an agent session. The only link to the parent is the completion announcement. Each subagent gets its own gateway subscription from the moment it's registered. + +```mermaid +flowchart TB + subgraph before [Current: Coupled] + GW1[Gateway] --> ParentCLI[Parent CLI stdout] + ParentCLI --> ARM1[ActiveRunManager] + ParentCLI -.->|"routeRawEvent
(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/.gitignore b/.gitignore index ea74e9fc3f5..07dce81b01f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ bun.lockb coverage __pycache__/ *.pyc -.tsbuildinfo +*.tsbuildinfo .pnpm-store .worktrees/ .DS_Store diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 43d465796a7..7af4818542e 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,12 +1,25 @@ 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, + 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"; @@ -14,11 +27,37 @@ 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, - }: { messages: UIMessage[]; sessionId?: string } = await req.json(); + 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(); @@ -35,10 +74,15 @@ export async function POST(req: Request) { 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 (sessionId && hasActiveRun(sessionId)) { + 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; @@ -52,7 +96,15 @@ export async function POST(req: Request) { // Persist the user message server-side so it survives a page reload // even if the client never gets a chance to save. - if (sessionId && lastUserMessage) { + 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, @@ -62,7 +114,14 @@ export async function POST(req: Request) { // Start the agent run (decoupled from this HTTP connection). // The child process will keep running even if this response is cancelled. - if (sessionId) { + 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, @@ -84,15 +143,38 @@ export async function POST(req: Request) { const stream = new ReadableStream({ start(controller) { - if (!sessionId) { + if (!sessionId && !sessionKey) { // No session — shouldn't happen but close gracefully. controller.close(); return; } - unsubscribe = subscribeToRun( - sessionId, - (event: SseEvent | null) => { + 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. diff --git a/apps/web/app/api/chat/stop/route.ts b/apps/web/app/api/chat/stop/route.ts index 1642915773c..02b1a66e488 100644 --- a/apps/web/app/api/chat/stop/route.ts +++ b/apps/web/app/api/chat/stop/route.ts @@ -5,16 +5,54 @@ * 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 } = await req + 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 required", { status: 400 }); + return new Response("sessionId or subagent sessionKey required", { status: 400 }); } const aborted = abortRun(body.sessionId); diff --git a/apps/web/app/api/chat/stream/route.ts b/apps/web/app/api/chat/stream/route.ts index 0a23ee7f8b8..c1c16fd563e 100644 --- a/apps/web/app/api/chat/stream/route.ts +++ b/apps/web/app/api/chat/stream/route.ts @@ -10,21 +10,112 @@ import { getActiveRun, subscribeToRun, - type SseEvent, + 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) { - return new Response("sessionId required", { status: 400 }); + if (!sessionId && !sessionKey) { + return new Response("sessionId or subagent sessionKey required", { status: 400 }); } - const run = getActiveRun(sessionId); + 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 }); } @@ -32,18 +123,33 @@ export async function GET(req: Request) { 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, - (event: SseEvent | null) => { + 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 { @@ -66,12 +172,20 @@ export async function GET(req: Request) { 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?.(); }, }); @@ -81,7 +195,7 @@ export async function GET(req: Request) { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", - "X-Run-Active": run.status === "running" ? "true" : "false", + "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/jobs/[jobId]/runs/route.ts b/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts index 4f27a81b8e5..05fa62cea44 100644 --- a/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts +++ b/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts @@ -1,10 +1,10 @@ import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const CRON_DIR = join(homedir(), ".openclaw", "cron"); +const CRON_DIR = join(resolveOpenClawStateDir(), "cron"); type CronRunLogEntry = { ts: number; diff --git a/apps/web/app/api/cron/jobs/route.ts b/apps/web/app/api/cron/jobs/route.ts index 7d95010dd3c..0a77df08fb2 100644 --- a/apps/web/app/api/cron/jobs/route.ts +++ b/apps/web/app/api/cron/jobs/route.ts @@ -1,10 +1,10 @@ import { readFileSync, existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const CRON_DIR = join(homedir(), ".openclaw", "cron"); +const CRON_DIR = join(resolveOpenClawStateDir(), "cron"); const JOBS_FILE = join(CRON_DIR, "jobs.json"); type CronStoreFile = { @@ -46,7 +46,7 @@ function readHeartbeatInfo(): { intervalMs: number; nextDueEstimateMs: number | // Try to read agent session stores to estimate next heartbeat from lastRunMs try { - const agentsDir = join(homedir(), ".openclaw", "agents"); + const agentsDir = join(resolveOpenClawStateDir(), "agents"); if (!existsSync(agentsDir)) {return defaults;} const agentDirs = readdirSync(agentsDir, { withFileTypes: true }); diff --git a/apps/web/app/api/cron/runs/[sessionId]/route.ts b/apps/web/app/api/cron/runs/[sessionId]/route.ts index 36a420a1140..5d6369c4c1a 100644 --- a/apps/web/app/api/cron/runs/[sessionId]/route.ts +++ b/apps/web/app/api/cron/runs/[sessionId]/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -18,7 +18,7 @@ type ParsedMessage = { /** Search agent session directories for a session file by ID. */ function findSessionFile(sessionId: string): string | null { - const agentsDir = join(homedir(), ".openclaw", "agents"); + const agentsDir = join(resolveOpenClawStateDir(), "agents"); if (!existsSync(agentsDir)) {return null;} try { diff --git a/apps/web/app/api/cron/runs/search-transcript/route.ts b/apps/web/app/api/cron/runs/search-transcript/route.ts index f8e629bbd60..2b2a91be019 100644 --- a/apps/web/app/api/cron/runs/search-transcript/route.ts +++ b/apps/web/app/api/cron/runs/search-transcript/route.ts @@ -1,10 +1,10 @@ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const AGENTS_DIR = join(homedir(), ".openclaw", "agents"); +const AGENTS_DIR = join(resolveOpenClawStateDir(), "agents"); type MessagePart = | { type: "text"; text: string } diff --git a/apps/web/app/api/memories/route.ts b/apps/web/app/api/memories/route.ts index a42a614b7b7..d9b0ee2e1f6 100644 --- a/apps/web/app/api/memories/route.ts +++ b/apps/web/app/api/memories/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -11,7 +11,8 @@ type MemoryFile = { }; export async function GET() { - const workspaceDir = join(homedir(), ".openclaw", "workspace"); + const stateDir = resolveOpenClawStateDir(); + const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace"); let mainMemory: string | null = null; const dailyLogs: MemoryFile[] = []; 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 index b2d4b68944e..39de17f10b3 100644 --- a/apps/web/app/api/sessions/[sessionId]/route.ts +++ b/apps/web/app/api/sessions/[sessionId]/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -22,12 +22,8 @@ type JSONLMessage = { data?: unknown; }; -function resolveOpenClawDir(): string { - return join(homedir(), ".openclaw"); -} - function findSessionFile(sessionId: string): string | null { - const openclawDir = resolveOpenClawDir(); + const openclawDir = resolveOpenClawStateDir(); const agentsDir = join(openclawDir, "agents"); if (!existsSync(agentsDir)) { diff --git a/apps/web/app/api/sessions/route.ts b/apps/web/app/api/sessions/route.ts index 87cae3d7697..b6d84d2a02c 100644 --- a/apps/web/app/api/sessions/route.ts +++ b/apps/web/app/api/sessions/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -36,12 +36,8 @@ type SessionRow = { contextTokens?: number; }; -function resolveOpenClawDir(): string { - return join(homedir(), ".openclaw"); -} - export async function GET() { - const openclawDir = resolveOpenClawDir(); + const openclawDir = resolveOpenClawStateDir(); const agentsDir = join(openclawDir, "agents"); if (!existsSync(agentsDir)) { diff --git a/apps/web/app/api/skills/route.ts b/apps/web/app/api/skills/route.ts index 822015cc519..d773eb81859 100644 --- a/apps/web/app/api/skills/route.ts +++ b/apps/web/app/api/skills/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -69,12 +69,12 @@ function scanSkillDir(dir: string, source: string): SkillEntry[] { } export async function GET() { - const home = homedir(); - const openclawDir = join(home, ".openclaw"); + const stateDir = resolveOpenClawStateDir(); + const workspaceRoot = resolveWorkspaceRoot() ?? join(stateDir, "workspace"); - const managedSkills = scanSkillDir(join(openclawDir, "skills"), "managed"); + const managedSkills = scanSkillDir(join(stateDir, "skills"), "managed"); const workspaceSkills = scanSkillDir( - join(openclawDir, "workspace", "skills"), + join(workspaceRoot, "skills"), "workspace", ); diff --git a/apps/web/app/api/web-sessions/[id]/messages/route.ts b/apps/web/app/api/web-sessions/[id]/messages/route.ts index 2e7f08cae24..ba0912c5173 100644 --- a/apps/web/app/api/web-sessions/[id]/messages/route.ts +++ b/apps/web/app/api/web-sessions/[id]/messages/route.ts @@ -5,13 +5,10 @@ import { mkdirSync, } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveWebChatDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); -const INDEX_FILE = join(WEB_CHAT_DIR, "index.json"); - type IndexEntry = { id: string; title: string; @@ -33,11 +30,13 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - const filePath = join(WEB_CHAT_DIR, `${id}.jsonl`); + const chatDir = resolveWebChatDir(); + const filePath = join(chatDir, `${id}.jsonl`); + const indexPath = join(chatDir, "index.json"); - // Auto-create the session file if it doesn't exist yet - if (!existsSync(WEB_CHAT_DIR)) { - mkdirSync(WEB_CHAT_DIR, { recursive: true }); + // Auto-create the session directory if it doesn't exist yet + if (!existsSync(chatDir)) { + mkdirSync(chatDir, { recursive: true }); } if (!existsSync(filePath)) { writeFileSync(filePath, ""); @@ -84,16 +83,16 @@ export async function POST( // Update index metadata try { - if (existsSync(INDEX_FILE)) { + if (existsSync(indexPath)) { const index: IndexEntry[] = JSON.parse( - readFileSync(INDEX_FILE, "utf-8"), + 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(INDEX_FILE, JSON.stringify(index, null, 2)); + writeFileSync(indexPath, JSON.stringify(index, null, 2)); } } } catch { diff --git a/apps/web/app/api/web-sessions/[id]/route.ts b/apps/web/app/api/web-sessions/[id]/route.ts index 8607c9c9ba5..1596f7be30d 100644 --- a/apps/web/app/api/web-sessions/[id]/route.ts +++ b/apps/web/app/api/web-sessions/[id]/route.ts @@ -1,11 +1,9 @@ import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveWebChatDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); - export type ChatLine = { id: string; role: "user" | "assistant"; @@ -24,7 +22,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - const filePath = join(WEB_CHAT_DIR, `${id}.jsonl`); + const filePath = join(resolveWebChatDir(), `${id}.jsonl`); if (!existsSync(filePath)) { return Response.json({ error: "Session not found" }, { status: 404 }); diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts index 3327e74a81c..204e81756ff 100644 --- a/apps/web/app/api/web-sessions/route.ts +++ b/apps/web/app/api/web-sessions/route.ts @@ -1,13 +1,10 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; import { randomUUID } from "node:crypto"; +import { resolveWebChatDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); -const INDEX_FILE = join(WEB_CHAT_DIR, "index.json"); - export type WebSessionMeta = { id: string; title: string; @@ -19,24 +16,80 @@ export type WebSessionMeta = { }; function ensureDir() { - if (!existsSync(WEB_CHAT_DIR)) { - mkdirSync(WEB_CHAT_DIR, { recursive: true }); + 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). + */ function readIndex(): WebSessionMeta[] { - ensureDir(); - if (!existsSync(INDEX_FILE)) {return [];} - try { - return JSON.parse(readFileSync(INDEX_FILE, "utf-8")); - } catch { - return []; + 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;} + + // Build a minimal index entry from the file + 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; + // Try to extract a title from the first user message + 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; } function writeIndex(sessions: WebSessionMeta[]) { - ensureDir(); - writeFileSync(INDEX_FILE, JSON.stringify(sessions, null, 2)); + const dir = ensureDir(); + writeFileSync(join(dir, "index.json"), JSON.stringify(sessions, null, 2)); } /** GET /api/web-sessions — list web chat sessions. @@ -72,8 +125,8 @@ export async function POST(req: Request) { writeIndex(sessions); // Create empty .jsonl file - ensureDir(); - writeFileSync(join(WEB_CHAT_DIR, `${id}.jsonl`), ""); + const dir = ensureDir(); + writeFileSync(join(dir, `${id}.jsonl`), ""); return Response.json({ session }); } diff --git a/apps/web/app/api/workspace/browse-file/route.ts b/apps/web/app/api/workspace/browse-file/route.ts index 3b5b3300ec5..eeed22d9857 100644 --- a/apps/web/app/api/workspace/browse-file/route.ts +++ b/apps/web/app/api/workspace/browse-file/route.ts @@ -18,6 +18,8 @@ const MIME_MAP: Record = { wav: "audio/wav", ogg: "audio/ogg", pdf: "application/pdf", + html: "text/html", + htm: "text/html", }; /** Extensions recognized as code files for syntax-highlighted viewing. */ diff --git a/apps/web/app/api/workspace/browse/route.ts b/apps/web/app/api/workspace/browse/route.ts index 3304bfbb29e..bba02cbddd7 100644 --- a/apps/web/app/api/workspace/browse/route.ts +++ b/apps/web/app/api/workspace/browse/route.ts @@ -1,5 +1,6 @@ -import { readdirSync, type Dirent } from "node:fs"; +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"; @@ -10,16 +11,34 @@ type BrowseNode = { 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 [];} @@ -30,29 +49,43 @@ function buildBrowseTree( return []; } - const sorted = entries - .filter((e) => !e.name.startsWith(".")) - .filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name))) - .toSorted((a, b) => { - if (a.isDirectory() && !b.isDirectory()) {return -1;} - if (!a.isDirectory() && b.isDirectory()) {return 1;} - return a.name.localeCompare(b.name); + 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 (entry.isDirectory()) { - const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1); + 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 (entry.isFile()) { + } 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"; @@ -61,6 +94,7 @@ function buildBrowseTree( name: entry.name, path: absPath, type: isDatabase ? "database" : isDocument ? "document" : "file", + ...(isSymlink && { symlink: true }), }); } } @@ -71,8 +105,8 @@ function buildBrowseTree( 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"; - // Default to the workspace root if (!dir) { dir = resolveWorkspaceRoot(); } @@ -83,10 +117,13 @@ export async function GET(req: Request) { ); } - // Resolve and normalize the directory path + if (dir.startsWith("~")) { + dir = join(homedir(), dir.slice(1)); + } + const resolved = resolve(dir); - const entries = buildBrowseTree(resolved, 3); + const entries = buildBrowseTree(resolved, 3, 0, showHidden); const parentDir = resolved === "/" ? null : dirname(resolved); return Response.json({ 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 index 14b513956e2..fcbe54acb73 100644 --- a/apps/web/app/api/workspace/mkdir/route.ts +++ b/apps/web/app/api/workspace/mkdir/route.ts @@ -1,4 +1,5 @@ import { mkdirSync, existsSync } from "node:fs"; +import { resolve, normalize } from "node:path"; import { safeResolveNewPath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -6,27 +7,44 @@ export const runtime = "nodejs"; /** * POST /api/workspace/mkdir - * Body: { path: string } + * Body: { path: string; absolute?: boolean } * - * Creates a new directory in the workspace. + * 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 }; + let body: { path?: string; absolute?: boolean }; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } - const { path: relPath } = body; - if (!relPath || typeof relPath !== "string") { + const { path: rawPath, absolute: useAbsolute } = body; + if (!rawPath || typeof rawPath !== "string") { return Response.json( { error: "Missing 'path' field" }, { status: 400 }, ); } - const absPath = safeResolveNewPath(relPath); + 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" }, @@ -43,7 +61,7 @@ export async function POST(req: Request) { try { mkdirSync(absPath, { recursive: true }); - return Response.json({ ok: true, path: relPath }); + return Response.json({ ok: true, path: absPath }); } catch (err) { return Response.json( { error: err instanceof Error ? err.message : "mkdir failed" }, diff --git a/apps/web/app/api/workspace/objects/[name]/route.ts b/apps/web/app/api/workspace/objects/[name]/route.ts index 80ae11c50c8..d5ffd5619b8 100644 --- a/apps/web/app/api/workspace/objects/[name]/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/route.ts @@ -405,7 +405,7 @@ export async function GET( // Pagination const page = Math.max(1, Number(pageParam) || 1); - const pageSize = Math.min(500, Math.max(1, Number(pageSizeParam) || 200)); + const pageSize = Math.min(5000, Math.max(1, Number(pageSizeParam) || 100)); const offset = (page - 1) * pageSize; const limitClause = ` LIMIT ${pageSize} OFFSET ${offset}`; @@ -424,8 +424,15 @@ export async function GET( // 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}`, ); @@ -477,5 +484,8 @@ export async function GET( effectiveDisplayField, savedViews, activeView, + totalCount, + page, + pageSize, }); } diff --git a/apps/web/app/api/workspace/open-file/route.ts b/apps/web/app/api/workspace/open-file/route.ts index 6da26fc8351..402f0e2f527 100644 --- a/apps/web/app/api/workspace/open-file/route.ts +++ b/apps/web/app/api/workspace/open-file/route.ts @@ -35,7 +35,24 @@ export async function POST(req: Request) { ? rawPath.replace(/^~/, homedir()) : rawPath; - const resolved = resolve(normalize(expanded)); + 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( 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/raw-file/route.ts b/apps/web/app/api/workspace/raw-file/route.ts index 82861ef8073..25c0084fe13 100644 --- a/apps/web/app/api/workspace/raw-file/route.ts +++ b/apps/web/app/api/workspace/raw-file/route.ts @@ -33,6 +33,8 @@ const MIME_MAP: Record = { m4a: "audio/mp4", // Documents pdf: "application/pdf", + html: "text/html", + htm: "text/html", }; /** 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/route.ts b/apps/web/app/api/workspace/tree/route.ts index 307712273e9..c603d38b4eb 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -1,7 +1,6 @@ -import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs"; +import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; -import { resolveWorkspaceRoot, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace"; +import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -15,6 +14,8 @@ export type TreeNode = { 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 = { @@ -59,11 +60,28 @@ function loadDbObjects(): Map { 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[] = []; @@ -74,32 +92,44 @@ function buildTree( 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 = entries - .filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml") - .toSorted((a, b) => { - if (a.isDirectory() && !b.isDirectory()) {return -1;} - if (!a.isDirectory() && b.isDirectory()) {return 1;} - return a.name.localeCompare(b.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); + }); for (const entry of sorted) { - // Skip hidden files except .object.yaml (but don't list it as a node) - if (entry.name === ".object.yaml") {continue;} - if (entry.name.startsWith(".")) {continue;} + // .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; - if (entry.isDirectory()) { + 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); + const children = buildTree(absPath, relPath, dbObjects, showHidden); if (objectMeta || dbObject) { - // This directory represents a CRM object (from .object.yaml OR DuckDB) nodes.push({ name: entry.name, path: relPath, @@ -110,17 +140,18 @@ function buildTree( | "table" | "kanban") ?? "table", children: children.length > 0 ? children : undefined, + ...(isSymlink && { symlink: true }), }); } else { - // Regular folder nodes.push({ name: entry.name, path: relPath, type: "folder", children: children.length > 0 ? children : undefined, + ...(isSymlink && { symlink: true }), }); } - } else if (entry.isFile()) { + } else if (effectiveType === "file") { const ext = entry.name.split(".").pop()?.toLowerCase(); const isReport = entry.name.endsWith(".report.json"); const isDocument = ext === "md" || ext === "mdx"; @@ -130,6 +161,7 @@ function buildTree( name: entry.name, path: relPath, type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file", + ...(isSymlink && { symlink: true }), }); } } @@ -152,11 +184,11 @@ function parseSkillFrontmatter(content: string): { name?: string; emoji?: string return { name: result.name, emoji: result.emoji }; } -/** Build a virtual "Skills" folder from ~/.openclaw/skills/. */ +/** Build a virtual "Skills" folder from /skills/. */ function buildSkillsVirtualFolder(): TreeNode | null { - const home = homedir(); + const stateDir = resolveOpenClawStateDir(); const dirs = [ - join(home, ".openclaw", "skills"), + join(stateDir, "skills"), ]; const children: TreeNode[] = []; @@ -207,29 +239,26 @@ function buildSkillsVirtualFolder(): TreeNode | null { } -export async function GET() { - const home = homedir(); - const openclawDir = join(home, ".openclaw"); +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) { - // Even without a workspace, return virtual folders if they exist const tree: TreeNode[] = []; const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} - return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir }); + return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile }); } - // Load objects from DuckDB for smart directory detection const dbObjects = loadDbObjects(); - // Scan the workspace root — it IS the knowledge base. - // All top-level directories, files, objects, and documents are visible - // in the sidebar (USER.md, SOUL.md, memory/, etc. are all part of the tree). - const tree = buildTree(root, "", dbObjects); + const tree = buildTree(root, "", dbObjects, showHidden); - // Virtual folders go after all real files/folders const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} - return Response.json({ tree, exists: true, workspaceRoot: root, openclawDir }); + return Response.json({ tree, exists: true, workspaceRoot: root, openclawDir, profile }); } diff --git a/apps/web/app/api/workspace/virtual-file/route.ts b/apps/web/app/api/workspace/virtual-file/route.ts index f37787de656..33b17f0ae6c 100644 --- a/apps/web/app/api/workspace/virtual-file/route.ts +++ b/apps/web/app/api/workspace/virtual-file/route.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname, resolve, normalize } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -10,7 +10,8 @@ export const runtime = "nodejs"; * Returns null if the path is invalid or tries to escape. */ function resolveVirtualPath(virtualPath: string): string | null { - const home = homedir(); + const stateDir = resolveOpenClawStateDir(); + const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace"); if (virtualPath.startsWith("~skills/")) { // ~skills//SKILL.md @@ -28,8 +29,8 @@ function resolveVirtualPath(virtualPath: string): string | null { // Check workspace skills first, then managed skills const candidates = [ - join(home, ".openclaw", "workspace", "skills", skillName, "SKILL.md"), - join(home, ".openclaw", "skills", skillName, "SKILL.md"), + join(workspaceDir, "skills", skillName, "SKILL.md"), + join(stateDir, "skills", skillName, "SKILL.md"), ]; for (const candidate of candidates) { if (existsSync(candidate)) { @@ -47,8 +48,6 @@ function resolveVirtualPath(virtualPath: string): string | null { return null; } - const workspaceDir = join(home, ".openclaw", "workspace"); - if (rest === "MEMORY.md") { // Check both casing for (const filename of ["MEMORY.md", "memory.md"]) { @@ -74,7 +73,7 @@ function resolveVirtualPath(virtualPath: string): string | null { if (!rest || rest.includes("..") || rest.includes("/")) { return null; } - return join(home, ".openclaw", "workspace", rest); + return join(workspaceDir, rest); } return null; @@ -84,12 +83,13 @@ function resolveVirtualPath(virtualPath: string): string | null { * Double-check that the resolved path stays within expected directories. */ function isSafePath(absPath: string): boolean { - const home = homedir(); + const stateDir = resolveOpenClawStateDir(); + const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace"); const normalized = normalize(resolve(absPath)); const allowed = [ - normalize(join(home, ".openclaw", "skills")), - normalize(join(home, ".openclaw", "workspace", "skills")), - normalize(join(home, ".openclaw", "workspace")), + normalize(join(stateDir, "skills")), + normalize(join(workspaceDir, "skills")), + normalize(workspaceDir), ]; return allowed.some((dir) => normalized.startsWith(dir)); } diff --git a/apps/web/app/api/workspace/watch/route.ts b/apps/web/app/api/workspace/watch/route.ts index e9062fcc119..9bb11bb50ef 100644 --- a/apps/web/app/api/workspace/watch/route.ts +++ b/apps/web/app/api/workspace/watch/route.ts @@ -3,95 +3,137 @@ 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. - * Sends events: { type: "add"|"change"|"unlink"|"addDir"|"unlinkDir", path: string } * Falls back gracefully if chokidar is unavailable. */ -export async function GET() { +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) { - // Send initial heartbeat so the client knows the connection is alive controller.enqueue(encoder.encode("event: connected\ndata: {}\n\n")); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let watcher: any = null; - let closed = false; - - // Debounce: batch rapid events into a single "refresh" signal - let debounceTimer: ReturnType | null = null; - - function sendEvent(type: string, filePath: string) { + const listener: Listener = (_type, _rel) => { if (closed) {return;} if (debounceTimer) {clearTimeout(debounceTimer);} debounceTimer = setTimeout(() => { if (closed) {return;} try { - const data = JSON.stringify({ type, path: filePath }); + const data = JSON.stringify({ type: _type, path: _rel }); controller.enqueue(encoder.encode(`event: change\ndata: ${data}\n\n`)); - } catch { - // Stream may have been closed - } - }, 200); - } + } catch { /* stream closed */ } + }, 300); + }; - // Keep-alive heartbeat every 30s to prevent proxy/timeout disconnects - const heartbeat = setInterval(() => { + heartbeat = setInterval(() => { if (closed) {return;} try { controller.enqueue(encoder.encode(": heartbeat\n\n")); - } catch { - // Ignore if closed - } + } catch { /* closed */ } }, 30_000); - try { - // Dynamic import so the route still compiles if chokidar is missing - const chokidar = await import("chokidar"); - watcher = chokidar.watch(root, { - ignoreInitial: true, - awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }, - ignored: [ - /(^|[\\/])node_modules([\\/]|$)/, - /\.duckdb\.wal$/, - /\.duckdb\.tmp$/, - ], - depth: 10, - }); + function teardown() { + if (closed) {return;} + closed = true; + listeners.delete(listener); + if (heartbeat) {clearInterval(heartbeat);} + if (debounceTimer) {clearTimeout(debounceTimer);} + stopWatcherIfIdle(); + } - watcher.on("all", (eventType: string, filePath: string) => { - // Make path relative to workspace root - const rel = filePath.startsWith(root) - ? filePath.slice(root.length + 1) - : filePath; - sendEvent(eventType, rel); - }); - } catch { - // chokidar not available, send a fallback event and close + 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"), ); } - - // Cleanup when the client disconnects - // The cancel callback is invoked by the runtime when the response is aborted - const originalCancel = stream.cancel?.bind(stream); - stream.cancel = async (reason) => { - closed = true; - clearInterval(heartbeat); - if (debounceTimer) {clearTimeout(debounceTimer);} - if (watcher) {await watcher.close();} - if (originalCancel) {return originalCancel(reason);} - }; + }, + cancel() { + closed = true; }, }); diff --git a/apps/web/app/components/chain-of-thought.tsx b/apps/web/app/components/chain-of-thought.tsx index eb62d212315..5f7fa9d32fe 100644 --- a/apps/web/app/components/chain-of-thought.tsx +++ b/apps/web/app/components/chain-of-thought.tsx @@ -566,7 +566,7 @@ function groupToolSteps(tools: ToolPart[]): VisualItem[] { /* ─── Main component ─── */ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isStreaming?: boolean }) { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(!!isStreaming); const isActive = parts.some( (p) => @@ -691,7 +691,7 @@ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isS className="flex items-start gap-2.5 py-1.5" >
- {anyRunning ? ( - - ) : ( + - )} +
- + {anyRunning ? `Fetching ${items.length} sources...` : `Fetched ${items.length} sources`} @@ -841,7 +833,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
@@ -984,18 +976,10 @@ function MediaGroup({ return (
- {anyRunning ? ( - - ) : ( + - )} +
- + + +
)} {hasMore && ( @@ -1259,27 +1237,21 @@ function ToolStep({ return (
- {status === "running" ? ( - - ) : status === "error" ? ( + {status === "error" ? ( ) : ( - + + + )}
- {label} + {label} {/* Exit code badge for exec tools */} {kind === "exec" && status === "done" && output?.exitCode !== undefined && ( = 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: "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" { @@ -76,8 +89,9 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { for (const part of parts) { if (part.type === "text") { - flush(true); const text = (part as { type: "text"; text: string }).text; + if (isLeakedSilentToken(text)) { continue; } + flush(true); if (hasReportBlocks(text)) { segments.push( ...(splitReportBlocks(text) as MessageSegment[]), @@ -115,15 +129,22 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { 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; - }; + } 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, @@ -132,22 +153,34 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { 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; - }; + } + } 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 = @@ -155,10 +188,7 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { (tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available"); chain.push({ kind: "tool", - toolName: - tp.title ?? - tp.toolName ?? - part.type.replace("tool-", ""), + toolName: resolvedToolName, toolCallId: tp.toolCallId, status: toolStatus(resolvedState), args: asRecord(tp.input) ?? asRecord(tp.args), @@ -166,6 +196,7 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { }); } } + } flush(); return segments; @@ -329,93 +360,42 @@ function AttachFileIcon({ category }: { category: string }) { function AttachedFilesCard({ paths }: { paths: string[] }) { return ( -
-
- - - - - {paths.length}{" "} - {paths.length === 1 ? "file" : "files"}{" "} - attached - -
-
- {paths.map((filePath, i) => { - const category = - getCategoryFromPath(filePath); - const filename = - filePath.split("/").pop() ?? - filePath; - const meta = - attachCategoryMeta[category] ?? - attachCategoryMeta.other; - const short = shortenPath(filePath); +
+ {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 ( -
-
-
- -
-
-

- {filename} -

-

- {short} -

-
-
-
- ); - })} -
+ return ( +
+ {filePath.split("/").pop() { (e.currentTarget as HTMLImageElement).style.display = "none"; }} + /> + {category !== "image" && ( + + {ext} + + )} +
+ ); + })}
); } @@ -434,17 +414,28 @@ function AttachedFilesCard({ paths }: { paths: string[] }) { function looksLikeFilePath(text: string): boolean { const t = text.trim(); if (!t || t.length < 3 || t.length > 500) {return false;} - // Must start with a path prefix - if (!(t.startsWith("~/") || t.startsWith("/") || t.startsWith("./") || t.startsWith("../"))) { - 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("."); } - // Must have at least one path separator beyond the prefix - // (avoids matching bare `/` or standalone commands like `/bin`) - 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. */ @@ -464,13 +455,41 @@ async function openFilePath(path: string, reveal = false) { } } +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"); @@ -478,16 +497,26 @@ function FilePathCode({ e.preventDefault(); setStatus("opening"); try { - 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 { + 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"); @@ -503,35 +532,18 @@ function FilePathCode({ return ( - - {status === "error" ? ( - <> - - - - - ) : ( - <> - - - - )} - {children} ); @@ -539,107 +551,147 @@ function FilePathCode({ /* ─── Markdown component overrides for chat ─── */ -const mdComponents: Components = { - // Open external links in new tab - a: ({ href, children, ...props }) => { - const isExternal = - href && (href.startsWith("http") || href.startsWith("//")); - return ( - - {children} - - ); - }, - // Render images — route local file paths through raw-file API - 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 - {alt - ); - }, - pre: ({ children, ...props }) => { - // react-markdown wraps code blocks in
...
-		// Extract the code element to get lang + content
-		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;
+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
+				{alt
+			);
+		},
+		// 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$/, "")
-					: "";
+				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 ;
-			}
+				// Diff language: render as DiffCard
+				if (lang === "diff") {
+					return ;
+				}
 
-			// Known language: syntax-highlight with shiki
-			if (lang) {
-				return (
-					
-
- {lang} + // 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}
+					
 				);
 			}
-		}
-		// 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 : "";
-		if (text && looksLikeFilePath(text)) {
-			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};
-	},
-};
+			// 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 }: { message: UIMessage; isStreaming?: boolean }) {
+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
@@ -654,35 +706,41 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: {
 		// 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 (
- {attachmentInfo ? ( - <> - - {attachmentInfo.message && ( -

- { - attachmentInfo.message - } -

- )} - - ) : ( -

- {textContent} -

- )} +

+ {textContent} +

); @@ -707,7 +765,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: { return (
{segment.text} @@ -778,12 +836,12 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: { initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: "easeOut" }} - className="chat-prose font-bookerly text-sm" + className="chat-prose chat-message-font text-sm" style={{ color: "var(--color-text)" }} > {segment.text} @@ -802,31 +860,79 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: { ); } - 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 index e622be6d7db..26daa5a0060 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -17,6 +17,13 @@ import { 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 ── @@ -24,6 +31,10 @@ 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( @@ -148,6 +159,153 @@ function FileTypeIcon({ category }: { category: string }) { } } +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 ? ( +