🚀 RELEASE: Workspaces
This commit is contained in:
parent
c7842901e2
commit
21f60da24d
97
apps/web/app/api/chat/subagent-stream/route.ts
Normal file
97
apps/web/app/api/chat/subagent-stream/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
64
apps/web/app/api/chat/subagents/route.ts
Normal file
64
apps/web/app/api/chat/subagents/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RegistryEntry = {
|
||||
runId: string;
|
||||
childSessionKey: string;
|
||||
requesterSessionKey: string;
|
||||
task: string;
|
||||
label?: string;
|
||||
createdAt?: number;
|
||||
endedAt?: number;
|
||||
outcome?: { status: string; error?: string };
|
||||
};
|
||||
|
||||
function readSubagentRegistry(): RegistryEntry[] {
|
||||
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
|
||||
if (!existsSync(registryPath)) {return [];}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(registryPath, "utf-8"));
|
||||
if (!raw || typeof raw !== "object") {return [];}
|
||||
const runs = raw.runs;
|
||||
if (!runs || typeof runs !== "object") {return [];}
|
||||
return Object.values(runs);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
|
||||
if (!sessionId) {
|
||||
return Response.json({ error: "sessionId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const webSessionKey = `agent:main:web:${sessionId}`;
|
||||
const entries = readSubagentRegistry();
|
||||
|
||||
const subagents = entries
|
||||
.filter((e) => e.requesterSessionKey === webSessionKey)
|
||||
.map((e) => ({
|
||||
sessionKey: e.childSessionKey,
|
||||
runId: e.runId,
|
||||
task: e.task,
|
||||
label: e.label || undefined,
|
||||
status: resolveStatus(e),
|
||||
startedAt: e.createdAt,
|
||||
endedAt: e.endedAt,
|
||||
}))
|
||||
.toSorted((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
|
||||
|
||||
return Response.json({ subagents });
|
||||
}
|
||||
|
||||
function resolveStatus(e: RegistryEntry): "running" | "completed" | "error" {
|
||||
if (typeof e.endedAt !== "number") {return "running";}
|
||||
if (e.outcome?.status === "error") {return "error";}
|
||||
return "completed";
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CRON_DIR = join(homedir(), ".openclaw", "cron");
|
||||
const CRON_DIR = join(resolveOpenClawStateDir(), "cron");
|
||||
|
||||
type CronRunLogEntry = {
|
||||
ts: number;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CRON_DIR = join(homedir(), ".openclaw", "cron");
|
||||
const CRON_DIR = join(resolveOpenClawStateDir(), "cron");
|
||||
const JOBS_FILE = join(CRON_DIR, "jobs.json");
|
||||
|
||||
type CronStoreFile = {
|
||||
@ -46,7 +46,7 @@ function readHeartbeatInfo(): { intervalMs: number; nextDueEstimateMs: number |
|
||||
|
||||
// Try to read agent session stores to estimate next heartbeat from lastRunMs
|
||||
try {
|
||||
const agentsDir = join(homedir(), ".openclaw", "agents");
|
||||
const agentsDir = join(resolveOpenClawStateDir(), "agents");
|
||||
if (!existsSync(agentsDir)) {return defaults;}
|
||||
|
||||
const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@ -18,7 +18,7 @@ type ParsedMessage = {
|
||||
|
||||
/** Search agent session directories for a session file by ID. */
|
||||
function findSessionFile(sessionId: string): string | null {
|
||||
const agentsDir = join(homedir(), ".openclaw", "agents");
|
||||
const agentsDir = join(resolveOpenClawStateDir(), "agents");
|
||||
if (!existsSync(agentsDir)) {return null;}
|
||||
|
||||
try {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const AGENTS_DIR = join(homedir(), ".openclaw", "agents");
|
||||
const AGENTS_DIR = join(resolveOpenClawStateDir(), "agents");
|
||||
|
||||
type MessagePart =
|
||||
| { type: "text"; text: string }
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@ -11,7 +11,8 @@ type MemoryFile = {
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const workspaceDir = join(homedir(), ".openclaw", "workspace");
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
|
||||
let mainMemory: string | null = null;
|
||||
const dailyLogs: MemoryFile[] = [];
|
||||
|
||||
|
||||
16
apps/web/app/api/profiles/route.ts
Normal file
16
apps/web/app/api/profiles/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { discoverProfiles, getEffectiveProfile, resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const profiles = discoverProfiles();
|
||||
const activeProfile = getEffectiveProfile();
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
|
||||
return Response.json({
|
||||
profiles,
|
||||
activeProfile: activeProfile || "default",
|
||||
stateDir,
|
||||
});
|
||||
}
|
||||
34
apps/web/app/api/profiles/switch/route.ts
Normal file
34
apps/web/app/api/profiles/switch/route.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = (await req.json()) as { profile?: string };
|
||||
const profileName = body.profile?.trim();
|
||||
|
||||
if (!profileName) {
|
||||
return Response.json({ error: "Missing profile name" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate profile name: letters, numbers, hyphens, underscores only
|
||||
if (profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// "default" clears the override
|
||||
setUIActiveProfile(profileName === "default" ? null : profileName);
|
||||
|
||||
const activeProfile = getEffectiveProfile();
|
||||
const workspaceRoot = resolveWorkspaceRoot();
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
|
||||
return Response.json({
|
||||
activeProfile: activeProfile || "default",
|
||||
workspaceRoot,
|
||||
stateDir,
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@ -22,12 +22,8 @@ type JSONLMessage = {
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
function resolveOpenClawDir(): string {
|
||||
return join(homedir(), ".openclaw");
|
||||
}
|
||||
|
||||
function findSessionFile(sessionId: string): string | null {
|
||||
const openclawDir = resolveOpenClawDir();
|
||||
const openclawDir = resolveOpenClawStateDir();
|
||||
const agentsDir = join(openclawDir, "agents");
|
||||
|
||||
if (!existsSync(agentsDir)) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@ -36,12 +36,8 @@ type SessionRow = {
|
||||
contextTokens?: number;
|
||||
};
|
||||
|
||||
function resolveOpenClawDir(): string {
|
||||
return join(homedir(), ".openclaw");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const openclawDir = resolveOpenClawDir();
|
||||
const openclawDir = resolveOpenClawStateDir();
|
||||
const agentsDir = join(openclawDir, "agents");
|
||||
|
||||
if (!existsSync(agentsDir)) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@ -69,12 +69,12 @@ function scanSkillDir(dir: string, source: string): SkillEntry[] {
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const home = homedir();
|
||||
const openclawDir = join(home, ".openclaw");
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const workspaceRoot = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
|
||||
|
||||
const managedSkills = scanSkillDir(join(openclawDir, "skills"), "managed");
|
||||
const managedSkills = scanSkillDir(join(stateDir, "skills"), "managed");
|
||||
const workspaceSkills = scanSkillDir(
|
||||
join(openclawDir, "workspace", "skills"),
|
||||
join(workspaceRoot, "skills"),
|
||||
"workspace",
|
||||
);
|
||||
|
||||
|
||||
@ -5,13 +5,10 @@ import {
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat");
|
||||
const INDEX_FILE = join(WEB_CHAT_DIR, "index.json");
|
||||
|
||||
type IndexEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -33,11 +30,13 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const filePath = join(WEB_CHAT_DIR, `${id}.jsonl`);
|
||||
const chatDir = resolveWebChatDir();
|
||||
const filePath = join(chatDir, `${id}.jsonl`);
|
||||
const indexPath = join(chatDir, "index.json");
|
||||
|
||||
// Auto-create the session file if it doesn't exist yet
|
||||
if (!existsSync(WEB_CHAT_DIR)) {
|
||||
mkdirSync(WEB_CHAT_DIR, { recursive: true });
|
||||
// Auto-create the session directory if it doesn't exist yet
|
||||
if (!existsSync(chatDir)) {
|
||||
mkdirSync(chatDir, { recursive: true });
|
||||
}
|
||||
if (!existsSync(filePath)) {
|
||||
writeFileSync(filePath, "");
|
||||
@ -84,16 +83,16 @@ export async function POST(
|
||||
|
||||
// Update index metadata
|
||||
try {
|
||||
if (existsSync(INDEX_FILE)) {
|
||||
if (existsSync(indexPath)) {
|
||||
const index: IndexEntry[] = JSON.parse(
|
||||
readFileSync(INDEX_FILE, "utf-8"),
|
||||
readFileSync(indexPath, "utf-8"),
|
||||
);
|
||||
const session = index.find((s) => s.id === id);
|
||||
if (session) {
|
||||
session.updatedAt = Date.now();
|
||||
if (newCount > 0) {session.messageCount += newCount;}
|
||||
if (title) {session.title = title;}
|
||||
writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2));
|
||||
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat");
|
||||
|
||||
export type ChatLine = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
@ -24,7 +22,7 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const filePath = join(WEB_CHAT_DIR, `${id}.jsonl`);
|
||||
const filePath = join(resolveWebChatDir(), `${id}.jsonl`);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { 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 });
|
||||
}
|
||||
|
||||
98
apps/web/app/api/workspace/init/route.ts
Normal file
98
apps/web/app/api/workspace/init/route.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
349
apps/web/app/components/subagent-panel.tsx
Normal file
349
apps/web/app/components/subagent-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
314
apps/web/app/components/workspace/create-workspace-dialog.tsx
Normal file
314
apps/web/app/components/workspace/create-workspace-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -74,6 +74,16 @@ export type DataTableProps<TData, TValue> = {
|
||||
titleIcon?: React.ReactNode;
|
||||
// sticky
|
||||
stickyFirstColumn?: boolean;
|
||||
// server-side pagination
|
||||
serverPagination?: {
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
};
|
||||
// server-side search callback (replaces client-side fuzzy filter)
|
||||
onServerSearch?: (query: string) => void;
|
||||
};
|
||||
|
||||
/* ─── Fuzzy filter ─── */
|
||||
@ -173,6 +183,8 @@ export function DataTable<TData, TValue>({
|
||||
title,
|
||||
titleIcon,
|
||||
stickyFirstColumn: stickyFirstProp = true,
|
||||
serverPagination,
|
||||
onServerSearch,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
@ -315,6 +327,11 @@ export function DataTable<TData, TValue>({
|
||||
return cols;
|
||||
}, [columns, selectionColumn, actionsColumn]);
|
||||
|
||||
// Server-side pagination state derived from props
|
||||
const serverPaginationState = serverPagination
|
||||
? { pageIndex: serverPagination.page - 1, pageSize: serverPagination.pageSize }
|
||||
: undefined;
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: allColumns,
|
||||
@ -325,7 +342,7 @@ export function DataTable<TData, TValue>({
|
||||
columnVisibility,
|
||||
rowSelection: rowSelectionState,
|
||||
columnOrder: enableColumnReordering ? columnOrder : undefined,
|
||||
pagination,
|
||||
pagination: serverPaginationState ?? pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
@ -338,11 +355,26 @@ export function DataTable<TData, TValue>({
|
||||
setInternalRowSelection(updater);
|
||||
}
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: serverPagination
|
||||
? (updater) => {
|
||||
const newVal = typeof updater === "function"
|
||||
? updater(serverPaginationState!)
|
||||
: updater;
|
||||
if (newVal.pageSize !== serverPagination.pageSize) {
|
||||
serverPagination.onPageSizeChange(newVal.pageSize);
|
||||
} else if (newVal.pageIndex !== serverPagination.page - 1) {
|
||||
serverPagination.onPageChange(newVal.pageIndex + 1);
|
||||
}
|
||||
}
|
||||
: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFilteredRowModel: serverPagination ? undefined : getFilteredRowModel(),
|
||||
getPaginationRowModel: serverPagination ? undefined : getPaginationRowModel(),
|
||||
...(serverPagination ? {
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil(serverPagination.totalCount / serverPagination.pageSize),
|
||||
} : {}),
|
||||
enableRowSelection,
|
||||
enableSorting,
|
||||
globalFilterFn: fuzzyFilter,
|
||||
@ -379,7 +411,10 @@ export function DataTable<TData, TValue>({
|
||||
<input
|
||||
type="text"
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setGlobalFilter(e.target.value);
|
||||
onServerSearch?.(e.target.value);
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-9 pr-3 py-1.5 text-xs rounded-full outline-none"
|
||||
style={{
|
||||
@ -391,7 +426,7 @@ export function DataTable<TData, TValue>({
|
||||
{globalFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
onClick={() => { setGlobalFilter(""); onServerSearch?.(""); }}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
@ -683,14 +718,23 @@ export function DataTable<TData, TValue>({
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Showing {table.getRowModel().rows.length} of {data.length} results
|
||||
{serverPagination
|
||||
? `Showing ${(serverPagination.page - 1) * serverPagination.pageSize + 1}–${Math.min(serverPagination.page * serverPagination.pageSize, serverPagination.totalCount)} of ${serverPagination.totalCount} results`
|
||||
: `Showing ${table.getRowModel().rows.length} of ${data.length} results`}
|
||||
{selectedCount > 0 && ` (${selectedCount} selected)`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Rows per page</span>
|
||||
<select
|
||||
value={pagination.pageSize}
|
||||
onChange={(e) => setPagination((p) => ({ ...p, pageSize: Number(e.target.value), pageIndex: 0 }))}
|
||||
value={serverPagination ? serverPagination.pageSize : pagination.pageSize}
|
||||
onChange={(e) => {
|
||||
const newSize = Number(e.target.value);
|
||||
if (serverPagination) {
|
||||
serverPagination.onPageSizeChange(newSize);
|
||||
} else {
|
||||
setPagination((p) => ({ ...p, pageSize: newSize, pageIndex: 0 }));
|
||||
}
|
||||
}}
|
||||
className="px-1.5 py-0.5 rounded-md text-xs outline-none"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
@ -703,13 +747,24 @@ export function DataTable<TData, TValue>({
|
||||
))}
|
||||
</select>
|
||||
<span>
|
||||
Page {pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
Page {serverPagination ? serverPagination.page : pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" />
|
||||
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" />
|
||||
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="›" />
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" />
|
||||
{serverPagination ? (
|
||||
<>
|
||||
<PaginationButton onClick={() => serverPagination.onPageChange(1)} disabled={serverPagination.page <= 1} label="«" />
|
||||
<PaginationButton onClick={() => serverPagination.onPageChange(serverPagination.page - 1)} disabled={serverPagination.page <= 1} label="‹" />
|
||||
<PaginationButton onClick={() => serverPagination.onPageChange(serverPagination.page + 1)} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="›" />
|
||||
<PaginationButton onClick={() => serverPagination.onPageChange(Math.ceil(serverPagination.totalCount / serverPagination.pageSize))} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="»" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" />
|
||||
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" />
|
||||
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="›" />
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CreateWorkspaceDialog } from "./create-workspace-dialog";
|
||||
|
||||
export function EmptyState({
|
||||
workspaceExists,
|
||||
expectedPath,
|
||||
onWorkspaceCreated,
|
||||
}: {
|
||||
workspaceExists: boolean;
|
||||
/** The resolved workspace path to display (e.g. from the tree API). */
|
||||
expectedPath?: string | null;
|
||||
/** Called after a workspace is created from this empty state. */
|
||||
onWorkspaceCreated?: () => void;
|
||||
}) {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-6 px-8">
|
||||
{/* Icon */}
|
||||
@ -79,15 +90,31 @@ export function EmptyState({
|
||||
) : (
|
||||
<>
|
||||
The workspace directory was not
|
||||
found. To initialize it, start a
|
||||
conversation with the CRM agent and it
|
||||
will create the workspace structure
|
||||
found. Create one to get started, or start a
|
||||
conversation and the agent will set it up
|
||||
automatically.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create workspace button — prominent when no workspace exists */}
|
||||
{!workspaceExists && (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14" /><path d="M5 12h14" />
|
||||
</svg>
|
||||
Create Workspace
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
|
||||
@ -125,7 +152,9 @@ export function EmptyState({
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
~/.openclaw/workspace
|
||||
{expectedPath
|
||||
? expectedPath.replace(/^\/Users\/[^/]+/, "~")
|
||||
: "~/.openclaw/workspace"}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
@ -151,6 +180,13 @@ export function EmptyState({
|
||||
</svg>
|
||||
Back to Home
|
||||
</a>
|
||||
|
||||
{/* Create workspace dialog */}
|
||||
<CreateWorkspaceDialog
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={onWorkspaceCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,6 +28,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 */}
|
||||
|
||||
201
apps/web/app/components/workspace/profile-switcher.tsx
Normal file
201
apps/web/app/components/workspace/profile-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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} />
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"],
|
||||
});
|
||||
}
|
||||
|
||||
215
apps/web/lib/gateway-events.ts
Normal file
215
apps/web/lib/gateway-events.ts
Normal 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();
|
||||
}
|
||||
529
apps/web/lib/subagent-runs.ts
Normal file
529
apps/web/lib/subagent-runs.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
@ -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) {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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") {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user