Merge pull request #5 from DenchHQ/kumareth/workspaces
feat: Workspaces, Subagent Infrastructure, and Gateway Event Subscriptions
This commit is contained in:
commit
4180d047ff
102
.cursor/plans/cli-only-streaming-hardening_8cba61e1.plan.md
Normal file
102
.cursor/plans/cli-only-streaming-hardening_8cba61e1.plan.md
Normal file
@ -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 <scriptPath> agent --stream-json --subscribe-session-key <key> --after-seq <n>`
|
||||
- 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
|
||||
```
|
||||
132
.cursor/plans/interactive_subagent_panel_fb79f5b8.plan.md
Normal file
132
.cursor/plans/interactive_subagent_panel_fb79f5b8.plan.md
Normal file
@ -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<br/>(filtered by runId, never arrives)"| SRM1[SubagentRunManager]
|
||||
ARM1 -.->|"activateGatewayFallback<br/>(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`.
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -9,7 +9,7 @@ bun.lockb
|
||||
coverage
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
|
||||
@ -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<string, Record<string, unknown>>;
|
||||
};
|
||||
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.
|
||||
|
||||
@ -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<string, Record<string, unknown>>;
|
||||
};
|
||||
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);
|
||||
|
||||
@ -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<string, Record<string, unknown>>;
|
||||
};
|
||||
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<typeof setInterval> | 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
64
apps/web/app/api/chat/subagents/route.ts
Normal file
64
apps/web/app/api/chat/subagents/route.ts
Normal file
@ -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";
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
|
||||
16
apps/web/app/api/profiles/route.ts
Normal file
16
apps/web/app/api/profiles/route.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
34
apps/web/app/api/profiles/switch/route.ts
Normal file
34
apps/web/app/api/profiles/switch/route.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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",
|
||||
);
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -18,6 +18,8 @@ const MIME_MAP: Record<string, string> = {
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
pdf: "application/pdf",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
};
|
||||
|
||||
/** Extensions recognized as code files for syntax-highlighted viewing. */
|
||||
|
||||
@ -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({
|
||||
|
||||
332
apps/web/app/api/workspace/init/route.ts
Normal file
332
apps/web/app/api/workspace/init/route.ts
Normal file
@ -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<string, string> = {
|
||||
"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(),
|
||||
});
|
||||
}
|
||||
@ -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" },
|
||||
|
||||
@ -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<string, unknown>[] = [];
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<string | null>((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(
|
||||
|
||||
88
apps/web/app/api/workspace/path-info/route.ts
Normal file
88
apps/web/app/api/workspace/path-info/route.ts
Normal file
@ -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<string | null>((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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,8 @@ const MIME_MAP: Record<string, string> = {
|
||||
m4a: "audio/mp4",
|
||||
// Documents
|
||||
pdf: "application/pdf",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
69
apps/web/app/api/workspace/thumbnail/route.ts
Normal file
69
apps/web/app/api/workspace/thumbnail/route.ts
Normal file
@ -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 <original-basename>.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 });
|
||||
}
|
||||
}
|
||||
@ -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<string, DbObject> {
|
||||
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<string, DbObject>,
|
||||
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 <stateDir>/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 });
|
||||
}
|
||||
|
||||
@ -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/<skillName>/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));
|
||||
}
|
||||
|
||||
@ -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<Listener>();
|
||||
// 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<typeof setInterval> | null = null;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-[18px] h-[18px] flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
@ -800,20 +800,12 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-[18px] h-[18px] flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
{anyRunning ? (
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border-strong)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={anyRunning ? "animate-pulse" : ""}>
|
||||
<StepIcon kind="fetch" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
@ -824,7 +816,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className={anyRunning ? "animate-pulse" : ""}>
|
||||
{anyRunning
|
||||
? `Fetching ${items.length} sources...`
|
||||
: `Fetched ${items.length} sources`}
|
||||
@ -841,7 +833,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.5)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
@ -984,18 +976,10 @@ function MediaGroup({
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-[18px] h-[18px] flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
{anyRunning ? (
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border-strong)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={anyRunning ? "animate-pulse" : ""}>
|
||||
<StepIcon
|
||||
kind={
|
||||
mediaKind === "image"
|
||||
@ -1003,11 +987,11 @@ function MediaGroup({
|
||||
: "read"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[13px] leading-snug mb-1.5"
|
||||
className={`text-[13px] leading-snug mb-1.5${anyRunning ? " animate-pulse" : ""}`}
|
||||
style={{
|
||||
color: anyRunning
|
||||
? "var(--color-text)"
|
||||
@ -1038,15 +1022,9 @@ function MediaGroup({
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor:
|
||||
"var(--color-border-strong)",
|
||||
borderTopColor:
|
||||
"var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
<span className="animate-pulse" style={{ color: "var(--color-text-muted)" }}>
|
||||
<StepIcon kind="image" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
@ -1259,27 +1237,21 @@ function ToolStep({
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-[18px] h-[18px] flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
{status === "running" ? (
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border-strong)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
) : status === "error" ? (
|
||||
{status === "error" ? (
|
||||
<ErrorCircleIcon />
|
||||
) : (
|
||||
<StepIcon kind={kind} />
|
||||
<span className={status === "running" ? "animate-pulse" : ""}>
|
||||
<StepIcon kind={kind} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[13px] leading-snug flex items-start gap-2 flex-wrap"
|
||||
className="text-[13px] leading-snug flex items-center gap-2 flex-wrap"
|
||||
style={{
|
||||
color:
|
||||
status === "running"
|
||||
@ -1287,7 +1259,7 @@ function ToolStep({
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span className="break-all">{label}</span>
|
||||
<span className={`break-all${status === "running" ? " animate-pulse" : ""}`}>{label}</span>
|
||||
{/* Exit code badge for exec tools */}
|
||||
{kind === "exec" && status === "done" && output?.exitCode !== undefined && (
|
||||
<span
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { UIMessage } from "ai";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@ -31,13 +31,26 @@ const ReportCard = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
/* ─── Silent-reply leak filter ─── */
|
||||
|
||||
const _SILENT_TOKEN = "NO_REPLY";
|
||||
|
||||
function isLeakedSilentToken(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t) {return false;}
|
||||
if (new RegExp(`^${_SILENT_TOKEN}\\W*$`).test(t)) {return true;}
|
||||
if (_SILENT_TOKEN.startsWith(t) && t.length >= 2 && t.length < _SILENT_TOKEN.length) {return true;}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ─── Part grouping ─── */
|
||||
|
||||
type MessageSegment =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "chain"; parts: ChainPart[] }
|
||||
| { type: "report-artifact"; config: ReportConfig }
|
||||
| { type: "diff-artifact"; diff: string };
|
||||
| { type: "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 (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-[11px] font-medium uppercase tracking-wider"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
{paths.length}{" "}
|
||||
{paths.length === 1 ? "file" : "files"}{" "}
|
||||
attached
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{paths.map((filePath, i) => {
|
||||
const category =
|
||||
getCategoryFromPath(filePath);
|
||||
const filename =
|
||||
filePath.split("/").pop() ??
|
||||
filePath;
|
||||
const meta =
|
||||
attachCategoryMeta[category] ??
|
||||
attachCategoryMeta.other;
|
||||
const short = shortenPath(filePath);
|
||||
<div className="flex flex-wrap gap-1.5 mb-2 justify-end">
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"rgba(0,0,0,0.04)",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
meta.bg,
|
||||
color: meta.fg,
|
||||
}}
|
||||
>
|
||||
<AttachFileIcon
|
||||
category={
|
||||
category
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-[12px] font-medium truncate max-w-[160px]"
|
||||
title={
|
||||
filePath
|
||||
}
|
||||
>
|
||||
{filename}
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] truncate max-w-[160px]"
|
||||
style={{
|
||||
opacity: 0.45,
|
||||
}}
|
||||
title={
|
||||
filePath
|
||||
}
|
||||
>
|
||||
{short}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative rounded-xl overflow-hidden shrink-0"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={filePath.split("/").pop() ?? ""}
|
||||
className="block rounded-xl object-cover"
|
||||
style={{ maxHeight: 140, maxWidth: 160, background: "rgba(0,0,0,0.04)" }}
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
{category !== "image" && (
|
||||
<span
|
||||
className="absolute bottom-2 left-2 rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
color: "rgba(0,0,0,0.5)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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> | 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 (
|
||||
<code
|
||||
className={`inline-flex items-center gap-[0.2em] px-[0.3em] py-0 whitespace-nowrap max-w-full overflow-hidden text-ellipsis no-underline transition-colors duration-150 rounded-md text-[color:var(--color-accent)] border border-[color:var(--color-border)] bg-white/20 hover:bg-white/40 active:bg-white ${status === "opening" ? "cursor-wait opacity-70" : "cursor-pointer"}`}
|
||||
className={`px-[0.3em] no-underline transition-colors duration-150 rounded-[4px] border border-[color:var(--color-border)] bg-white/20 hover:bg-white/40 active:bg-white ${status === "opening" ? "cursor-wait opacity-70" : "cursor-pointer"}`}
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
title={status === "error" ? "File not found" : "Click to open · Right-click to reveal in Finder"}
|
||||
title={
|
||||
status === "error"
|
||||
? "File not found"
|
||||
: onFilePathClick
|
||||
? "Click to preview in workspace · Right-click to reveal in Finder"
|
||||
: "Click to open · Right-click to reveal in Finder"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 opacity-60"
|
||||
>
|
||||
{status === "error" ? (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" x2="9" y1="9" y2="15" />
|
||||
<line x1="9" x2="15" y1="9" y2="15" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
@ -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 (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// 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
|
||||
<img src={resolvedSrc} alt={alt ?? ""} loading="lazy" {...props} />
|
||||
);
|
||||
},
|
||||
pre: ({ children, ...props }) => {
|
||||
// react-markdown wraps code blocks in <pre><code>...
|
||||
// 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 (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
if (!isLocalPathLink || !onFilePathClick) {return;}
|
||||
e.preventDefault();
|
||||
void onFilePathClick(normalizedHref);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// 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
|
||||
<img src={resolvedSrc} alt={alt ?? ""} loading="lazy" {...props} />
|
||||
);
|
||||
},
|
||||
// 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 <DiffCard diff={code} />;
|
||||
}
|
||||
// Diff language: render as DiffCard
|
||||
if (lang === "diff") {
|
||||
return <DiffCard diff={code} />;
|
||||
}
|
||||
|
||||
// Known language: syntax-highlight with shiki
|
||||
if (lang) {
|
||||
return (
|
||||
<div className="chat-code-block">
|
||||
<div
|
||||
className="chat-code-lang"
|
||||
>
|
||||
{lang}
|
||||
// Known language: syntax-highlight with shiki
|
||||
if (lang) {
|
||||
return (
|
||||
<div className="chat-code-block">
|
||||
<div
|
||||
className="chat-code-lang"
|
||||
>
|
||||
{lang}
|
||||
</div>
|
||||
<SyntaxBlock code={code} lang={lang} />
|
||||
</div>
|
||||
<SyntaxBlock code={code} lang={lang} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fallback: default pre rendering
|
||||
return <pre {...props}>{children}</pre>;
|
||||
},
|
||||
// Inline code — detect file paths and make them clickable
|
||||
code: ({ children, className, ...props }) => {
|
||||
// If this code has a language class, it's inside a <pre> and
|
||||
// will be handled by the pre override above. Just return raw.
|
||||
if (className?.startsWith("language-")) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fallback: default pre rendering
|
||||
return <pre {...props}>{children}</pre>;
|
||||
},
|
||||
// Inline code — detect file paths and make them clickable
|
||||
code: ({ children, className, ...props }) => {
|
||||
// If this code has a language class, it's inside a <pre> and
|
||||
// will be handled by the pre override above. Just return raw.
|
||||
if (className?.startsWith("language-")) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the inline code content looks like a file path
|
||||
const text = typeof children === "string" ? children : "";
|
||||
if (text && looksLikeFilePath(text)) {
|
||||
return <FilePathCode path={text}>{children}</FilePathCode>;
|
||||
}
|
||||
// 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 (
|
||||
<FilePathCode path={normalizedText} onFilePathClick={onFilePathClick}>
|
||||
{children}
|
||||
</FilePathCode>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular inline code
|
||||
return <code {...props}>{children}</code>;
|
||||
},
|
||||
};
|
||||
// Regular inline code
|
||||
return <code {...props}>{children}</code>;
|
||||
},
|
||||
// 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 (
|
||||
<strong {...props}>
|
||||
<FilePathCode path={text} onFilePathClick={onFilePathClick}>
|
||||
{children}
|
||||
</FilePathCode>
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
return <strong {...props}>{children}</strong>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── 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 (
|
||||
<div className="flex flex-col items-end gap-1.5 py-2">
|
||||
{/* Attachment previews — standalone above the text bubble */}
|
||||
<AttachedFilesCard paths={attachmentInfo.paths} />
|
||||
{/* Text bubble */}
|
||||
{attachmentInfo.message && (
|
||||
<div
|
||||
className="max-w-[80%] w-fit rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 break-words chat-message-font"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{attachmentInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end py-2">
|
||||
<div
|
||||
className="font-bookerly max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-all"
|
||||
className="max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-words chat-message-font"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
{attachmentInfo ? (
|
||||
<>
|
||||
<AttachedFilesCard
|
||||
paths={
|
||||
attachmentInfo.paths
|
||||
}
|
||||
/>
|
||||
{attachmentInfo.message && (
|
||||
<p className="whitespace-pre-wrap break-all">
|
||||
{
|
||||
attachmentInfo.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-all">
|
||||
{textContent}
|
||||
</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap break-words text-right">
|
||||
{textContent}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -707,7 +765,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="font-bookerly flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed overflow-hidden"
|
||||
className="chat-message-font flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed overflow-hidden"
|
||||
style={{
|
||||
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
|
||||
color: "var(--color-error)",
|
||||
@ -764,7 +822,7 @@ 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 whitespace-pre-wrap break-all"
|
||||
className="chat-prose chat-message-font text-sm whitespace-pre-wrap break-all"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{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)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={mdComponents}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{segment.text}
|
||||
</ReactMarkdown>
|
||||
@ -802,31 +860,79 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: {
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (segment.type === "diff-artifact") {
|
||||
return (
|
||||
<motion.div
|
||||
key={`diff-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
if (segment.type === "diff-artifact") {
|
||||
return (
|
||||
<motion.div
|
||||
key={`diff-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<DiffCard diff={segment.diff} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (segment.type === "subagent-card") {
|
||||
const truncatedTask = segment.task.length > 80 ? segment.task.slice(0, 80) + "..." : segment.task;
|
||||
const isRunning = segment.status === "running";
|
||||
return (
|
||||
<motion.div
|
||||
key={`subagent-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubagentClick?.(segment.task)}
|
||||
className="w-full text-left rounded-xl px-3.5 py-2.5 transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
border: "1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<DiffCard diff={segment.diff} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
key={`chain-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<ChainOfThought
|
||||
parts={segment.parts}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isRunning ? (
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full animate-pulse flex-shrink-0"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
/>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ color: "var(--color-accent)" }}>
|
||||
<path d="M16 3h5v5" /><path d="m21 3-7 7" /><path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "var(--color-accent)" }}>
|
||||
{isRunning ? "Running Subagent" : "Subagent"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: "var(--color-text)" }}>
|
||||
{segment.label || truncatedTask}
|
||||
</p>
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0 opacity-40" style={{ color: "var(--color-text)" }}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
key={`chain-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<ChainOfThought
|
||||
parts={segment.parts}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { FileManagerTree } from "./workspace/file-manager-tree";
|
||||
import { ProfileSwitcher } from "./workspace/profile-switcher";
|
||||
import { CreateWorkspaceDialog } from "./workspace/create-workspace-dialog";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@ -352,6 +354,8 @@ export function Sidebar({
|
||||
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
|
||||
const [workspaceTree, setWorkspaceTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
|
||||
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
|
||||
|
||||
const toggleSection = (section: SidebarSection) => {
|
||||
setOpenSections((prev) => {
|
||||
@ -362,7 +366,12 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch sidebar data (re-runs when refreshKey changes)
|
||||
// Full sidebar re-fetch after profile switch or workspace creation
|
||||
const handleProfileSwitch = useCallback(() => {
|
||||
setSidebarRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// Fetch sidebar data (re-runs when refreshKey or sidebarRefreshKey changes)
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@ -385,7 +394,7 @@ export function Sidebar({
|
||||
}
|
||||
}
|
||||
void load();
|
||||
}, [refreshKey]);
|
||||
}, [refreshKey, sidebarRefreshKey]);
|
||||
|
||||
const refreshWorkspace = useCallback(async () => {
|
||||
try {
|
||||
@ -399,32 +408,46 @@ export function Sidebar({
|
||||
|
||||
return (
|
||||
<aside className="w-72 h-screen flex flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] overflow-hidden">
|
||||
{/* Header with New Chat button */}
|
||||
<div className="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between">
|
||||
<h1 className="text-base font-bold flex items-center gap-2">
|
||||
<span>Ironclaw</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
title="New Chat"
|
||||
className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-[var(--color-border)]">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<h1 className="text-base font-bold flex items-center gap-2">
|
||||
<span>Ironclaw</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
title="New Chat"
|
||||
className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ProfileSwitcher
|
||||
onProfileSwitch={handleProfileSwitch}
|
||||
onCreateWorkspace={() => setShowCreateWorkspace(true)}
|
||||
activeProfileHint={String(sidebarRefreshKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create workspace dialog */}
|
||||
<CreateWorkspaceDialog
|
||||
isOpen={showCreateWorkspace}
|
||||
onClose={() => setShowCreateWorkspace(false)}
|
||||
onCreated={handleProfileSwitch}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-1">
|
||||
{loading ? (
|
||||
|
||||
424
apps/web/app/components/subagent-panel.tsx
Normal file
424
apps/web/app/components/subagent-panel.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { createStreamParser } from "./chat-panel";
|
||||
import { UnicodeSpinner } from "./unicode-spinner";
|
||||
import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
|
||||
|
||||
type SubagentPanelProps = {
|
||||
sessionKey: string;
|
||||
task: string;
|
||||
label?: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
mentionedFiles: Array<{ name: string; path: string }>;
|
||||
};
|
||||
|
||||
function taskMessage(sessionKey: string, task: string): UIMessage {
|
||||
return {
|
||||
id: `task-${sessionKey}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: task }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
function buildMessagesFromParsed(
|
||||
sessionKey: string,
|
||||
task: string,
|
||||
parts: Array<Record<string, unknown>>,
|
||||
): UIMessage[] {
|
||||
const messages: UIMessage[] = [taskMessage(sessionKey, task)];
|
||||
let assistantParts: UIMessage["parts"] = [];
|
||||
let assistantCount = 0;
|
||||
let userCount = 0;
|
||||
|
||||
const pushAssistant = () => {
|
||||
if (assistantParts.length === 0) {return;}
|
||||
messages.push({
|
||||
id: `assistant-${sessionKey}-${assistantCount++}`,
|
||||
role: "assistant",
|
||||
parts: assistantParts,
|
||||
} as UIMessage);
|
||||
assistantParts = [];
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "user-message") {
|
||||
pushAssistant();
|
||||
messages.push({
|
||||
id: (part.id as string | undefined) ?? `user-${sessionKey}-${userCount++}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: (part.text as string) ?? "" }],
|
||||
} as UIMessage);
|
||||
continue;
|
||||
}
|
||||
assistantParts.push(part as UIMessage["parts"][number]);
|
||||
}
|
||||
pushAssistant();
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function SubagentPanel({ sessionKey, task, label, onBack }: SubagentPanelProps) {
|
||||
const editorRef = useRef<ChatEditorHandle>(null);
|
||||
const [editorEmpty, setEditorEmpty] = useState(true);
|
||||
const [messages, setMessages] = useState<UIMessage[]>(() => [taskMessage(sessionKey, task)]);
|
||||
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const userScrolledAwayRef = useRef(false);
|
||||
const streamAbortRef = useRef<AbortController | null>(null);
|
||||
const scrollRafRef = useRef(0);
|
||||
|
||||
const displayLabel = label || (task.length > 60 ? task.slice(0, 60) + "..." : task);
|
||||
|
||||
const streamFromResponse = useCallback(
|
||||
async (
|
||||
res: Response,
|
||||
onUpdate: (parts: Array<Record<string, unknown>>) => void,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (!res.body) {return;}
|
||||
const parser = createStreamParser();
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let frameRequested = false;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {break;}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
||||
const chunk = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
if (chunk.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(chunk.slice(6)) as Record<string, unknown>;
|
||||
parser.processEvent(event);
|
||||
} catch {
|
||||
// ignore malformed event
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!frameRequested) {
|
||||
frameRequested = true;
|
||||
requestAnimationFrame(() => {
|
||||
frameRequested = false;
|
||||
if (!signal.aborted) {
|
||||
onUpdate(parser.getParts() as Array<Record<string, unknown>>);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
onUpdate(parser.getParts() as Array<Record<string, unknown>>);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
streamAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
streamAbortRef.current = abort;
|
||||
setIsReconnecting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/chat/stream?sessionKey=${encodeURIComponent(sessionKey)}`, {
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
setConnected(true);
|
||||
setIsStreaming(res.headers.get("X-Run-Active") !== "false");
|
||||
await streamFromResponse(
|
||||
res,
|
||||
(parts) => setMessages(buildMessagesFromParsed(sessionKey, task, parts)),
|
||||
abort.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.error("Subagent reconnect error:", err);
|
||||
}
|
||||
} finally {
|
||||
setIsReconnecting(false);
|
||||
if (!abort.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [sessionKey, task, streamFromResponse]);
|
||||
|
||||
const sendSubagentMessage = useCallback(
|
||||
async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => {
|
||||
const trimmed = text.trim();
|
||||
const hasMentions = mentionedFiles.length > 0;
|
||||
if (!trimmed && !hasMentions) {return;}
|
||||
|
||||
const allFilePaths = mentionedFiles.map((f) => f.path);
|
||||
const payloadText = allFilePaths.length > 0
|
||||
? `[Attached files: ${allFilePaths.join(", ")}]\n\n${trimmed}`
|
||||
: trimmed;
|
||||
|
||||
const optimisticUser: UIMessage = {
|
||||
id: `user-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: payloadText }],
|
||||
} as UIMessage;
|
||||
const baseMessages = [...messages, optimisticUser];
|
||||
setMessages(baseMessages);
|
||||
|
||||
streamAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
streamAbortRef.current = abort;
|
||||
setIsStreaming(true);
|
||||
setConnected(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: abort.signal,
|
||||
body: JSON.stringify({
|
||||
sessionKey,
|
||||
messages: [optimisticUser],
|
||||
}),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
await streamFromResponse(
|
||||
res,
|
||||
(parts) => {
|
||||
const assistantMsg: UIMessage = {
|
||||
id: `assistant-${sessionKey}-${Date.now()}`,
|
||||
role: "assistant",
|
||||
parts: parts as UIMessage["parts"],
|
||||
} as UIMessage;
|
||||
setMessages([...baseMessages, assistantMsg]);
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.error("Subagent send error:", err);
|
||||
}
|
||||
} finally {
|
||||
if (!abort.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[messages, sessionKey, streamFromResponse],
|
||||
);
|
||||
|
||||
const handleEditorSubmit = useCallback(
|
||||
async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => {
|
||||
if (isStreaming || isReconnecting) {
|
||||
setQueuedMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
mentionedFiles,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await sendSubagentMessage(text, mentionedFiles);
|
||||
},
|
||||
[isStreaming, isReconnecting, sendSubagentMessage],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
streamAbortRef.current?.abort();
|
||||
streamAbortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
setIsReconnecting(false);
|
||||
try {
|
||||
await fetch("/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionKey }),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void reconnect();
|
||||
return () => {
|
||||
streamAbortRef.current?.abort();
|
||||
};
|
||||
}, [reconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasBusy = isStreaming || isReconnecting;
|
||||
if (wasBusy || queuedMessages.length === 0) {return;}
|
||||
const [next, ...rest] = queuedMessages;
|
||||
setQueuedMessages(rest);
|
||||
queueMicrotask(() => {
|
||||
void sendSubagentMessage(next.text, next.mentionedFiles);
|
||||
});
|
||||
}, [isStreaming, isReconnecting, queuedMessages, sendSubagentMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) {return;}
|
||||
const onScroll = () => {
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
userScrolledAwayRef.current = distanceFromBottom > 80;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (userScrolledAwayRef.current) {return;}
|
||||
if (scrollRafRef.current) {return;}
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = 0;
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
if (!connected && (isStreaming || isReconnecting)) {return <UnicodeSpinner name="braille">Connecting</UnicodeSpinner>;}
|
||||
if (isReconnecting) {return <UnicodeSpinner name="braille">Resuming</UnicodeSpinner>;}
|
||||
if (isStreaming) {return <UnicodeSpinner name="braille" />;}
|
||||
return "Completed";
|
||||
}, [connected, isStreaming, isReconnecting]);
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col min-h-full">
|
||||
<header
|
||||
className="px-3 py-2 md:px-6 md:py-3 flex items-center gap-3 sticky top-0 z-20 backdrop-blur-md"
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to parent chat"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold truncate" style={{ color: "var(--color-text)" }}>
|
||||
{displayLabel}
|
||||
</h2>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{statusLabel}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-6">
|
||||
<div className="max-w-2xl mx-auto py-3">
|
||||
{messages.map((message, i) => (
|
||||
<ChatMessage key={message.id} message={message} isStreaming={(isStreaming || isReconnecting) && i === messages.length - 1} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-3 pb-3 pt-0 md:px-6 md:pb-5 sticky bottom-0 z-20 backdrop-blur-md"
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<div className="max-w-[720px] mx-auto rounded-3xl overflow-hidden" style={{ background: "var(--color-chat-input-bg)", border: "1px solid var(--color-border)" }}>
|
||||
{queuedMessages.length > 0 && (
|
||||
<div className="px-3 pt-3">
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px dashed var(--color-border-strong)", background: "var(--color-bg-elevated)" }}>
|
||||
<div className="px-3 py-1.5 text-[11px] font-medium tracking-wide uppercase" style={{ borderBottom: "1px solid var(--color-border)", color: "var(--color-text-muted)", fontFamily: "var(--font-mono, monospace)" }}>
|
||||
Queued ({queuedMessages.length})
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1.5">
|
||||
{queuedMessages.map((msg) => (
|
||||
<div key={msg.id} className="flex items-center justify-between gap-2 rounded-lg px-2.5 py-2" style={{ background: "var(--color-bg-secondary)" }}>
|
||||
<p className="flex-1 text-[13px] leading-[1.45] line-clamp-2" style={{ color: "var(--color-text)", whiteSpace: "pre-wrap" }}>
|
||||
{msg.text}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 transition-colors hover:bg-[var(--color-bg)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onClick={() => setQueuedMessages((prev) => prev.filter((m) => m.id !== msg.id))}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ChatEditor
|
||||
ref={editorRef}
|
||||
onSubmit={handleEditorSubmit}
|
||||
onChange={(isEmpty) => setEditorEmpty(isEmpty)}
|
||||
placeholder={isStreaming || isReconnecting ? "Type to queue a message..." : "Type @ to mention files..."}
|
||||
/>
|
||||
<div className="flex items-center justify-end px-3 pb-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(isStreaming || isReconnecting) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStop()}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--color-text)", color: "var(--color-bg)" }}
|
||||
title="Stop generating"
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||||
<rect width="10" height="10" rx="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
disabled={editorEmpty}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: !editorEmpty ? ((isStreaming || isReconnecting) ? "var(--color-text-muted)" : "var(--color-accent)") : "var(--color-border-strong)",
|
||||
color: "white",
|
||||
}}
|
||||
title={(isStreaming || isReconnecting) ? "Queue message" : "Send message"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -259,6 +259,31 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
// otherwise consume the event or insert the text/plain
|
||||
// fallback data as raw text.
|
||||
handleDOMEvents: {
|
||||
paste: (_view, event) => {
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) {return false;}
|
||||
|
||||
// Collect files from clipboard (images, screenshots, etc.)
|
||||
const pastedFiles: File[] = [];
|
||||
if (clipboardData.items) {
|
||||
for (const item of Array.from(clipboardData.items)) {
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) {pastedFiles.push(file);}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
const dt = new DataTransfer();
|
||||
for (const f of pastedFiles) {dt.items.add(f);}
|
||||
nativeFileDropRef.current?.(dt.files);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
dragover: (_view, event) => {
|
||||
const de = event;
|
||||
if (de.dataTransfer?.types.includes("application/x-file-mention")) {
|
||||
@ -396,7 +421,7 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
<style>{`
|
||||
.chat-editor-content {
|
||||
outline: none;
|
||||
min-height: 20px;
|
||||
min-height: ${compact ? "16px" : "28px"};
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: ${compact ? "10px 12px" : "14px 16px"};
|
||||
|
||||
314
apps/web/app/components/ui/dropdown-menu.tsx
Normal file
314
apps/web/app/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Root>) {
|
||||
return (
|
||||
<MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Portal>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
className={cn("cursor-pointer outline-none ring-0 border-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Popup> &
|
||||
Pick<
|
||||
React.ComponentProps<typeof MenuPrimitive.Positioner>,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-[100] outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"bg-neutral-100/[0.67] border border-white backdrop-blur-md text-[var(--color-text)] z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-3xl p-1 shadow-[0_0_25px_0_rgba(0,0,0,0.16)] outline-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Group>) {
|
||||
return (
|
||||
<MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.GroupLabel> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
onSelect,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
onSelect?: () => void;
|
||||
}) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement> & { preventBaseUIHandler: () => void }) => {
|
||||
onClick?.(e);
|
||||
onSelect?.();
|
||||
};
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"bg-transparent hover:bg-neutral-400/15 text-sm transition-all relative flex cursor-pointer items-center gap-2 rounded-full px-2 py-1.5 outline-none ring-0 border-none select-none",
|
||||
"data-[variant=destructive]:text-[var(--color-error)] data-[variant=destructive]:hover:bg-[var(--color-error)]/10 data-[variant=destructive]:hover:text-[var(--color-error)]",
|
||||
inset && "pl-8",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.SubmenuRoot>) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"bg-transparent hover:bg-neutral-400/15 focus:bg-neutral-400/15 data-open:bg-neutral-400/15 flex cursor-pointer items-center gap-2 rounded-full px-2 py-1.5 text-sm outline-none select-none transition-all focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:outline-none",
|
||||
inset && "pl-8",
|
||||
"[&_svg:not([class*='text-'])]:text-[var(--color-text-muted)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("min-w-[96px]", className)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"hover:bg-neutral-400/15 focus:bg-neutral-400/15 relative flex cursor-pointer items-center gap-2 rounded-full py-1.5 pr-2 pl-8 text-sm outline-none select-none transition-colors",
|
||||
inset && "pl-8",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.RadioItem> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"hover:bg-neutral-400/15 focus:bg-neutral-400/15 relative flex cursor-pointer items-center gap-2 rounded-full py-1.5 pr-2 pl-8 text-sm outline-none select-none transition-colors",
|
||||
inset && "pl-8",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn(
|
||||
"bg-neutral-400/15 -mx-1 my-1 h-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-[var(--color-text-muted)] ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
36
apps/web/app/components/unicode-spinner.tsx
Normal file
36
apps/web/app/components/unicode-spinner.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import spinners from "unicode-animations";
|
||||
|
||||
type SpinnerName = keyof typeof spinners;
|
||||
|
||||
export function UnicodeSpinner({
|
||||
name = "braille",
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
name?: SpinnerName;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [frame, setFrame] = useState(0);
|
||||
const s = spinners[name];
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(
|
||||
() => setFrame((f) => (f + 1) % s.frames.length),
|
||||
s.interval,
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [name, s.frames.length, s.interval]);
|
||||
|
||||
return (
|
||||
<span className={className} style={{ fontFamily: "monospace", ...style }}>
|
||||
{s.frames[frame]}
|
||||
{children != null && <> {children}</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { UnicodeSpinner } from "../unicode-spinner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
type WebSession = {
|
||||
id: string;
|
||||
@ -10,6 +17,15 @@ type WebSession = {
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
export type SidebarSubagentInfo = {
|
||||
childSessionKey: string;
|
||||
runId: string;
|
||||
task: string;
|
||||
label?: string;
|
||||
parentSessionId: string;
|
||||
status?: "running" | "completed" | "error";
|
||||
};
|
||||
|
||||
type ChatSessionsSidebarProps = {
|
||||
sessions: WebSession[];
|
||||
activeSessionId: string | null;
|
||||
@ -17,12 +33,28 @@ type ChatSessionsSidebarProps = {
|
||||
activeSessionTitle?: string;
|
||||
/** Session IDs with an actively running agent stream. */
|
||||
streamingSessionIds?: Set<string>;
|
||||
/** Subagents spawned by chat sessions. */
|
||||
subagents?: SidebarSubagentInfo[];
|
||||
/** Currently selected subagent session key (if viewing a subagent). */
|
||||
activeSubagentKey?: string | null;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
/** Called when a subagent is selected in the sidebar. */
|
||||
onSelectSubagent?: (sessionKey: string) => void;
|
||||
/** When true, renders as a mobile overlay drawer instead of a static sidebar. */
|
||||
mobile?: boolean;
|
||||
/** Close the mobile drawer. */
|
||||
onClose?: () => void;
|
||||
/** Fixed width in px when not mobile (overrides default 260). */
|
||||
width?: number;
|
||||
/** Called when the user deletes a session from the sidebar menu. */
|
||||
onDeleteSession?: (sessionId: string) => void;
|
||||
/** Called when the user renames a session from the sidebar menu. */
|
||||
onRenameSession?: (sessionId: string, newTitle: string) => void;
|
||||
/** Called when the user clicks the collapse/hide sidebar button. */
|
||||
onCollapse?: () => void;
|
||||
/** When true, show a loader instead of empty state (e.g. initial sessions fetch). */
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
/** Format a timestamp into a human-readable relative time string. */
|
||||
@ -60,6 +92,25 @@ function PlusIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M16 3h5v5" />
|
||||
<path d="m21 3-7 7" />
|
||||
<path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubbleIcon() {
|
||||
return (
|
||||
<svg
|
||||
@ -77,17 +128,46 @@ function ChatBubbleIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function MoreHorizontalIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatSessionsSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSessionTitle: _activeSessionTitle,
|
||||
streamingSessionIds,
|
||||
subagents,
|
||||
activeSubagentKey,
|
||||
onSelectSession,
|
||||
onNewSession,
|
||||
onSelectSubagent,
|
||||
onDeleteSession,
|
||||
onRenameSession,
|
||||
onCollapse,
|
||||
mobile,
|
||||
onClose,
|
||||
width: widthProp,
|
||||
loading = false,
|
||||
}: ChatSessionsSidebarProps) {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
@ -97,48 +177,86 @@ export function ChatSessionsSidebar({
|
||||
[onSelectSession, onClose],
|
||||
);
|
||||
|
||||
const handleSelectSubagentItem = useCallback(
|
||||
(sessionKey: string) => {
|
||||
onSelectSubagent?.(sessionKey);
|
||||
onClose?.();
|
||||
},
|
||||
[onSelectSubagent, onClose],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
onDeleteSession?.(sessionId);
|
||||
},
|
||||
[onDeleteSession],
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((sessionId: string, currentTitle: string) => {
|
||||
setRenamingId(sessionId);
|
||||
setRenameValue(currentTitle || "");
|
||||
}, []);
|
||||
|
||||
const handleCommitRename = useCallback(() => {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
onRenameSession?.(renamingId, renameValue.trim());
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, [renamingId, renameValue, onRenameSession]);
|
||||
|
||||
// Index subagents by parent session ID
|
||||
const subagentsByParent = useMemo(() => {
|
||||
const map = new Map<string, SidebarSubagentInfo[]>();
|
||||
if (!subagents) {return map;}
|
||||
for (const sa of subagents) {
|
||||
let list = map.get(sa.parentSessionId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
map.set(sa.parentSessionId, list);
|
||||
}
|
||||
list.push(sa);
|
||||
}
|
||||
return map;
|
||||
}, [subagents]);
|
||||
|
||||
// Group sessions: today, yesterday, this week, this month, older
|
||||
const grouped = groupSessions(sessions);
|
||||
|
||||
const width = mobile ? "280px" : (widthProp ?? 260);
|
||||
const headerHeight = 40; // px — match padding so list content clears the overlay
|
||||
const sidebar = (
|
||||
<aside
|
||||
className={`flex flex-col h-full flex-shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
|
||||
className={`flex flex-col h-full shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
|
||||
style={{
|
||||
width: mobile ? "280px" : 260,
|
||||
width: typeof width === "number" ? `${width}px` : width,
|
||||
minWidth: typeof width === "number" ? `${width}px` : width,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
background: "var(--color-sidebar-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className="text-sm font-medium truncate block"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
Chats
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSession}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer flex-shrink-0 ml-2"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
background: "var(--color-accent-light)",
|
||||
}}
|
||||
title="New chat"
|
||||
{/* Scrollable list fills the sidebar; header overlays the top with blur */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{/* Session list — scrolls under the header */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-y-auto"
|
||||
style={{ paddingTop: headerHeight }}
|
||||
>
|
||||
<PlusIcon />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
{loading && sessions.length === 0 ? (
|
||||
<div className="px-4 py-8 flex flex-col items-center justify-center min-h-[120px]">
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-xl mb-2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Loading…
|
||||
</p>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<div
|
||||
className="mx-auto w-10 h-10 rounded-xl flex items-center justify-center mb-3"
|
||||
@ -169,69 +287,165 @@ export function ChatSessionsSidebar({
|
||||
{group.label}
|
||||
</div>
|
||||
{group.sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const isActive = session.id === activeSessionId && !activeSubagentKey;
|
||||
const isHovered = session.id === hoveredId;
|
||||
const showMore = isHovered;
|
||||
const isStreamingSession = streamingSessionIds?.has(session.id) ?? false;
|
||||
const sessionSubagents = subagentsByParent.get(session.id);
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(session.id)}
|
||||
className="group relative"
|
||||
onMouseEnter={() => setHoveredId(session.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className="w-full text-left px-2 py-2 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="flex items-stretch w-full rounded-lg"
|
||||
style={{
|
||||
background: isActive
|
||||
? "var(--color-accent-light)"
|
||||
? "var(--color-chat-sidebar-active-bg)"
|
||||
: isHovered
|
||||
? "var(--color-surface-hover)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStreamingSession && (
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
title="Agent is running"
|
||||
{renamingId === session.id ? (
|
||||
<form
|
||||
className="flex-1 min-w-0 px-2 py-1.5"
|
||||
onSubmit={(e) => { e.preventDefault(); handleCommitRename(); }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={handleCommitRename}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { setRenamingId(null); setRenameValue(""); } }}
|
||||
autoFocus
|
||||
className="w-full text-xs font-medium px-1 py-0.5 rounded outline-none border"
|
||||
style={{ color: "var(--color-text)", background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{
|
||||
color: isActive
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{session.title || "Untitled chat"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5" style={{ paddingLeft: isStreamingSession ? "calc(0.375rem + 6px)" : undefined }}>
|
||||
{isStreamingSession && (
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(session.id)}
|
||||
className="flex-1 min-w-0 text-left px-2 py-2 rounded-l-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStreamingSession && (
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-[10px] flex-shrink-0"
|
||||
style={{ color: "var(--color-chat-sidebar-muted)" }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{
|
||||
color: isActive
|
||||
? "var(--color-chat-sidebar-active-text)"
|
||||
: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
Streaming
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
{session.messageCount > 0 && (
|
||||
{session.title || "Untitled chat"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5" style={{ paddingLeft: isStreamingSession ? "calc(0.375rem + 6px)" : undefined }}>
|
||||
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
)}
|
||||
{session.messageCount > 0 && (
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{onDeleteSession && (
|
||||
<div className={`shrink-0 flex items-center pr-1 transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-center w-6 h-6 rounded-md"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleStartRename(session.id, session.title)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /></svg>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => handleDeleteSession(session.id)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Subagent sub-items */}
|
||||
{sessionSubagents && sessionSubagents.length > 0 && (
|
||||
<div className="ml-4 border-l" style={{ borderColor: "var(--color-border)" }}>
|
||||
{sessionSubagents.map((sa) => {
|
||||
const isSubActive = activeSubagentKey === sa.childSessionKey;
|
||||
const isSubRunning = sa.status === "running";
|
||||
const subLabel = sa.label || sa.task;
|
||||
const truncated = subLabel.length > 40 ? subLabel.slice(0, 40) + "..." : subLabel;
|
||||
return (
|
||||
<button
|
||||
key={sa.childSessionKey}
|
||||
type="button"
|
||||
onClick={() => handleSelectSubagentItem(sa.childSessionKey)}
|
||||
className="w-full text-left pl-3 pr-2 py-1.5 rounded-r-lg transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: isSubActive
|
||||
? "var(--color-chat-sidebar-active-bg)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isSubRunning && (
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-[9px] flex-shrink-0"
|
||||
style={{ color: "var(--color-chat-sidebar-muted)" }}
|
||||
/>
|
||||
)}
|
||||
<SubagentIcon />
|
||||
<span
|
||||
className="text-[11px] truncate"
|
||||
style={{
|
||||
color: isSubActive
|
||||
? "var(--color-chat-sidebar-active-text)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{truncated}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -239,6 +453,52 @@ export function ChatSessionsSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Header overlay: backdrop blur + 80% bg; list scrolls under it */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between border-b px-4 py-2 backdrop-blur-md"
|
||||
style={{
|
||||
height: headerHeight,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "color-mix(in srgb, var(--color-sidebar-bg) 80%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
{onCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
className="p-1 rounded-md shrink-0 transition-colors hover:bg-black/5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Hide chat sidebar (⌘⇧B)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M15 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium truncate block"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
Chats
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSession}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition-colors cursor-pointer shrink-0 ml-1.5"
|
||||
style={{
|
||||
color: "var(--color-chat-sidebar-active-text)",
|
||||
background: "var(--color-chat-sidebar-active-bg)",
|
||||
}}
|
||||
title="New chat"
|
||||
>
|
||||
<PlusIcon />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
|
||||
376
apps/web/app/components/workspace/create-workspace-dialog.tsx
Normal file
376
apps/web/app/components/workspace/create-workspace-dialog.tsx
Normal file
@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { DirectoryPickerModal } from "./directory-picker-modal";
|
||||
|
||||
type CreateWorkspaceDialogProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreated?: () => void;
|
||||
};
|
||||
|
||||
function shortenPath(p: string): string {
|
||||
return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
|
||||
}
|
||||
|
||||
export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) {
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [customPath, setCustomPath] = useState("");
|
||||
const [useCustomPath, setUseCustomPath] = useState(false);
|
||||
const [showDirPicker, setShowDirPicker] = useState(false);
|
||||
const [seedBootstrap, setSeedBootstrap] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ workspaceDir: string; seededFiles: string[] } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus input on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setProfileName("");
|
||||
setCustomPath("");
|
||||
setUseCustomPath(false);
|
||||
setShowDirPicker(false);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape (only if dir picker is not open)
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !showDirPicker) {onClose();}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}
|
||||
}, [isOpen, onClose, showDirPicker]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = profileName.trim();
|
||||
if (!name) {
|
||||
setError("Please enter a workspace name.");
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
setError("Name must use only letters, numbers, hyphens, or underscores.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
profile: name,
|
||||
seedBootstrap,
|
||||
};
|
||||
if (useCustomPath && customPath.trim()) {
|
||||
body.path = customPath.trim();
|
||||
}
|
||||
|
||||
const res = await fetch("/api/workspace/init", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to create workspace.");
|
||||
return;
|
||||
}
|
||||
|
||||
setResult({
|
||||
workspaceDir: data.workspaceDir,
|
||||
seededFiles: data.seededFiles ?? [],
|
||||
});
|
||||
onCreated?.();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {return null;}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: "rgba(0,0,0,0.5)" }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {onClose();}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="w-full max-w-md rounded-xl overflow-hidden"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "var(--shadow-xl)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-4"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<h2
|
||||
className="text-base font-semibold"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
New Workspace
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{result ? (
|
||||
/* Success state */
|
||||
<div className="text-center py-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3"
|
||||
style={{ background: "rgba(22, 163, 74, 0.1)" }}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
Workspace created
|
||||
</p>
|
||||
<code
|
||||
className="text-xs px-2 py-1 rounded mt-2 inline-block"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-secondary)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{result.workspaceDir.replace(/^\/Users\/[^/]+/, "~")}
|
||||
</code>
|
||||
{result.seededFiles.length > 0 && (
|
||||
<p
|
||||
className="text-xs mt-2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Seeded: {result.seededFiles.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Form */
|
||||
<>
|
||||
{/* Profile name */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
Workspace name
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={profileName}
|
||||
onChange={(e) => {
|
||||
setProfileName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !creating) {void handleCreate();}
|
||||
}}
|
||||
placeholder="e.g. work, personal, project-x"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className="text-xs mt-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
This creates a new profile with its own workspace directory.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom path toggle */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setUseCustomPath(!useCustomPath)}
|
||||
className="flex items-center gap-2 text-xs transition-colors hover:opacity-80"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform ${useCustomPath ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Custom directory path
|
||||
</button>
|
||||
|
||||
{useCustomPath && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{customPath ? (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "rgba(245, 158, 11, 0.12)", color: "#f59e0b" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{customPath.split("/").pop()}
|
||||
</p>
|
||||
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={customPath}>
|
||||
{shortenPath(customPath)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDirPicker(true)}
|
||||
className="px-2 py-1 text-xs rounded-md transition-colors hover:opacity-80"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCustomPath("")}
|
||||
className="p-1 rounded-md transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowDirPicker(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-3 rounded-lg text-sm transition-colors hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px dashed var(--color-border-strong)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
Browse for a directory...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bootstrap toggle */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seedBootstrap}
|
||||
onChange={(e) => setSeedBootstrap(e.target.checked)}
|
||||
className="rounded"
|
||||
style={{ accentColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
Seed bootstrap files and workspace database
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
className="text-sm px-3 py-2 rounded-lg"
|
||||
style={{
|
||||
background: "rgba(220, 38, 38, 0.08)",
|
||||
color: "var(--color-error)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 px-5 py-3"
|
||||
style={{ borderTop: "1px solid var(--color-border)" }}
|
||||
>
|
||||
{result ? (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating || !profileName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{creating ? "Creating..." : "Create Workspace"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory picker modal */}
|
||||
<DirectoryPickerModal
|
||||
open={showDirPicker}
|
||||
onClose={() => setShowDirPicker(false)}
|
||||
onSelect={(path) => setCustomPath(path)}
|
||||
startDir="~"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -74,6 +74,16 @@ export type DataTableProps<TData, TValue> = {
|
||||
titleIcon?: React.ReactNode;
|
||||
// sticky
|
||||
stickyFirstColumn?: boolean;
|
||||
// server-side pagination
|
||||
serverPagination?: {
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
};
|
||||
// server-side search callback (replaces client-side fuzzy filter)
|
||||
onServerSearch?: (query: string) => void;
|
||||
};
|
||||
|
||||
/* ─── Fuzzy filter ─── */
|
||||
@ -173,6 +183,8 @@ export function DataTable<TData, TValue>({
|
||||
title,
|
||||
titleIcon,
|
||||
stickyFirstColumn: stickyFirstProp = true,
|
||||
serverPagination,
|
||||
onServerSearch,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
@ -315,6 +327,11 @@ export function DataTable<TData, TValue>({
|
||||
return cols;
|
||||
}, [columns, selectionColumn, actionsColumn]);
|
||||
|
||||
// Server-side pagination state derived from props
|
||||
const serverPaginationState = serverPagination
|
||||
? { pageIndex: serverPagination.page - 1, pageSize: serverPagination.pageSize }
|
||||
: undefined;
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: allColumns,
|
||||
@ -325,7 +342,7 @@ export function DataTable<TData, TValue>({
|
||||
columnVisibility,
|
||||
rowSelection: rowSelectionState,
|
||||
columnOrder: enableColumnReordering ? columnOrder : undefined,
|
||||
pagination,
|
||||
pagination: serverPaginationState ?? pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
@ -338,11 +355,26 @@ export function DataTable<TData, TValue>({
|
||||
setInternalRowSelection(updater);
|
||||
}
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: serverPagination
|
||||
? (updater) => {
|
||||
const newVal = typeof updater === "function"
|
||||
? updater(serverPaginationState!)
|
||||
: updater;
|
||||
if (newVal.pageSize !== serverPagination.pageSize) {
|
||||
serverPagination.onPageSizeChange(newVal.pageSize);
|
||||
} else if (newVal.pageIndex !== serverPagination.page - 1) {
|
||||
serverPagination.onPageChange(newVal.pageIndex + 1);
|
||||
}
|
||||
}
|
||||
: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFilteredRowModel: serverPagination ? undefined : getFilteredRowModel(),
|
||||
getPaginationRowModel: serverPagination ? undefined : getPaginationRowModel(),
|
||||
...(serverPagination ? {
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil(serverPagination.totalCount / serverPagination.pageSize),
|
||||
} : {}),
|
||||
enableRowSelection,
|
||||
enableSorting,
|
||||
globalFilterFn: fuzzyFilter,
|
||||
@ -379,7 +411,10 @@ export function DataTable<TData, TValue>({
|
||||
<input
|
||||
type="text"
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setGlobalFilter(e.target.value);
|
||||
onServerSearch?.(e.target.value);
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-9 pr-3 py-1.5 text-xs rounded-full outline-none"
|
||||
style={{
|
||||
@ -391,7 +426,7 @@ export function DataTable<TData, TValue>({
|
||||
{globalFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
onClick={() => { setGlobalFilter(""); onServerSearch?.(""); }}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
@ -683,14 +718,23 @@ export function DataTable<TData, TValue>({
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Showing {table.getRowModel().rows.length} of {data.length} results
|
||||
{serverPagination
|
||||
? `Showing ${(serverPagination.page - 1) * serverPagination.pageSize + 1}–${Math.min(serverPagination.page * serverPagination.pageSize, serverPagination.totalCount)} of ${serverPagination.totalCount} results`
|
||||
: `Showing ${table.getRowModel().rows.length} of ${data.length} results`}
|
||||
{selectedCount > 0 && ` (${selectedCount} selected)`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Rows per page</span>
|
||||
<select
|
||||
value={pagination.pageSize}
|
||||
onChange={(e) => setPagination((p) => ({ ...p, pageSize: Number(e.target.value), pageIndex: 0 }))}
|
||||
value={serverPagination ? serverPagination.pageSize : pagination.pageSize}
|
||||
onChange={(e) => {
|
||||
const newSize = Number(e.target.value);
|
||||
if (serverPagination) {
|
||||
serverPagination.onPageSizeChange(newSize);
|
||||
} else {
|
||||
setPagination((p) => ({ ...p, pageSize: newSize, pageIndex: 0 }));
|
||||
}
|
||||
}}
|
||||
className="px-1.5 py-0.5 rounded-md text-xs outline-none"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
@ -703,13 +747,24 @@ export function DataTable<TData, TValue>({
|
||||
))}
|
||||
</select>
|
||||
<span>
|
||||
Page {pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
Page {serverPagination ? serverPagination.page : pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" />
|
||||
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" />
|
||||
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="›" />
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" />
|
||||
{serverPagination ? (
|
||||
<>
|
||||
<PaginationButton onClick={() => serverPagination.onPageChange(1)} disabled={serverPagination.page <= 1} label="«" />
|
||||
<PaginationButton onClick={() => serverPagination.onPageChange(serverPagination.page - 1)} disabled={serverPagination.page <= 1} label="‹" />
|
||||
<PaginationButton onClick={() => serverPagination.onPageChange(serverPagination.page + 1)} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="›" />
|
||||
<PaginationButton onClick={() => serverPagination.onPageChange(Math.ceil(serverPagination.totalCount / serverPagination.pageSize))} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="»" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" />
|
||||
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" />
|
||||
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="›" />
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
479
apps/web/app/components/workspace/directory-picker-modal.tsx
Normal file
479
apps/web/app/components/workspace/directory-picker-modal.tsx
Normal file
@ -0,0 +1,479 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type BrowseEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
};
|
||||
|
||||
type DirectoryPickerModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (path: string) => void;
|
||||
/** Starting directory (absolute). Falls back to the workspace root / home. */
|
||||
startDir?: string;
|
||||
};
|
||||
|
||||
function buildBreadcrumbs(dir: string): { label: string; path: string }[] {
|
||||
const segments: { label: string; path: string }[] = [];
|
||||
const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/);
|
||||
const homeDir = homeMatch?.[1];
|
||||
|
||||
if (homeDir) {
|
||||
segments.push({ label: "~", path: homeDir });
|
||||
const rest = dir.slice(homeDir.length);
|
||||
const parts = rest.split("/").filter(Boolean);
|
||||
let currentPath = homeDir;
|
||||
for (const part of parts) {
|
||||
currentPath += "/" + part;
|
||||
segments.push({ label: part, path: currentPath });
|
||||
}
|
||||
} else if (dir === "/") {
|
||||
segments.push({ label: "/", path: "/" });
|
||||
} else {
|
||||
segments.push({ label: "/", path: "/" });
|
||||
const parts = dir.split("/").filter(Boolean);
|
||||
let currentPath = "";
|
||||
for (const part of parts) {
|
||||
currentPath += "/" + part;
|
||||
segments.push({ label: part, path: currentPath });
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
const folderColors = { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" };
|
||||
|
||||
function FolderIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectoryPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
startDir,
|
||||
}: DirectoryPickerModalProps) {
|
||||
const [currentDir, setCurrentDir] = useState<string | null>(startDir ?? null);
|
||||
const [displayDir, setDisplayDir] = useState("");
|
||||
const [entries, setEntries] = useState<BrowseEntry[]>([]);
|
||||
const [parentDir, setParentDir] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset to startDir when reopening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCurrentDir(startDir ?? null);
|
||||
}
|
||||
}, [open, startDir]);
|
||||
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchDir = useCallback(async (dir: string | null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = dir
|
||||
? `/api/workspace/browse?dir=${encodeURIComponent(dir)}`
|
||||
: "/api/workspace/browse";
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {throw new Error("Failed to list directory");}
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setDisplayDir(data.currentDir || "");
|
||||
setParentDir(data.parentDir ?? null);
|
||||
} catch {
|
||||
setError("Could not load this directory");
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { void fetchDir(currentDir); }
|
||||
}, [open, currentDir, fetchDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {return;}
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {onClose();}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
const navigateInto = useCallback((path: string) => {
|
||||
setCurrentDir(path);
|
||||
setSearch("");
|
||||
setCreatingFolder(false);
|
||||
}, []);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!newFolderName.trim() || !displayDir) {return;}
|
||||
const folderPath = `${displayDir}/${newFolderName.trim()}`;
|
||||
try {
|
||||
const res = await fetch("/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: folderPath, absolute: true }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError((data as { error?: string }).error || "Failed to create folder");
|
||||
return;
|
||||
}
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
void fetchDir(currentDir);
|
||||
} catch {
|
||||
setError("Failed to create folder");
|
||||
}
|
||||
}, [newFolderName, displayDir, currentDir, fetchDir]);
|
||||
|
||||
const handleSelectCurrent = useCallback(() => {
|
||||
if (displayDir) {
|
||||
onSelect(displayDir);
|
||||
onClose();
|
||||
}
|
||||
}, [displayDir, onSelect, onClose]);
|
||||
|
||||
// Only show folders
|
||||
const folders = entries
|
||||
.filter((e) => e.type === "folder")
|
||||
.filter((e) => !search || e.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const breadcrumbs = displayDir ? buildBreadcrumbs(displayDir) : [];
|
||||
|
||||
// Shorten display path for the footer
|
||||
const shortDir = displayDir
|
||||
.replace(/^\/Users\/[^/]+/, "~")
|
||||
.replace(/^\/home\/[^/]+/, "~");
|
||||
|
||||
if (!open) {return null;}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center"
|
||||
style={{
|
||||
opacity: visible ? 1 : 0,
|
||||
transition: "opacity 150ms ease-out",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: "rgba(0,0,0,0.4)", backdropFilter: "blur(4px)" }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden w-[calc(100%-2rem)] max-w-[540px]"
|
||||
style={{
|
||||
maxHeight: "70vh",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
transform: visible ? "scale(1)" : "scale(0.97)",
|
||||
transition: "transform 150ms ease-out",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3.5 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ background: folderColors.bg, color: folderColors.fg }}
|
||||
>
|
||||
<FolderIcon size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
|
||||
Choose Directory
|
||||
</h2>
|
||||
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
Navigate to a folder for the workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center"
|
||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
{displayDir && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-5 py-2 border-b overflow-x-auto flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)", scrollbarWidth: "thin" }}
|
||||
>
|
||||
{breadcrumbs.map((seg, i) => (
|
||||
<Fragment key={seg.path}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
className="text-[10px] flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateInto(seg.path)}
|
||||
className="text-[12px] font-medium flex-shrink-0 rounded px-1 py-0.5 hover:underline"
|
||||
style={{
|
||||
color: i === breadcrumbs.length - 1
|
||||
? "var(--color-text)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{seg.label}
|
||||
</button>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search + New Folder */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div
|
||||
className="flex-1 flex items-center gap-2 rounded-lg px-2.5 py-1.5"
|
||||
style={{ background: "var(--color-bg)", border: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)", flexShrink: 0 }}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter folders..."
|
||||
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreatingFolder(true);
|
||||
setTimeout(() => newFolderRef.current?.focus(), 50);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium whitespace-nowrap"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M12 5v14" /><path d="M5 12h14" />
|
||||
</svg>
|
||||
New Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folder list */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ background: "var(--color-bg)", minHeight: 200 }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Go up */}
|
||||
{parentDir && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateInto(parentDir)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "var(--color-surface-hover)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[13px] font-medium">..</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* New folder input */}
|
||||
{creatingFolder && (
|
||||
<div className="flex items-center gap-3 px-4 py-2">
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: folderColors.bg, color: folderColors.fg }}
|
||||
>
|
||||
<FolderIcon />
|
||||
</div>
|
||||
<input
|
||||
ref={newFolderRef}
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {void handleCreateFolder();}
|
||||
if (e.key === "Escape") {
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
}
|
||||
}}
|
||||
placeholder="Folder name..."
|
||||
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder entries */}
|
||||
{folders.length === 0 && !parentDir && (
|
||||
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
No subfolders here
|
||||
</div>
|
||||
)}
|
||||
{folders.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => navigateInto(entry.path)}
|
||||
className="w-full flex items-center gap-3 px-4 py-1.5 group text-left hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: folderColors.bg, color: folderColors.fg }}
|
||||
>
|
||||
<FolderIcon />
|
||||
</div>
|
||||
<span
|
||||
className="flex-1 text-[13px] font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-50 transition-opacity"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3 border-t flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={displayDir}>
|
||||
{shortDir || "Loading..."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-[13px] font-medium"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectCurrent}
|
||||
disabled={!displayDir}
|
||||
className="px-3 py-1.5 rounded-lg text-[13px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
color: "white",
|
||||
background: displayDir ? "var(--color-accent)" : "var(--color-border-strong)",
|
||||
}}
|
||||
>
|
||||
Select This Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CreateWorkspaceDialog } from "./create-workspace-dialog";
|
||||
|
||||
export function EmptyState({
|
||||
workspaceExists,
|
||||
expectedPath,
|
||||
onWorkspaceCreated,
|
||||
}: {
|
||||
workspaceExists: boolean;
|
||||
/** The resolved workspace path to display (e.g. from the tree API). */
|
||||
expectedPath?: string | null;
|
||||
/** Called after a workspace is created from this empty state. */
|
||||
onWorkspaceCreated?: () => void;
|
||||
}) {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-6 px-8">
|
||||
{/* Icon */}
|
||||
@ -79,15 +90,31 @@ export function EmptyState({
|
||||
) : (
|
||||
<>
|
||||
The workspace directory was not
|
||||
found. To initialize it, start a
|
||||
conversation with the CRM agent and it
|
||||
will create the workspace structure
|
||||
found. Create one to get started, or start a
|
||||
conversation and the agent will set it up
|
||||
automatically.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create workspace button — prominent when no workspace exists */}
|
||||
{!workspaceExists && (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14" /><path d="M5 12h14" />
|
||||
</svg>
|
||||
Create Workspace
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
|
||||
@ -125,7 +152,9 @@ export function EmptyState({
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
~/.openclaw/workspace
|
||||
{expectedPath
|
||||
? expectedPath.replace(/^\/Users\/[^/]+/, "~")
|
||||
: "~/.openclaw/workspace"}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
@ -151,6 +180,13 @@ export function EmptyState({
|
||||
</svg>
|
||||
Back to Home
|
||||
</a>
|
||||
|
||||
{/* Create workspace dialog */}
|
||||
<CreateWorkspaceDialog
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={onWorkspaceCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,6 +28,8 @@ export type TreeNode = {
|
||||
children?: TreeNode[];
|
||||
/** When true, the node represents a virtual folder/file outside the real workspace (e.g. Skills, Memories). CRUD ops are disabled. */
|
||||
virtual?: boolean;
|
||||
/** True when the entry is a symbolic link / shortcut. */
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
|
||||
@ -81,14 +83,15 @@ function isSystemFile(path: string): boolean {
|
||||
// --- Icons (inline SVG, zero-dep) ---
|
||||
|
||||
function FolderIcon({ open }: { open?: boolean }) {
|
||||
return open ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
return (
|
||||
<img
|
||||
src={open ? "/icons/folder-open.png" : "/icons/folder.png"}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
draggable={false}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -110,17 +113,13 @@ function KanbanIcon() {
|
||||
|
||||
function DocumentIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
||||
</svg>
|
||||
<img src="/icons/document.png" alt="" width={16} height={16} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
<img src="/icons/document.png" alt="" width={16} height={16} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -156,6 +155,14 @@ function LockBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
function SymlinkBadge() {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.55 }}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
@ -548,7 +555,7 @@ function DraggableNode({
|
||||
onCancel={onCancelRename}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span>
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
)}
|
||||
|
||||
{/* Workspace badge for the workspace root entry point */}
|
||||
@ -566,6 +573,13 @@ function DraggableNode({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Symlink indicator */}
|
||||
{node.symlink && !compact && (
|
||||
<span className="flex-shrink-0 ml-0.5" title="Symbolic link" style={{ color: "var(--color-text-muted)" }}>
|
||||
<SymlinkBadge />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Type badge for objects */}
|
||||
{node.type === "object" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
||||
|
||||
@ -1,53 +1,36 @@
|
||||
"use client";
|
||||
|
||||
type FileViewerProps = {
|
||||
content: string;
|
||||
filename: string;
|
||||
type: "yaml" | "text";
|
||||
};
|
||||
import { useState, useEffect } from "react";
|
||||
import { read, utils, type WorkBook } from "xlsx";
|
||||
|
||||
export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
const SPREADSHEET_EXTENSIONS = new Set([
|
||||
"xlsx", "xls", "xlsb", "xlsm", "xltx", "xltm",
|
||||
"ods", "fods",
|
||||
"csv", "tsv",
|
||||
"numbers",
|
||||
]);
|
||||
|
||||
export function isSpreadsheetFile(filename: string): boolean {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return SPREADSHEET_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
type FileViewerProps =
|
||||
| { content: string; filename: string; type: "yaml" | "text" }
|
||||
| { filename: string; type: "spreadsheet"; url: string; content?: never };
|
||||
|
||||
export function FileViewer(props: FileViewerProps) {
|
||||
if (props.type === "spreadsheet") {
|
||||
return <SpreadsheetViewer filename={props.filename} url={props.url} />;
|
||||
}
|
||||
|
||||
const { content, filename, type } = props;
|
||||
const lines = content.split("\n");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* File header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded ml-auto"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<FileHeader filename={filename} label={type.toUpperCase()} />
|
||||
|
||||
{/* File content */}
|
||||
<div
|
||||
className="rounded-b-lg border overflow-x-auto"
|
||||
style={{
|
||||
@ -62,7 +45,6 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
key={idx}
|
||||
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
{/* Line number */}
|
||||
<span
|
||||
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
|
||||
style={{
|
||||
@ -75,7 +57,6 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* Line content */}
|
||||
<span
|
||||
className="pr-4 flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
@ -95,6 +76,272 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function FileHeader({ filename, label, icon }: { filename: string; label: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{icon ?? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded ml-auto"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spreadsheet viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SpreadsheetViewer({ filename, url }: { filename: string; url: string }) {
|
||||
const [workbook, setWorkbook] = useState<WorkBook | null>(null);
|
||||
const [activeSheet, setActiveSheet] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setWorkbook(null);
|
||||
setActiveSheet(0);
|
||||
setError(null);
|
||||
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) {throw new Error(`Failed to load file (${res.status})`);}
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((buf) => {
|
||||
if (cancelled) {return;}
|
||||
const wb = read(buf, { type: "array" });
|
||||
setWorkbook(wb);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {setError(String(err));}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
const ext = filename.split(".").pop()?.toUpperCase() ?? "SPREADSHEET";
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
<div
|
||||
className="rounded-b-lg border p-8 text-center"
|
||||
style={{ background: "var(--color-bg)", borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Failed to load spreadsheet: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workbook) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
<div
|
||||
className="rounded-b-lg border p-8 text-center"
|
||||
style={{ background: "var(--color-bg)", borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Loading spreadsheet...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetNames = workbook.SheetNames;
|
||||
const sheet = workbook.Sheets[sheetNames[activeSheet]];
|
||||
const rows: string[][] = sheet ? utils.sheet_to_json(sheet, { header: 1, defval: "" }) : [];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
|
||||
{/* Sheet tabs */}
|
||||
{sheetNames.length > 1 && (
|
||||
<div
|
||||
className="flex gap-0 border-x overflow-x-auto"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{sheetNames.map((name, idx) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setActiveSheet(idx)}
|
||||
className="px-4 py-1.5 text-xs font-medium whitespace-nowrap border-b-2 transition-colors"
|
||||
style={{
|
||||
background: idx === activeSheet ? "var(--color-bg)" : "var(--color-surface)",
|
||||
color: idx === activeSheet ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
borderBottomColor: idx === activeSheet ? "var(--color-accent)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
className="rounded-b-lg border overflow-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
maxHeight: "70vh",
|
||||
}}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
This sheet is empty.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Row number header */}
|
||||
<th
|
||||
className="sticky top-0 z-10 px-3 py-2 text-right select-none"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
minWidth: "3rem",
|
||||
}}
|
||||
/>
|
||||
{rows[0]?.map((_cell, colIdx) => (
|
||||
<th
|
||||
key={colIdx}
|
||||
className="sticky top-0 z-10 px-3 py-2 text-left font-medium whitespace-nowrap"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{columnLabel(colIdx)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className="hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
<td
|
||||
className="px-3 py-1.5 text-right select-none tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
{rowIdx + 1}
|
||||
</td>
|
||||
{row.map((cell, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
className="px-3 py-1.5 whitespace-pre-wrap"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
maxWidth: "300px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{String(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 text-xs text-right"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{rows.length} row{rows.length !== 1 ? "s" : ""}
|
||||
{rows[0] ? ` \u00d7 ${rows[0].length} column${rows[0].length !== 1 ? "s" : ""}` : ""}
|
||||
{sheetNames.length > 1 ? ` \u00b7 ${sheetNames.length} sheets` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert zero-based column index to Excel-style label (A, B, ..., Z, AA, AB, ...) */
|
||||
function columnLabel(idx: number): string {
|
||||
let label = "";
|
||||
let n = idx;
|
||||
do {
|
||||
label = String.fromCharCode(65 + (n % 26)) + label;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
} while (n >= 0);
|
||||
return label;
|
||||
}
|
||||
|
||||
function SpreadsheetIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "#22c55e" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M8 13h2" />
|
||||
<path d="M14 13h2" />
|
||||
<path d="M8 17h2" />
|
||||
<path d="M14 17h2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Simple YAML syntax highlighting */
|
||||
function YamlLine({ line }: { line: string }) {
|
||||
// Comment
|
||||
|
||||
276
apps/web/app/components/workspace/html-viewer.tsx
Normal file
276
apps/web/app/components/workspace/html-viewer.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { createHighlighter, type Highlighter } from "shiki";
|
||||
|
||||
type HtmlViewerProps = {
|
||||
filename: string;
|
||||
/** Raw URL for iframe rendering (served with text/html) */
|
||||
rawUrl: string;
|
||||
/** JSON API URL to fetch source content on demand (for code view) */
|
||||
contentUrl: string;
|
||||
};
|
||||
|
||||
type ViewMode = "rendered" | "code";
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
function getHighlighter(): Promise<Highlighter> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-dark", "github-light"],
|
||||
langs: ["html"],
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
export function HtmlViewer({ filename, rawUrl, contentUrl }: HtmlViewerProps) {
|
||||
const [mode, setMode] = useState<ViewMode>("rendered");
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [sourceLoading, setSourceLoading] = useState(false);
|
||||
|
||||
const handleCodeToggle = useCallback(() => {
|
||||
setMode("code");
|
||||
if (source !== null) {return;}
|
||||
setSourceLoading(true);
|
||||
void fetch(contentUrl)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content: string }) => setSource(data.content))
|
||||
.catch(() => setSource("<!-- Failed to load source -->"))
|
||||
.finally(() => setSourceLoading(false));
|
||||
}, [contentUrl, source]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<HtmlIcon />
|
||||
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "#f9731618",
|
||||
color: "#f97316",
|
||||
border: "1px solid #f9731630",
|
||||
}}
|
||||
>
|
||||
HTML
|
||||
</span>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div
|
||||
className="flex items-center ml-auto rounded-lg p-0.5"
|
||||
style={{ background: "var(--color-surface-hover)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("rendered")}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: mode === "rendered" ? "var(--color-surface)" : "transparent",
|
||||
color: mode === "rendered" ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
boxShadow: mode === "rendered" ? "0 1px 2px rgba(0,0,0,0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
<EyeIcon />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCodeToggle}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: mode === "code" ? "var(--color-surface)" : "transparent",
|
||||
color: mode === "code" ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
boxShadow: mode === "code" ? "0 1px 2px rgba(0,0,0,0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
<CodeIcon />
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open in new tab */}
|
||||
<a
|
||||
href={rawUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded-md transition-colors duration-100"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Open in new tab"
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{mode === "rendered" ? (
|
||||
<RenderedView rawUrl={rawUrl} />
|
||||
) : sourceLoading || source === null ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CodeView content={source} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Rendered HTML view (sandboxed iframe) ---
|
||||
|
||||
function RenderedView({ rawUrl }: { rawUrl: string }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden" style={{ background: "white" }}>
|
||||
<iframe
|
||||
src={rawUrl}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups"
|
||||
title="HTML preview"
|
||||
style={{ minHeight: "calc(100vh - 120px)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Syntax-highlighted code view ---
|
||||
|
||||
function CodeView({ content }: { content: string }) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const lineCount = useMemo(() => content.split("\n").length, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getHighlighter().then((highlighter) => {
|
||||
if (cancelled) {return;}
|
||||
const result = highlighter.codeToHtml(content, {
|
||||
lang: "html",
|
||||
themes: { dark: "github-dark", light: "github-light" },
|
||||
});
|
||||
setHtml(result);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto" style={{ background: "var(--color-surface)" }}>
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<CodeIcon />
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
HTML
|
||||
</span>
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
{lineCount} lines
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="code-viewer-content rounded-b-lg border overflow-x-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{html ? (
|
||||
<div
|
||||
className="code-viewer-highlighted"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is trusted
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-sm leading-6" style={{ margin: 0 }}>
|
||||
<code>
|
||||
{content.split("\n").map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
<span
|
||||
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
minWidth: "3rem",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="pr-4 flex-1" style={{ color: "var(--color-text)" }}>
|
||||
{line || " "}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function HtmlIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
<line x1="12" x2="10" y1="2" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -28,6 +28,14 @@ type ReverseRelation = {
|
||||
entries: Record<string, Array<{ id: string; label: string }>>;
|
||||
};
|
||||
|
||||
type ServerPaginationProps = {
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
};
|
||||
|
||||
type ObjectTableProps = {
|
||||
objectName: string;
|
||||
fields: Field[];
|
||||
@ -40,6 +48,10 @@ type ObjectTableProps = {
|
||||
onRefresh?: () => void;
|
||||
/** Column visibility state keyed by field ID. */
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
/** Server-side pagination props. */
|
||||
serverPagination?: ServerPaginationProps;
|
||||
/** Server-side search callback. */
|
||||
onServerSearch?: (query: string) => void;
|
||||
};
|
||||
|
||||
type EntryRow = Record<string, unknown> & { entry_id?: string };
|
||||
@ -368,6 +380,8 @@ export function ObjectTable({
|
||||
onEntryClick,
|
||||
onRefresh,
|
||||
columnVisibility,
|
||||
serverPagination,
|
||||
onServerSearch,
|
||||
}: ObjectTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
@ -576,6 +590,8 @@ export function ObjectTable({
|
||||
rowActions={getRowActions}
|
||||
stickyFirstColumn
|
||||
initialColumnVisibility={columnVisibility}
|
||||
serverPagination={serverPagination}
|
||||
onServerSearch={onServerSearch}
|
||||
/>
|
||||
|
||||
{/* Add Entry Modal */}
|
||||
|
||||
228
apps/web/app/components/workspace/profile-switcher.tsx
Normal file
228
apps/web/app/components/workspace/profile-switcher.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
export type ProfileInfo = {
|
||||
name: string;
|
||||
stateDir: string;
|
||||
workspaceDir: string | null;
|
||||
isActive: boolean;
|
||||
hasConfig: boolean;
|
||||
};
|
||||
|
||||
export type ProfileSwitcherTriggerProps = {
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
activeProfile: string;
|
||||
switching: boolean;
|
||||
};
|
||||
|
||||
type ProfileSwitcherProps = {
|
||||
onProfileSwitch?: () => void;
|
||||
onCreateWorkspace?: () => void;
|
||||
/** Parent-tracked active profile -- triggers a re-fetch when it changes (e.g. after workspace creation). */
|
||||
activeProfileHint?: string | null;
|
||||
/** When set, this renders instead of the default button; dropdown still opens below. */
|
||||
trigger?: (props: ProfileSwitcherTriggerProps) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProfileHint, trigger }: ProfileSwitcherProps) {
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [activeProfile, setActiveProfile] = useState("default");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchProfiles = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/profiles");
|
||||
const data = await res.json();
|
||||
setProfiles(data.profiles ?? []);
|
||||
setActiveProfile(data.activeProfile ?? "default");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchProfiles();
|
||||
}, [fetchProfiles, activeProfileHint]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSwitch = async (profileName: string) => {
|
||||
if (profileName === activeProfile) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
setSwitching(true);
|
||||
try {
|
||||
const res = await fetch("/api/profiles/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: profileName }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setActiveProfile(data.activeProfile ?? "default");
|
||||
onProfileSwitch?.();
|
||||
void fetchProfiles();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't show the switcher if there's only one profile and no way to create more
|
||||
const showSwitcher = profiles.length > 0;
|
||||
const handleToggle = () => {
|
||||
if (showSwitcher) { setIsOpen((o) => !o); }
|
||||
};
|
||||
|
||||
if (!trigger && !showSwitcher) { return null; }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${trigger ? "flex-1 min-w-0" : ""}`}
|
||||
ref={dropdownRef}
|
||||
>
|
||||
{trigger ? (
|
||||
trigger({
|
||||
isOpen,
|
||||
onClick: handleToggle,
|
||||
activeProfile,
|
||||
switching,
|
||||
})
|
||||
) : (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={switching}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
title="Switch workspace profile"
|
||||
>
|
||||
{/* Workspace icon */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{activeProfile === "default" ? "Default" : activeProfile}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSwitcher && isOpen && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-1 w-64 rounded-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: "var(--color-surface-raised)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-medium"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
Workspace Profiles
|
||||
</div>
|
||||
|
||||
{/* Profile list */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{profiles.map((p) => {
|
||||
const isCurrent = p.name === activeProfile;
|
||||
return (
|
||||
<button
|
||||
key={p.name}
|
||||
onClick={() => handleSwitch(p.name)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{/* Active indicator */}
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: isCurrent ? "var(--color-success)" : "transparent",
|
||||
border: isCurrent ? "none" : "1px solid var(--color-border-strong)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">
|
||||
{p.name === "default" ? "Default" : p.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs truncate mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{p.workspaceDir
|
||||
? p.workspaceDir.replace(/^\/Users\/[^/]+/, "~")
|
||||
: "No workspace yet"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCurrent && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
}}
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Create new */}
|
||||
<div style={{ borderTop: "1px solid var(--color-border)" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCreateWorkspace?.();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14" /><path d="M5 12h14" />
|
||||
</svg>
|
||||
New Workspace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { FileManagerTree, type TreeNode } from "./file-manager-tree";
|
||||
import { ProfileSwitcher } from "./profile-switcher";
|
||||
import { CreateWorkspaceDialog } from "./create-workspace-dialog";
|
||||
import { UnicodeSpinner } from "../unicode-spinner";
|
||||
|
||||
/** Shape returned by /api/workspace/suggest-files */
|
||||
type SuggestItem = {
|
||||
@ -37,6 +40,18 @@ type WorkspaceSidebarProps = {
|
||||
mobile?: boolean;
|
||||
/** Close the mobile drawer. */
|
||||
onClose?: () => void;
|
||||
/** Active workspace profile name (null = default). */
|
||||
activeProfile?: string | null;
|
||||
/** Fixed width in px when not mobile (overrides default 260). */
|
||||
width?: number;
|
||||
/** Called after the user switches to a different profile. */
|
||||
onProfileSwitch?: () => void;
|
||||
/** Whether hidden (dot) files/folders are currently shown. */
|
||||
showHidden?: boolean;
|
||||
/** Toggle hidden files visibility. */
|
||||
onToggleHidden?: () => void;
|
||||
/** Called when the user clicks the collapse/hide sidebar button. */
|
||||
onCollapse?: () => void;
|
||||
};
|
||||
|
||||
function HomeIcon() {
|
||||
@ -59,18 +74,14 @@ function HomeIcon() {
|
||||
|
||||
function FolderOpenIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
<img
|
||||
src="/icons/folder-open.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
draggable={false}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -164,25 +175,26 @@ function SearchIcon() {
|
||||
|
||||
function SmallFolderIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
<img
|
||||
src="/icons/folder.png"
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
draggable={false}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallFileIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
<img src="/icons/document.png" alt="" width={14} height={14} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
|
||||
);
|
||||
}
|
||||
|
||||
function SmallDocIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
||||
</svg>
|
||||
<img src="/icons/document.png" alt="" width={14} height={14} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -311,9 +323,10 @@ function FileSearch({ onSelect }: { onSelect: (item: SuggestItem) => void }) {
|
||||
/>
|
||||
{loading && (
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
<div
|
||||
className="w-3 h-3 border border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-text-muted)" }}
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
@ -393,21 +406,30 @@ export function WorkspaceSidebar({
|
||||
onExternalDrop,
|
||||
mobile,
|
||||
onClose,
|
||||
activeProfile,
|
||||
onProfileSwitch,
|
||||
showHidden,
|
||||
onToggleHidden,
|
||||
width: widthProp,
|
||||
onCollapse,
|
||||
}: WorkspaceSidebarProps) {
|
||||
const isBrowsing = browseDir != null;
|
||||
const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
|
||||
const width = mobile ? "280px" : (widthProp ?? 260);
|
||||
|
||||
const sidebar = (
|
||||
<aside
|
||||
className={`flex flex-col h-screen flex-shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
|
||||
className={`flex flex-col h-screen shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
|
||||
style={{
|
||||
width: mobile ? "280px" : "260px",
|
||||
background: "var(--color-surface)",
|
||||
width: typeof width === "number" ? `${width}px` : width,
|
||||
minWidth: typeof width === "number" ? `${width}px` : width,
|
||||
background: "var(--color-sidebar-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-3 border-b"
|
||||
className="flex items-center gap-2 px-3 py-2.5 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{isBrowsing ? (
|
||||
@ -457,40 +479,85 @@ export function WorkspaceSidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onGoToChat?.()}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 cursor-pointer transition-opacity"
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
background: "transparent",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
title="All Chats"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.opacity = "0.7"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{orgName || "Workspace"}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
Ironclaw
|
||||
</div>
|
||||
</div>
|
||||
<ProfileSwitcher
|
||||
onProfileSwitch={onProfileSwitch}
|
||||
onCreateWorkspace={() => setShowCreateWorkspace(true)}
|
||||
activeProfileHint={activeProfile}
|
||||
trigger={({ isOpen, onClick, activeProfile: profileName, switching }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={switching}
|
||||
className="flex-1 min-w-0 w-full flex items-center justify-between gap-1.5 text-left rounded-lg py-1 px-1.5 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800 disabled:opacity-50"
|
||||
title="Switch workspace profile"
|
||||
>
|
||||
<div className="min-w-0 truncate">
|
||||
<div
|
||||
className="text-[13px] font-semibold truncate text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
{orgName || "Workspace"}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] flex items-center gap-1 truncate text-stone-400 dark:text-stone-500"
|
||||
>
|
||||
<span>Ironclaw</span>
|
||||
{profileName && profileName !== "default" && (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded text-[10px] shrink-0 bg-stone-200 text-stone-500 dark:bg-stone-700 dark:text-stone-400"
|
||||
>
|
||||
{profileName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-3 h-3 shrink-0 transition-transform text-stone-400 ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{onCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
className="p-1 rounded-md shrink-0 transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Hide sidebar (⌘B)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create workspace dialog */}
|
||||
<CreateWorkspaceDialog
|
||||
isOpen={showCreateWorkspace}
|
||||
onClose={() => setShowCreateWorkspace(false)}
|
||||
onCreated={onProfileSwitch}
|
||||
/>
|
||||
|
||||
{/* File search */}
|
||||
{onFileSearchSelect && (
|
||||
<FileSearch onSelect={onFileSearchSelect} />
|
||||
@ -500,13 +567,10 @@ export function WorkspaceSidebar({
|
||||
<div className="flex-1 overflow-y-auto px-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor:
|
||||
"var(--color-accent)",
|
||||
}}
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-2xl"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -538,7 +602,43 @@ export function WorkspaceSidebar({
|
||||
>
|
||||
ironclaw.sh
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
<div className="flex items-center gap-0.5">
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleHidden}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: showHidden ? "var(--color-accent)" : "var(--color-text-muted)" }}
|
||||
title={showHidden ? "Hide dotfiles" : "Show dotfiles"}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{showHidden ? (
|
||||
<>
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
||||
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
||||
<path d="m2 2 20 20" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
/* Background / Surface */
|
||||
--color-bg: #f5f5f4;
|
||||
--color-surface: #ffffff;
|
||||
--color-sidebar-bg: #ffffff;
|
||||
--color-main-bg: rgba(250, 250, 249, 0.5);
|
||||
--color-surface-hover: #f5f4f1;
|
||||
--color-surface-raised: #ffffff;
|
||||
|
||||
@ -29,6 +31,10 @@
|
||||
--color-user-bubble: #eae8e4;
|
||||
--color-user-bubble-text: #1c1c1a;
|
||||
--color-chat-input-bg: rgba(255, 255, 255, 0.8);
|
||||
/* Chat sidebar (right) — stone-style selected/active, light theme */
|
||||
--color-chat-sidebar-active-bg: #f5f5f4;
|
||||
--color-chat-sidebar-active-text: #44403c;
|
||||
--color-chat-sidebar-muted: #57534e;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #16a34a;
|
||||
@ -39,7 +45,7 @@
|
||||
/* Glassmorphism */
|
||||
--color-glass: rgba(255, 255, 255, 0.72);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.85);
|
||||
--color-bg-glass: rgba(245, 245, 244, 0.8);
|
||||
--color-bg-glass: rgba(250, 250, 249, 0.8);
|
||||
|
||||
/* Object type chips */
|
||||
--color-chip-object: rgba(37, 99, 235, 0.08);
|
||||
@ -70,6 +76,8 @@
|
||||
/* Background / Surface */
|
||||
--color-bg: #0c0c0b;
|
||||
--color-surface: #161615;
|
||||
--color-sidebar-bg: #141413;
|
||||
--color-main-bg: #161615;
|
||||
--color-surface-hover: #1e1e1c;
|
||||
--color-surface-raised: #1a1a18;
|
||||
|
||||
@ -91,6 +99,10 @@
|
||||
--color-user-bubble: #1e1e1c;
|
||||
--color-user-bubble-text: #ececea;
|
||||
--color-chat-input-bg: #1e1e1c;
|
||||
/* Chat sidebar (right) — stone-style selected/active, dark theme */
|
||||
--color-chat-sidebar-active-bg: #1e1e1c;
|
||||
--color-chat-sidebar-active-text: #ececea;
|
||||
--color-chat-sidebar-muted: #78776f;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #22c55e;
|
||||
@ -101,7 +113,7 @@
|
||||
/* Glassmorphism */
|
||||
--color-glass: rgba(22, 22, 21, 0.72);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.06);
|
||||
--color-bg-glass: rgba(12, 12, 11, 0.8);
|
||||
--color-bg-glass: rgba(22, 22, 21, 0.8);
|
||||
|
||||
/* Object type chips */
|
||||
--color-chip-object: rgba(59, 130, 246, 0.12);
|
||||
@ -128,6 +140,10 @@
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Disable iframe pointer events during sidebar resize so the
|
||||
drag isn't swallowed by embedded content (e.g. PDF viewer). */
|
||||
body.resizing iframe { pointer-events: none; }
|
||||
|
||||
/* ============================================================
|
||||
Fonts — Bookerly (local)
|
||||
============================================================ */
|
||||
@ -192,6 +208,11 @@ body {
|
||||
font-family: "Bookerly", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* Message bubbles and assistant text: use Bookerly for a polished reading experience. */
|
||||
.chat-message-font {
|
||||
font-family: "Bookerly", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* Smooth theme transitions */
|
||||
*,
|
||||
*::before,
|
||||
@ -217,6 +238,21 @@ a,
|
||||
Scrollbar
|
||||
============================================================ */
|
||||
|
||||
/* Base UI menu — remove default focus outlines */
|
||||
[data-slot="dropdown-menu-content"],
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-content"],
|
||||
[data-slot="dropdown-menu-checkbox-item"],
|
||||
[data-slot="dropdown-menu-radio-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"] {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-content"] {
|
||||
border: 1px solid white !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@ -865,6 +901,7 @@ a,
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-family: "Bookerly", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.chat-prose > *:first-child {
|
||||
@ -889,15 +926,15 @@ a,
|
||||
}
|
||||
|
||||
.chat-prose h1 {
|
||||
font-family: "Instrument Serif", serif;
|
||||
font-family: inherit;
|
||||
font-size: 1.6em;
|
||||
font-weight: 400;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-prose h2 {
|
||||
font-family: "Instrument Serif", serif;
|
||||
font-family: inherit;
|
||||
font-size: 1.35em;
|
||||
font-weight: 400;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-prose h3 {
|
||||
|
||||
@ -9,6 +9,8 @@ export type TreeNode = {
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
/** True when the entry is a symbolic link. */
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -28,6 +30,10 @@ export function useWorkspaceWatcher() {
|
||||
const [parentDir, setParentDir] = useState<string | null>(null);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null);
|
||||
const [openclawDir, setOpenclawDir] = useState<string | null>(null);
|
||||
const [activeProfile, setActiveProfile] = useState<string | null>(null);
|
||||
|
||||
// Show hidden (dot) files/folders
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const retryDelayRef = useRef(1000);
|
||||
@ -35,30 +41,37 @@ export function useWorkspaceWatcher() {
|
||||
// Each fetch increments the counter; only the latest version's response is applied.
|
||||
const fetchVersionRef = useRef(0);
|
||||
|
||||
// Bumping this key forces the SSE connection to tear down and reconnect
|
||||
// (used after profile switches so the watcher targets the new workspace).
|
||||
const [sseReconnectKey, setSseReconnectKey] = useState(0);
|
||||
|
||||
// Fetch the workspace tree from the tree API
|
||||
const fetchWorkspaceTree = useCallback(async () => {
|
||||
const version = ++fetchVersionRef.current;
|
||||
try {
|
||||
const res = await fetch("/api/workspace/tree");
|
||||
const qs = showHidden ? "?showHidden=1" : "";
|
||||
const res = await fetch(`/api/workspace/tree${qs}`);
|
||||
const data = await res.json();
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {
|
||||
setTree(data.tree ?? []);
|
||||
setExists(data.exists ?? false);
|
||||
setWorkspaceRoot(data.workspaceRoot ?? null);
|
||||
setOpenclawDir(data.openclawDir ?? null);
|
||||
setActiveProfile(data.profile ?? null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);}
|
||||
}
|
||||
}, []);
|
||||
}, [showHidden]);
|
||||
|
||||
// Fetch a directory listing from the browse API
|
||||
const fetchBrowseTree = useCallback(async (dir: string) => {
|
||||
const version = ++fetchVersionRef.current;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`);
|
||||
const hiddenQs = showHidden ? "&showHidden=1" : "";
|
||||
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}${hiddenQs}`);
|
||||
const data = await res.json();
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {
|
||||
setTree(data.entries ?? []);
|
||||
@ -69,7 +82,7 @@ export function useWorkspaceWatcher() {
|
||||
} catch {
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);}
|
||||
}
|
||||
}, []);
|
||||
}, [showHidden]);
|
||||
|
||||
// Smart setBrowseDir: auto-return to workspace mode when navigating to the
|
||||
// workspace root, so all virtual folders (Chats, Cron, etc.) and DuckDB
|
||||
@ -107,6 +120,12 @@ export function useWorkspaceWatcher() {
|
||||
void fetchTree();
|
||||
}, [fetchTree]);
|
||||
|
||||
// Force SSE reconnection + tree refresh (e.g. after profile switch).
|
||||
const reconnect = useCallback(() => {
|
||||
setSseReconnectKey((k) => k + 1);
|
||||
void fetchTree();
|
||||
}, [fetchTree]);
|
||||
|
||||
// Re-fetch when browseDir changes
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@ -197,7 +216,7 @@ export function useWorkspaceWatcher() {
|
||||
if (reconnectTimeout) {clearTimeout(reconnectTimeout);}
|
||||
if (debounceTimer) {clearTimeout(debounceTimer);}
|
||||
};
|
||||
}, [browseDirRaw, fetchWorkspaceTree]);
|
||||
}, [browseDirRaw, fetchWorkspaceTree, sseReconnectKey]);
|
||||
|
||||
return { tree, loading, exists, refresh, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir };
|
||||
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile, showHidden, setShowHidden };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -17,10 +17,11 @@ import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveWebChatDir } from "./workspace";
|
||||
import {
|
||||
type AgentEvent,
|
||||
spawnAgentProcess,
|
||||
spawnAgentSubscribeProcess,
|
||||
resolvePackageRoot,
|
||||
extractToolResult,
|
||||
buildToolOutput,
|
||||
@ -28,6 +29,9 @@ import {
|
||||
parseErrorBody,
|
||||
parseErrorFromStderr,
|
||||
} from "./agent-runner";
|
||||
import {
|
||||
hasRunningSubagentsForParent,
|
||||
} from "./subagent-runs";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@ -62,7 +66,7 @@ export type ActiveRun = {
|
||||
eventBuffer: SseEvent[];
|
||||
subscribers: Set<RunSubscriber>;
|
||||
accumulated: AccumulatedMessage;
|
||||
status: "running" | "completed" | "error";
|
||||
status: "running" | "waiting-for-subagents" | "completed" | "error";
|
||||
startedAt: number;
|
||||
exitCode: number | null;
|
||||
abortController: AbortController;
|
||||
@ -70,14 +74,36 @@ export type ActiveRun = {
|
||||
_persistTimer: ReturnType<typeof setTimeout> | null;
|
||||
/** @internal last time persistence was flushed */
|
||||
_lastPersistedAt: number;
|
||||
/** @internal last globalSeq seen from the gateway event stream */
|
||||
lastGlobalSeq: number;
|
||||
/** @internal subscribe child process for waiting-for-subagents continuation */
|
||||
_subscribeProcess?: ChildProcess | null;
|
||||
};
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const PERSIST_INTERVAL_MS = 2_000;
|
||||
const CLEANUP_GRACE_MS = 30_000;
|
||||
const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat");
|
||||
const INDEX_FILE = join(WEB_CHAT_DIR, "index.json");
|
||||
|
||||
const SILENT_REPLY_TOKEN = "NO_REPLY";
|
||||
|
||||
/**
|
||||
* Detect leaked silent-reply fragments in finalized text parts.
|
||||
* The agent runner suppresses full "NO_REPLY" tokens, but during streaming
|
||||
* the model may emit a partial prefix (e.g. "NO") before the full token is
|
||||
* assembled and caught. This catches both the full token and known partial
|
||||
* prefixes so they don't leak into persisted/displayed messages.
|
||||
*/
|
||||
function isLeakedSilentReplyToken(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t) {return false;}
|
||||
if (new RegExp(`^${SILENT_REPLY_TOKEN}\\W*$`).test(t)) {return true;}
|
||||
if (SILENT_REPLY_TOKEN.startsWith(t) && t.length >= 2 && t.length < SILENT_REPLY_TOKEN.length) {return true;}
|
||||
return false;
|
||||
}
|
||||
// Evaluated per-call so it tracks profile switches at runtime.
|
||||
function webChatDir(): string { return resolveWebChatDir(); }
|
||||
function indexFile(): string { return join(webChatDir(), "index.json"); }
|
||||
|
||||
// ── Singleton registry ──
|
||||
// Store on globalThis so the Map survives Next.js HMR reloads in dev mode.
|
||||
@ -102,14 +128,14 @@ export function getActiveRun(sessionId: string): ActiveRun | undefined {
|
||||
/** Check whether a *running* (not just completed) run exists for a session. */
|
||||
export function hasActiveRun(sessionId: string): boolean {
|
||||
const run = activeRuns.get(sessionId);
|
||||
return run !== undefined && run.status === "running";
|
||||
return run !== undefined && (run.status === "running" || run.status === "waiting-for-subagents");
|
||||
}
|
||||
|
||||
/** Return the session IDs of all currently running agent runs. */
|
||||
export function getRunningSessionIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [sessionId, run] of activeRuns) {
|
||||
if (run.status === "running") {
|
||||
if (run.status === "running" || run.status === "waiting-for-subagents") {
|
||||
ids.push(sessionId);
|
||||
}
|
||||
}
|
||||
@ -143,7 +169,7 @@ export function subscribeToRun(
|
||||
}
|
||||
|
||||
// If the run already finished, signal completion immediately.
|
||||
if (run.status !== "running") {
|
||||
if (run.status !== "running" && run.status !== "waiting-for-subagents") {
|
||||
callback(null);
|
||||
return () => {};
|
||||
}
|
||||
@ -157,14 +183,20 @@ export function subscribeToRun(
|
||||
/** Abort a running agent. Returns true if a run was actually aborted. */
|
||||
export function abortRun(sessionId: string): boolean {
|
||||
const run = activeRuns.get(sessionId);
|
||||
if (!run || run.status !== "running") {return false;}
|
||||
if (!run || (run.status !== "running" && run.status !== "waiting-for-subagents")) {return false;}
|
||||
|
||||
// Immediately mark the run as non-running so hasActiveRun() returns
|
||||
// false and the next user message isn't rejected with 409.
|
||||
const wasWaiting = run.status === "waiting-for-subagents";
|
||||
run.status = "error";
|
||||
|
||||
// Clean up waiting subscribe process if present.
|
||||
stopSubscribeProcess(run);
|
||||
|
||||
run.abortController.abort();
|
||||
run.childProcess.kill("SIGTERM");
|
||||
if (!wasWaiting) {
|
||||
run.childProcess.kill("SIGTERM");
|
||||
}
|
||||
|
||||
// Send chat.abort directly to the gateway so the agent run stops
|
||||
// even if the CLI child's best-effort onAbort doesn't complete in time.
|
||||
@ -189,12 +221,14 @@ export function abortRun(sessionId: string): boolean {
|
||||
// Fallback: if the child doesn't exit within 5 seconds after
|
||||
// SIGTERM (e.g. the CLI's best-effort chat.abort RPC hangs),
|
||||
// send SIGKILL to force-terminate.
|
||||
const killTimer = setTimeout(() => {
|
||||
try {
|
||||
run.childProcess.kill("SIGKILL");
|
||||
} catch { /* already dead */ }
|
||||
}, 5_000);
|
||||
run.childProcess.once("close", () => clearTimeout(killTimer));
|
||||
if (!wasWaiting) {
|
||||
const killTimer = setTimeout(() => {
|
||||
try {
|
||||
run.childProcess.kill("SIGKILL");
|
||||
} catch { /* already dead */ }
|
||||
}, 5_000);
|
||||
run.childProcess.once("close", () => clearTimeout(killTimer));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -277,6 +311,7 @@ export function startRun(params: {
|
||||
abortController,
|
||||
_persistTimer: null,
|
||||
_lastPersistedAt: 0,
|
||||
lastGlobalSeq: 0,
|
||||
};
|
||||
|
||||
activeRuns.set(sessionId, run);
|
||||
@ -306,7 +341,7 @@ export function persistUserMessage(
|
||||
msg: { id: string; content: string; parts?: unknown[] },
|
||||
): void {
|
||||
ensureDir();
|
||||
const filePath = join(WEB_CHAT_DIR, `${sessionId}.jsonl`);
|
||||
const filePath = join(webChatDir(), `${sessionId}.jsonl`);
|
||||
if (!existsSync(filePath)) {writeFileSync(filePath, "");}
|
||||
|
||||
const line = JSON.stringify({
|
||||
@ -337,8 +372,9 @@ export function persistUserMessage(
|
||||
// ── Internals ──
|
||||
|
||||
function ensureDir() {
|
||||
if (!existsSync(WEB_CHAT_DIR)) {
|
||||
mkdirSync(WEB_CHAT_DIR, { recursive: true });
|
||||
const dir = webChatDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@ -347,19 +383,43 @@ function updateIndex(
|
||||
opts: { incrementCount?: number; title?: string },
|
||||
) {
|
||||
try {
|
||||
if (!existsSync(INDEX_FILE)) {return;}
|
||||
const index = JSON.parse(
|
||||
readFileSync(INDEX_FILE, "utf-8"),
|
||||
const idxPath = indexFile();
|
||||
let index: Array<Record<string, unknown>>;
|
||||
if (!existsSync(idxPath)) {
|
||||
// Auto-create index with a bootstrap entry for this session so
|
||||
// orphaned .jsonl files become visible in the sidebar.
|
||||
index = [{
|
||||
id: sessionId,
|
||||
title: opts.title || "New Chat",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: opts.incrementCount || 0,
|
||||
}];
|
||||
writeFileSync(idxPath, JSON.stringify(index, null, 2));
|
||||
return;
|
||||
}
|
||||
index = JSON.parse(
|
||||
readFileSync(idxPath, "utf-8"),
|
||||
) as Array<Record<string, unknown>>;
|
||||
const session = index.find((s) => s.id === sessionId);
|
||||
if (!session) {return;}
|
||||
let session = index.find((s) => s.id === sessionId);
|
||||
if (!session) {
|
||||
// Session file exists but wasn't indexed — add it.
|
||||
session = {
|
||||
id: sessionId,
|
||||
title: opts.title || "New Chat",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: 0,
|
||||
};
|
||||
index.unshift(session);
|
||||
}
|
||||
session.updatedAt = Date.now();
|
||||
if (opts.incrementCount) {
|
||||
session.messageCount =
|
||||
((session.messageCount as number) || 0) + opts.incrementCount;
|
||||
}
|
||||
if (opts.title) {session.title = opts.title;}
|
||||
writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2));
|
||||
writeFileSync(idxPath, JSON.stringify(index, null, 2));
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
@ -430,6 +490,15 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
|
||||
const closeText = () => {
|
||||
if (textStarted) {
|
||||
if (accTextIdx >= 0) {
|
||||
const part = run.accumulated.parts[accTextIdx] as { type: "text"; text: string };
|
||||
if (isLeakedSilentReplyToken(part.text)) {
|
||||
run.accumulated.parts.splice(accTextIdx, 1);
|
||||
for (const [k, v] of accToolMap) {
|
||||
if (v > accTextIdx) { accToolMap.set(k, v - 1); }
|
||||
}
|
||||
}
|
||||
}
|
||||
emit({ type: "text-end", id: currentTextId });
|
||||
textStarted = false;
|
||||
}
|
||||
@ -466,7 +535,7 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
// ── Parse stdout JSON lines ──
|
||||
|
||||
const rl = createInterface({ input: child.stdout! });
|
||||
|
||||
const parentSessionKey = `agent:main:web:${run.sessionId}`;
|
||||
// Prevent unhandled 'error' events on the readline interface.
|
||||
// When the child process fails to start (e.g. ENOENT — missing script)
|
||||
// the stdout pipe is destroyed and readline re-emits the error. Without
|
||||
@ -477,16 +546,12 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
// emitting user-visible diagnostics.
|
||||
});
|
||||
|
||||
rl.on("line", (line: string) => {
|
||||
if (!line.trim()) {return;}
|
||||
|
||||
let ev: AgentEvent;
|
||||
try {
|
||||
ev = JSON.parse(line) as AgentEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// ── Reusable parent event processor ──
|
||||
// Handles lifecycle, thinking, assistant text, tool, compaction, and error
|
||||
// events for the parent agent. Used by both the CLI NDJSON stream and the
|
||||
// subscribe-only CLI fallback (waiting-for-subagents state).
|
||||
|
||||
const processParentEvent = (ev: AgentEvent) => {
|
||||
// Lifecycle start
|
||||
if (
|
||||
ev.event === "agent" &&
|
||||
@ -602,7 +667,6 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
toolName,
|
||||
input: args,
|
||||
});
|
||||
// Accumulate tool start in ordered parts
|
||||
run.accumulated.parts.push({
|
||||
type: "tool-invocation",
|
||||
toolCallId,
|
||||
@ -623,7 +687,6 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
toolCallId,
|
||||
errorText,
|
||||
});
|
||||
// Update the accumulated tool part
|
||||
const idx = accToolMap.get(toolCallId);
|
||||
if (idx !== undefined) {
|
||||
const part = run.accumulated.parts[idx];
|
||||
@ -638,7 +701,6 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
toolCallId,
|
||||
output,
|
||||
});
|
||||
// Update the accumulated tool part
|
||||
const idx = accToolMap.get(toolCallId);
|
||||
if (idx !== undefined) {
|
||||
const part = run.accumulated.parts[idx];
|
||||
@ -710,6 +772,52 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
emitError(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processParentSubscribeEvent = (ev: AgentEvent) => {
|
||||
const gSeq = typeof (ev as Record<string, unknown>).globalSeq === "number"
|
||||
? (ev as Record<string, unknown>).globalSeq as number
|
||||
: undefined;
|
||||
if (gSeq !== undefined) {
|
||||
if (gSeq <= run.lastGlobalSeq) {return;}
|
||||
run.lastGlobalSeq = gSeq;
|
||||
}
|
||||
processParentEvent(ev);
|
||||
if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
|
||||
if (hasRunningSubagentsForParent(run.sessionId)) {
|
||||
openStatusReasoning("Waiting for subagent results...");
|
||||
flushPersistence(run);
|
||||
} else {
|
||||
finalizeWaitingRun(run);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rl.on("line", (line: string) => {
|
||||
if (!line.trim()) {return;}
|
||||
|
||||
let ev: AgentEvent;
|
||||
try {
|
||||
ev = JSON.parse(line) as AgentEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip events from other sessions (e.g. subagent broadcasts that
|
||||
// the gateway delivers on the same WS connection).
|
||||
if (ev.sessionKey && ev.sessionKey !== parentSessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track the global event cursor from the gateway for replay on handoff.
|
||||
const gSeq = typeof (ev as Record<string, unknown>).globalSeq === "number"
|
||||
? (ev as Record<string, unknown>).globalSeq as number
|
||||
: undefined;
|
||||
if (gSeq !== undefined && gSeq > run.lastGlobalSeq) {
|
||||
run.lastGlobalSeq = gSeq;
|
||||
}
|
||||
|
||||
processParentEvent(ev);
|
||||
});
|
||||
|
||||
// ── Child process exit ──
|
||||
@ -731,23 +839,46 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
}
|
||||
|
||||
closeReasoning();
|
||||
if (!everSentText) {
|
||||
|
||||
const exitedClean = code === 0 || code === null;
|
||||
|
||||
if (!everSentText && !exitedClean) {
|
||||
const tid = nextId("text");
|
||||
emit({ type: "text-start", id: tid });
|
||||
const errMsg =
|
||||
code !== null && code !== 0
|
||||
? `[error] Agent exited with code ${code}. Check server logs for details.`
|
||||
: "[error] No response from agent.";
|
||||
const errMsg = `[error] Agent exited with code ${code}. Check server logs for details.`;
|
||||
emit({ type: "text-delta", id: tid, delta: errMsg });
|
||||
emit({ type: "text-end", id: tid });
|
||||
accAppendText(errMsg);
|
||||
} else if (!everSentText && exitedClean) {
|
||||
const tid = nextId("text");
|
||||
emit({ type: "text-start", id: tid });
|
||||
const msg = "No response from agent.";
|
||||
emit({ type: "text-delta", id: tid, delta: msg });
|
||||
emit({ type: "text-end", id: tid });
|
||||
accAppendText(msg);
|
||||
} else {
|
||||
closeText();
|
||||
}
|
||||
|
||||
run.status = code === 0 || code === null ? "completed" : "error";
|
||||
run.exitCode = code;
|
||||
|
||||
const hasRunningSubagents = hasRunningSubagentsForParent(run.sessionId);
|
||||
|
||||
// If the CLI exited cleanly and subagents are still running,
|
||||
// keep the SSE stream open and wait for announcement-triggered
|
||||
// parent turns via subscribe-only CLI NDJSON.
|
||||
if (exitedClean && hasRunningSubagents) {
|
||||
run.status = "waiting-for-subagents";
|
||||
|
||||
openStatusReasoning("Waiting for subagent results...");
|
||||
flushPersistence(run);
|
||||
startParentSubscribeStream(run, parentSessionKey, processParentSubscribeEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal completion path.
|
||||
run.status = exitedClean ? "completed" : "error";
|
||||
|
||||
// Final persistence flush (removes _streaming flag).
|
||||
flushPersistence(run);
|
||||
|
||||
@ -801,6 +932,90 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
});
|
||||
}
|
||||
|
||||
function startParentSubscribeStream(
|
||||
run: ActiveRun,
|
||||
parentSessionKey: string,
|
||||
onEvent: (ev: AgentEvent) => void,
|
||||
): void {
|
||||
stopSubscribeProcess(run);
|
||||
const child = spawnAgentSubscribeProcess(parentSessionKey, run.lastGlobalSeq);
|
||||
run._subscribeProcess = child;
|
||||
const rl = createInterface({ input: child.stdout! });
|
||||
|
||||
rl.on("line", (line: string) => {
|
||||
if (!line.trim()) {return;}
|
||||
let ev: AgentEvent;
|
||||
try {
|
||||
ev = JSON.parse(line) as AgentEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (ev.sessionKey && ev.sessionKey !== parentSessionKey) {
|
||||
return;
|
||||
}
|
||||
onEvent(ev);
|
||||
});
|
||||
|
||||
child.on("close", () => {
|
||||
if (run._subscribeProcess === child) {
|
||||
run._subscribeProcess = null;
|
||||
}
|
||||
if (run.status !== "waiting-for-subagents") {return;}
|
||||
// If still waiting, restart subscribe stream from the latest cursor.
|
||||
setTimeout(() => {
|
||||
if (run.status === "waiting-for-subagents" && !run._subscribeProcess) {
|
||||
startParentSubscribeStream(run, parentSessionKey, onEvent);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error("[active-runs] Parent subscribe child error:", err);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
console.error("[active-runs subscribe stderr]", chunk.toString());
|
||||
});
|
||||
}
|
||||
|
||||
function stopSubscribeProcess(run: ActiveRun): void {
|
||||
if (!run._subscribeProcess) {return;}
|
||||
try {
|
||||
run._subscribeProcess.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
run._subscribeProcess = null;
|
||||
}
|
||||
|
||||
// ── Finalize a waiting-for-subagents run ──
|
||||
|
||||
/**
|
||||
* Transition a run from "waiting-for-subagents" to "completed".
|
||||
* Called when the last subagent finishes and the parent's announcement-
|
||||
* triggered turn completes.
|
||||
*/
|
||||
function finalizeWaitingRun(run: ActiveRun): void {
|
||||
if (run.status !== "waiting-for-subagents") {return;}
|
||||
|
||||
run.status = "completed";
|
||||
|
||||
stopSubscribeProcess(run);
|
||||
|
||||
flushPersistence(run);
|
||||
|
||||
for (const sub of run.subscribers) {
|
||||
try { sub(null); } catch { /* ignore */ }
|
||||
}
|
||||
run.subscribers.clear();
|
||||
|
||||
setTimeout(() => {
|
||||
if (activeRuns.get(run.sessionId) === run) {
|
||||
cleanupRun(run.sessionId);
|
||||
}
|
||||
}, CLEANUP_GRACE_MS);
|
||||
}
|
||||
|
||||
// ── Debounced persistence ──
|
||||
|
||||
function schedulePersist(run: ActiveRun) {
|
||||
@ -825,22 +1040,27 @@ function flushPersistence(run: ActiveRun) {
|
||||
return; // Nothing to persist yet.
|
||||
}
|
||||
|
||||
// Filter out leaked silent-reply text fragments before persisting.
|
||||
const cleanParts = parts.filter((p) =>
|
||||
p.type !== "text" || !isLeakedSilentReplyToken((p as { text: string }).text),
|
||||
);
|
||||
|
||||
// Build content text from text parts for the backwards-compatible
|
||||
// content field (used when parts are not available).
|
||||
const text = parts
|
||||
const text = cleanParts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
|
||||
const isStillRunning = run.status === "running";
|
||||
const isStillStreaming = run.status === "running" || run.status === "waiting-for-subagents";
|
||||
const message: Record<string, unknown> = {
|
||||
id: run.accumulated.id,
|
||||
role: "assistant",
|
||||
content: text,
|
||||
parts, // Ordered parts — preserves interleaving of reasoning, tools, text
|
||||
parts: cleanParts,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (isStillRunning) {
|
||||
if (isStillStreaming) {
|
||||
message._streaming = true;
|
||||
}
|
||||
|
||||
@ -860,7 +1080,7 @@ function upsertMessage(
|
||||
message: Record<string, unknown>,
|
||||
) {
|
||||
ensureDir();
|
||||
const fp = join(WEB_CHAT_DIR, `${sessionId}.jsonl`);
|
||||
const fp = join(webChatDir(), `${sessionId}.jsonl`);
|
||||
if (!existsSync(fp)) {writeFileSync(fp, "");}
|
||||
|
||||
const msgId = message.id as string;
|
||||
@ -895,5 +1115,6 @@ function cleanupRun(sessionId: string) {
|
||||
const run = activeRuns.get(sessionId);
|
||||
if (!run) {return;}
|
||||
if (run._persistTimer) {clearTimeout(run._persistTimer);}
|
||||
stopSubscribeProcess(run);
|
||||
activeRuns.delete(sessionId);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { getEffectiveProfile, resolveWorkspaceRoot } from "./workspace";
|
||||
|
||||
export type AgentEvent = {
|
||||
event: string;
|
||||
@ -9,6 +10,7 @@ export type AgentEvent = {
|
||||
stream?: string;
|
||||
data?: Record<string, unknown>;
|
||||
seq?: number;
|
||||
globalSeq?: number;
|
||||
ts?: number;
|
||||
sessionKey?: string;
|
||||
status?: string;
|
||||
@ -184,9 +186,52 @@ export function spawnAgentProcess(
|
||||
args.push("--session-key", sessionKey, "--lane", "web", "--channel", "webchat");
|
||||
}
|
||||
|
||||
const profile = getEffectiveProfile();
|
||||
const workspace = resolveWorkspaceRoot();
|
||||
return spawn("node", args, {
|
||||
cwd: root,
|
||||
env: { ...process.env },
|
||||
env: {
|
||||
...process.env,
|
||||
...(profile ? { OPENCLAW_PROFILE: profile } : {}),
|
||||
...(workspace ? { OPENCLAW_WORKSPACE: workspace } : {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a subscribe-only agent child process that tails a session key's events.
|
||||
* Uses the same runtime/env wiring as spawnAgentProcess.
|
||||
*/
|
||||
export function spawnAgentSubscribeProcess(
|
||||
sessionKey: string,
|
||||
afterSeq = 0,
|
||||
): ReturnType<typeof spawn> {
|
||||
const root = resolvePackageRoot();
|
||||
|
||||
const devScript = join(root, "scripts", "run-node.mjs");
|
||||
const prodScript = join(root, "openclaw.mjs");
|
||||
const scriptPath = existsSync(devScript) ? devScript : prodScript;
|
||||
|
||||
const args = [
|
||||
scriptPath,
|
||||
"agent",
|
||||
"--stream-json",
|
||||
"--subscribe-session-key",
|
||||
sessionKey,
|
||||
"--after-seq",
|
||||
String(Math.max(0, Number.isFinite(afterSeq) ? afterSeq : 0)),
|
||||
];
|
||||
|
||||
const profile = getEffectiveProfile();
|
||||
const workspace = resolveWorkspaceRoot();
|
||||
return spawn("node", args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
...(profile ? { OPENCLAW_PROFILE: profile } : {}),
|
||||
...(workspace ? { OPENCLAW_WORKSPACE: workspace } : {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
848
apps/web/lib/subagent-runs.ts
Normal file
848
apps/web/lib/subagent-runs.ts
Normal file
@ -0,0 +1,848 @@
|
||||
/**
|
||||
* Server-side manager for subagent runs spawned by the web chat agent.
|
||||
*
|
||||
* Mirrors the ActiveRunManager pattern: buffers SSE events, supports
|
||||
* subscriber fan-out, and tracks subagent metadata per parent web session.
|
||||
*
|
||||
* Events are fed from CLI NDJSON streams (parent run + subscribe continuations).
|
||||
*/
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type AgentEvent,
|
||||
spawnAgentSubscribeProcess,
|
||||
resolvePackageRoot,
|
||||
extractToolResult,
|
||||
buildToolOutput,
|
||||
parseAgentErrorMessage,
|
||||
parseErrorBody,
|
||||
} from "./agent-runner";
|
||||
import { resolveOpenClawStateDir, resolveWebChatDir } from "./workspace";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SseEvent = Record<string, unknown> & { type: string };
|
||||
export type SubagentSubscriber = (event: SseEvent | null) => void;
|
||||
|
||||
export type SubagentInfo = {
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
parentWebSessionId: string;
|
||||
task: string;
|
||||
label?: string;
|
||||
status: "running" | "completed" | "error";
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
};
|
||||
|
||||
type SubagentRun = SubagentInfo & {
|
||||
eventBuffer: SseEvent[];
|
||||
subscribers: Set<SubagentSubscriber>;
|
||||
/** Internal state for event-to-SSE transformation */
|
||||
_state: TransformState;
|
||||
_subscribeProcess: ChildProcess | null;
|
||||
_cleanupTimer: ReturnType<typeof setTimeout> | null;
|
||||
/** Last globalSeq seen from the gateway event stream for replay cursor. */
|
||||
lastGlobalSeq: number;
|
||||
};
|
||||
|
||||
type TransformState = {
|
||||
idCounter: number;
|
||||
currentTextId: string;
|
||||
currentReasoningId: string;
|
||||
textStarted: boolean;
|
||||
reasoningStarted: boolean;
|
||||
everSentText: boolean;
|
||||
statusReasoningActive: boolean;
|
||||
};
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const CLEANUP_GRACE_MS = 24 * 60 * 60_000; // 24 hours — events are persisted to disk
|
||||
const GLOBAL_KEY = "__openclaw_subagentRuns" as const;
|
||||
|
||||
// ── Singleton registry ──
|
||||
|
||||
type SubagentRegistry = {
|
||||
runs: Map<string, SubagentRun>;
|
||||
/** Reverse index: parent web session ID → subagent session keys */
|
||||
parentIndex: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
function getRegistry(): SubagentRegistry {
|
||||
const existing = (globalThis as Record<string, unknown>)[GLOBAL_KEY] as
|
||||
| SubagentRegistry
|
||||
| undefined;
|
||||
if (existing) {return existing;}
|
||||
|
||||
const registry: SubagentRegistry = {
|
||||
runs: new Map(),
|
||||
parentIndex: new Map(),
|
||||
};
|
||||
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ── Event persistence ──
|
||||
|
||||
/** Profile-scoped directory for subagent event JSONL files. */
|
||||
function subagentEventsDir(): string {
|
||||
return join(resolveWebChatDir(), "subagent-events");
|
||||
}
|
||||
|
||||
/** Pre-profile-scoping legacy path — used as a read fallback for migration. */
|
||||
function legacySubagentEventsDir(): string {
|
||||
return join(resolveOpenClawStateDir(), "web-chat", "subagent-events");
|
||||
}
|
||||
|
||||
/** Filesystem-safe filename derived from a session key. */
|
||||
function safeFilename(sessionKey: string): string {
|
||||
return sessionKey.replaceAll(":", "_") + ".jsonl";
|
||||
}
|
||||
|
||||
function persistEvent(sessionKey: string, event: SseEvent): void {
|
||||
try {
|
||||
const dir = subagentEventsDir();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, safeFilename(sessionKey)), JSON.stringify(event) + "\n");
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
function loadPersistedEvents(sessionKey: string): SseEvent[] {
|
||||
const fname = safeFilename(sessionKey);
|
||||
|
||||
// Try profile-scoped dir first, fall back to legacy shared dir.
|
||||
let filePath = join(subagentEventsDir(), fname);
|
||||
if (!existsSync(filePath)) {
|
||||
const legacyPath = join(legacySubagentEventsDir(), fname);
|
||||
if (existsSync(legacyPath)) {
|
||||
filePath = legacyPath;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = readFileSync(filePath, "utf-8").split("\n");
|
||||
const events: SseEvent[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {continue;}
|
||||
try { events.push(JSON.parse(line) as SseEvent); } catch { /* skip */ }
|
||||
}
|
||||
return events;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── Profile-scoped subagent index ──
|
||||
|
||||
type SubagentIndexEntry = {
|
||||
runId: string;
|
||||
parentWebSessionId: string;
|
||||
task: string;
|
||||
label?: string;
|
||||
status: "running" | "completed" | "error";
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
};
|
||||
|
||||
function subagentIndexPath(): string {
|
||||
return join(resolveWebChatDir(), "subagent-index.json");
|
||||
}
|
||||
|
||||
function loadSubagentIndex(): Record<string, SubagentIndexEntry> {
|
||||
const p = subagentIndexPath();
|
||||
if (!existsSync(p)) {return {};}
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, "utf-8")) as Record<string, SubagentIndexEntry>;
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function upsertSubagentIndex(sessionKey: string, entry: SubagentIndexEntry): void {
|
||||
try {
|
||||
const dir = resolveWebChatDir();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const index = loadSubagentIndex();
|
||||
index[sessionKey] = entry;
|
||||
writeFileSync(subagentIndexPath(), JSON.stringify(index, null, 2));
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/** Read the on-disk registry entry and derive the proper status. */
|
||||
function readDiskStatus(sessionKey: string): "running" | "completed" | "error" {
|
||||
// Check profile-scoped index first.
|
||||
const profileIndex = loadSubagentIndex();
|
||||
const profileEntry = profileIndex[sessionKey];
|
||||
if (profileEntry) {
|
||||
return profileEntry.status;
|
||||
}
|
||||
|
||||
// Fall back to the shared gateway registry.
|
||||
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
|
||||
if (!existsSync(registryPath)) {return "running";}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as { runs?: Record<string, Record<string, unknown>> };
|
||||
const runs = raw?.runs;
|
||||
if (!runs) {return "running";}
|
||||
for (const entry of Object.values(runs)) {
|
||||
if (entry.childSessionKey === sessionKey) {
|
||||
if (typeof entry.endedAt !== "number") {return "running";}
|
||||
const outcome = entry.outcome as { status?: string } | undefined;
|
||||
if (outcome?.status === "error") {return "error";}
|
||||
return "completed";
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return "running";
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
/**
|
||||
* Register a newly spawned subagent. Called when the parent agent's
|
||||
* `sessions_spawn` tool result is detected in active-runs.ts.
|
||||
*
|
||||
* When `fromDisk` is true, the run is being rehydrated after a refresh,
|
||||
* so we load persisted events and set the correct status from the registry.
|
||||
*/
|
||||
export function registerSubagent(
|
||||
parentWebSessionId: string,
|
||||
info: { sessionKey: string; runId: string; task: string; label?: string },
|
||||
options?: { fromDisk?: boolean },
|
||||
): void {
|
||||
const reg = getRegistry();
|
||||
|
||||
// Avoid duplicate registration
|
||||
if (reg.runs.has(info.sessionKey)) {return;}
|
||||
|
||||
const fromDisk = options?.fromDisk ?? false;
|
||||
const diskStatus = fromDisk ? readDiskStatus(info.sessionKey) : "running";
|
||||
|
||||
const run: SubagentRun = {
|
||||
sessionKey: info.sessionKey,
|
||||
runId: info.runId,
|
||||
parentWebSessionId,
|
||||
task: info.task,
|
||||
label: info.label,
|
||||
status: diskStatus,
|
||||
startedAt: Date.now(),
|
||||
eventBuffer: [],
|
||||
subscribers: new Set(),
|
||||
_state: createTransformState(),
|
||||
_subscribeProcess: null,
|
||||
_cleanupTimer: null,
|
||||
lastGlobalSeq: 0,
|
||||
};
|
||||
|
||||
// Load persisted events from disk (fills the replay buffer)
|
||||
if (fromDisk) {
|
||||
run.eventBuffer = loadPersistedEvents(info.sessionKey);
|
||||
}
|
||||
|
||||
reg.runs.set(info.sessionKey, run);
|
||||
|
||||
// Update parent index
|
||||
let keys = reg.parentIndex.get(parentWebSessionId);
|
||||
if (!keys) {
|
||||
keys = new Set();
|
||||
reg.parentIndex.set(parentWebSessionId, keys);
|
||||
}
|
||||
keys.add(info.sessionKey);
|
||||
|
||||
// Persist to the profile-scoped subagent index.
|
||||
upsertSubagentIndex(info.sessionKey, {
|
||||
runId: info.runId,
|
||||
parentWebSessionId,
|
||||
task: info.task,
|
||||
label: info.label,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
});
|
||||
|
||||
// Subagents are first-class sessions; subscribe immediately.
|
||||
if (run.status === "running") {
|
||||
startSubagentSubscribeStream(run);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a rehydrated subagent can receive live events. Called when a client
|
||||
* actually connects to the subagent's SSE stream after a page refresh.
|
||||
* For still-running subagents, this activates the subscribe-child fallback.
|
||||
*/
|
||||
export function ensureSubagentStreamable(sessionKey: string): void {
|
||||
const run = getRegistry().runs.get(sessionKey);
|
||||
if (!run || run.status !== "running" || run._subscribeProcess) {return;}
|
||||
startSubagentSubscribeStream(run);
|
||||
}
|
||||
|
||||
/** Get metadata for all subagents belonging to a parent web session. */
|
||||
export function getSubagentsForSession(parentWebSessionId: string): SubagentInfo[] {
|
||||
const reg = getRegistry();
|
||||
const keys = reg.parentIndex.get(parentWebSessionId);
|
||||
if (!keys) {return [];}
|
||||
|
||||
const result: SubagentInfo[] = [];
|
||||
for (const key of keys) {
|
||||
const run = reg.runs.get(key);
|
||||
if (run) {
|
||||
result.push({
|
||||
sessionKey: run.sessionKey,
|
||||
runId: run.runId,
|
||||
parentWebSessionId: run.parentWebSessionId,
|
||||
task: run.task,
|
||||
label: run.label,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a subagent's SSE events. Replays buffered events first
|
||||
* (synchronously), then live events follow.
|
||||
*/
|
||||
export function subscribeToSubagent(
|
||||
sessionKey: string,
|
||||
callback: SubagentSubscriber,
|
||||
options?: { replay?: boolean },
|
||||
): (() => void) | null {
|
||||
const reg = getRegistry();
|
||||
const run = reg.runs.get(sessionKey);
|
||||
if (!run) {return null;}
|
||||
|
||||
const replay = options?.replay ?? true;
|
||||
if (replay) {
|
||||
for (const event of run.eventBuffer) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (run.status !== "running") {
|
||||
callback(null);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
run.subscribers.add(callback);
|
||||
return () => {
|
||||
run.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if a subagent run exists (running or completed with buffered data). */
|
||||
export function hasActiveSubagent(sessionKey: string): boolean {
|
||||
return getRegistry().runs.has(sessionKey);
|
||||
}
|
||||
|
||||
/** Check if a subagent is currently running (not yet completed). */
|
||||
export function isSubagentRunning(sessionKey: string): boolean {
|
||||
const run = getRegistry().runs.get(sessionKey);
|
||||
return run !== undefined && run.status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate subscribe-child streams for all subagent runs that are
|
||||
* still in "running" status and don't already have a gateway subscription.
|
||||
*
|
||||
* Called when the parent agent's NDJSON stream ends (child process exits).
|
||||
* After that point the NDJSON routing is no longer available, so the
|
||||
* subscribe child streams become the only event source for orphaned subagents.
|
||||
*/
|
||||
export function hasRunningSubagentsForParent(parentWebSessionId: string): boolean {
|
||||
const reg = getRegistry();
|
||||
const keys = reg.parentIndex.get(parentWebSessionId);
|
||||
if (!keys) {return false;}
|
||||
let anyRunning = false;
|
||||
for (const key of keys) {
|
||||
const run = reg.runs.get(key);
|
||||
if (run?.status !== "running") {continue;}
|
||||
const diskStatus = readDiskStatus(key);
|
||||
if (diskStatus !== "running") {
|
||||
finalizeRun(run, diskStatus === "error" ? "error" : "completed");
|
||||
continue;
|
||||
}
|
||||
anyRunning = true;
|
||||
}
|
||||
return anyRunning;
|
||||
}
|
||||
|
||||
/** Return session keys of all currently running subagents. */
|
||||
export function getRunningSubagentKeys(): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [key, run] of getRegistry().runs) {
|
||||
if (run.status === "running") {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function persistUserMessage(
|
||||
sessionKey: string,
|
||||
msg: { id?: string; text: string },
|
||||
): boolean {
|
||||
const run = getRegistry().runs.get(sessionKey);
|
||||
if (!run) {return false;}
|
||||
const event: SseEvent = {
|
||||
type: "user-message",
|
||||
id: msg.id ?? `user-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
text: msg.text,
|
||||
};
|
||||
run.eventBuffer.push(event);
|
||||
persistEvent(sessionKey, event);
|
||||
for (const sub of run.subscribers) {
|
||||
try { sub(event); } catch { /* ignore */ }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reactivateSubagent(sessionKey: string): boolean {
|
||||
const run = getRegistry().runs.get(sessionKey);
|
||||
if (!run) {return false;}
|
||||
if (run._cleanupTimer) {
|
||||
clearTimeout(run._cleanupTimer);
|
||||
run._cleanupTimer = null;
|
||||
}
|
||||
run.status = "running";
|
||||
run.endedAt = undefined;
|
||||
upsertSubagentIndex(run.sessionKey, {
|
||||
runId: run.runId,
|
||||
parentWebSessionId: run.parentWebSessionId,
|
||||
task: run.task,
|
||||
label: run.label,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
});
|
||||
startSubagentSubscribeStream(run);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendGatewayAbortForSubagent(sessionKey: string): void {
|
||||
try {
|
||||
const root = resolvePackageRoot();
|
||||
const devScript = join(root, "scripts", "run-node.mjs");
|
||||
const prodScript = join(root, "openclaw.mjs");
|
||||
const scriptPath = existsSync(devScript) ? devScript : prodScript;
|
||||
const child = spawn(
|
||||
"node",
|
||||
[
|
||||
scriptPath,
|
||||
"gateway",
|
||||
"call",
|
||||
"chat.abort",
|
||||
"--params",
|
||||
JSON.stringify({ sessionKey }),
|
||||
"--json",
|
||||
"--timeout",
|
||||
"4000",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...process.env },
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export function abortSubagent(sessionKey: string): boolean {
|
||||
const run = getRegistry().runs.get(sessionKey);
|
||||
if (!run || run.status !== "running") {return false;}
|
||||
sendGatewayAbortForSubagent(sessionKey);
|
||||
finalizeRun(run, "error");
|
||||
return true;
|
||||
}
|
||||
|
||||
export function spawnSubagentMessage(sessionKey: string, message: string): boolean {
|
||||
try {
|
||||
const run = getRegistry().runs.get(sessionKey);
|
||||
if (!run) {return false;}
|
||||
const root = resolvePackageRoot();
|
||||
const devScript = join(root, "scripts", "run-node.mjs");
|
||||
const prodScript = join(root, "openclaw.mjs");
|
||||
const scriptPath = existsSync(devScript) ? devScript : prodScript;
|
||||
const idempotencyKey = randomUUID();
|
||||
const child = spawn(
|
||||
"node",
|
||||
[
|
||||
scriptPath,
|
||||
"gateway",
|
||||
"call",
|
||||
"agent",
|
||||
"--params",
|
||||
JSON.stringify({
|
||||
message,
|
||||
sessionKey,
|
||||
idempotencyKey,
|
||||
deliver: false,
|
||||
channel: "webchat",
|
||||
lane: "subagent",
|
||||
timeout: 0,
|
||||
}),
|
||||
"--json",
|
||||
"--timeout",
|
||||
"10000",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...process.env },
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily register a subagent by reading the on-disk registries.
|
||||
* Checks the profile-scoped subagent-index.json first, then falls back
|
||||
* to the shared gateway registry (~/.openclaw/subagents/runs.json).
|
||||
* Returns true if the subagent was found and registered (or already registered).
|
||||
*/
|
||||
export function ensureRegisteredFromDisk(
|
||||
sessionKey: string,
|
||||
parentWebSessionId: string,
|
||||
): boolean {
|
||||
if (getRegistry().runs.has(sessionKey)) {return true;}
|
||||
|
||||
// 1. Check profile-scoped index.
|
||||
const profileIndex = loadSubagentIndex();
|
||||
const profileEntry = profileIndex[sessionKey];
|
||||
if (profileEntry) {
|
||||
registerSubagent(profileEntry.parentWebSessionId || parentWebSessionId, {
|
||||
sessionKey,
|
||||
runId: profileEntry.runId,
|
||||
task: profileEntry.task,
|
||||
label: profileEntry.label,
|
||||
}, { fromDisk: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Fall back to the shared gateway registry.
|
||||
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
|
||||
if (!existsSync(registryPath)) {return false;}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as { runs?: Record<string, Record<string, unknown>> };
|
||||
const runs = raw?.runs;
|
||||
if (!runs) {return false;}
|
||||
|
||||
for (const entry of Object.values(runs)) {
|
||||
if (entry.childSessionKey === sessionKey) {
|
||||
registerSubagent(parentWebSessionId, {
|
||||
sessionKey,
|
||||
runId: typeof entry.runId === "string" ? entry.runId : "",
|
||||
task: typeof entry.task === "string" ? entry.task : "",
|
||||
label: typeof entry.label === "string" ? entry.label : undefined,
|
||||
}, { fromDisk: true });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Event transformation (gateway event → SSE events) ──
|
||||
|
||||
function createTransformState(): TransformState {
|
||||
return {
|
||||
idCounter: 0,
|
||||
currentTextId: "",
|
||||
currentReasoningId: "",
|
||||
textStarted: false,
|
||||
reasoningStarted: false,
|
||||
everSentText: false,
|
||||
statusReasoningActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
function handleAgentEvent(run: SubagentRun, evt: AgentEvent): void {
|
||||
if (evt.event !== "agent") {return;}
|
||||
const gSeq = typeof (evt as Record<string, unknown>).globalSeq === "number"
|
||||
? (evt as Record<string, unknown>).globalSeq as number
|
||||
: undefined;
|
||||
if (gSeq !== undefined) {
|
||||
if (gSeq <= run.lastGlobalSeq) {return;}
|
||||
run.lastGlobalSeq = gSeq;
|
||||
}
|
||||
const stream = typeof evt.stream === "string" ? evt.stream : undefined;
|
||||
const data =
|
||||
evt.data && typeof evt.data === "object"
|
||||
? (evt.data)
|
||||
: undefined;
|
||||
|
||||
if (!stream || !data) {return;}
|
||||
|
||||
const st = run._state;
|
||||
const nextId = (prefix: string) => `${prefix}-${Date.now()}-${++st.idCounter}`;
|
||||
|
||||
const emit = (event: SseEvent) => {
|
||||
run.eventBuffer.push(event);
|
||||
persistEvent(run.sessionKey, event);
|
||||
for (const sub of run.subscribers) {
|
||||
try { sub(event); } catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
const closeReasoning = () => {
|
||||
if (st.reasoningStarted) {
|
||||
emit({ type: "reasoning-end", id: st.currentReasoningId });
|
||||
st.reasoningStarted = false;
|
||||
st.statusReasoningActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeText = () => {
|
||||
if (st.textStarted) {
|
||||
emit({ type: "text-end", id: st.currentTextId });
|
||||
st.textStarted = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openStatusReasoning = (label: string) => {
|
||||
closeReasoning();
|
||||
closeText();
|
||||
st.currentReasoningId = nextId("status");
|
||||
emit({ type: "reasoning-start", id: st.currentReasoningId });
|
||||
emit({ type: "reasoning-delta", id: st.currentReasoningId, delta: label });
|
||||
st.reasoningStarted = true;
|
||||
st.statusReasoningActive = true;
|
||||
};
|
||||
|
||||
const emitError = (message: string) => {
|
||||
closeReasoning();
|
||||
closeText();
|
||||
const tid = nextId("text");
|
||||
emit({ type: "text-start", id: tid });
|
||||
emit({ type: "text-delta", id: tid, delta: `[error] ${message}` });
|
||||
emit({ type: "text-end", id: tid });
|
||||
st.everSentText = true;
|
||||
};
|
||||
|
||||
// Lifecycle start
|
||||
if (stream === "lifecycle" && data.phase === "start") {
|
||||
openStatusReasoning("Preparing response...");
|
||||
}
|
||||
|
||||
// Thinking / reasoning
|
||||
if (stream === "thinking") {
|
||||
const delta = typeof data.delta === "string" ? data.delta : undefined;
|
||||
if (delta) {
|
||||
if (st.statusReasoningActive) {closeReasoning();}
|
||||
if (!st.reasoningStarted) {
|
||||
st.currentReasoningId = nextId("reasoning");
|
||||
emit({ type: "reasoning-start", id: st.currentReasoningId });
|
||||
st.reasoningStarted = true;
|
||||
}
|
||||
emit({ type: "reasoning-delta", id: st.currentReasoningId, delta });
|
||||
}
|
||||
}
|
||||
|
||||
// Assistant text
|
||||
if (stream === "assistant") {
|
||||
const delta = typeof data.delta === "string" ? data.delta : undefined;
|
||||
if (delta) {
|
||||
closeReasoning();
|
||||
if (!st.textStarted) {
|
||||
st.currentTextId = nextId("text");
|
||||
emit({ type: "text-start", id: st.currentTextId });
|
||||
st.textStarted = true;
|
||||
}
|
||||
st.everSentText = true;
|
||||
emit({ type: "text-delta", id: st.currentTextId, delta });
|
||||
}
|
||||
// Inline error
|
||||
if (
|
||||
typeof data.stopReason === "string" &&
|
||||
data.stopReason === "error" &&
|
||||
typeof data.errorMessage === "string"
|
||||
) {
|
||||
emitError(parseErrorBody(data.errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
// Tool events
|
||||
if (stream === "tool") {
|
||||
const phase = typeof data.phase === "string" ? data.phase : undefined;
|
||||
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
|
||||
const toolName = typeof data.name === "string" ? data.name : "";
|
||||
|
||||
if (phase === "start") {
|
||||
closeReasoning();
|
||||
closeText();
|
||||
const args =
|
||||
data.args && typeof data.args === "object"
|
||||
? (data.args as Record<string, unknown>)
|
||||
: {};
|
||||
emit({ type: "tool-input-start", toolCallId, toolName });
|
||||
emit({ type: "tool-input-available", toolCallId, toolName, input: args });
|
||||
} else if (phase === "result") {
|
||||
const isError = data.isError === true;
|
||||
const result = extractToolResult(data.result);
|
||||
if (isError) {
|
||||
const errorText =
|
||||
result?.text ||
|
||||
(result?.details?.error as string | undefined) ||
|
||||
"Tool execution failed";
|
||||
emit({ type: "tool-output-error", toolCallId, errorText });
|
||||
} else {
|
||||
const output = buildToolOutput(result);
|
||||
emit({ type: "tool-output-available", toolCallId, output });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compaction
|
||||
if (stream === "compaction") {
|
||||
const phase = typeof data.phase === "string" ? data.phase : undefined;
|
||||
if (phase === "start") {
|
||||
openStatusReasoning("Optimizing session context...");
|
||||
} else if (phase === "end") {
|
||||
if (st.statusReasoningActive) {
|
||||
if (data.willRetry === true) {
|
||||
emit({
|
||||
type: "reasoning-delta",
|
||||
id: st.currentReasoningId,
|
||||
delta: "\nRetrying with compacted context...",
|
||||
});
|
||||
} else {
|
||||
closeReasoning();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle end → mark run completed
|
||||
if (stream === "lifecycle" && data.phase === "end") {
|
||||
closeReasoning();
|
||||
closeText();
|
||||
finalizeRun(run, "completed");
|
||||
}
|
||||
|
||||
// Lifecycle error
|
||||
if (stream === "lifecycle" && data.phase === "error") {
|
||||
const msg = parseAgentErrorMessage(data);
|
||||
if (msg) {emitError(msg);}
|
||||
finalizeRun(run, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeRun(run: SubagentRun, status: "completed" | "error"): void {
|
||||
if (run.status !== "running") {return;}
|
||||
|
||||
run.status = status;
|
||||
run.endedAt = Date.now();
|
||||
|
||||
// Update the profile-scoped subagent index with final status.
|
||||
upsertSubagentIndex(run.sessionKey, {
|
||||
runId: run.runId,
|
||||
parentWebSessionId: run.parentWebSessionId,
|
||||
task: run.task,
|
||||
label: run.label,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
});
|
||||
|
||||
// Signal completion to all subscribers
|
||||
for (const sub of run.subscribers) {
|
||||
try { sub(null); } catch { /* ignore */ }
|
||||
}
|
||||
run.subscribers.clear();
|
||||
|
||||
stopSubagentSubscribeStream(run);
|
||||
|
||||
// Schedule cleanup after grace period
|
||||
run._cleanupTimer = setTimeout(() => {
|
||||
cleanupRun(run.sessionKey);
|
||||
}, CLEANUP_GRACE_MS);
|
||||
}
|
||||
|
||||
function cleanupRun(sessionKey: string): void {
|
||||
const reg = getRegistry();
|
||||
const run = reg.runs.get(sessionKey);
|
||||
if (!run) {return;}
|
||||
|
||||
if (run._cleanupTimer) {
|
||||
clearTimeout(run._cleanupTimer);
|
||||
run._cleanupTimer = null;
|
||||
}
|
||||
stopSubagentSubscribeStream(run);
|
||||
reg.runs.delete(sessionKey);
|
||||
|
||||
// Clean up parent index
|
||||
const keys = reg.parentIndex.get(run.parentWebSessionId);
|
||||
if (keys) {
|
||||
keys.delete(sessionKey);
|
||||
if (keys.size === 0) {
|
||||
reg.parentIndex.delete(run.parentWebSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startSubagentSubscribeStream(run: SubagentRun): void {
|
||||
stopSubagentSubscribeStream(run);
|
||||
const child = spawnAgentSubscribeProcess(run.sessionKey, run.lastGlobalSeq);
|
||||
run._subscribeProcess = child;
|
||||
const rl = createInterface({ input: child.stdout! });
|
||||
|
||||
rl.on("line", (line: string) => {
|
||||
if (!line.trim()) {return;}
|
||||
let ev: AgentEvent;
|
||||
try {
|
||||
ev = JSON.parse(line) as AgentEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (ev.sessionKey && ev.sessionKey !== run.sessionKey) {
|
||||
return;
|
||||
}
|
||||
handleAgentEvent(run, ev);
|
||||
});
|
||||
|
||||
child.on("close", () => {
|
||||
if (run._subscribeProcess === child) {
|
||||
run._subscribeProcess = null;
|
||||
}
|
||||
if (run.status !== "running") {return;}
|
||||
setTimeout(() => {
|
||||
if (run.status === "running" && !run._subscribeProcess) {
|
||||
startSubagentSubscribeStream(run);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error("[subagent-runs] Subscribe child error:", err);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
console.error("[subagent-runs subscribe stderr]", chunk.toString());
|
||||
});
|
||||
}
|
||||
|
||||
function stopSubagentSubscribeStream(run: SubagentRun): void {
|
||||
if (!run._subscribeProcess) {return;}
|
||||
try {
|
||||
run._subscribeProcess.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
run._subscribeProcess = null;
|
||||
}
|
||||
6
apps/web/lib/utils.ts
Normal file
6
apps/web/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@ -1,22 +1,234 @@
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { join, resolve, normalize, relative } from "node:path";
|
||||
import { join, resolve, normalize, relative, basename } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import YAML from "yaml";
|
||||
import type { SavedView } from "./object-filters";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI profile override — allows switching profiles at runtime without env vars.
|
||||
// The active profile is held in-memory for immediate effect and persisted to
|
||||
// ~/.openclaw/.ironclaw-ui-state.json so it survives server restarts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UI_STATE_FILENAME = ".ironclaw-ui-state.json";
|
||||
|
||||
/** In-memory override; takes precedence over the persisted file. */
|
||||
let _uiActiveProfile: string | null | undefined;
|
||||
|
||||
type UIState = {
|
||||
activeProfile?: string | null;
|
||||
/** Maps profile names to absolute workspace paths for workspaces outside ~/.openclaw/. */
|
||||
workspaceRegistry?: Record<string, string>;
|
||||
};
|
||||
|
||||
function uiStatePath(): string {
|
||||
const home = process.env.OPENCLAW_HOME?.trim() || homedir();
|
||||
return join(home, ".openclaw", UI_STATE_FILENAME);
|
||||
}
|
||||
|
||||
function readUIState(): UIState {
|
||||
try {
|
||||
const raw = readFileSync(uiStatePath(), "utf-8");
|
||||
return JSON.parse(raw) as UIState;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeUIState(state: UIState): void {
|
||||
const p = uiStatePath();
|
||||
const dir = join(p, "..");
|
||||
if (!existsSync(dir)) {mkdirSync(dir, { recursive: true });}
|
||||
writeFileSync(p, JSON.stringify(state, null, 2) + "\n");
|
||||
}
|
||||
|
||||
/** Get the effective profile: env var > in-memory override > persisted file. */
|
||||
export function getEffectiveProfile(): string | null {
|
||||
const envProfile = process.env.OPENCLAW_PROFILE?.trim();
|
||||
if (envProfile) {return envProfile;}
|
||||
if (_uiActiveProfile !== undefined) {return _uiActiveProfile;}
|
||||
const persisted = readUIState().activeProfile;
|
||||
return persisted?.trim() || null;
|
||||
}
|
||||
|
||||
/** Set the UI-level profile override (in-memory + persisted). */
|
||||
export function setUIActiveProfile(profile: string | null): void {
|
||||
const normalized = profile?.trim() || null;
|
||||
_uiActiveProfile = normalized;
|
||||
const existing = readUIState();
|
||||
writeUIState({ ...existing, activeProfile: normalized });
|
||||
}
|
||||
|
||||
/** Reset the in-memory override (re-reads from file on next call). */
|
||||
export function clearUIActiveProfileCache(): void {
|
||||
_uiActiveProfile = undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace registry — remembers workspaces created outside ~/.openclaw/.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Read the full workspace registry (profile → absolute path). */
|
||||
export function getWorkspaceRegistry(): Record<string, string> {
|
||||
return readUIState().workspaceRegistry ?? {};
|
||||
}
|
||||
|
||||
/** Look up a single profile's registered workspace path. */
|
||||
export function getRegisteredWorkspacePath(profile: string | null): string | null {
|
||||
if (!profile) {return null;}
|
||||
return getWorkspaceRegistry()[profile] ?? null;
|
||||
}
|
||||
|
||||
/** Persist a profile → workspace-path mapping in the registry. */
|
||||
export function registerWorkspacePath(profile: string, absolutePath: string): void {
|
||||
const state = readUIState();
|
||||
const registry = state.workspaceRegistry ?? {};
|
||||
registry[profile] = absolutePath;
|
||||
writeUIState({ ...state, workspaceRegistry: registry });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile discovery — scans the filesystem for all profiles/workspaces.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DiscoveredProfile = {
|
||||
name: string;
|
||||
stateDir: string;
|
||||
workspaceDir: string | null;
|
||||
isActive: boolean;
|
||||
hasConfig: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover all profiles by scanning ~/.openclaw for workspace-* directories
|
||||
* and checking for profile-specific state dirs.
|
||||
*/
|
||||
export function discoverProfiles(): DiscoveredProfile[] {
|
||||
const home = process.env.OPENCLAW_HOME?.trim() || homedir();
|
||||
const baseStateDir = join(home, ".openclaw");
|
||||
const activeProfile = getEffectiveProfile();
|
||||
const profiles: DiscoveredProfile[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Default profile
|
||||
const defaultWs = join(baseStateDir, "workspace");
|
||||
profiles.push({
|
||||
name: "default",
|
||||
stateDir: baseStateDir,
|
||||
workspaceDir: existsSync(defaultWs) ? defaultWs : null,
|
||||
isActive: !activeProfile || activeProfile.toLowerCase() === "default",
|
||||
hasConfig: existsSync(join(baseStateDir, "openclaw.json")),
|
||||
});
|
||||
seen.add("default");
|
||||
|
||||
// Scan for workspace-<profile> directories inside the state dir
|
||||
if (existsSync(baseStateDir)) {
|
||||
try {
|
||||
const entries = readdirSync(baseStateDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {continue;}
|
||||
const match = entry.name.match(/^workspace-(.+)$/);
|
||||
if (!match) {continue;}
|
||||
const profileName = match[1];
|
||||
if (seen.has(profileName)) {continue;}
|
||||
seen.add(profileName);
|
||||
|
||||
const wsDir = join(baseStateDir, entry.name);
|
||||
profiles.push({
|
||||
name: profileName,
|
||||
stateDir: baseStateDir,
|
||||
workspaceDir: existsSync(wsDir) ? wsDir : null,
|
||||
isActive: activeProfile === profileName,
|
||||
hasConfig: existsSync(join(baseStateDir, "openclaw.json")),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
}
|
||||
|
||||
// Merge workspaces registered via custom paths (outside ~/.openclaw/)
|
||||
const registry = getWorkspaceRegistry();
|
||||
for (const [profileName, wsPath] of Object.entries(registry)) {
|
||||
if (seen.has(profileName)) {
|
||||
const existing = profiles.find((p) => p.name === profileName);
|
||||
if (existing && !existing.workspaceDir && existsSync(wsPath)) {
|
||||
existing.workspaceDir = wsPath;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.add(profileName);
|
||||
profiles.push({
|
||||
name: profileName,
|
||||
stateDir: baseStateDir,
|
||||
workspaceDir: existsSync(wsPath) ? wsPath : null,
|
||||
isActive: activeProfile === profileName,
|
||||
hasConfig: existsSync(join(baseStateDir, "openclaw.json")),
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State directory & workspace resolution (profile-aware)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the OpenClaw state directory (base dir for config, sessions, agents, etc.).
|
||||
* Mirrors src/config/paths.ts:resolveStateDir() logic for the web app.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. OPENCLAW_STATE_DIR env var
|
||||
* 2. OPENCLAW_HOME env var → <home>/.openclaw
|
||||
* 3. ~/.openclaw (default)
|
||||
*/
|
||||
export function resolveOpenClawStateDir(): string {
|
||||
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim();
|
||||
if (stateOverride) {
|
||||
return stateOverride.startsWith("~")
|
||||
? join(homedir(), stateOverride.slice(1))
|
||||
: stateOverride;
|
||||
}
|
||||
const home = process.env.OPENCLAW_HOME?.trim() || homedir();
|
||||
return join(home, ".openclaw");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the web-chat sessions directory, scoped to the active profile.
|
||||
* Default profile: <stateDir>/web-chat
|
||||
* Named profile: <stateDir>/web-chat-<profile>
|
||||
*/
|
||||
export function resolveWebChatDir(): string {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const profile = getEffectiveProfile();
|
||||
if (profile && profile.toLowerCase() !== "default") {
|
||||
return join(stateDir, `web-chat-${profile}`);
|
||||
}
|
||||
return join(stateDir, "web-chat");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace directory, checking in order:
|
||||
* 1. OPENCLAW_WORKSPACE env var
|
||||
* 2. ~/.openclaw/workspace/
|
||||
* 2. Effective profile → <stateDir>/workspace-<profile>
|
||||
* 3. <stateDir>/workspace
|
||||
*/
|
||||
export function resolveWorkspaceRoot(): string | null {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const profile = getEffectiveProfile();
|
||||
const registryPath = getRegisteredWorkspacePath(profile);
|
||||
const candidates = [
|
||||
process.env.OPENCLAW_WORKSPACE,
|
||||
join(homedir(), ".openclaw", "workspace"),
|
||||
registryPath,
|
||||
profile && profile.toLowerCase() !== "default"
|
||||
? join(stateDir, `workspace-${profile}`)
|
||||
: null,
|
||||
join(stateDir, "workspace"),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const dir of candidates) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Produce a self-contained standalone build so npm global installs
|
||||
@ -17,6 +18,23 @@ const nextConfig: NextConfig = {
|
||||
|
||||
// Transpile ESM-only packages so webpack can bundle them
|
||||
transpilePackages: ["react-markdown", "remark-gfm"],
|
||||
|
||||
webpack: (config, { dev }) => {
|
||||
if (dev) {
|
||||
config.watchOptions = {
|
||||
...config.watchOptions,
|
||||
ignored: [
|
||||
"**/node_modules/**",
|
||||
"**/.git/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
path.join(homedir(), ".openclaw", "**"),
|
||||
],
|
||||
poll: 1500,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.75",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@ -32,8 +33,10 @@
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@tiptap/suggestion": "^3.19.0",
|
||||
"ai": "^6.0.73",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
@ -42,7 +45,10 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.22.0"
|
||||
"shiki": "^3.22.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"unicode-animations": "^1.0.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
|
||||
BIN
apps/web/public/icons/document.png
Normal file
BIN
apps/web/public/icons/document.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/web/public/icons/folder-open.png
Normal file
BIN
apps/web/public/icons/folder-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/web/public/icons/folder.png
Normal file
BIN
apps/web/public/icons/folder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
File diff suppressed because one or more lines are too long
174
pnpm-lock.yaml
generated
174
pnpm-lock.yaml
generated
@ -296,6 +296,9 @@ importers:
|
||||
'@ai-sdk/react':
|
||||
specifier: ^3.0.75
|
||||
version: 3.0.88(react@19.2.4)(zod@4.3.6)
|
||||
'@base-ui/react':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -359,12 +362,18 @@ importers:
|
||||
ai:
|
||||
specifier: ^6.0.73
|
||||
version: 6.0.86(zod@4.3.6)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
framer-motion:
|
||||
specifier: ^12.34.0
|
||||
version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
fuse.js:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
lucide-react:
|
||||
specifier: ^0.575.0
|
||||
version: 0.575.0(react@19.2.4)
|
||||
next:
|
||||
specifier: ^15.3.3
|
||||
version: 15.5.12(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -392,6 +401,15 @@ importers:
|
||||
shiki:
|
||||
specifier: ^3.22.0
|
||||
version: 3.22.0
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
unicode-animations:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.8
|
||||
@ -1097,6 +1115,27 @@ packages:
|
||||
resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
'@base-ui/react@1.2.0':
|
||||
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@base-ui/utils@0.2.5':
|
||||
resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
@ -1356,6 +1395,12 @@ packages:
|
||||
'@floating-ui/dom@1.7.5':
|
||||
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.7':
|
||||
resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
@ -3913,6 +3958,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@ -4140,6 +4189,10 @@ packages:
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
@ -4218,6 +4271,10 @@ packages:
|
||||
engines: {node: '>= 14.15.0'}
|
||||
hasBin: true
|
||||
|
||||
codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@ -4284,6 +4341,11 @@ packages:
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
@ -4711,6 +4773,10 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
framer-motion@12.34.0:
|
||||
resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==}
|
||||
peerDependencies:
|
||||
@ -5361,6 +5427,11 @@ packages:
|
||||
lru-memoizer@2.3.0:
|
||||
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
|
||||
|
||||
lucide-react@0.575.0:
|
||||
resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@ -6541,6 +6612,10 @@ packages:
|
||||
sqlite-vec@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==}
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
sshpk@1.18.0:
|
||||
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -6643,10 +6718,16 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
tabbable@6.4.0:
|
||||
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
||||
|
||||
table-layout@4.1.1:
|
||||
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
tailwindcss@4.1.18:
|
||||
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
||||
|
||||
@ -6828,6 +6909,10 @@ packages:
|
||||
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicode-animations@1.0.3:
|
||||
resolution: {integrity: sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==}
|
||||
hasBin: true
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@ -7040,6 +7125,14 @@ packages:
|
||||
win-guid@0.2.1:
|
||||
resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==}
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
word@0.3.0:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wordwrapjs@5.1.1:
|
||||
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
|
||||
engines: {node: '>=12.17'}
|
||||
@ -7067,6 +7160,11 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -7830,6 +7928,30 @@ snapshots:
|
||||
'@babel/helper-string-parser': 8.0.0-rc.2
|
||||
'@babel/helper-validator-identifier': 8.0.0-rc.1
|
||||
|
||||
'@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@base-ui/utils': 0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
tabbable: 6.4.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@base-ui/utils@0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
reselect: 5.1.1
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
@ -8072,16 +8194,19 @@ snapshots:
|
||||
'@floating-ui/core@1.7.4':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
optional: true
|
||||
|
||||
'@floating-ui/dom@1.7.5':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.4
|
||||
'@floating-ui/utils': 0.2.10
|
||||
optional: true
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
optional: true
|
||||
'@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@google/genai@1.41.0':
|
||||
dependencies:
|
||||
@ -10708,6 +10833,8 @@ snapshots:
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ai@6.0.86(zod@4.3.6):
|
||||
@ -10943,6 +11070,11 @@ snapshots:
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk-template@0.4.0:
|
||||
@ -11024,6 +11156,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@ -11076,6 +11210,8 @@ snapshots:
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
croner@10.0.1: {}
|
||||
@ -11516,6 +11652,8 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.34.0
|
||||
@ -12209,6 +12347,10 @@ snapshots:
|
||||
lodash.clonedeep: 4.5.0
|
||||
lru-cache: 6.0.0
|
||||
|
||||
lucide-react@0.575.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@ -13911,6 +14053,10 @@ snapshots:
|
||||
sqlite-vec-linux-x64: 0.1.7-alpha.2
|
||||
sqlite-vec-windows-x64: 0.1.7-alpha.2
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
sshpk@1.18.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
@ -14016,11 +14162,15 @@ snapshots:
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
tabbable@6.4.0: {}
|
||||
|
||||
table-layout@4.1.1:
|
||||
dependencies:
|
||||
array-back: 6.2.2
|
||||
wordwrapjs: 5.1.1
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwindcss@4.1.18: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
@ -14178,6 +14328,8 @@ snapshots:
|
||||
|
||||
undici@7.22.0: {}
|
||||
|
||||
unicode-animations@1.0.3: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@ -14429,6 +14581,10 @@ snapshots:
|
||||
|
||||
win-guid@0.2.1: {}
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wordwrapjs@5.1.1: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
@ -14447,6 +14603,16 @@ snapshots:
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
cfb: 1.2.2
|
||||
codepage: 1.15.0
|
||||
crc-32: 1.2.2
|
||||
ssf: 0.11.2
|
||||
wmf: 1.0.2
|
||||
word: 0.3.0
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
@ -667,6 +667,19 @@ VALUES ('Roadmap', 'map', 'projects/roadmap.md', '<parent_doc_id>', 0);
|
||||
- **Field names**: human-readable, proper capitalization ("Email Address" not "email")
|
||||
- **Be descriptive**: "Phone Number" not "Phone"
|
||||
- **Be consistent**: Don't mix "Full Name" and "Name" in the same object
|
||||
- **TRIPLE ALIGNMENT (MANDATORY)**: The DuckDB object `name`, the filesystem directory name, and the `.object.yaml` `name` field MUST all be identical. If any one of these three diverges, the UI will fail to render the object. For example, if DuckDB has `name = 'contract'`, the directory MUST be `contract/` (in workspace) and the yaml MUST have `name: "contract"`. Never use plural for one and singular for another.
|
||||
|
||||
### Renaming / Moving Objects
|
||||
|
||||
When renaming or relocating an object, you MUST update ALL THREE in a single operation:
|
||||
|
||||
1. **DuckDB**: Update `objects.name` (if FK constraints block this, recreate the object with the new name and migrate entries)
|
||||
2. **Directory**: `mv` the old directory to the new name
|
||||
3. **`.object.yaml`**: Update the `name` field to match
|
||||
4. **PIVOT view**: `DROP VIEW IF EXISTS v_{old_name}; CREATE OR REPLACE VIEW v_{new_name} ...`
|
||||
5. **Verify**: Confirm all three match and the view returns data
|
||||
|
||||
Never rename partially. If you can't complete all steps, don't start the rename — explain the constraint to the user first.
|
||||
|
||||
## Error Handling
|
||||
|
||||
@ -872,7 +885,7 @@ After creating a `.report.json` file:
|
||||
## Critical Reminders
|
||||
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution to filesystem projection to summary
|
||||
- **NEVER SKIP FILESYSTEM PROJECTION**: After creating/modifying any object, you MUST create/update `~/.openclaw/workspace/{object}/.object.yaml` AND the `v_{object}` view. If you skip this, the object will be invisible in the sidebar. This is NOT optional.
|
||||
- **NEVER SKIP FILESYSTEM PROJECTION**: After creating/modifying any object, you MUST create/update `{object}/.object.yaml` in workspace AND the `v_{object}` view. If you skip this, the object will be invisible in the sidebar. This is NOT optional.
|
||||
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection (.object.yaml + directory), (3) verify. An operation is NOT complete until all three are done.
|
||||
- Always check existing data before creating (`SELECT` before `INSERT`, or `ON CONFLICT`)
|
||||
- Use views (`v_{object}`) for all reads — never write raw PIVOT queries for search
|
||||
@ -890,8 +903,9 @@ After creating a `.report.json` file:
|
||||
- **workspace_context.yaml**: READ-ONLY. Never modify. Data flows from Dench UI only.
|
||||
- **Source of truth**: DuckDB for all structured data. Filesystem for document content and navigation tree. Never duplicate entry data to the filesystem.
|
||||
- **ENTRY COUNT**: After adding entries, update `entry_count` in `.object.yaml`.
|
||||
- **NEVER POLLUTE THE WORKSPACE**: Always keep cleaning / organising the workspace to something more nicely structured. Always look out for bloat and too many random files scattered around everywhere for no reason, every time you do any actions in filesystem always try to come up with the most efficient and nice file system structure inside `~/.openclaw/workspace`.
|
||||
- **TEMPORARY FILES**: All temporary scripts / code / text / other files as and when needed for processing must go into `~/.openclaw/workspace/tmp/` directory (create it if it doesn't exist, only if needed).
|
||||
- **NAME CONSISTENCY**: The DuckDB `objects.name`, the filesystem directory name, and `.object.yaml` `name` MUST be identical. A mismatch between ANY of these three will break the UI. Before finishing any object creation or modification, verify: `objects.name == directory_name == yaml.name`. See "Renaming / Moving Objects" under Naming Conventions.
|
||||
- **NEVER POLLUTE THE WORKSPACE**: Always keep cleaning / organising the workspace to something more nicely structured. Always look out for bloat and too many random files scattered around everywhere for no reason, every time you do any actions in filesystem always try to come up with the most efficient and nice file system structure inside the workspace.
|
||||
- **TEMPORARY FILES**: All temporary scripts / code / text / other files as and when needed for processing must go into `tmp/` directory (create it in the workspace if it doesn't exist, only if needed).
|
||||
|
||||
## Browser Use
|
||||
|
||||
|
||||
@ -180,6 +180,11 @@ export function resolveEffectiveModelFallbacks(params: {
|
||||
}
|
||||
|
||||
export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) {
|
||||
// OPENCLAW_WORKSPACE overrides everything (set by the web UI for profile switching).
|
||||
const envWorkspace = process.env.OPENCLAW_WORKSPACE?.trim();
|
||||
if (envWorkspace) {
|
||||
return resolveUserPath(envWorkspace);
|
||||
}
|
||||
const id = normalizeAgentId(agentId);
|
||||
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
|
||||
if (configured) {
|
||||
|
||||
@ -94,4 +94,6 @@ export type SkillSnapshot = {
|
||||
/** Skills with `inject: true` whose full content should be included in the system prompt. */
|
||||
injectedSkills?: InjectedSkillContent[];
|
||||
version?: number;
|
||||
/** Workspace dir this snapshot was built for (used to invalidate on profile switch). */
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
@ -265,11 +265,28 @@ export function buildWorkspaceSkillSnapshot(
|
||||
const remoteNote = opts?.eligibility?.remote?.note?.trim();
|
||||
const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n");
|
||||
|
||||
// Read full content of injected skills
|
||||
// Read full content of injected skills, substituting workspace path placeholders.
|
||||
// We replace both the tilde form and the expanded default path to handle
|
||||
// cases where the replacement target is a profile-specific workspace dir.
|
||||
//
|
||||
// Use regex with a negative lookahead so "~/.openclaw/workspace" doesn't
|
||||
// match inside "~/.openclaw/workspace-<profile>", which would double the
|
||||
// profile suffix (e.g. workspace-kumareth -> workspace-kumareth-kumareth).
|
||||
const defaultExpandedWorkspace = resolveUserPath("~/.openclaw/workspace");
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const tildePattern = new RegExp(escapeRegex("~/.openclaw/workspace") + "(?![\\w-])", "g");
|
||||
const injectedSkills: InjectedSkillContent[] = [];
|
||||
for (const entry of injectedEntries) {
|
||||
const content = readSkillContent(entry.skill.filePath);
|
||||
if (content) {
|
||||
const rawContent = readSkillContent(entry.skill.filePath);
|
||||
if (rawContent) {
|
||||
let content = rawContent.replace(tildePattern, workspaceDir);
|
||||
if (workspaceDir !== defaultExpandedWorkspace) {
|
||||
const expandedPattern = new RegExp(
|
||||
escapeRegex(defaultExpandedWorkspace) + "(?![\\w-])",
|
||||
"g",
|
||||
);
|
||||
content = content.replace(expandedPattern, workspaceDir);
|
||||
}
|
||||
injectedSkills.push({ name: entry.skill.name, content });
|
||||
}
|
||||
}
|
||||
@ -283,6 +300,7 @@ export function buildWorkspaceSkillSnapshot(
|
||||
resolvedSkills,
|
||||
injectedSkills: injectedSkills.length > 0 ? injectedSkills : undefined,
|
||||
version: opts?.snapshotVersion,
|
||||
workspaceDir,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -148,10 +148,24 @@ function buildVoiceSection(params: { isMinimal: boolean; ttsHint?: string }) {
|
||||
return ["## Voice (TTS)", hint, ""];
|
||||
}
|
||||
|
||||
function buildIronclawSection(isMinimal: boolean) {
|
||||
function buildIronclawSection(isMinimal: boolean, workspaceDir?: string) {
|
||||
if (isMinimal) {
|
||||
return [];
|
||||
}
|
||||
// Derive web-chat path from workspace dir. web-chat follows the same
|
||||
// profile suffix convention: web-chat (default) or web-chat-<profile>.
|
||||
const webChatPath = (() => {
|
||||
if (!workspaceDir) {
|
||||
return "~/.openclaw/web-chat/";
|
||||
}
|
||||
const match = workspaceDir.match(/\/workspace(-[^/]+)?$/);
|
||||
if (!match) {
|
||||
return "~/.openclaw/web-chat/";
|
||||
}
|
||||
const stateDir = workspaceDir.replace(/\/workspace(-[^/]+)?$/, "");
|
||||
const suffix = match[1] ?? ""; // e.g. "-work" or ""
|
||||
return `${stateDir}/web-chat${suffix}/`;
|
||||
})();
|
||||
return [
|
||||
"## Ironclaw",
|
||||
"You are **Ironclaw** — a personal AI agent and a CRM Database System built by Kumar Abhirup (kumareth.com), running on top of the [OpenClaw](https://github.com/openclaw/openclaw) framework.",
|
||||
@ -170,7 +184,7 @@ function buildIronclawSection(isMinimal: boolean) {
|
||||
"- **Web UI**: Next.js app that usually runs at `localhost:3100` — chat panel, workspace sidebar, object tables, kanban boards, report cards, document editor, media viewer.",
|
||||
"- **DuckDB workspace**: all structured data (objects, fields, entries, relations) in a local DuckDB database with EAV pattern and auto-generated PIVOT views (`v_<object>`).",
|
||||
"- **Skills platform**: extend capabilities via `SKILL.md` files — browse at [skills.sh](https://skills.sh) and [ClawHub](https://clawhub.com).",
|
||||
`- **Past Web Sessions**: Your past Ironclaw web chat sessions are stored in: ~/.openclaw/web-chat/ (or near wherever you store your workspace)`,
|
||||
`- **Past Web Sessions**: Your past Ironclaw web chat sessions are stored in: ${webChatPath} (or near wherever you store your workspace)`,
|
||||
"",
|
||||
"### Links",
|
||||
"- Website: https://ironclaw.sh",
|
||||
@ -437,7 +451,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
cliName: cli,
|
||||
});
|
||||
const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean);
|
||||
const ironclawSection = buildIronclawSection(isMinimal);
|
||||
const ironclawSection = buildIronclawSection(isMinimal, params.workspaceDir);
|
||||
|
||||
// For "none" mode, return just the basic identity line
|
||||
if (promptMode === "none") {
|
||||
|
||||
@ -21,7 +21,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
|
||||
program
|
||||
.command("agent")
|
||||
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
||||
.requiredOption("-m, --message <text>", "Message body for the agent")
|
||||
.option("-m, --message <text>", "Message body for the agent")
|
||||
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
|
||||
.option("--session-id <id>", "Use an explicit session id")
|
||||
.option("--session-key <key>", "Explicit session key (e.g. agent:main:subagent:uuid)")
|
||||
@ -44,6 +44,11 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
|
||||
.option("--deliver", "Send the agent's reply back to the selected channel", false)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--stream-json", "Stream NDJSON events to stdout", false)
|
||||
.option(
|
||||
"--subscribe-session-key <key>",
|
||||
"Subscribe to gateway events for a session key (subscribe-only, no message required)",
|
||||
)
|
||||
.option("--after-seq <n>", "Replay events after this global sequence cursor", "0")
|
||||
.option(
|
||||
"--timeout <seconds>",
|
||||
"Override agent command timeout (seconds, default 600 or config value)",
|
||||
|
||||
@ -6,10 +6,63 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(),
|
||||
randomIdempotencyKey: () => "idem-1",
|
||||
buildGatewayConnectionDetails: vi.fn(() => ({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
urlSource: "test",
|
||||
message: "Gateway target: ws://127.0.0.1:18789",
|
||||
})),
|
||||
}));
|
||||
vi.mock("./agent.js", () => ({
|
||||
agentCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock("../gateway/client.js", () => {
|
||||
class MockGatewayClient {
|
||||
static instances: MockGatewayClient[] = [];
|
||||
private opts: Record<string, unknown>;
|
||||
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
this.opts = opts;
|
||||
MockGatewayClient.instances.push(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
setTimeout(async () => {
|
||||
const onHelloOk = this.opts.onHelloOk as (() => Promise<void>) | undefined;
|
||||
await onHelloOk?.();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async request(method: string, params?: Record<string, unknown>) {
|
||||
if (method === "agent.subscribe") {
|
||||
const sessionKey = typeof params?.sessionKey === "string" ? params.sessionKey : "";
|
||||
const onEvent = this.opts.onEvent as ((evt: Record<string, unknown>) => void) | undefined;
|
||||
onEvent?.({
|
||||
event: "agent",
|
||||
payload: {
|
||||
sessionKey,
|
||||
stream: "assistant",
|
||||
data: { delta: "match" },
|
||||
globalSeq: 11,
|
||||
},
|
||||
});
|
||||
onEvent?.({
|
||||
event: "agent",
|
||||
payload: {
|
||||
sessionKey: "agent:main:web:other",
|
||||
stream: "assistant",
|
||||
data: { delta: "ignore" },
|
||||
globalSeq: 12,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
stop() {}
|
||||
}
|
||||
|
||||
return { GatewayClient: MockGatewayClient };
|
||||
});
|
||||
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@ -222,6 +275,49 @@ describe("agentCliCommand", () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps subscribe mode alive until signaled and filters to target session", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-"));
|
||||
const store = path.join(dir, "sessions.json");
|
||||
mockConfig(store);
|
||||
|
||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
try {
|
||||
const promise = agentCliCommand(
|
||||
{
|
||||
message: "unused",
|
||||
streamJson: true,
|
||||
subscribeSessionKey: "agent:main:web:target",
|
||||
afterSeq: "10",
|
||||
} as unknown as Parameters<typeof agentCliCommand>[0],
|
||||
runtime,
|
||||
);
|
||||
|
||||
// Subscribe mode should not resolve immediately.
|
||||
let settled = false;
|
||||
void promise.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
// Trigger signal-driven shutdown.
|
||||
(process as unknown as { emit: (event: string) => boolean }).emit("SIGTERM");
|
||||
await promise;
|
||||
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
const writes = stdoutSpy.mock.calls.map(([data]) => String(data));
|
||||
const parsed = writes.map((line) => JSON.parse(line));
|
||||
const agentEvents = parsed.filter((evt) => evt.event === "agent");
|
||||
expect(agentEvents).toHaveLength(1);
|
||||
expect(agentEvents[0].sessionKey).toBe("agent:main:web:target");
|
||||
expect(parsed[parsed.length - 1]).toMatchObject({ event: "aborted", reason: "signal" });
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitNdjsonLine", () => {
|
||||
|
||||
@ -4,8 +4,16 @@ import { listAgentIds } from "../agents/agent-scope.js";
|
||||
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import { loadConfig, resolveConfigPath, resolveStateDir } from "../config/config.js";
|
||||
import {
|
||||
buildGatewayConnectionDetails,
|
||||
callGateway,
|
||||
randomIdempotencyKey,
|
||||
} from "../gateway/call.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
@ -132,6 +140,8 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
|
||||
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
|
||||
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
||||
|
||||
const workspaceOverride = process.env.OPENCLAW_WORKSPACE?.trim() || undefined;
|
||||
|
||||
const response = await withProgress(
|
||||
{
|
||||
label: "Waiting for agent reply…",
|
||||
@ -157,6 +167,7 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
|
||||
lane: opts.lane,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
idempotencyKey,
|
||||
workspace: workspaceOverride,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: gatewayTimeoutMs,
|
||||
@ -228,6 +239,8 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
|
||||
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
|
||||
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
||||
|
||||
const streamWorkspaceOverride = process.env.OPENCLAW_WORKSPACE?.trim() || undefined;
|
||||
|
||||
// Capture the runId from early gateway events so we can abort the
|
||||
// correct run when the process receives SIGTERM/SIGINT.
|
||||
let capturedRunId: string | undefined;
|
||||
@ -259,6 +272,7 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
|
||||
lane: opts.lane,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
idempotencyKey,
|
||||
workspace: streamWorkspaceOverride,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: gatewayTimeoutMs,
|
||||
@ -307,7 +321,206 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a session key's events via the gateway `agent.subscribe` RPC.
|
||||
* Streams NDJSON to stdout until SIGTERM/SIGINT.
|
||||
*/
|
||||
async function agentSubscribeStreamJson(
|
||||
sessionKey: string,
|
||||
afterSeq: number,
|
||||
_runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
|
||||
const remoteUrl =
|
||||
typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined;
|
||||
if (isRemoteMode && !remoteUrl) {
|
||||
const configPath = resolveConfigPath(process.env, resolveStateDir(process.env));
|
||||
throw new Error(
|
||||
[
|
||||
"gateway remote mode misconfigured: gateway.remote.url missing",
|
||||
`Config: ${configPath}`,
|
||||
"Fix: set gateway.remote.url, or set gateway.mode=local.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
const connectionDetails = buildGatewayConnectionDetails({ config: cfg });
|
||||
const useLocalTls =
|
||||
cfg.gateway?.tls?.enabled === true && !remoteUrl && connectionDetails.url.startsWith("wss://");
|
||||
const tlsRuntime = useLocalTls ? await loadGatewayTlsRuntime(cfg.gateway?.tls) : undefined;
|
||||
const tlsFingerprint =
|
||||
(isRemoteMode && remoteUrl && typeof remote?.tlsFingerprint === "string"
|
||||
? remote.tlsFingerprint.trim()
|
||||
: undefined) || (tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined);
|
||||
const authToken = cfg.gateway?.auth?.token;
|
||||
const authPassword = cfg.gateway?.auth?.password;
|
||||
const token =
|
||||
isRemoteMode && remoteUrl
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim().length > 0
|
||||
? authToken.trim()
|
||||
: undefined);
|
||||
const password =
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
|
||||
(isRemoteMode && remoteUrl
|
||||
? typeof remote?.password === "string" && remote.password.trim().length > 0
|
||||
? remote.password.trim()
|
||||
: undefined
|
||||
: typeof authPassword === "string" && authPassword.trim().length > 0
|
||||
? authPassword.trim()
|
||||
: undefined);
|
||||
|
||||
let cursor = Math.max(0, Number.isFinite(afterSeq) ? afterSeq : 0);
|
||||
const abortController = new AbortController();
|
||||
let client: GatewayClient | null = null;
|
||||
const onSignal = () => {
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
process.on("SIGTERM", onSignal);
|
||||
process.on("SIGINT", onSignal);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let aborting = false;
|
||||
|
||||
const settle = (err?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
if (aborting) {
|
||||
return;
|
||||
}
|
||||
aborting = true;
|
||||
const unsubscribeAndStop = async () => {
|
||||
try {
|
||||
await client?.request("agent.unsubscribe", { sessionKey }).catch(() => {});
|
||||
} finally {
|
||||
client?.stop();
|
||||
emitNdjsonLine({ event: "aborted", reason: "signal" });
|
||||
settle();
|
||||
}
|
||||
};
|
||||
void unsubscribeAndStop();
|
||||
};
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
abortController.signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
client = new GatewayClient({
|
||||
url: connectionDetails.url,
|
||||
token,
|
||||
password,
|
||||
tlsFingerprint,
|
||||
instanceId: randomIdempotencyKey(),
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
clientVersion: "dev",
|
||||
platform: process.platform,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
|
||||
caps: ["tool-events"],
|
||||
deviceIdentity: loadOrCreateDeviceIdentity(),
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
onHelloOk: async () => {
|
||||
try {
|
||||
await client?.request("agent.subscribe", { sessionKey, afterSeq: cursor });
|
||||
} catch (err) {
|
||||
client?.stop();
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
settle(error);
|
||||
}
|
||||
},
|
||||
onEvent: (evt) => {
|
||||
if (evt.event !== "agent") {
|
||||
return;
|
||||
}
|
||||
const payload =
|
||||
evt.payload && typeof evt.payload === "object"
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (!payload || payload.sessionKey !== sessionKey) {
|
||||
return;
|
||||
}
|
||||
const globalSeq = typeof payload.globalSeq === "number" ? payload.globalSeq : undefined;
|
||||
if (globalSeq !== undefined && globalSeq > cursor) {
|
||||
cursor = globalSeq;
|
||||
}
|
||||
emitNdjsonLine({ event: evt.event, ...payload });
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
if (aborting || settled) {
|
||||
return;
|
||||
}
|
||||
client?.stop();
|
||||
settle(err);
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
if (aborting || settled || abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// For reconnectable closes, let GatewayClient retry.
|
||||
if (code === 1000) {
|
||||
return;
|
||||
}
|
||||
client?.stop();
|
||||
const reasonText = reason?.trim() || "no close reason";
|
||||
settle(
|
||||
new Error(
|
||||
`gateway subscribe closed (${code}): ${reasonText}\n${connectionDetails.message}`,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
client.start();
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
emitNdjsonLine({ event: "aborted", reason: "signal" });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
process.removeListener("SIGTERM", onSignal);
|
||||
process.removeListener("SIGINT", onSignal);
|
||||
}
|
||||
}
|
||||
|
||||
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
|
||||
// Subscribe-only mode: tail events for a session key with replay cursor.
|
||||
const subscribeKey = (opts as Record<string, unknown>).subscribeSessionKey as string | undefined;
|
||||
if (subscribeKey && opts.streamJson) {
|
||||
const rawAfterSeq = (opts as Record<string, unknown>).afterSeq;
|
||||
const afterSeq = Number.parseInt(typeof rawAfterSeq === "string" ? rawAfterSeq : "0", 10) || 0;
|
||||
return await agentSubscribeStreamJson(subscribeKey.trim(), afterSeq, runtime);
|
||||
}
|
||||
|
||||
// --message is required for all non-subscribe paths.
|
||||
if (!opts.message?.trim()) {
|
||||
throw new Error("Message (--message) is required");
|
||||
}
|
||||
|
||||
const localOpts = {
|
||||
...opts,
|
||||
agentId: opts.agent,
|
||||
|
||||
@ -217,7 +217,7 @@ export async function agentCommand(
|
||||
}
|
||||
const agentCfg = cfg.agents?.defaults;
|
||||
const sessionAgentId = agentIdOverride ?? resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
|
||||
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
|
||||
const workspaceDirRaw = opts.workspace?.trim() || resolveAgentWorkspaceDir(cfg, sessionAgentId);
|
||||
const agentDir = resolveAgentDir(cfg, sessionAgentId);
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
@ -332,7 +332,11 @@ export async function agentCommand(
|
||||
});
|
||||
}
|
||||
|
||||
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
|
||||
const cachedSnapshot = sessionEntry?.skillsSnapshot;
|
||||
const needsSkillsSnapshot =
|
||||
isNewSession ||
|
||||
!cachedSnapshot ||
|
||||
(cachedSnapshot.workspaceDir && cachedSnapshot.workspaceDir !== workspaceDir);
|
||||
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
|
||||
const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId);
|
||||
const skillsSnapshot = needsSkillsSnapshot
|
||||
|
||||
@ -76,6 +76,8 @@ export type AgentCommandOpts = {
|
||||
runId?: string;
|
||||
extraSystemPrompt?: string;
|
||||
inputProvenance?: InputProvenance;
|
||||
/** Workspace directory override (passed via RPC from the web UI for profile switching). */
|
||||
workspace?: string;
|
||||
/** Per-call stream param overrides (best-effort). */
|
||||
streamParams?: AgentStreamParams;
|
||||
};
|
||||
|
||||
@ -146,6 +146,8 @@ export type SessionSkillSnapshot = {
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
resolvedSkills?: Skill[];
|
||||
version?: number;
|
||||
/** Workspace dir this snapshot was built for (used to invalidate on profile switch). */
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
export type SessionSystemPromptReport = {
|
||||
|
||||
@ -42,6 +42,10 @@ import {
|
||||
AgentsListResultSchema,
|
||||
type AgentWaitParams,
|
||||
AgentWaitParamsSchema,
|
||||
type AgentSubscribeParams,
|
||||
AgentSubscribeParamsSchema,
|
||||
type AgentUnsubscribeParams,
|
||||
AgentUnsubscribeParamsSchema,
|
||||
type ChannelsLogoutParams,
|
||||
ChannelsLogoutParamsSchema,
|
||||
type TalkConfigParams,
|
||||
@ -240,6 +244,12 @@ export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
||||
export const validateAgentIdentityParams =
|
||||
ajv.compile<AgentIdentityParams>(AgentIdentityParamsSchema);
|
||||
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
|
||||
export const validateAgentSubscribeParams = ajv.compile<AgentSubscribeParams>(
|
||||
AgentSubscribeParamsSchema,
|
||||
);
|
||||
export const validateAgentUnsubscribeParams = ajv.compile<AgentUnsubscribeParams>(
|
||||
AgentUnsubscribeParamsSchema,
|
||||
);
|
||||
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
|
||||
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema);
|
||||
export const validateAgentsCreateParams = ajv.compile<AgentsCreateParams>(AgentsCreateParamsSchema);
|
||||
@ -515,6 +525,8 @@ export type {
|
||||
AgentIdentityParams,
|
||||
AgentIdentityResult,
|
||||
AgentWaitParams,
|
||||
AgentSubscribeParams,
|
||||
AgentUnsubscribeParams,
|
||||
ChatEvent,
|
||||
TickEvent,
|
||||
ShutdownEvent,
|
||||
|
||||
@ -73,6 +73,7 @@ export const AgentParamsSchema = Type.Object(
|
||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lane: Type.Optional(Type.String()),
|
||||
extraSystemPrompt: Type.Optional(Type.String()),
|
||||
workspace: Type.Optional(Type.String()),
|
||||
inputProvenance: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
@ -124,3 +125,18 @@ export const WakeParamsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentSubscribeParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
afterSeq: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentUnsubscribeParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@ -4,6 +4,8 @@ import type {
|
||||
AgentIdentityParamsSchema,
|
||||
AgentIdentityResultSchema,
|
||||
AgentWaitParamsSchema,
|
||||
AgentSubscribeParamsSchema,
|
||||
AgentUnsubscribeParamsSchema,
|
||||
PollParamsSchema,
|
||||
WakeParamsSchema,
|
||||
} from "./agent.js";
|
||||
@ -147,6 +149,8 @@ export type AgentIdentityParams = Static<typeof AgentIdentityParamsSchema>;
|
||||
export type AgentIdentityResult = Static<typeof AgentIdentityResultSchema>;
|
||||
export type PollParams = Static<typeof PollParamsSchema>;
|
||||
export type AgentWaitParams = Static<typeof AgentWaitParamsSchema>;
|
||||
export type AgentSubscribeParams = Static<typeof AgentSubscribeParamsSchema>;
|
||||
export type AgentUnsubscribeParams = Static<typeof AgentUnsubscribeParamsSchema>;
|
||||
export type WakeParams = Static<typeof WakeParamsSchema>;
|
||||
export type NodePairRequestParams = Static<typeof NodePairRequestParamsSchema>;
|
||||
export type NodePairListParams = Static<typeof NodePairListParamsSchema>;
|
||||
|
||||
@ -4,6 +4,8 @@ import {
|
||||
createAgentEventHandler,
|
||||
createChatRunState,
|
||||
createToolEventRecipientRegistry,
|
||||
createSessionEventLog,
|
||||
createSessionSubscriptionRegistry,
|
||||
} from "./server-chat.js";
|
||||
|
||||
describe("agent event handler", () => {
|
||||
@ -20,6 +22,9 @@ describe("agent event handler", () => {
|
||||
const chatRunState = createChatRunState();
|
||||
const toolEventRecipients = createToolEventRecipientRegistry();
|
||||
|
||||
const sessionEventLog = createSessionEventLog();
|
||||
const sessionSubscriptions = createSessionSubscriptionRegistry();
|
||||
|
||||
const handler = createAgentEventHandler({
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
@ -29,6 +34,8 @@ describe("agent event handler", () => {
|
||||
resolveSessionKeyForRun: params?.resolveSessionKeyForRun ?? (() => undefined),
|
||||
clearAgentRunContext: vi.fn(),
|
||||
toolEventRecipients,
|
||||
sessionEventLog,
|
||||
sessionSubscriptions,
|
||||
});
|
||||
|
||||
return {
|
||||
@ -39,6 +46,8 @@ describe("agent event handler", () => {
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
toolEventRecipients,
|
||||
sessionEventLog,
|
||||
sessionSubscriptions,
|
||||
handler,
|
||||
};
|
||||
}
|
||||
@ -252,4 +261,97 @@ describe("agent event handler", () => {
|
||||
expect(payload.data?.result).toEqual(result);
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
// ── Session event log + replay cursor tests ──
|
||||
|
||||
it("assigns globalSeq to broadcast events and logs them", () => {
|
||||
const { broadcast, sessionEventLog, handler } = createHarness({
|
||||
resolveSessionKeyForRun: () => "session-log",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-log",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "start" },
|
||||
});
|
||||
handler({
|
||||
runId: "run-log",
|
||||
seq: 2,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "hello" },
|
||||
});
|
||||
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
const firstPayload = broadcast.mock.calls[0]?.[1] as { globalSeq?: number };
|
||||
const secondPayload = broadcast.mock.calls[1]?.[1] as { globalSeq?: number };
|
||||
expect(typeof firstPayload.globalSeq).toBe("number");
|
||||
expect(typeof secondPayload.globalSeq).toBe("number");
|
||||
expect(secondPayload.globalSeq).toBeGreaterThan(firstPayload.globalSeq!);
|
||||
expect(sessionEventLog.currentSeq()).toBe(2);
|
||||
});
|
||||
|
||||
it("routes events to session subscribers and replays from cursor", () => {
|
||||
const { broadcastToConnIds, sessionEventLog, sessionSubscriptions, handler } = createHarness({
|
||||
resolveSessionKeyForRun: () => "session-sub",
|
||||
});
|
||||
|
||||
// Emit two events before subscribing.
|
||||
handler({ runId: "run-sub", seq: 1, stream: "lifecycle", ts: 1000, data: { phase: "start" } });
|
||||
handler({ runId: "run-sub", seq: 2, stream: "assistant", ts: 1001, data: { delta: "hi" } });
|
||||
|
||||
const seqAfterTwo = sessionEventLog.currentSeq();
|
||||
|
||||
// Subscribe with cursor 0 — should be able to replay both events.
|
||||
const replayed = sessionEventLog.replayAfter("session-sub", 0);
|
||||
expect(replayed.length).toBe(2);
|
||||
expect(replayed[0].globalSeq).toBe(seqAfterTwo - 1);
|
||||
expect(replayed[1].globalSeq).toBe(seqAfterTwo);
|
||||
|
||||
// Subscribe after first event — should replay only the second.
|
||||
const partial = sessionEventLog.replayAfter("session-sub", seqAfterTwo - 1);
|
||||
expect(partial.length).toBe(1);
|
||||
expect(partial[0].globalSeq).toBe(seqAfterTwo);
|
||||
|
||||
// Register a session subscriber and emit a new event.
|
||||
sessionSubscriptions.add("session-sub", "conn-1");
|
||||
broadcastToConnIds.mockClear();
|
||||
|
||||
handler({ runId: "run-sub", seq: 3, stream: "assistant", ts: 1002, data: { delta: " world" } });
|
||||
|
||||
// Session subscriber should receive the event via broadcastToConnIds (twice:
|
||||
// once from the general tool/broadcast path, once from session subscriber routing).
|
||||
const subCalls = broadcastToConnIds.mock.calls.filter((c) => {
|
||||
const connIds = c[2] as ReadonlySet<string>;
|
||||
return connIds.has("conn-1");
|
||||
});
|
||||
expect(subCalls.length).toBeGreaterThanOrEqual(1);
|
||||
const subPayload = subCalls[0]?.[1] as { globalSeq?: number };
|
||||
expect(typeof subPayload.globalSeq).toBe("number");
|
||||
});
|
||||
|
||||
it("replays nothing for unknown session key", () => {
|
||||
const { sessionEventLog, handler } = createHarness({
|
||||
resolveSessionKeyForRun: () => "session-x",
|
||||
});
|
||||
|
||||
handler({ runId: "run-x", seq: 1, stream: "lifecycle", ts: 1000, data: { phase: "start" } });
|
||||
|
||||
const replayed = sessionEventLog.replayAfter("unknown-session", 0);
|
||||
expect(replayed.length).toBe(0);
|
||||
});
|
||||
|
||||
it("replays nothing when afterSeq >= current cursor", () => {
|
||||
const { sessionEventLog, handler } = createHarness({
|
||||
resolveSessionKeyForRun: () => "session-y",
|
||||
});
|
||||
|
||||
handler({ runId: "run-y", seq: 1, stream: "lifecycle", ts: 1000, data: { phase: "start" } });
|
||||
const current = sessionEventLog.currentSeq();
|
||||
|
||||
const replayed = sessionEventLog.replayAfter("session-y", current);
|
||||
expect(replayed.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -194,6 +194,112 @@ export function createToolEventRecipientRegistry(): ToolEventRecipientRegistry {
|
||||
return { add, get, markFinal };
|
||||
}
|
||||
|
||||
// ── Global event log with replay cursor ──
|
||||
|
||||
export type SessionEventLogEntry = {
|
||||
globalSeq: number;
|
||||
sessionKey: string;
|
||||
appendedAt: number;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SessionEventLog = {
|
||||
/** Append an event and return its assigned globalSeq. */
|
||||
append: (sessionKey: string, payload: Record<string, unknown>) => number;
|
||||
/** Return all events for a sessionKey where globalSeq > afterSeq. */
|
||||
replayAfter: (sessionKey: string, afterSeq: number) => SessionEventLogEntry[];
|
||||
/** Current global sequence value (latest assigned). */
|
||||
currentSeq: () => number;
|
||||
};
|
||||
|
||||
const DEFAULT_EVENT_LOG_MAX = 50_000;
|
||||
const EVENT_LOG_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export function createSessionEventLog(maxEntries = DEFAULT_EVENT_LOG_MAX): SessionEventLog {
|
||||
let seq = 0;
|
||||
const buffer: SessionEventLogEntry[] = [];
|
||||
|
||||
const prune = () => {
|
||||
const cutoff = Date.now() - EVENT_LOG_TTL_MS;
|
||||
while (buffer.length > maxEntries || (buffer.length > 0 && buffer[0].appendedAt < cutoff)) {
|
||||
buffer.shift();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
append(sessionKey, payload) {
|
||||
const entry: SessionEventLogEntry = {
|
||||
globalSeq: ++seq,
|
||||
sessionKey,
|
||||
appendedAt: Date.now(),
|
||||
payload,
|
||||
};
|
||||
buffer.push(entry);
|
||||
prune();
|
||||
return entry.globalSeq;
|
||||
},
|
||||
replayAfter(sessionKey, afterSeq) {
|
||||
const result: SessionEventLogEntry[] = [];
|
||||
for (let i = buffer.length - 1; i >= 0; i--) {
|
||||
if (buffer[i].globalSeq <= afterSeq) {
|
||||
break;
|
||||
}
|
||||
if (buffer[i].sessionKey === sessionKey) {
|
||||
result.push(buffer[i]);
|
||||
}
|
||||
}
|
||||
result.reverse();
|
||||
return result;
|
||||
},
|
||||
currentSeq: () => seq,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Session subscription registry ──
|
||||
|
||||
export type SessionSubscriptionRegistry = {
|
||||
add: (sessionKey: string, connId: string) => void;
|
||||
remove: (sessionKey: string, connId: string) => void;
|
||||
removeConn: (connId: string) => void;
|
||||
get: (sessionKey: string) => ReadonlySet<string> | undefined;
|
||||
};
|
||||
|
||||
export function createSessionSubscriptionRegistry(): SessionSubscriptionRegistry {
|
||||
const subs = new Map<string, Set<string>>();
|
||||
|
||||
return {
|
||||
add(sessionKey, connId) {
|
||||
let set = subs.get(sessionKey);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
subs.set(sessionKey, set);
|
||||
}
|
||||
set.add(connId);
|
||||
},
|
||||
remove(sessionKey, connId) {
|
||||
const set = subs.get(sessionKey);
|
||||
if (!set) {
|
||||
return;
|
||||
}
|
||||
set.delete(connId);
|
||||
if (set.size === 0) {
|
||||
subs.delete(sessionKey);
|
||||
}
|
||||
},
|
||||
removeConn(connId) {
|
||||
for (const [key, set] of subs) {
|
||||
set.delete(connId);
|
||||
if (set.size === 0) {
|
||||
subs.delete(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
get(sessionKey) {
|
||||
return subs.get(sessionKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ChatEventBroadcast = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
@ -216,6 +322,8 @@ export type AgentEventHandlerOptions = {
|
||||
resolveSessionKeyForRun: (runId: string) => string | undefined;
|
||||
clearAgentRunContext: (runId: string) => void;
|
||||
toolEventRecipients: ToolEventRecipientRegistry;
|
||||
sessionEventLog: SessionEventLog;
|
||||
sessionSubscriptions: SessionSubscriptionRegistry;
|
||||
};
|
||||
|
||||
export function createAgentEventHandler({
|
||||
@ -227,6 +335,8 @@ export function createAgentEventHandler({
|
||||
resolveSessionKeyForRun,
|
||||
clearAgentRunContext,
|
||||
toolEventRecipients,
|
||||
sessionEventLog,
|
||||
sessionSubscriptions,
|
||||
}: AgentEventHandlerOptions) {
|
||||
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
|
||||
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
|
||||
@ -358,17 +468,43 @@ export function createAgentEventHandler({
|
||||
});
|
||||
}
|
||||
agentRunSeq.set(evt.runId, evt.seq);
|
||||
|
||||
// Assign a global cursor and log the event for replay.
|
||||
const globalSeq = sessionKey
|
||||
? sessionEventLog.append(
|
||||
sessionKey,
|
||||
(isToolEvent ? toolPayload : agentPayload) as Record<string, unknown>,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (isToolEvent) {
|
||||
// Always broadcast tool events to registered WS recipients with
|
||||
// tool-events capability, regardless of verboseLevel. The verbose
|
||||
// setting only controls whether tool details are sent as channel
|
||||
// messages to messaging surfaces (Telegram, Discord, etc.).
|
||||
const recipients = toolEventRecipients.get(evt.runId);
|
||||
if (recipients && recipients.size > 0) {
|
||||
broadcastToConnIds("agent", toolPayload, recipients);
|
||||
const payload =
|
||||
globalSeq !== undefined
|
||||
? { ...(toolPayload as Record<string, unknown>), globalSeq }
|
||||
: toolPayload;
|
||||
broadcastToConnIds("agent", payload, recipients);
|
||||
}
|
||||
} else {
|
||||
broadcast("agent", agentPayload);
|
||||
const payload =
|
||||
globalSeq !== undefined
|
||||
? { ...(agentPayload as Record<string, unknown>), globalSeq }
|
||||
: agentPayload;
|
||||
broadcast("agent", payload);
|
||||
}
|
||||
|
||||
// Route to session subscribers (replay-cursor protocol consumers).
|
||||
if (sessionKey && globalSeq !== undefined) {
|
||||
const sessionSubs = sessionSubscriptions.get(sessionKey);
|
||||
if (sessionSubs && sessionSubs.size > 0) {
|
||||
const outPayload = isToolEvent ? toolPayload : agentPayload;
|
||||
broadcastToConnIds(
|
||||
"agent",
|
||||
{ ...(outPayload as Record<string, unknown>), globalSeq },
|
||||
sessionSubs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const lifecyclePhase =
|
||||
|
||||
@ -85,6 +85,8 @@ const BASE_METHODS = [
|
||||
"agent",
|
||||
"agent.identity.get",
|
||||
"agent.wait",
|
||||
"agent.subscribe",
|
||||
"agent.unsubscribe",
|
||||
"browser.request",
|
||||
// WebChat WebSocket-native chat methods
|
||||
"chat.history",
|
||||
|
||||
@ -38,6 +38,8 @@ import {
|
||||
validateAgentIdentityParams,
|
||||
validateAgentParams,
|
||||
validateAgentWaitParams,
|
||||
validateAgentSubscribeParams,
|
||||
validateAgentUnsubscribeParams,
|
||||
} from "../protocol/index.js";
|
||||
import {
|
||||
canonicalizeSpawnedByForAgent,
|
||||
@ -189,6 +191,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
groupSpace?: string;
|
||||
lane?: string;
|
||||
extraSystemPrompt?: string;
|
||||
workspace?: string;
|
||||
idempotencyKey: string;
|
||||
timeout?: number;
|
||||
label?: string;
|
||||
@ -556,6 +559,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
runId,
|
||||
lane: request.lane,
|
||||
extraSystemPrompt: request.extraSystemPrompt,
|
||||
workspace: typeof request.workspace === "string" ? request.workspace.trim() : undefined,
|
||||
inputProvenance,
|
||||
},
|
||||
defaultRuntime,
|
||||
@ -688,4 +692,47 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
error: snapshot.error,
|
||||
});
|
||||
},
|
||||
|
||||
"agent.subscribe": ({ params, client, respond, context }) => {
|
||||
const validated = validateAgentSubscribeParams(params);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_PARAMS, formatValidationErrors(validated.errors)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = validated.value;
|
||||
const connId = client?.connId;
|
||||
if (!connId) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_PARAMS, "no connection id"));
|
||||
return;
|
||||
}
|
||||
context.registerSessionSubscription(p.sessionKey, connId);
|
||||
|
||||
// Replay buffered events past the caller's cursor.
|
||||
const afterSeq = typeof p.afterSeq === "number" ? p.afterSeq : 0;
|
||||
const replayed = context.replaySessionEvents(p.sessionKey, afterSeq, connId);
|
||||
respond(true, { cursor: context.currentGlobalSeq(), replayed });
|
||||
},
|
||||
|
||||
"agent.unsubscribe": ({ params, client, respond, context }) => {
|
||||
const validated = validateAgentUnsubscribeParams(params);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_PARAMS, formatValidationErrors(validated.errors)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const connId = client?.connId;
|
||||
if (!connId) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_PARAMS, "no connection id"));
|
||||
return;
|
||||
}
|
||||
context.unregisterSessionSubscription(validated.value.sessionKey, connId);
|
||||
respond(true, {});
|
||||
},
|
||||
};
|
||||
|
||||
@ -59,6 +59,10 @@ export type GatewayRequestContext = {
|
||||
sessionKey?: string,
|
||||
) => { sessionKey: string; clientRunId: string } | undefined;
|
||||
registerToolEventRecipient: (runId: string, connId: string) => void;
|
||||
registerSessionSubscription: (sessionKey: string, connId: string) => void;
|
||||
unregisterSessionSubscription: (sessionKey: string, connId: string) => void;
|
||||
replaySessionEvents: (sessionKey: string, afterSeq: number, connId: string) => number;
|
||||
currentGlobalSeq: () => number;
|
||||
dedupe: Map<string, DedupeEntry>;
|
||||
wizardSessions: Map<string, WizardSession>;
|
||||
findRunningWizard: () => string | null;
|
||||
|
||||
@ -24,6 +24,8 @@ import {
|
||||
type ChatRunEntry,
|
||||
createChatRunState,
|
||||
createToolEventRecipientRegistry,
|
||||
createSessionEventLog,
|
||||
createSessionSubscriptionRegistry,
|
||||
} from "./server-chat.js";
|
||||
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
|
||||
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
|
||||
@ -181,6 +183,8 @@ export async function createGatewayRuntimeState(params: {
|
||||
const removeChatRun = chatRunRegistry.remove;
|
||||
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
|
||||
const toolEventRecipients = createToolEventRecipientRegistry();
|
||||
const sessionEventLog = createSessionEventLog();
|
||||
const sessionSubscriptions = createSessionSubscriptionRegistry();
|
||||
|
||||
return {
|
||||
canvasHost,
|
||||
@ -200,5 +204,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
removeChatRun,
|
||||
chatAbortControllers,
|
||||
toolEventRecipients,
|
||||
sessionEventLog,
|
||||
sessionSubscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
@ -371,6 +371,8 @@ export async function startGatewayServer(
|
||||
removeChatRun,
|
||||
chatAbortControllers,
|
||||
toolEventRecipients,
|
||||
sessionEventLog,
|
||||
sessionSubscriptions,
|
||||
} = await createGatewayRuntimeState({
|
||||
cfg: cfgAtStart,
|
||||
bindHost,
|
||||
@ -508,6 +510,8 @@ export async function startGatewayServer(
|
||||
resolveSessionKeyForRun,
|
||||
clearAgentRunContext,
|
||||
toolEventRecipients,
|
||||
sessionEventLog,
|
||||
sessionSubscriptions,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -598,6 +602,20 @@ export async function startGatewayServer(
|
||||
addChatRun,
|
||||
removeChatRun,
|
||||
registerToolEventRecipient: toolEventRecipients.add,
|
||||
registerSessionSubscription: sessionSubscriptions.add,
|
||||
unregisterSessionSubscription: sessionSubscriptions.remove,
|
||||
replaySessionEvents: (sessionKey: string, afterSeq: number, connId: string) => {
|
||||
const events = sessionEventLog.replayAfter(sessionKey, afterSeq);
|
||||
for (const entry of events) {
|
||||
broadcastToConnIds(
|
||||
"agent",
|
||||
{ ...entry.payload, globalSeq: entry.globalSeq },
|
||||
new Set([connId]),
|
||||
);
|
||||
}
|
||||
return events.length;
|
||||
},
|
||||
currentGlobalSeq: () => sessionEventLog.currentSeq(),
|
||||
dedupe,
|
||||
wizardSessions,
|
||||
findRunningWizard,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user