🚀 RELEASE: Workspaces

This commit is contained in:
kumarabhirup 2026-02-19 14:59:34 -08:00
parent c7842901e2
commit 21f60da24d
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
41 changed files with 2973 additions and 228 deletions

View File

@ -0,0 +1,97 @@
import { subscribeToSubagent, hasActiveSubagent, isSubagentRunning, ensureRegisteredFromDisk } from "@/lib/subagent-runs";
import type { SseEvent } 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;
/**
* Ensure the subagent is registered in the in-memory SubagentRunManager.
* Tries the shared ensureRegisteredFromDisk helper first, which reads the
* on-disk registry (~/.openclaw/subagents/runs.json).
*/
function ensureRegistered(sessionKey: string): boolean {
if (hasActiveSubagent(sessionKey)) {return true;}
// Look up the parent web session ID from the on-disk registry
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
if (!existsSync(registryPath)) {return false;}
try {
const raw = JSON.parse(readFileSync(registryPath, "utf-8"));
const runs = raw?.runs;
if (!runs || typeof runs !== "object") {return false;}
for (const entry of Object.values(runs)) {
if (entry.childSessionKey === sessionKey) {
const rsk = typeof entry.requesterSessionKey === "string" ? entry.requesterSessionKey : "";
const webIdMatch = rsk.match(/^agent:[^:]+:web:(.+)$/);
const parentWebSessionId = webIdMatch?.[1] ?? "";
return ensureRegisteredFromDisk(sessionKey, parentWebSessionId);
}
}
} catch { /* ignore */ }
return false;
}
export async function GET(req: Request) {
const url = new URL(req.url);
const sessionKey = url.searchParams.get("sessionKey");
if (!sessionKey) {
return new Response("sessionKey required", { status: 400 });
}
// Lazily register the subagent so events get buffered
const registered = ensureRegistered(sessionKey);
if (!registered && !hasActiveSubagent(sessionKey)) {
return new Response("Subagent not found", { status: 404 });
}
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: SseEvent | 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 */ }
},
{ 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",
},
});
}

View 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";
}

View File

@ -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;

View File

@ -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 });

View File

@ -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 {

View File

@ -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 }

View File

@ -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[] = [];

View 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,
});
}

View 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,
});
}

View File

@ -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)) {

View File

@ -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)) {

View File

@ -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",
);

View File

@ -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 {

View File

@ -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 });

View File

@ -1,13 +1,10 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } 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,27 @@ 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;
}
function readIndex(): WebSessionMeta[] {
ensureDir();
if (!existsSync(INDEX_FILE)) {return [];}
const dir = ensureDir();
const indexFile = join(dir, "index.json");
if (!existsSync(indexFile)) {return [];}
try {
return JSON.parse(readFileSync(INDEX_FILE, "utf-8"));
return JSON.parse(readFileSync(indexFile, "utf-8"));
} catch {
return [];
}
}
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 +72,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 });
}

View File

@ -0,0 +1,98 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const BOOTSTRAP_FILES: Record<string, string> = {
"AGENTS.md": `# Workspace Agent Instructions
Add instructions here that your agent should follow when working in this workspace.
`,
"SOUL.md": `# Soul
Describe the personality and behavior of your agent here.
`,
"USER.md": `# User
Describe yourself your preferences, context, and how you'd like the agent to interact with you.
`,
};
export async function POST(req: Request) {
const body = (await req.json()) as {
profile?: string;
/** Absolute path override (optional; defaults to profile-based resolution). */
path?: string;
/** Seed bootstrap files into the new workspace. Default true. */
seedBootstrap?: boolean;
};
const profileName = body.profile?.trim() || null;
// Validate profile name if provided
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));
}
} else {
const stateDir = resolveOpenClawStateDir();
if (profileName && profileName !== "default") {
workspaceDir = join(stateDir, `workspace-${profileName}`);
} else {
workspaceDir = join(stateDir, "workspace");
}
}
// Create the workspace directory
try {
mkdirSync(workspaceDir, { recursive: true });
} catch (err) {
return Response.json(
{ error: `Failed to create workspace directory: ${(err as Error).message}` },
{ status: 500 },
);
}
// Seed bootstrap files
const seedBootstrap = body.seedBootstrap !== false;
const seeded: string[] = [];
if (seedBootstrap) {
for (const [filename, content] of Object.entries(BOOTSTRAP_FILES)) {
const filePath = join(workspaceDir, filename);
if (!existsSync(filePath)) {
try {
writeFileSync(filePath, content, "utf-8");
seeded.push(filename);
} catch {
// Skip files that can't be written (permissions, etc.)
}
}
}
}
// If a profile was specified, switch to it
if (profileName) {
setUIActiveProfile(profileName === "default" ? null : profileName);
}
return Response.json({
workspaceDir,
profile: profileName || "default",
activeProfile: getEffectiveProfile() || "default",
seededFiles: seeded,
workspaceRoot: resolveWorkspaceRoot(),
});
}

View File

@ -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,
});
}

View File

@ -1,7 +1,6 @@
import { readdirSync, readFileSync, existsSync, 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";
@ -152,11 +151,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[] = [];
@ -208,15 +207,15 @@ function buildSkillsVirtualFolder(): TreeNode | null {
export async function GET() {
const home = homedir();
const openclawDir = join(home, ".openclaw");
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
@ -231,5 +230,5 @@ export async function GET() {
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 });
}

View File

@ -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));
}

View File

@ -37,7 +37,8 @@ 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" {
@ -115,15 +116,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 +140,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 +175,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 +183,7 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
});
}
}
}
flush();
return segments;
@ -633,7 +651,7 @@ const mdComponents: Components = {
/* ─── Chat message ─── */
export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: { message: UIMessage; isStreaming?: boolean }) {
export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void }) {
const isUser = message.role === "user";
const segments = groupParts(message.parts);
@ -798,31 +816,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>

View File

@ -458,6 +458,15 @@ type QueuedMessage = {
createdAt: number;
};
export type SubagentSpawnInfo = {
childSessionKey: string;
runId: string;
task: string;
label?: string;
parentSessionId: string;
status?: "running" | "completed" | "error";
};
type ChatPanelProps = {
/** When set, scopes sessions to this file and prepends content as context. */
fileContext?: FileContext;
@ -473,6 +482,10 @@ type ChatPanelProps = {
onActiveSessionChange?: (sessionId: string | null) => void;
/** Called when session list needs refresh (for external sidebar). */
onSessionsChange?: () => void;
/** Called when the agent spawns a subagent. */
onSubagentSpawned?: (info: SubagentSpawnInfo) => void;
/** Called when user clicks a subagent card in the chat to view its output. */
onSubagentClick?: (task: string) => void;
};
export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
@ -485,6 +498,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
onFileChanged,
onActiveSessionChange,
onSessionsChange,
onSubagentSpawned,
onSubagentClick,
},
ref,
) {
@ -865,6 +880,43 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount
}, []);
// ── Poll for subagent spawns during active streaming ──
useEffect(() => {
if (!currentSessionId || !onSubagentSpawned) {return;}
let cancelled = false;
const poll = async () => {
try {
const res = await fetch(
`/api/chat/subagents?sessionId=${encodeURIComponent(currentSessionId)}`,
);
if (cancelled || !res.ok) {return;}
const data = await res.json();
const subagents: Array<{
sessionKey: string;
runId: string;
task: string;
label?: string;
status: "running" | "completed" | "error";
}> = data.subagents ?? [];
for (const sa of subagents) {
onSubagentSpawned({
childSessionKey: sa.sessionKey,
runId: sa.runId,
task: sa.task,
label: sa.label,
parentSessionId: currentSessionId,
status: sa.status,
});
}
} catch { /* ignore */ }
};
void poll();
const id = setInterval(poll, 3_000);
return () => { cancelled = true; clearInterval(id); };
}, [currentSessionId, onSubagentSpawned]);
// ── Post-stream side-effects (file-reload, session refresh) ──
// Message persistence is handled server-side by ActiveRunManager,
// so we only refresh the file sessions list and notify the parent
@ -1489,13 +1541,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<div
className={`${compact ? "" : "max-w-2xl mx-auto"} py-3`}
>
{messages.map((message, i) => (
<ChatMessage
key={message.id}
message={message}
isStreaming={isStreaming && i === messages.length - 1}
/>
))}
{messages.map((message, i) => (
<ChatMessage
key={message.id}
message={message}
isStreaming={isStreaming && i === messages.length - 1}
onSubagentClick={onSubagentClick}
/>
))}
<div ref={messagesEndRef} />
</div>
)}

View File

@ -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,45 @@ 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)}
/>
</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 ? (

View File

@ -0,0 +1,349 @@
"use client";
import { useEffect, useRef, useState, useMemo } from "react";
import { ChatMessage } from "./chat-message";
import type { UIMessage } from "ai";
type ParsedPart =
| { type: "text"; text: string }
| { type: "reasoning"; text: string; state?: string }
| {
type: "dynamic-tool";
toolName: string;
toolCallId: string;
state: string;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
};
function createSubagentStreamParser() {
const parts: ParsedPart[] = [];
let currentTextIdx = -1;
let currentReasoningIdx = -1;
function processEvent(event: Record<string, unknown>) {
const t = event.type as string;
switch (t) {
case "reasoning-start":
parts.push({ type: "reasoning", text: "", state: "streaming" });
currentReasoningIdx = parts.length - 1;
break;
case "reasoning-delta": {
if (currentReasoningIdx >= 0) {
const p = parts[currentReasoningIdx] as { type: "reasoning"; text: string };
p.text += event.delta as string;
}
break;
}
case "reasoning-end":
if (currentReasoningIdx >= 0) {
const p = parts[currentReasoningIdx] as { type: "reasoning"; state?: string };
delete p.state;
}
currentReasoningIdx = -1;
break;
case "text-start":
parts.push({ type: "text", text: "" });
currentTextIdx = parts.length - 1;
break;
case "text-delta": {
if (currentTextIdx >= 0) {
const p = parts[currentTextIdx] as { type: "text"; text: string };
p.text += event.delta as string;
}
break;
}
case "text-end":
currentTextIdx = -1;
break;
case "tool-input-start":
parts.push({
type: "dynamic-tool",
toolCallId: event.toolCallId as string,
toolName: event.toolName as string,
state: "input-available",
input: {},
});
break;
case "tool-input-available":
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p.type === "dynamic-tool" && p.toolCallId === event.toolCallId) {
p.input = (event.input as Record<string, unknown>) ?? {};
break;
}
}
break;
case "tool-output-available":
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p.type === "dynamic-tool" && p.toolCallId === event.toolCallId) {
p.state = "output-available";
p.output = (event.output as Record<string, unknown>) ?? {};
break;
}
}
break;
case "tool-output-error":
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p.type === "dynamic-tool" && p.toolCallId === event.toolCallId) {
p.state = "error";
p.output = { error: event.errorText as string };
break;
}
}
break;
}
}
return {
processEvent,
getParts: (): ParsedPart[] => parts.map((p) => ({ ...p })),
};
}
type SubagentPanelProps = {
sessionKey: string;
task: string;
label?: string;
onBack: () => void;
};
export function SubagentPanel({ sessionKey, task, label, onBack }: SubagentPanelProps) {
const [messages, setMessages] = useState<
Array<{ id: string; role: "assistant"; parts: UIMessage["parts"] }>
>([]);
const [isStreaming, setIsStreaming] = useState(true);
const [connected, setConnected] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const userScrolledAwayRef = useRef(false);
const abortRef = useRef<AbortController | null>(null);
const displayLabel = label || (task.length > 60 ? task.slice(0, 60) + "..." : task);
// Auto-scroll
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;}
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Reset state when switching between subagents
useEffect(() => {
setMessages([]);
setIsStreaming(true);
setConnected(false);
userScrolledAwayRef.current = false;
}, [sessionKey]);
// Connect to subagent SSE stream
useEffect(() => {
const abort = new AbortController();
abortRef.current = abort;
const connect = async () => {
try {
const res = await fetch(
`/api/chat/subagent-stream?sessionKey=${encodeURIComponent(sessionKey)}`,
{ signal: abort.signal },
);
if (!res.ok || !res.body) {
setIsStreaming(false);
return;
}
const isActive = res.headers.get("X-Run-Active") !== "false";
setConnected(true);
setIsStreaming(isActive);
const parser = createSubagentStreamParser();
const reader = res.body.getReader();
const decoder = new TextDecoder();
const msgId = `subagent-${sessionKey}`;
let buffer = "";
let frameRequested = false;
const updateUI = () => {
if (abort.signal.aborted) {return;}
const assistantMsg = {
id: msgId,
role: "assistant" as const,
parts: parser.getParts() as UIMessage["parts"],
};
setMessages([assistantMsg]);
};
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- loop
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));
parser.processEvent(event);
} catch { /* skip */ }
}
}
if (!frameRequested) {
frameRequested = true;
requestAnimationFrame(() => {
frameRequested = false;
updateUI();
});
}
}
updateUI();
setIsStreaming(false);
} catch (err) {
if ((err as Error).name !== "AbortError") {
console.error("Subagent stream error:", err);
}
setIsStreaming(false);
}
};
void connect();
return () => { abort.abort(); };
}, [sessionKey]);
const statusLabel = useMemo(() => {
if (!connected && isStreaming) {return "Connecting...";}
if (isStreaming) {return "Streaming...";}
return "Completed";
}, [connected, isStreaming]);
return (
<div ref={scrollContainerRef} className="h-full overflow-y-auto">
<div className="flex flex-col min-h-full">
{/* Header */}
<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">
<div className="flex items-center gap-2">
<span
className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
Subagent
</span>
<h2
className="text-sm font-semibold truncate"
style={{ color: "var(--color-text)" }}
>
{displayLabel}
</h2>
</div>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{statusLabel}
</p>
</div>
{isStreaming && (
<span
className="inline-block w-2 h-2 rounded-full animate-pulse flex-shrink-0"
style={{ background: "var(--color-accent)" }}
/>
)}
</header>
{/* Messages */}
<div className="flex-1 px-6">
{messages.length === 0 && isStreaming ? (
<div className="flex items-center justify-center h-full min-h-[40vh]">
<div className="text-center">
<div
className="w-6 h-6 border-2 rounded-full animate-spin mx-auto mb-3"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Waiting for subagent...
</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full min-h-[40vh]">
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
No output from subagent.
</p>
</div>
) : (
<div className="max-w-2xl mx-auto py-3">
{messages.map((message, i) => (
<ChatMessage
key={message.id}
message={message}
isStreaming={isStreaming && i === messages.length - 1}
/>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Task description */}
{task && task.length > 60 && (
<div
className="px-6 py-3 sticky bottom-0 z-10 backdrop-blur-md"
style={{ background: "var(--color-bg-glass)" }}
>
<details className="text-xs" style={{ color: "var(--color-text-muted)" }}>
<summary className="cursor-pointer font-medium">Task description</summary>
<p className="mt-1 whitespace-pre-wrap leading-relaxed">{task}</p>
</details>
</div>
)}
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
type WebSession = {
id: string;
@ -10,6 +10,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,8 +26,14 @@ 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. */
@ -60,6 +75,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
@ -82,8 +116,11 @@ export function ChatSessionsSidebar({
activeSessionId,
activeSessionTitle: _activeSessionTitle,
streamingSessionIds,
subagents,
activeSubagentKey,
onSelectSession,
onNewSession,
onSelectSubagent,
mobile,
onClose,
}: ChatSessionsSidebarProps) {
@ -97,6 +134,29 @@ export function ChatSessionsSidebar({
[onSelectSession, onClose],
);
const handleSelectSubagentItem = useCallback(
(sessionKey: string) => {
onSelectSubagent?.(sessionKey);
onClose?.();
},
[onSelectSubagent, onClose],
);
// 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);
@ -169,12 +229,13 @@ 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 isStreamingSession = streamingSessionIds?.has(session.id) ?? false;
const sessionSubagents = subagentsByParent.get(session.id);
return (
<div key={session.id}>
<button
key={session.id}
type="button"
onClick={() => handleSelect(session.id)}
onMouseEnter={() => setHoveredId(session.id)}
@ -232,6 +293,52 @@ export function ChatSessionsSidebar({
)}
</div>
</button>
{/* 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-accent-light)"
: "transparent",
}}
>
<div className="flex items-center gap-1.5">
{isSubRunning && (
<span
className="inline-block w-1 h-1 rounded-full flex-shrink-0 animate-pulse"
style={{ background: "var(--color-accent)" }}
title="Subagent running"
/>
)}
<SubagentIcon />
<span
className="text-[11px] truncate"
style={{
color: isSubActive
? "var(--color-accent)"
: "var(--color-text-muted)",
}}
>
{truncated}
</span>
</div>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>

View File

@ -0,0 +1,314 @@
"use client";
import { useState, useRef, useEffect } from "react";
type CreateWorkspaceDialogProps = {
isOpen: boolean;
onClose: () => void;
onCreated?: () => void;
};
export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) {
const [profileName, setProfileName] = useState("");
const [customPath, setCustomPath] = useState("");
const [useCustomPath, setUseCustomPath] = 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);
setError(null);
setResult(null);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
// Close on Escape
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {onClose();}
}
if (isOpen) {
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}
}, [isOpen, onClose]);
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 && (
<input
type="text"
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
placeholder="~/my-workspace or /absolute/path"
className="w-full mt-2 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)",
}}
/>
)}
</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 (AGENTS.md, SOUL.md, USER.md)
</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>
</div>
);
}

View File

@ -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="&laquo;" />
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="&lsaquo;" />
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="&rsaquo;" />
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="&raquo;" />
{serverPagination ? (
<>
<PaginationButton onClick={() => serverPagination.onPageChange(1)} disabled={serverPagination.page <= 1} label="&laquo;" />
<PaginationButton onClick={() => serverPagination.onPageChange(serverPagination.page - 1)} disabled={serverPagination.page <= 1} label="&lsaquo;" />
<PaginationButton onClick={() => serverPagination.onPageChange(serverPagination.page + 1)} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="&rsaquo;" />
<PaginationButton onClick={() => serverPagination.onPageChange(Math.ceil(serverPagination.totalCount / serverPagination.pageSize))} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="&raquo;" />
</>
) : (
<>
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="&laquo;" />
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="&lsaquo;" />
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="&rsaquo;" />
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="&raquo;" />
</>
)}
</div>
</div>
</div>

View File

@ -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>
);
}

View File

@ -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 */}

View File

@ -0,0 +1,201 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
export type ProfileInfo = {
name: string;
stateDir: string;
workspaceDir: string | null;
isActive: boolean;
hasConfig: boolean;
};
type ProfileSwitcherProps = {
onProfileSwitch?: () => void;
onCreateWorkspace?: () => void;
};
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace }: 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]);
// 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;
if (!showSwitcher) {return null;}
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
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>
{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>
);
}

View File

@ -2,6 +2,8 @@
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";
/** Shape returned by /api/workspace/suggest-files */
type SuggestItem = {
@ -37,6 +39,10 @@ type WorkspaceSidebarProps = {
mobile?: boolean;
/** Close the mobile drawer. */
onClose?: () => void;
/** Active workspace profile name (null = default). */
activeProfile?: string | null;
/** Called after the user switches to a different profile. */
onProfileSwitch?: () => void;
};
function HomeIcon() {
@ -393,8 +399,11 @@ export function WorkspaceSidebar({
onExternalDrop,
mobile,
onClose,
activeProfile,
onProfileSwitch,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
const sidebar = (
<aside
@ -479,18 +488,49 @@ export function WorkspaceSidebar({
{orgName || "Workspace"}
</div>
<div
className="text-[11px]"
className="text-[11px] flex items-center gap-1"
style={{
color: "var(--color-text-muted)",
}}
>
Ironclaw
<span>Ironclaw</span>
{activeProfile && activeProfile !== "default" && (
<span
className="px-1 py-0.5 rounded text-[10px]"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
{activeProfile}
</span>
)}
</div>
</div>
</>
)}
</div>
{/* Profile switcher — only in workspace mode */}
{!isBrowsing && (
<div
className="px-3 py-1.5 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<ProfileSwitcher
onProfileSwitch={onProfileSwitch}
onCreateWorkspace={() => setShowCreateWorkspace(true)}
/>
</div>
)}
{/* Create workspace dialog */}
<CreateWorkspaceDialog
isOpen={showCreateWorkspace}
onClose={() => setShowCreateWorkspace(false)}
onCreated={onProfileSwitch}
/>
{/* File search */}
{onFileSearchSelect && (
<FileSearch onSelect={onFileSearchSelect} />

View File

@ -28,6 +28,7 @@ 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);
const mountedRef = useRef(true);
const retryDelayRef = useRef(1000);
@ -35,6 +36,10 @@ 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;
@ -46,6 +51,7 @@ export function useWorkspaceWatcher() {
setExists(data.exists ?? false);
setWorkspaceRoot(data.workspaceRoot ?? null);
setOpenclawDir(data.openclawDir ?? null);
setActiveProfile(data.profile ?? null);
setLoading(false);
}
} catch {
@ -107,6 +113,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 +209,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 };
}

View File

@ -16,7 +16,8 @@ import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar";
import { EmptyState } from "../components/workspace/empty-state";
import { ReportViewer } from "../components/charts/report-viewer";
import { ChatPanel, type ChatPanelHandle } from "../components/chat-panel";
import { ChatPanel, type ChatPanelHandle, type SubagentSpawnInfo } from "../components/chat-panel";
import { SubagentPanel } from "../components/subagent-panel";
import { EntryDetailModal } from "../components/workspace/entry-detail-modal";
import { useSearchIndex } from "@/lib/search-index";
import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links";
@ -26,7 +27,7 @@ import { CronJobDetail } from "../components/cron/cron-job-detail";
import type { CronJob, CronJobsResponse } from "../types/cron";
import { useIsMobile } from "../hooks/use-mobile";
import { ObjectFilterBar } from "../components/workspace/object-filter-bar";
import { type FilterGroup, type SavedView, emptyFilterGroup, matchesFilter } from "@/lib/object-filters";
import { type FilterGroup, type SortRule, type SavedView, emptyFilterGroup, serializeFilters } from "@/lib/object-filters";
// --- Types ---
@ -77,6 +78,9 @@ type ObjectData = {
effectiveDisplayField?: string;
savedViews?: import("@/lib/object-filters").SavedView[];
activeView?: string;
totalCount?: number;
page?: number;
pageSize?: number;
};
type FileData = {
@ -229,9 +233,13 @@ function WorkspacePageInner() {
// Live-reactive tree via SSE watcher (with browse-mode support)
const {
tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree,
reconnect: reconnectWorkspace,
browseDir, setBrowseDir, parentDir: browseParentDir, workspaceRoot, openclawDir,
activeProfile,
} = useWorkspaceWatcher();
// handleProfileSwitch is defined below fetchSessions/fetchCronJobs (avoids TDZ)
// Search index for @ mention fuzzy search (files + entries)
const { search: searchIndex } = useSearchIndex();
@ -246,6 +254,46 @@ function WorkspacePageInner() {
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
const [streamingSessionIds, setStreamingSessionIds] = useState<Set<string>>(new Set());
// Subagent tracking
const [subagents, setSubagents] = useState<SubagentSpawnInfo[]>([]);
const [activeSubagentKey, setActiveSubagentKey] = useState<string | null>(null);
const handleSubagentSpawned = useCallback((info: SubagentSpawnInfo) => {
setSubagents((prev) => {
const idx = prev.findIndex((sa) => sa.childSessionKey === info.childSessionKey);
if (idx >= 0) {
// Update status if changed
if (prev[idx].status === info.status) {return prev;}
const updated = [...prev];
updated[idx] = { ...prev[idx], ...info };
return updated;
}
return [...prev, info];
});
}, []);
const handleSelectSubagent = useCallback((sessionKey: string) => {
setActiveSubagentKey(sessionKey);
}, []);
const handleBackFromSubagent = useCallback(() => {
setActiveSubagentKey(null);
}, []);
// Navigate to a subagent panel when its card is clicked in the chat
const handleSubagentClickFromChat = useCallback((task: string) => {
const match = subagents.find((sa) => sa.task === task);
if (match) {
setActiveSubagentKey(match.childSessionKey);
}
}, [subagents]);
// Find the active subagent's info for the panel
const activeSubagent = useMemo(() => {
if (!activeSubagentKey) {return null;}
return subagents.find((sa) => sa.childSessionKey === activeSubagentKey) ?? null;
}, [activeSubagentKey, subagents]);
// Cron jobs state
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
@ -357,6 +405,18 @@ function WorkspacePageInner() {
return () => clearInterval(id);
}, [fetchCronJobs]);
// After profile switch or workspace creation, reconnect SSE + refresh all data
const handleProfileSwitch = useCallback(() => {
reconnectWorkspace();
void fetchSessions();
void fetchCronJobs();
setActivePath(null);
setContent({ kind: "none" });
setActiveSessionId(null);
setSubagents([]);
setActiveSubagentKey(null);
}, [reconnectWorkspace, fetchSessions, fetchCronJobs]);
// Load content when path changes
const loadContent = useCallback(
async (node: TreeNode) => {
@ -446,8 +506,8 @@ function WorkspacePageInner() {
setContent({ kind: "cron-dashboard" });
return;
}
// Clicking the web-chat directory → switch to workspace mode & open chats
if (node.path === openclawDir + "/web-chat") {
// Clicking any web-chat directory → switch to workspace mode & open chats
if (openclawDir && node.path.startsWith(openclawDir + "/web-chat")) {
setBrowseDir(null);
setActivePath(null);
setContent({ kind: "none" });
@ -878,6 +938,8 @@ function WorkspacePageInner() {
workspaceRoot={workspaceRoot}
onGoToChat={() => { handleGoToChat(); setSidebarOpen(false); }}
onExternalDrop={handleSidebarExternalDrop}
activeProfile={activeProfile}
onProfileSwitch={handleProfileSwitch}
mobile
onClose={() => setSidebarOpen(false)}
/>
@ -898,6 +960,8 @@ function WorkspacePageInner() {
workspaceRoot={workspaceRoot}
onGoToChat={handleGoToChat}
onExternalDrop={handleSidebarExternalDrop}
activeProfile={activeProfile}
onProfileSwitch={handleProfileSwitch}
/>
)}
@ -1012,16 +1076,28 @@ function WorkspacePageInner() {
/* Main chat view (default when no file is selected) */
<>
<div className="flex-1 flex flex-col min-w-0">
{activeSubagent ? (
<SubagentPanel
sessionKey={activeSubagent.childSessionKey}
task={activeSubagent.task}
label={activeSubagent.label}
onBack={handleBackFromSubagent}
/>
) : (
<ChatPanel
ref={chatRef}
sessionTitle={activeSessionTitle}
initialSessionId={activeSessionId ?? undefined}
onActiveSessionChange={(id) => {
setActiveSessionId(id);
setActiveSubagentKey(null);
}}
onSessionsChange={refreshSessions}
onSubagentSpawned={handleSubagentSpawned}
onSubagentClick={handleSubagentClickFromChat}
compact={isMobile}
/>
)}
</div>
{/* Chat sessions sidebar — static on desktop, drawer overlay on mobile */}
{isMobile ? (
@ -1031,16 +1107,21 @@ function WorkspacePageInner() {
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
void chatRef.current?.loadSession(sessionId);
}}
onNewSession={() => {
setActiveSessionId(null);
setActiveSubagentKey(null);
void chatRef.current?.newSession();
router.replace("/workspace", { scroll: false });
setChatSessionsOpen(false);
}}
onSelectSubagent={handleSelectSubagent}
mobile
onClose={() => setChatSessionsOpen(false)}
/>
@ -1051,15 +1132,20 @@ function WorkspacePageInner() {
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
void chatRef.current?.loadSession(sessionId);
}}
onNewSession={() => {
setActiveSessionId(null);
setActiveSubagentKey(null);
void chatRef.current?.newSession();
router.replace("/workspace", { scroll: false });
}}
onSelectSubagent={handleSelectSubagent}
/>
)}
</>
@ -1084,6 +1170,7 @@ function WorkspacePageInner() {
searchFn={searchIndex}
onSelectCronJob={handleSelectCronJob}
onBackToCronDashboard={handleBackToCronDashboard}
onWorkspaceCreated={handleProfileSwitch}
/>
</div>
@ -1148,6 +1235,7 @@ function ContentRenderer({
searchFn,
onSelectCronJob,
onBackToCronDashboard,
onWorkspaceCreated,
}: {
content: ContentState;
workspaceExists: boolean;
@ -1167,6 +1255,8 @@ function ContentRenderer({
searchFn: (query: string, limit?: number) => import("@/lib/search-index").SearchIndexItem[];
onSelectCronJob: (jobId: string) => void;
onBackToCronDashboard: () => void;
/** Called after a new workspace is created from the empty state. */
onWorkspaceCreated?: () => void;
}) {
switch (content.kind) {
case "loading":
@ -1298,7 +1388,7 @@ function ContentRenderer({
case "none":
default:
if (tree.length === 0) {
return <EmptyState workspaceExists={workspaceExists} />;
return <EmptyState workspaceExists={workspaceExists} onWorkspaceCreated={onWorkspaceCreated} />;
}
return <WelcomeView tree={tree} onNodeSelect={onNodeSelect} />;
}
@ -1326,6 +1416,15 @@ function ObjectView({
const [savedViews, setSavedViews] = useState<SavedView[]>(data.savedViews ?? []);
const [activeViewName, setActiveViewName] = useState<string | undefined>(data.activeView);
// --- Server-side pagination state ---
const [serverPage, setServerPage] = useState(data.page ?? 1);
const [serverPageSize, setServerPageSize] = useState(data.pageSize ?? 100);
const [totalCount, setTotalCount] = useState(data.totalCount ?? data.entries.length);
const [entries, setEntries] = useState(data.entries);
const [serverSearch, setServerSearch] = useState("");
const [sortRules, setSortRules] = useState<SortRule[] | undefined>(undefined);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Column visibility: maps field IDs to boolean (false = hidden)
const [viewColumns, setViewColumns] = useState<string[] | undefined>(undefined);
@ -1339,6 +1438,54 @@ function ObjectView({
return vis;
}, [viewColumns, data.fields]);
// Fetch entries from server with current pagination/filter/sort/search state
const fetchEntries = useCallback(async (opts?: {
page?: number;
pageSize?: number;
filters?: FilterGroup;
sort?: SortRule[];
search?: string;
}) => {
const p = opts?.page ?? serverPage;
const ps = opts?.pageSize ?? serverPageSize;
const f = opts?.filters ?? filters;
const s = opts?.sort ?? sortRules;
const q = opts?.search ?? serverSearch;
const params = new URLSearchParams();
params.set("page", String(p));
params.set("pageSize", String(ps));
if (f && f.rules.length > 0) {
params.set("filters", serializeFilters(f));
}
if (s && s.length > 0) {
params.set("sort", JSON.stringify(s));
}
if (q) {
params.set("search", q);
}
try {
const res = await fetch(
`/api/workspace/objects/${encodeURIComponent(data.object.name)}?${params.toString()}`
);
if (!res.ok) {return;}
const result: ObjectData = await res.json();
setEntries(result.entries);
setTotalCount(result.totalCount ?? result.entries.length);
setServerPage(result.page ?? p);
setServerPageSize(result.pageSize ?? ps);
} catch {
// ignore
}
}, [serverPage, serverPageSize, filters, sortRules, serverSearch, data.object.name]);
// Sync initial data from props (when parent refreshes via SSE)
useEffect(() => {
setEntries(data.entries);
setTotalCount(data.totalCount ?? data.entries.length);
}, [data.entries, data.totalCount]);
// Sync saved views when data changes (e.g. SSE refresh from AI editing .object.yaml)
useEffect(() => {
setSavedViews(data.savedViews ?? []);
@ -1348,17 +1495,51 @@ function ObjectView({
setFilters(view.filters ?? emptyFilterGroup());
setViewColumns(view.columns);
setActiveViewName(view.name);
// Re-fetch with new filters from the view
void fetchEntries({ page: 1, filters: view.filters ?? emptyFilterGroup() });
}
}
// Only re-run when the API data itself changes (not our local state)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.savedViews, data.activeView]);
// Apply client-side filtering
const filteredEntries = useMemo(
() => matchesFilter(data.entries, filters),
[data.entries, filters],
);
// When filters change, reset to page 1 and re-fetch
const handleFiltersChange = useCallback((newFilters: FilterGroup) => {
setFilters(newFilters);
setServerPage(1);
void fetchEntries({ page: 1, filters: newFilters });
}, [fetchEntries]);
// Server-side search with debounce
const handleServerSearch = useCallback((query: string) => {
setServerSearch(query);
if (searchTimerRef.current) {clearTimeout(searchTimerRef.current);}
searchTimerRef.current = setTimeout(() => {
setServerPage(1);
void fetchEntries({ page: 1, search: query });
}, 300);
}, [fetchEntries]);
// Page change
const handlePageChange = useCallback((page: number) => {
setServerPage(page);
void fetchEntries({ page });
}, [fetchEntries]);
// Page size change
const handlePageSizeChange = useCallback((size: number) => {
setServerPageSize(size);
setServerPage(1);
void fetchEntries({ page: 1, pageSize: size });
}, [fetchEntries]);
// Override onRefreshObject to re-fetch with current pagination state
const handleRefresh = useCallback(() => {
void fetchEntries();
onRefreshObject();
}, [fetchEntries, onRefreshObject]);
// Use entries from server (already filtered server-side)
const filteredEntries = entries;
// Save view to .object.yaml via API
const handleSaveView = useCallback(async (name: string) => {
@ -1381,10 +1562,13 @@ function ObjectView({
}, [filters, savedViews, data.object.name]);
const handleLoadView = useCallback((view: SavedView) => {
setFilters(view.filters ?? emptyFilterGroup());
const newFilters = view.filters ?? emptyFilterGroup();
setFilters(newFilters);
setViewColumns(view.columns);
setActiveViewName(view.name);
}, []);
setServerPage(1);
void fetchEntries({ page: 1, filters: newFilters });
}, [fetchEntries]);
const handleDeleteView = useCallback(async (name: string) => {
const updated = savedViews.filter((v) => v.name !== name);
@ -1491,7 +1675,7 @@ function ObjectView({
border: "1px solid var(--color-border)",
}}
>
{filteredEntries.length}{filters.rules.length > 0 ? `/${data.entries.length}` : ""} entries
{totalCount} entries
</span>
<span
className="text-xs px-2 py-1 rounded-full"
@ -1583,7 +1767,7 @@ function ObjectView({
<ObjectFilterBar
fields={data.fields}
filters={filters}
onFiltersChange={setFilters}
onFiltersChange={handleFiltersChange}
savedViews={savedViews}
activeViewName={activeViewName}
onSaveView={handleSaveView}
@ -1604,7 +1788,7 @@ function ObjectView({
members={members}
relationLabels={data.relationLabels}
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
onRefresh={onRefreshObject}
onRefresh={handleRefresh}
/>
) : (
<ObjectTable
@ -1616,8 +1800,16 @@ function ObjectView({
reverseRelations={data.reverseRelations}
onNavigateToObject={onNavigateToObject}
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
onRefresh={onRefreshObject}
onRefresh={handleRefresh}
columnVisibility={columnVisibility}
serverPagination={{
totalCount,
page: serverPage,
pageSize: serverPageSize,
onPageChange: handlePageChange,
onPageSizeChange: handlePageSizeChange,
}}
onServerSearch={handleServerSearch}
/>
)}
</div>

View File

@ -17,7 +17,7 @@ import {
existsSync,
mkdirSync,
} from "node:fs";
import { homedir } from "node:os";
import { resolveWebChatDir } from "./workspace";
import {
type AgentEvent,
spawnAgentProcess,
@ -28,6 +28,12 @@ import {
parseErrorBody,
parseErrorFromStderr,
} from "./agent-runner";
import {
routeRawEvent as routeSubagentEvent,
ensureRegisteredFromDisk,
hasActiveSubagent as hasSubagentRun,
activateGatewayFallback,
} from "./subagent-runs";
// ── Types ──
@ -76,8 +82,9 @@ export type ActiveRun = {
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");
// 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.
@ -306,7 +313,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 +344,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,9 +355,10 @@ function updateIndex(
opts: { incrementCount?: number; title?: string },
) {
try {
if (!existsSync(INDEX_FILE)) {return;}
const idxPath = indexFile();
if (!existsSync(idxPath)) {return;}
const index = JSON.parse(
readFileSync(INDEX_FILE, "utf-8"),
readFileSync(idxPath, "utf-8"),
) as Array<Record<string, unknown>>;
const session = index.find((s) => s.id === sessionId);
if (!session) {return;}
@ -359,7 +368,7 @@ function updateIndex(
((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 */
}
@ -466,6 +475,9 @@ function wireChildProcess(run: ActiveRun): void {
// ── Parse stdout JSON lines ──
const rl = createInterface({ input: child.stdout! });
const parentSessionKey = `agent:main:web:${run.sessionId}`;
// Track which subagent session keys we've already attempted to register
const seenSubagentKeys = new Set<string>();
// Prevent unhandled 'error' events on the readline interface.
// When the child process fails to start (e.g. ENOENT — missing script)
@ -487,6 +499,29 @@ function wireChildProcess(run: ActiveRun): void {
return;
}
// ── Route non-parent events to SubagentRunManager ──
// The CLI child process receives ALL gateway broadcasts, including
// events from subagent runs. Filter them out of the parent chat
// and route to the SubagentRunManager for separate streaming.
if (ev.sessionKey && ev.sessionKey !== parentSessionKey) {
const childKey = ev.sessionKey;
// Try to register the subagent if not yet known. Events
// arriving before runs.json is written get buffered inside
// routeRawEvent and replayed upon successful registration.
if (!hasSubagentRun(childKey) && !seenSubagentKeys.has(childKey)) {
if (ensureRegisteredFromDisk(childKey, run.sessionId)) {
seenSubagentKeys.add(childKey);
}
// Don't add to seenSubagentKeys on failure — retry on the next event
}
routeSubagentEvent(childKey, {
event: ev.event,
stream: ev.stream,
data: ev.data,
});
return;
}
// Lifecycle start
if (
ev.event === "agent" &&
@ -748,6 +783,12 @@ function wireChildProcess(run: ActiveRun): void {
run.status = code === 0 || code === null ? "completed" : "error";
run.exitCode = code;
// The parent's NDJSON stream has ended. Any subagents that are
// still running lose their event source (sessions_spawn is
// fire-and-forget). Switch to gateway WebSocket subscriptions
// so they keep streaming.
activateGatewayFallback();
// Final persistence flush (removes _streaming flag).
flushPersistence(run);
@ -860,7 +901,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;

View File

@ -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;
@ -184,9 +185,15 @@ 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"],
});
}

View File

@ -0,0 +1,215 @@
/**
* Persistent WebSocket connection to the OpenClaw gateway daemon.
*
* Lazily initialized when the first subagent is detected. Receives
* broadcast agent events and routes them to the SubagentRunManager
* for live streaming in the web UI.
*/
import WebSocket from "ws";
import { randomUUID } from "node:crypto";
export type GatewayEvent = {
event: string;
payload?: Record<string, unknown>;
seq?: number;
};
type GatewayEventListener = (evt: GatewayEvent) => void;
const GLOBAL_KEY = "__openclaw_gatewayEvents" as const;
const DEFAULT_PORT = 18789;
const PROTOCOL_VERSION = 3;
type GatewayConnection = {
ws: WebSocket | null;
closed: boolean;
backoffMs: number;
listeners: Set<GatewayEventListener>;
subscribedKeys: Set<string>;
reconnectTimer: ReturnType<typeof setTimeout> | null;
};
function getConnection(): GatewayConnection {
const existing = (globalThis as Record<string, unknown>)[GLOBAL_KEY] as
| GatewayConnection
| undefined;
if (existing) {return existing;}
const conn: GatewayConnection = {
ws: null,
closed: false,
backoffMs: 1000,
listeners: new Set(),
subscribedKeys: new Set(),
reconnectTimer: null,
};
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = conn;
return conn;
}
function resolveGatewayUrl(): string {
const envPort =
process.env.OPENCLAW_GATEWAY_PORT?.trim() ||
process.env.CLAWDBOT_GATEWAY_PORT?.trim();
const port = envPort ? Number.parseInt(envPort, 10) || DEFAULT_PORT : DEFAULT_PORT;
return `ws://127.0.0.1:${port}`;
}
function resolveAuthToken(): string | undefined {
return (
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
undefined
);
}
function connect(conn: GatewayConnection): void {
if (conn.closed || conn.ws) {return;}
const url = resolveGatewayUrl();
let connectSent = false;
try {
const ws = new WebSocket(url, { maxPayload: 5 * 1024 * 1024 });
conn.ws = ws;
ws.on("open", () => {
// Wait for connect.challenge before sending connect
});
ws.on("message", (data) => {
try {
const raw = typeof data === "string" ? data : data.toString("utf-8");
const msg = JSON.parse(raw);
// Event frame: { type: "evt", event, payload, seq }
if (msg.type === "evt") {
if (msg.event === "connect.challenge" && !connectSent) {
connectSent = true;
sendConnectRequest(ws, msg.payload?.nonce);
return;
}
if (msg.event === "tick") {return;}
const evt: GatewayEvent = {
event: msg.event,
payload: msg.payload,
seq: msg.seq,
};
for (const listener of conn.listeners) {
try { listener(evt); } catch { /* ignore */ }
}
return;
}
// Response frame: { type: "res", id, ok, payload }
if (msg.type === "res" && msg.ok) {
conn.backoffMs = 1000;
}
} catch {
// ignore parse errors
}
});
ws.on("close", () => {
conn.ws = null;
scheduleReconnect(conn);
});
ws.on("error", () => {
// Error events are followed by close; reconnect handled there.
});
} catch {
conn.ws = null;
scheduleReconnect(conn);
}
}
function sendConnectRequest(ws: WebSocket, nonce?: string): void {
const token = resolveAuthToken();
const id = randomUUID();
const frame = {
type: "req",
id,
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "web-subagent-listener",
displayName: "Web Subagent Listener",
version: "dev",
platform: process.platform,
mode: "backend",
instanceId: randomUUID(),
},
caps: [],
...(nonce ? { nonce } : {}),
...(token ? { auth: { token } } : {}),
role: "operator",
scopes: ["operator.admin"],
},
};
ws.send(JSON.stringify(frame));
}
function scheduleReconnect(conn: GatewayConnection): void {
if (conn.closed || conn.subscribedKeys.size === 0) {return;}
if (conn.reconnectTimer) {return;}
const delay = conn.backoffMs;
conn.backoffMs = Math.min(conn.backoffMs * 2, 30_000);
conn.reconnectTimer = setTimeout(() => {
conn.reconnectTimer = null;
connect(conn);
}, delay);
}
/**
* Ensure the gateway connection is active and subscribe to events
* for a specific session key. Returns an unsubscribe function.
*/
export function subscribeToSessionKey(
sessionKey: string,
callback: GatewayEventListener,
): () => void {
const conn = getConnection();
conn.subscribedKeys.add(sessionKey);
const filtered: GatewayEventListener = (evt) => {
const evtSessionKey =
typeof evt.payload?.sessionKey === "string"
? evt.payload.sessionKey
: undefined;
if (evtSessionKey === sessionKey) {
callback(evt);
}
};
conn.listeners.add(filtered);
// Ensure connection is live
if (!conn.ws && !conn.closed) {
connect(conn);
}
return () => {
conn.listeners.delete(filtered);
conn.subscribedKeys.delete(sessionKey);
// If no more subscriptions, let the connection close naturally
};
}
/** Shut down the gateway connection (e.g. during cleanup). */
export function closeGatewayConnection(): void {
const conn = getConnection();
conn.closed = true;
if (conn.reconnectTimer) {
clearTimeout(conn.reconnectTimer);
conn.reconnectTimer = null;
}
conn.ws?.close();
conn.ws = null;
conn.listeners.clear();
conn.subscribedKeys.clear();
}

View File

@ -0,0 +1,529 @@
/**
* 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 the gateway WebSocket connection (gateway-events.ts).
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
extractToolResult,
buildToolOutput,
parseAgentErrorMessage,
parseErrorBody,
} from "./agent-runner";
import { subscribeToSessionKey, type GatewayEvent } from "./gateway-events";
import { resolveOpenClawStateDir } 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;
_unsubGateway: (() => void) | null;
_cleanupTimer: ReturnType<typeof setTimeout> | null;
};
type TransformState = {
idCounter: number;
currentTextId: string;
currentReasoningId: string;
textStarted: boolean;
reasoningStarted: boolean;
everSentText: boolean;
statusReasoningActive: boolean;
};
// ── Constants ──
const CLEANUP_GRACE_MS = 60_000;
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>>;
/** Pre-registration buffer: events that arrive before the subagent is registered */
preRegBuffer: Map<string, GatewayEvent[]>;
};
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(),
preRegBuffer: new Map(),
};
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = registry;
return registry;
}
// ── Public API ──
/**
* Register a newly spawned subagent. Called when the parent agent's
* `sessions_spawn` tool result is detected in active-runs.ts.
*/
export function registerSubagent(
parentWebSessionId: string,
info: { sessionKey: string; runId: string; task: string; label?: string },
): void {
const reg = getRegistry();
// Avoid duplicate registration
if (reg.runs.has(info.sessionKey)) {return;}
const run: SubagentRun = {
sessionKey: info.sessionKey,
runId: info.runId,
parentWebSessionId,
task: info.task,
label: info.label,
status: "running",
startedAt: Date.now(),
eventBuffer: [],
subscribers: new Set(),
_state: createTransformState(),
_unsubGateway: null,
_cleanupTimer: null,
};
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);
// The primary event source is the parent agent's NDJSON stream, routed
// via routeRawEvent(). We do NOT subscribe to gateway WebSocket here to
// avoid duplicate events (the parent CLI already receives all broadcasts).
// Replay any pre-registration buffered events
const buf = reg.preRegBuffer.get(info.sessionKey);
if (buf && buf.length > 0) {
for (const evt of buf) {
handleGatewayEvent(run, evt);
}
reg.preRegBuffer.delete(info.sessionKey);
}
}
/** 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 gateway WebSocket subscriptions 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
* gateway WS becomes the only event source for orphaned subagents.
*/
export function activateGatewayFallback(): void {
const reg = getRegistry();
for (const [key, run] of reg.runs) {
if (run.status === "running" && !run._unsubGateway) {
run._unsubGateway = subscribeToSessionKey(key, (evt) => {
handleGatewayEvent(run, evt);
});
}
}
}
/** 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;
}
/**
* Route a raw NDJSON agent event (from the CLI child process stdout) to the
* appropriate subagent run. This is the primary event source -- the parent
* agent's CLI process already receives all gateway broadcasts, so we piggyback
* on its NDJSON stream instead of maintaining a separate WebSocket connection.
*
* Converts the flat NDJSON event shape to the nested GatewayEvent format that
* handleGatewayEvent expects.
*/
export function routeRawEvent(
sessionKey: string,
ev: { event: string; stream?: string; data?: Record<string, unknown> },
): void {
const gwEvt: GatewayEvent = {
event: ev.event,
payload: { sessionKey, stream: ev.stream, data: ev.data },
};
const run = getRegistry().runs.get(sessionKey);
if (run) {
handleGatewayEvent(run, gwEvt);
return;
}
// Buffer events that arrive before the subagent is registered
// (runs.json may not be written yet). These are replayed on registration.
const reg = getRegistry();
let buf = reg.preRegBuffer.get(sessionKey);
if (!buf) {
buf = [];
reg.preRegBuffer.set(sessionKey, buf);
}
if (buf.length < 10_000) {
buf.push(gwEvt);
}
}
/**
* Lazily register a subagent by reading the on-disk registry
* (~/.openclaw/subagents/runs.json). Returns true if the subagent was
* found and registered (or was already registered).
*/
export function ensureRegisteredFromDisk(
sessionKey: string,
parentWebSessionId: string,
): boolean {
if (getRegistry().runs.has(sessionKey)) {return true;}
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
if (!existsSync(registryPath)) {return false;}
try {
const raw = JSON.parse(readFileSync(registryPath, "utf-8"));
const runs = raw?.runs;
if (!runs || typeof runs !== "object") {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,
});
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 handleGatewayEvent(run: SubagentRun, evt: GatewayEvent): void {
if (evt.event !== "agent" || !evt.payload) {return;}
const payload = evt.payload;
const stream = typeof payload.stream === "string" ? payload.stream : undefined;
const data =
payload.data && typeof payload.data === "object"
? (payload.data as Record<string, unknown>)
: 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);
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();
// Signal completion to all subscribers
for (const sub of run.subscribers) {
try { sub(null); } catch { /* ignore */ }
}
run.subscribers.clear();
// Unsubscribe from gateway events
run._unsubGateway?.();
run._unsubGateway = null;
// 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;
}
run._unsubGateway?.();
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);
}
}
}

View File

@ -1,22 +1,184 @@
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 };
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;
writeUIState({ activeProfile: normalized });
}
/** Reset the in-memory override (re-reads from file on next call). */
export function clearUIActiveProfileCache(): void {
_uiActiveProfile = undefined;
}
// ---------------------------------------------------------------------------
// 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
}
}
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 candidates = [
process.env.OPENCLAW_WORKSPACE,
join(homedir(), ".openclaw", "workspace"),
profile && profile.toLowerCase() !== "default"
? join(stateDir, `workspace-${profile}`)
: null,
join(stateDir, "workspace"),
].filter(Boolean) as string[];
for (const dir of candidates) {

File diff suppressed because one or more lines are too long

View File

@ -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) {

View File

@ -265,11 +265,12 @@ 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
const injectedSkills: InjectedSkillContent[] = [];
for (const entry of injectedEntries) {
const content = readSkillContent(entry.skill.filePath);
if (content) {
const rawContent = readSkillContent(entry.skill.filePath);
if (rawContent) {
const content = rawContent.replaceAll("~/.openclaw/workspace", workspaceDir);
injectedSkills.push({ name: entry.skill.name, content });
}
}

View File

@ -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") {