diff --git a/apps/web/app/api/chat/subagent-stream/route.ts b/apps/web/app/api/chat/subagent-stream/route.ts new file mode 100644 index 00000000000..a0d1c8f74ba --- /dev/null +++ b/apps/web/app/api/chat/subagent-stream/route.ts @@ -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", + }, + }); +} diff --git a/apps/web/app/api/chat/subagents/route.ts b/apps/web/app/api/chat/subagents/route.ts new file mode 100644 index 00000000000..35f3ab6f811 --- /dev/null +++ b/apps/web/app/api/chat/subagents/route.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; + +export const runtime = "nodejs"; + +type RegistryEntry = { + runId: string; + childSessionKey: string; + requesterSessionKey: string; + task: string; + label?: string; + createdAt?: number; + endedAt?: number; + outcome?: { status: string; error?: string }; +}; + +function readSubagentRegistry(): RegistryEntry[] { + const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json"); + if (!existsSync(registryPath)) {return [];} + + try { + const raw = JSON.parse(readFileSync(registryPath, "utf-8")); + if (!raw || typeof raw !== "object") {return [];} + const runs = raw.runs; + if (!runs || typeof runs !== "object") {return [];} + return Object.values(runs); + } catch { + return []; + } +} + +export async function GET(req: Request) { + const url = new URL(req.url); + const sessionId = url.searchParams.get("sessionId"); + + if (!sessionId) { + return Response.json({ error: "sessionId required" }, { status: 400 }); + } + + const webSessionKey = `agent:main:web:${sessionId}`; + const entries = readSubagentRegistry(); + + const subagents = entries + .filter((e) => e.requesterSessionKey === webSessionKey) + .map((e) => ({ + sessionKey: e.childSessionKey, + runId: e.runId, + task: e.task, + label: e.label || undefined, + status: resolveStatus(e), + startedAt: e.createdAt, + endedAt: e.endedAt, + })) + .toSorted((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)); + + return Response.json({ subagents }); +} + +function resolveStatus(e: RegistryEntry): "running" | "completed" | "error" { + if (typeof e.endedAt !== "number") {return "running";} + if (e.outcome?.status === "error") {return "error";} + return "completed"; +} diff --git a/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts b/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts index 4f27a81b8e5..05fa62cea44 100644 --- a/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts +++ b/apps/web/app/api/cron/jobs/[jobId]/runs/route.ts @@ -1,10 +1,10 @@ import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const CRON_DIR = join(homedir(), ".openclaw", "cron"); +const CRON_DIR = join(resolveOpenClawStateDir(), "cron"); type CronRunLogEntry = { ts: number; diff --git a/apps/web/app/api/cron/jobs/route.ts b/apps/web/app/api/cron/jobs/route.ts index 7d95010dd3c..0a77df08fb2 100644 --- a/apps/web/app/api/cron/jobs/route.ts +++ b/apps/web/app/api/cron/jobs/route.ts @@ -1,10 +1,10 @@ import { readFileSync, existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const CRON_DIR = join(homedir(), ".openclaw", "cron"); +const CRON_DIR = join(resolveOpenClawStateDir(), "cron"); const JOBS_FILE = join(CRON_DIR, "jobs.json"); type CronStoreFile = { @@ -46,7 +46,7 @@ function readHeartbeatInfo(): { intervalMs: number; nextDueEstimateMs: number | // Try to read agent session stores to estimate next heartbeat from lastRunMs try { - const agentsDir = join(homedir(), ".openclaw", "agents"); + const agentsDir = join(resolveOpenClawStateDir(), "agents"); if (!existsSync(agentsDir)) {return defaults;} const agentDirs = readdirSync(agentsDir, { withFileTypes: true }); diff --git a/apps/web/app/api/cron/runs/[sessionId]/route.ts b/apps/web/app/api/cron/runs/[sessionId]/route.ts index 36a420a1140..5d6369c4c1a 100644 --- a/apps/web/app/api/cron/runs/[sessionId]/route.ts +++ b/apps/web/app/api/cron/runs/[sessionId]/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -18,7 +18,7 @@ type ParsedMessage = { /** Search agent session directories for a session file by ID. */ function findSessionFile(sessionId: string): string | null { - const agentsDir = join(homedir(), ".openclaw", "agents"); + const agentsDir = join(resolveOpenClawStateDir(), "agents"); if (!existsSync(agentsDir)) {return null;} try { diff --git a/apps/web/app/api/cron/runs/search-transcript/route.ts b/apps/web/app/api/cron/runs/search-transcript/route.ts index f8e629bbd60..2b2a91be019 100644 --- a/apps/web/app/api/cron/runs/search-transcript/route.ts +++ b/apps/web/app/api/cron/runs/search-transcript/route.ts @@ -1,10 +1,10 @@ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const AGENTS_DIR = join(homedir(), ".openclaw", "agents"); +const AGENTS_DIR = join(resolveOpenClawStateDir(), "agents"); type MessagePart = | { type: "text"; text: string } diff --git a/apps/web/app/api/memories/route.ts b/apps/web/app/api/memories/route.ts index a42a614b7b7..d9b0ee2e1f6 100644 --- a/apps/web/app/api/memories/route.ts +++ b/apps/web/app/api/memories/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -11,7 +11,8 @@ type MemoryFile = { }; export async function GET() { - const workspaceDir = join(homedir(), ".openclaw", "workspace"); + const stateDir = resolveOpenClawStateDir(); + const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace"); let mainMemory: string | null = null; const dailyLogs: MemoryFile[] = []; diff --git a/apps/web/app/api/profiles/route.ts b/apps/web/app/api/profiles/route.ts new file mode 100644 index 00000000000..f4a55e6458f --- /dev/null +++ b/apps/web/app/api/profiles/route.ts @@ -0,0 +1,16 @@ +import { discoverProfiles, getEffectiveProfile, resolveOpenClawStateDir } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET() { + const profiles = discoverProfiles(); + const activeProfile = getEffectiveProfile(); + const stateDir = resolveOpenClawStateDir(); + + return Response.json({ + profiles, + activeProfile: activeProfile || "default", + stateDir, + }); +} diff --git a/apps/web/app/api/profiles/switch/route.ts b/apps/web/app/api/profiles/switch/route.ts new file mode 100644 index 00000000000..3180cee59aa --- /dev/null +++ b/apps/web/app/api/profiles/switch/route.ts @@ -0,0 +1,34 @@ +import { setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, resolveOpenClawStateDir } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(req: Request) { + const body = (await req.json()) as { profile?: string }; + const profileName = body.profile?.trim(); + + if (!profileName) { + return Response.json({ error: "Missing profile name" }, { status: 400 }); + } + + // Validate profile name: letters, numbers, hyphens, underscores only + if (profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) { + return Response.json( + { error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." }, + { status: 400 }, + ); + } + + // "default" clears the override + setUIActiveProfile(profileName === "default" ? null : profileName); + + const activeProfile = getEffectiveProfile(); + const workspaceRoot = resolveWorkspaceRoot(); + const stateDir = resolveOpenClawStateDir(); + + return Response.json({ + activeProfile: activeProfile || "default", + workspaceRoot, + stateDir, + }); +} diff --git a/apps/web/app/api/sessions/[sessionId]/route.ts b/apps/web/app/api/sessions/[sessionId]/route.ts index b2d4b68944e..39de17f10b3 100644 --- a/apps/web/app/api/sessions/[sessionId]/route.ts +++ b/apps/web/app/api/sessions/[sessionId]/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -22,12 +22,8 @@ type JSONLMessage = { data?: unknown; }; -function resolveOpenClawDir(): string { - return join(homedir(), ".openclaw"); -} - function findSessionFile(sessionId: string): string | null { - const openclawDir = resolveOpenClawDir(); + const openclawDir = resolveOpenClawStateDir(); const agentsDir = join(openclawDir, "agents"); if (!existsSync(agentsDir)) { diff --git a/apps/web/app/api/sessions/route.ts b/apps/web/app/api/sessions/route.ts index 87cae3d7697..b6d84d2a02c 100644 --- a/apps/web/app/api/sessions/route.ts +++ b/apps/web/app/api/sessions/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -36,12 +36,8 @@ type SessionRow = { contextTokens?: number; }; -function resolveOpenClawDir(): string { - return join(homedir(), ".openclaw"); -} - export async function GET() { - const openclawDir = resolveOpenClawDir(); + const openclawDir = resolveOpenClawStateDir(); const agentsDir = join(openclawDir, "agents"); if (!existsSync(agentsDir)) { diff --git a/apps/web/app/api/skills/route.ts b/apps/web/app/api/skills/route.ts index 822015cc519..d773eb81859 100644 --- a/apps/web/app/api/skills/route.ts +++ b/apps/web/app/api/skills/route.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -69,12 +69,12 @@ function scanSkillDir(dir: string, source: string): SkillEntry[] { } export async function GET() { - const home = homedir(); - const openclawDir = join(home, ".openclaw"); + const stateDir = resolveOpenClawStateDir(); + const workspaceRoot = resolveWorkspaceRoot() ?? join(stateDir, "workspace"); - const managedSkills = scanSkillDir(join(openclawDir, "skills"), "managed"); + const managedSkills = scanSkillDir(join(stateDir, "skills"), "managed"); const workspaceSkills = scanSkillDir( - join(openclawDir, "workspace", "skills"), + join(workspaceRoot, "skills"), "workspace", ); diff --git a/apps/web/app/api/web-sessions/[id]/messages/route.ts b/apps/web/app/api/web-sessions/[id]/messages/route.ts index 2e7f08cae24..ba0912c5173 100644 --- a/apps/web/app/api/web-sessions/[id]/messages/route.ts +++ b/apps/web/app/api/web-sessions/[id]/messages/route.ts @@ -5,13 +5,10 @@ import { mkdirSync, } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveWebChatDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); -const INDEX_FILE = join(WEB_CHAT_DIR, "index.json"); - type IndexEntry = { id: string; title: string; @@ -33,11 +30,13 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - const filePath = join(WEB_CHAT_DIR, `${id}.jsonl`); + const chatDir = resolveWebChatDir(); + const filePath = join(chatDir, `${id}.jsonl`); + const indexPath = join(chatDir, "index.json"); - // Auto-create the session file if it doesn't exist yet - if (!existsSync(WEB_CHAT_DIR)) { - mkdirSync(WEB_CHAT_DIR, { recursive: true }); + // Auto-create the session directory if it doesn't exist yet + if (!existsSync(chatDir)) { + mkdirSync(chatDir, { recursive: true }); } if (!existsSync(filePath)) { writeFileSync(filePath, ""); @@ -84,16 +83,16 @@ export async function POST( // Update index metadata try { - if (existsSync(INDEX_FILE)) { + if (existsSync(indexPath)) { const index: IndexEntry[] = JSON.parse( - readFileSync(INDEX_FILE, "utf-8"), + readFileSync(indexPath, "utf-8"), ); const session = index.find((s) => s.id === id); if (session) { session.updatedAt = Date.now(); if (newCount > 0) {session.messageCount += newCount;} if (title) {session.title = title;} - writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2)); + writeFileSync(indexPath, JSON.stringify(index, null, 2)); } } } catch { diff --git a/apps/web/app/api/web-sessions/[id]/route.ts b/apps/web/app/api/web-sessions/[id]/route.ts index 8607c9c9ba5..1596f7be30d 100644 --- a/apps/web/app/api/web-sessions/[id]/route.ts +++ b/apps/web/app/api/web-sessions/[id]/route.ts @@ -1,11 +1,9 @@ import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { resolveWebChatDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); - export type ChatLine = { id: string; role: "user" | "assistant"; @@ -24,7 +22,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - const filePath = join(WEB_CHAT_DIR, `${id}.jsonl`); + const filePath = join(resolveWebChatDir(), `${id}.jsonl`); if (!existsSync(filePath)) { return Response.json({ error: "Session not found" }, { status: 404 }); diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts index 3327e74a81c..7c2de3e1f1a 100644 --- a/apps/web/app/api/web-sessions/route.ts +++ b/apps/web/app/api/web-sessions/route.ts @@ -1,13 +1,10 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { 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 }); } diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts new file mode 100644 index 00000000000..949ae402786 --- /dev/null +++ b/apps/web/app/api/workspace/init/route.ts @@ -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 = { + "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(), + }); +} diff --git a/apps/web/app/api/workspace/objects/[name]/route.ts b/apps/web/app/api/workspace/objects/[name]/route.ts index 80ae11c50c8..d5ffd5619b8 100644 --- a/apps/web/app/api/workspace/objects/[name]/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/route.ts @@ -405,7 +405,7 @@ export async function GET( // Pagination const page = Math.max(1, Number(pageParam) || 1); - const pageSize = Math.min(500, Math.max(1, Number(pageSizeParam) || 200)); + const pageSize = Math.min(5000, Math.max(1, Number(pageSizeParam) || 100)); const offset = (page - 1) * pageSize; const limitClause = ` LIMIT ${pageSize} OFFSET ${offset}`; @@ -424,8 +424,15 @@ export async function GET( // Try the PIVOT view first, then fall back to raw EAV query + client-side pivot let entries: Record[] = []; + let totalCount = 0; try { + // Get total count with same WHERE clause but no LIMIT/OFFSET + const countResult = q<{ cnt: number }>(dbFile, + `SELECT COUNT(*) as cnt FROM v_${name}${whereClause}`, + ); + totalCount = countResult[0]?.cnt ?? 0; + const pivotEntries = q(dbFile, `SELECT * FROM v_${name}${whereClause}${orderByClause}${limitClause}`, ); @@ -477,5 +484,8 @@ export async function GET( effectiveDisplayField, savedViews, activeView, + totalCount, + page, + pageSize, }); } diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index 307712273e9..30e759bb3c4 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -1,7 +1,6 @@ import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs"; import { 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 /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 }); } diff --git a/apps/web/app/api/workspace/virtual-file/route.ts b/apps/web/app/api/workspace/virtual-file/route.ts index f37787de656..33b17f0ae6c 100644 --- a/apps/web/app/api/workspace/virtual-file/route.ts +++ b/apps/web/app/api/workspace/virtual-file/route.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname, resolve, normalize } from "node:path"; -import { homedir } from "node:os"; +import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -10,7 +10,8 @@ export const runtime = "nodejs"; * Returns null if the path is invalid or tries to escape. */ function resolveVirtualPath(virtualPath: string): string | null { - const home = homedir(); + const stateDir = resolveOpenClawStateDir(); + const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace"); if (virtualPath.startsWith("~skills/")) { // ~skills//SKILL.md @@ -28,8 +29,8 @@ function resolveVirtualPath(virtualPath: string): string | null { // Check workspace skills first, then managed skills const candidates = [ - join(home, ".openclaw", "workspace", "skills", skillName, "SKILL.md"), - join(home, ".openclaw", "skills", skillName, "SKILL.md"), + join(workspaceDir, "skills", skillName, "SKILL.md"), + join(stateDir, "skills", skillName, "SKILL.md"), ]; for (const candidate of candidates) { if (existsSync(candidate)) { @@ -47,8 +48,6 @@ function resolveVirtualPath(virtualPath: string): string | null { return null; } - const workspaceDir = join(home, ".openclaw", "workspace"); - if (rest === "MEMORY.md") { // Check both casing for (const filename of ["MEMORY.md", "memory.md"]) { @@ -74,7 +73,7 @@ function resolveVirtualPath(virtualPath: string): string | null { if (!rest || rest.includes("..") || rest.includes("/")) { return null; } - return join(home, ".openclaw", "workspace", rest); + return join(workspaceDir, rest); } return null; @@ -84,12 +83,13 @@ function resolveVirtualPath(virtualPath: string): string | null { * Double-check that the resolved path stays within expected directories. */ function isSafePath(absPath: string): boolean { - const home = homedir(); + const stateDir = resolveOpenClawStateDir(); + const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace"); const normalized = normalize(resolve(absPath)); const allowed = [ - normalize(join(home, ".openclaw", "skills")), - normalize(join(home, ".openclaw", "workspace", "skills")), - normalize(join(home, ".openclaw", "workspace")), + normalize(join(stateDir, "skills")), + normalize(join(workspaceDir, "skills")), + normalize(workspaceDir), ]; return allowed.some((dir) => normalized.startsWith(dir)); } diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index 77740393e9a..b97a7158a27 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -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 }: { ); } - if (segment.type === "diff-artifact") { - return ( - + + + ); + } + if (segment.type === "subagent-card") { + const truncatedTask = segment.task.length > 80 ? segment.task.slice(0, 80) + "..." : segment.task; + const isRunning = segment.status === "running"; + return ( + + + + ); + } + return ( + + + + ); })} diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index e622be6d7db..9d52779cfbd 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -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( @@ -485,6 +498,8 @@ export const ChatPanel = forwardRef( onFileChanged, onActiveSessionChange, onSessionsChange, + onSubagentSpawned, + onSubagentClick, }, ref, ) { @@ -865,6 +880,43 @@ export const ChatPanel = forwardRef( // 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(
- {messages.map((message, i) => ( - - ))} + {messages.map((message, i) => ( + + ))}
)} diff --git a/apps/web/app/components/sidebar.tsx b/apps/web/app/components/sidebar.tsx index d14f7f4fdd8..094683da12f 100644 --- a/apps/web/app/components/sidebar.tsx +++ b/apps/web/app/components/sidebar.tsx @@ -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([]); const [workspaceTree, setWorkspaceTree] = useState([]); const [loading, setLoading] = useState(true); + const [showCreateWorkspace, setShowCreateWorkspace] = useState(false); + const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0); const toggleSection = (section: SidebarSection) => { setOpenSections((prev) => { @@ -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 (
+ {/* Create workspace dialog */} + setShowCreateWorkspace(false)} + onCreated={handleProfileSwitch} + /> + {/* Content */}
{loading ? ( diff --git a/apps/web/app/components/subagent-panel.tsx b/apps/web/app/components/subagent-panel.tsx new file mode 100644 index 00000000000..da7daa9f937 --- /dev/null +++ b/apps/web/app/components/subagent-panel.tsx @@ -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; + output?: Record; + }; + +function createSubagentStreamParser() { + const parts: ParsedPart[] = []; + let currentTextIdx = -1; + let currentReasoningIdx = -1; + + function processEvent(event: Record) { + 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) ?? {}; + 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) ?? {}; + 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(null); + const scrollContainerRef = useRef(null); + const userScrolledAwayRef = useRef(false); + const abortRef = useRef(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 ( +
+
+ {/* Header */} +
+ +
+
+ + Subagent + +

+ {displayLabel} +

+
+

+ {statusLabel} +

+
+ {isStreaming && ( + + )} +
+ + {/* Messages */} +
+ {messages.length === 0 && isStreaming ? ( +
+
+
+

+ Waiting for subagent... +

+
+
+ ) : messages.length === 0 ? ( +
+

+ No output from subagent. +

+
+ ) : ( +
+ {messages.map((message, i) => ( + + ))} +
+
+ )} +
+ + {/* Task description */} + {task && task.length > 60 && ( +
+
+ Task description +

{task}

+
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx index dc1824b0791..694785e5a9c 100644 --- a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx +++ b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx @@ -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; + /** 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 ( + + + + + + ); +} + function ChatBubbleIcon() { return ( { + onSelectSubagent?.(sessionKey); + onClose?.(); + }, + [onSelectSubagent, onClose], + ); + + // Index subagents by parent session ID + const subagentsByParent = useMemo(() => { + const map = new Map(); + 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}
{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 ( +
+ {/* Subagent sub-items */} + {sessionSubagents && sessionSubagents.length > 0 && ( +
+ {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 ( + + ); + })} +
+ )} +
); })}
diff --git a/apps/web/app/components/workspace/create-workspace-dialog.tsx b/apps/web/app/components/workspace/create-workspace-dialog.tsx new file mode 100644 index 00000000000..cdbdbf39b3e --- /dev/null +++ b/apps/web/app/components/workspace/create-workspace-dialog.tsx @@ -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(null); + const [result, setResult] = useState<{ workspaceDir: string; seededFiles: string[] } | null>(null); + const inputRef = useRef(null); + const dialogRef = useRef(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 = { + 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 ( +
{ + if (e.target === e.currentTarget) {onClose();} + }} + > +
+ {/* Header */} +
+

+ New Workspace +

+ +
+ + {/* Body */} +
+ {result ? ( + /* Success state */ +
+
+ + + +
+

+ Workspace created +

+ + {result.workspaceDir.replace(/^\/Users\/[^/]+/, "~")} + + {result.seededFiles.length > 0 && ( +

+ Seeded: {result.seededFiles.join(", ")} +

+ )} +
+ ) : ( + /* Form */ + <> + {/* Profile name */} +
+ + { + 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)", + }} + /> +

+ This creates a new profile with its own workspace directory. +

+
+ + {/* Custom path toggle */} +
+ + + {useCustomPath && ( + 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)", + }} + /> + )} +
+ + {/* Bootstrap toggle */} + + + {error && ( +

+ {error} +

+ )} + + )} +
+ + {/* Footer */} +
+ {result ? ( + + ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/apps/web/app/components/workspace/data-table.tsx b/apps/web/app/components/workspace/data-table.tsx index 475c9b5a3a8..988804d1874 100644 --- a/apps/web/app/components/workspace/data-table.tsx +++ b/apps/web/app/components/workspace/data-table.tsx @@ -74,6 +74,16 @@ export type DataTableProps = { 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({ title, titleIcon, stickyFirstColumn: stickyFirstProp = true, + serverPagination, + onServerSearch, }: DataTableProps) { const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); @@ -315,6 +327,11 @@ export function DataTable({ 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({ columnVisibility, rowSelection: rowSelectionState, columnOrder: enableColumnReordering ? columnOrder : undefined, - pagination, + pagination: serverPaginationState ?? pagination, }, onSortingChange: setSorting, onGlobalFilterChange: setGlobalFilter, @@ -338,11 +355,26 @@ export function DataTable({ 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({ 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({ {globalFilter && ( + )} + {/* Hint */}
- ~/.openclaw/workspace + {expectedPath + ? expectedPath.replace(/^\/Users\/[^/]+/, "~") + : "~/.openclaw/workspace"}
@@ -151,6 +180,13 @@ export function EmptyState({ Back to Home + + {/* Create workspace dialog */} + setShowCreate(false)} + onCreated={onWorkspaceCreated} + /> ); } diff --git a/apps/web/app/components/workspace/object-table.tsx b/apps/web/app/components/workspace/object-table.tsx index f6b7dbc7cef..13cb93a5c38 100644 --- a/apps/web/app/components/workspace/object-table.tsx +++ b/apps/web/app/components/workspace/object-table.tsx @@ -28,6 +28,14 @@ type ReverseRelation = { entries: Record>; }; +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; + /** Server-side pagination props. */ + serverPagination?: ServerPaginationProps; + /** Server-side search callback. */ + onServerSearch?: (query: string) => void; }; type EntryRow = Record & { entry_id?: string }; @@ -368,6 +380,8 @@ export function ObjectTable({ onEntryClick, onRefresh, columnVisibility, + serverPagination, + onServerSearch, }: ObjectTableProps) { const [rowSelection, setRowSelection] = useState>({}); const [showAddModal, setShowAddModal] = useState(false); @@ -576,6 +590,8 @@ export function ObjectTable({ rowActions={getRowActions} stickyFirstColumn initialColumnVisibility={columnVisibility} + serverPagination={serverPagination} + onServerSearch={onServerSearch} /> {/* Add Entry Modal */} diff --git a/apps/web/app/components/workspace/profile-switcher.tsx b/apps/web/app/components/workspace/profile-switcher.tsx new file mode 100644 index 00000000000..035ceab949b --- /dev/null +++ b/apps/web/app/components/workspace/profile-switcher.tsx @@ -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([]); + const [activeProfile, setActiveProfile] = useState("default"); + const [isOpen, setIsOpen] = useState(false); + const [switching, setSwitching] = useState(false); + const dropdownRef = useRef(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 ( +
+ + + {isOpen && ( +
+ {/* Header */} +
+ Workspace Profiles +
+ + {/* Profile list */} +
+ {profiles.map((p) => { + const isCurrent = p.name === activeProfile; + return ( + + ); + })} +
+ + {/* Create new */} +
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index 62852fce556..c045cf0297e 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -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 = (