diff --git a/apps/web/app/api/chat/chat.test.ts b/apps/web/app/api/chat/chat.test.ts
index 494483d30ab..48a23e7e030 100644
--- a/apps/web/app/api/chat/chat.test.ts
+++ b/apps/web/app/api/chat/chat.test.ts
@@ -13,7 +13,17 @@ vi.mock("@/lib/active-runs", () => ({
// Mock workspace module
vi.mock("@/lib/workspace", () => ({
+ ensureManagedWorkspaceRouting: vi.fn(),
+ getActiveWorkspaceName: vi.fn(() => "default"),
+ resolveActiveAgentId: vi.fn(() => "main"),
resolveAgentWorkspacePrefix: vi.fn(() => null),
+ resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-dench"),
+ resolveWorkspaceDirForName: vi.fn((name: string) =>
+ name === "default"
+ ? "/home/testuser/.openclaw-dench/workspace"
+ : `/home/testuser/.openclaw-dench/workspace-${name}`,
+ ),
+ resolveWorkspaceRoot: vi.fn(() => "/home/testuser/.openclaw-dench/workspace"),
}));
// Mock web-sessions shared module
@@ -42,7 +52,17 @@ describe("Chat API routes", () => {
getRunningSessionIds: vi.fn(() => []),
}));
vi.mock("@/lib/workspace", () => ({
+ ensureManagedWorkspaceRouting: vi.fn(),
+ getActiveWorkspaceName: vi.fn(() => "default"),
+ resolveActiveAgentId: vi.fn(() => "main"),
resolveAgentWorkspacePrefix: vi.fn(() => null),
+ resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-dench"),
+ resolveWorkspaceDirForName: vi.fn((name: string) =>
+ name === "default"
+ ? "/home/testuser/.openclaw-dench/workspace"
+ : `/home/testuser/.openclaw-dench/workspace-${name}`,
+ ),
+ resolveWorkspaceRoot: vi.fn(() => "/home/testuser/.openclaw-dench/workspace"),
}));
vi.mock("@/app/api/web-sessions/shared", () => ({
getSessionMeta: vi.fn(() => undefined),
@@ -115,6 +135,40 @@ describe("Chat API routes", () => {
expect(startRun).toHaveBeenCalled();
});
+ it("maps partial tool output into AI SDK preliminary output chunks", async () => {
+ const { hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
+ vi.mocked(hasActiveRun).mockReturnValue(false);
+ vi.mocked(subscribeToRun).mockImplementation(((_sessionId, callback) => {
+ callback({
+ type: "tool-output-partial",
+ toolCallId: "tool-1",
+ output: { text: "partial output" },
+ } as never);
+ callback(null);
+ return () => {};
+ }) as never);
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [
+ { id: "m1", role: "user", parts: [{ type: "text", text: "hello" }] },
+ ],
+ sessionId: "s1",
+ }),
+ });
+ const res = await POST(req);
+ const body = await res.text();
+
+ expect(body).toContain('"type":"tool-output-available"');
+ expect(body).toContain('"toolCallId":"tool-1"');
+ expect(body).toContain('"preliminary":true');
+ expect(body).toContain('"text":"partial output"');
+ expect(body).not.toContain("tool-output-partial");
+ });
+
it("does not reuse an old run when sessionId is absent", async () => {
const { startRun, hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs");
vi.mocked(hasActiveRun).mockReturnValue(true);
@@ -161,6 +215,48 @@ describe("Chat API routes", () => {
expect(persistUserMessage).toHaveBeenCalledWith("s1", expect.objectContaining({ id: "m1" }));
});
+ it("repairs managed workspace routing before starting a persisted session run", async () => {
+ const { ensureManagedWorkspaceRouting } = await import("@/lib/workspace");
+ const { getSessionMeta } = await import("@/app/api/web-sessions/shared");
+ const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
+ vi.mocked(hasActiveRun).mockReturnValue(false);
+ vi.mocked(subscribeToRun).mockReturnValue(() => {});
+ vi.mocked(getSessionMeta).mockReturnValue({
+ id: "s1",
+ title: "Chat",
+ createdAt: 1,
+ updatedAt: 1,
+ messageCount: 1,
+ workspaceName: "default",
+ workspaceRoot: "/home/testuser/.openclaw-dench/workspace",
+ workspaceAgentId: "main",
+ chatAgentId: "chat-slot-main-2",
+ } as never);
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: [
+ { id: "m1", role: "user", parts: [{ type: "text", text: "repair routing" }] },
+ ],
+ sessionId: "s1",
+ }),
+ });
+ await POST(req);
+ expect(ensureManagedWorkspaceRouting).toHaveBeenCalledWith(
+ "default",
+ "/home/testuser/.openclaw-dench/workspace",
+ { markDefault: false },
+ );
+ expect(startRun).toHaveBeenCalledWith(
+ expect.objectContaining({
+ overrideAgentId: "chat-slot-main-2",
+ }),
+ );
+ });
+
it("resolves workspace file paths in message", async () => {
const { resolveAgentWorkspacePrefix } = await import("@/lib/workspace");
vi.mocked(resolveAgentWorkspacePrefix).mockReturnValue("workspace");
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
index f138ec0505f..e5443438ce3 100644
--- a/apps/web/app/api/chat/route.ts
+++ b/apps/web/app/api/chat/route.ts
@@ -1,5 +1,13 @@
import type { UIMessage } from "ai";
-import { resolveAgentWorkspacePrefix } from "@/lib/workspace";
+import {
+ resolveActiveAgentId,
+ resolveAgentWorkspacePrefix,
+ resolveOpenClawStateDir,
+ resolveWorkspaceDirForName,
+ resolveWorkspaceRoot,
+ getActiveWorkspaceName,
+ ensureManagedWorkspaceRouting,
+} from "@/lib/workspace";
import {
startRun,
startSubscribeRun,
@@ -14,7 +22,6 @@ import {
import { trackServer } from "@/lib/telemetry";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
-import { resolveOpenClawStateDir } from "@/lib/workspace";
import { getSessionMeta } from "@/app/api/web-sessions/shared";
export const runtime = "nodejs";
@@ -40,6 +47,22 @@ function deriveSubagentInfo(sessionKey: string): { parentSessionId: string; task
return null;
}
+function normalizeLiveStreamEvent(event: SseEvent): SseEvent {
+ // AI SDK's UI stream schema does not define `tool-output-partial`.
+ // It expects repeated `tool-output-available` chunks with
+ // `preliminary: true` while the tool is still running.
+ if (event.type === "tool-output-partial") {
+ return {
+ type: "tool-output-available",
+ toolCallId: event.toolCallId,
+ output: event.output,
+ preliminary: true,
+ };
+ }
+
+ return event;
+}
+
export async function POST(req: Request) {
const {
messages,
@@ -122,7 +145,19 @@ export async function POST(req: Request) {
});
const sessionMeta = getSessionMeta(sessionId);
- const effectiveAgentId = sessionMeta?.chatAgentId ?? sessionMeta?.workspaceAgentId;
+ const workspaceName =
+ sessionMeta?.workspaceName
+ ?? getActiveWorkspaceName()
+ ?? "default";
+ const workspaceRoot =
+ sessionMeta?.workspaceRoot
+ ?? resolveWorkspaceRoot()
+ ?? resolveWorkspaceDirForName(workspaceName);
+ ensureManagedWorkspaceRouting(workspaceName, workspaceRoot, { markDefault: false });
+ const effectiveAgentId =
+ sessionMeta?.chatAgentId
+ ?? sessionMeta?.workspaceAgentId
+ ?? resolveActiveAgentId();
try {
startRun({
@@ -168,11 +203,8 @@ export async function POST(req: Request) {
try { controller.close(); } catch { /* already closed */ }
return;
}
- // Skip custom event types not in the AI SDK v6 data stream schema;
- // they're only consumed by the reconnection parser (processEvent).
- if (event.type === "tool-output-partial") {return;}
try {
- const json = JSON.stringify(event);
+ const json = JSON.stringify(normalizeLiveStreamEvent(event));
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
} catch { /* ignore */ }
},
diff --git a/apps/web/app/api/chat/runs/route.test.ts b/apps/web/app/api/chat/runs/route.test.ts
new file mode 100644
index 00000000000..da8523b8aa1
--- /dev/null
+++ b/apps/web/app/api/chat/runs/route.test.ts
@@ -0,0 +1,107 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/active-runs", () => ({
+ getActiveRun: vi.fn(),
+}));
+
+vi.mock("@/lib/subagent-registry", () => ({
+ listSubagentsForRequesterSession: vi.fn(),
+}));
+
+vi.mock("@/lib/workspace", () => ({
+ resolveActiveAgentId: vi.fn(() => "main"),
+}));
+
+vi.mock("@/app/api/web-sessions/shared", () => ({
+ readIndex: vi.fn(() => []),
+ resolveSessionKey: vi.fn((sessionId: string, fallbackAgentId: string) => `agent:${fallbackAgentId}:web:${sessionId}`),
+}));
+
+describe("GET /api/chat/runs", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ });
+
+ it("returns active parent runs plus subagents mapped back to their parent web session", async () => {
+ const { getActiveRun } = await import("@/lib/active-runs");
+ const { listSubagentsForRequesterSession } = await import("@/lib/subagent-registry");
+ const { readIndex } = await import("@/app/api/web-sessions/shared");
+
+ vi.mocked(readIndex).mockReturnValue([
+ { id: "parent-1", title: "Parent 1", createdAt: 1, updatedAt: 1, messageCount: 2 },
+ { id: "parent-2", title: "Parent 2", createdAt: 1, updatedAt: 1, messageCount: 3 },
+ ] as never);
+
+ vi.mocked(getActiveRun).mockImplementation(((sessionId: string) => {
+ if (sessionId === "parent-1") {
+ return { status: "running" };
+ }
+ if (sessionId === "parent-2") {
+ return { status: "waiting-for-subagents" };
+ }
+ return undefined;
+ }) as never);
+
+ vi.mocked(listSubagentsForRequesterSession).mockImplementation(((requesterSessionKey: string) => {
+ if (requesterSessionKey === "agent:main:web:parent-1") {
+ return [
+ {
+ runId: "run-1",
+ childSessionKey: "agent:chat-slot-main-1:subagent:child-1",
+ requesterSessionKey,
+ task: "Collect facts",
+ label: "Fact finding",
+ status: "running",
+ createdAt: 10,
+ },
+ ];
+ }
+ if (requesterSessionKey === "agent:main:web:parent-2") {
+ return [
+ {
+ runId: "run-2",
+ childSessionKey: "agent:chat-slot-main-2:subagent:child-2",
+ requesterSessionKey,
+ task: "Summarize",
+ status: "completed",
+ createdAt: 20,
+ endedAt: 30,
+ },
+ ];
+ }
+ return [];
+ }) as never);
+
+ const { GET } = await import("./route.js");
+ const res = await GET();
+ const json = await res.json();
+
+ expect(json.parentRuns).toEqual([
+ { sessionId: "parent-1", status: "running" },
+ { sessionId: "parent-2", status: "waiting-for-subagents" },
+ ]);
+ expect(json.subagents).toEqual([
+ {
+ childSessionKey: "agent:chat-slot-main-1:subagent:child-1",
+ parentSessionId: "parent-1",
+ runId: "run-1",
+ task: "Collect facts",
+ label: "Fact finding",
+ status: "running",
+ startedAt: 10,
+ endedAt: undefined,
+ },
+ {
+ childSessionKey: "agent:chat-slot-main-2:subagent:child-2",
+ parentSessionId: "parent-2",
+ runId: "run-2",
+ task: "Summarize",
+ label: undefined,
+ status: "completed",
+ startedAt: 20,
+ endedAt: 30,
+ },
+ ]);
+ });
+});
diff --git a/apps/web/app/api/chat/runs/route.ts b/apps/web/app/api/chat/runs/route.ts
new file mode 100644
index 00000000000..e285b28824d
--- /dev/null
+++ b/apps/web/app/api/chat/runs/route.ts
@@ -0,0 +1,47 @@
+import { getActiveRun } from "@/lib/active-runs";
+import { listSubagentsForRequesterSession } from "@/lib/subagent-registry";
+import { resolveActiveAgentId } from "@/lib/workspace";
+import { readIndex, resolveSessionKey } from "@/app/api/web-sessions/shared";
+
+export const runtime = "nodejs";
+
+export function GET() {
+ const sessions = readIndex();
+ const fallbackAgentId = resolveActiveAgentId();
+ const parentSessionKeys = new Map(
+ sessions.map((session) => [resolveSessionKey(session.id, fallbackAgentId), session.id]),
+ );
+
+ const parentRuns = sessions
+ .map((session) => {
+ const run = getActiveRun(session.id);
+ if (!run) {
+ return null;
+ }
+ return {
+ sessionId: session.id,
+ status: run.status,
+ };
+ })
+ .filter((run): run is { sessionId: string; status: "running" | "waiting-for-subagents" | "completed" | "error" } => Boolean(run));
+
+ const subagents = [...parentSessionKeys.entries()]
+ .flatMap(([requesterSessionKey, parentSessionId]) =>
+ listSubagentsForRequesterSession(requesterSessionKey).map((entry) => ({
+ childSessionKey: entry.childSessionKey,
+ parentSessionId,
+ runId: entry.runId,
+ task: entry.task,
+ label: entry.label || undefined,
+ status: entry.status,
+ startedAt: entry.createdAt,
+ endedAt: entry.endedAt,
+ })),
+ )
+ .toSorted((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
+
+ return Response.json({
+ parentRuns,
+ subagents,
+ });
+}
diff --git a/apps/web/app/api/chat/stop/route.test.ts b/apps/web/app/api/chat/stop/route.test.ts
new file mode 100644
index 00000000000..b1dbd85e564
--- /dev/null
+++ b/apps/web/app/api/chat/stop/route.test.ts
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/active-runs", () => ({
+ abortRun: vi.fn(() => false),
+ getActiveRun: vi.fn(),
+}));
+
+vi.mock("@/lib/subagent-registry", () => ({
+ listSubagentsForRequesterSession: vi.fn(() => []),
+}));
+
+vi.mock("@/lib/workspace", () => ({
+ resolveActiveAgentId: vi.fn(() => "main"),
+}));
+
+vi.mock("@/app/api/web-sessions/shared", () => ({
+ resolveSessionKey: vi.fn((sessionId: string, fallbackAgentId: string) => `agent:${fallbackAgentId}:web:${sessionId}`),
+}));
+
+vi.mock("@/lib/telemetry", () => ({
+ trackServer: vi.fn(),
+}));
+
+describe("POST /api/chat/stop", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ });
+
+ it("stops a parent session and all active child subagents when cascadeChildren is enabled (prevents orphan background work)", async () => {
+ const { abortRun, getActiveRun } = await import("@/lib/active-runs");
+ const { listSubagentsForRequesterSession } = await import("@/lib/subagent-registry");
+
+ vi.mocked(getActiveRun).mockImplementation(((runKey: string) => {
+ if (runKey === "parent-1") {
+ return { status: "waiting-for-subagents" };
+ }
+ if (runKey === "agent:chat-slot-main-1:subagent:child-1") {
+ return { status: "running" };
+ }
+ if (runKey === "agent:chat-slot-main-2:subagent:child-2") {
+ return { status: "completed" };
+ }
+ return undefined;
+ }) as never);
+
+ vi.mocked(listSubagentsForRequesterSession).mockReturnValue([
+ {
+ runId: "run-1",
+ childSessionKey: "agent:chat-slot-main-1:subagent:child-1",
+ requesterSessionKey: "agent:main:web:parent-1",
+ task: "Collect facts",
+ status: "running",
+ },
+ {
+ runId: "run-2",
+ childSessionKey: "agent:chat-slot-main-2:subagent:child-2",
+ requesterSessionKey: "agent:main:web:parent-1",
+ task: "Already done",
+ status: "completed",
+ },
+ ] as never);
+
+ vi.mocked(abortRun).mockReturnValue(true);
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "parent-1",
+ cascadeChildren: true,
+ }),
+ });
+
+ const res = await POST(req);
+ const json = await res.json();
+
+ expect(abortRun).toHaveBeenCalledWith("parent-1");
+ expect(abortRun).toHaveBeenCalledWith("agent:chat-slot-main-1:subagent:child-1");
+ expect(abortRun).not.toHaveBeenCalledWith("agent:chat-slot-main-2:subagent:child-2");
+ expect(json).toEqual({ aborted: true, abortedChildren: 1 });
+ });
+
+ it("stops only the requested subagent session when sessionKey is provided", async () => {
+ const { abortRun, getActiveRun } = await import("@/lib/active-runs");
+ const { listSubagentsForRequesterSession } = await import("@/lib/subagent-registry");
+
+ vi.mocked(getActiveRun).mockImplementation(((runKey: string) => {
+ if (runKey === "agent:chat-slot-main-1:subagent:child-1") {
+ return { status: "running" };
+ }
+ return undefined;
+ }) as never);
+ vi.mocked(abortRun).mockReturnValue(true);
+
+ const { POST } = await import("./route.js");
+ const req = new Request("http://localhost/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionKey: "agent:chat-slot-main-1:subagent:child-1",
+ }),
+ });
+
+ const res = await POST(req);
+ const json = await res.json();
+
+ expect(abortRun).toHaveBeenCalledWith("agent:chat-slot-main-1:subagent:child-1");
+ expect(listSubagentsForRequesterSession).not.toHaveBeenCalled();
+ expect(json).toEqual({ aborted: true, abortedChildren: 0 });
+ });
+});
diff --git a/apps/web/app/api/chat/stop/route.ts b/apps/web/app/api/chat/stop/route.ts
index 22ee1a6b905..c4886dca8aa 100644
--- a/apps/web/app/api/chat/stop/route.ts
+++ b/apps/web/app/api/chat/stop/route.ts
@@ -5,12 +5,15 @@
* Works for both parent sessions (by sessionId) and subagent sessions (by sessionKey).
*/
import { abortRun, getActiveRun } from "@/lib/active-runs";
+import { listSubagentsForRequesterSession } from "@/lib/subagent-registry";
import { trackServer } from "@/lib/telemetry";
+import { resolveActiveAgentId } from "@/lib/workspace";
+import { resolveSessionKey } from "@/app/api/web-sessions/shared";
export const runtime = "nodejs";
export async function POST(req: Request) {
- const body: { sessionId?: string; sessionKey?: string } = await req
+ const body: { sessionId?: string; sessionKey?: string; cascadeChildren?: boolean } = await req
.json()
.catch(() => ({}));
@@ -25,8 +28,22 @@ export async function POST(req: Request) {
const canAbort =
run?.status === "running" || run?.status === "waiting-for-subagents";
const aborted = canAbort ? abortRun(runKey) : false;
- if (aborted) {
+ let abortedChildren = 0;
+
+ if (!isSubagentSession && body.sessionId && body.cascadeChildren) {
+ const fallbackAgentId = resolveActiveAgentId();
+ const requesterSessionKey = resolveSessionKey(body.sessionId, fallbackAgentId);
+ for (const subagent of listSubagentsForRequesterSession(requesterSessionKey)) {
+ const childRun = getActiveRun(subagent.childSessionKey);
+ const canAbortChild =
+ childRun?.status === "running" || childRun?.status === "waiting-for-subagents";
+ if (canAbortChild && abortRun(subagent.childSessionKey)) {
+ abortedChildren += 1;
+ }
+ }
+ }
+ if (aborted || abortedChildren > 0) {
trackServer("chat_stopped");
}
- return Response.json({ aborted });
+ return Response.json({ aborted, abortedChildren });
}
diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts
index 772dea7df67..6373753baf5 100644
--- a/apps/web/app/api/web-sessions/route.ts
+++ b/apps/web/app/api/web-sessions/route.ts
@@ -3,8 +3,10 @@ import { randomUUID } from "node:crypto";
import { trackServer } from "@/lib/telemetry";
import { type WebSessionMeta, ensureDir, readIndex, writeIndex } from "./shared";
import {
+ ensureManagedWorkspaceRouting,
getActiveWorkspaceName,
resolveActiveAgentId,
+ resolveWorkspaceDirForName,
resolveWorkspaceRoot,
} from "@/lib/workspace";
import { allocateChatAgent } from "@/lib/chat-agent-registry";
@@ -34,9 +36,10 @@ export async function POST(req: Request) {
const id = randomUUID();
const now = Date.now();
- const workspaceName = getActiveWorkspaceName() ?? undefined;
+ const workspaceName = getActiveWorkspaceName() ?? "default";
+ const workspaceRoot = resolveWorkspaceRoot() ?? resolveWorkspaceDirForName(workspaceName);
+ ensureManagedWorkspaceRouting(workspaceName, workspaceRoot, { markDefault: false });
const workspaceAgentId = resolveActiveAgentId();
- const workspaceRoot = resolveWorkspaceRoot() ?? undefined;
// Assign a pool slot agent for concurrent chat support.
// Falls back to the workspace agent if no slots are available.
@@ -59,7 +62,7 @@ export async function POST(req: Request) {
updatedAt: now,
messageCount: 0,
...(body.filePath ? { filePath: body.filePath } : {}),
- workspaceName,
+ workspaceName: workspaceName || undefined,
workspaceRoot,
workspaceAgentId,
chatAgentId,
diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx
index d02123c6be2..7967acb995b 100644
--- a/apps/web/app/components/chat-message.tsx
+++ b/apps/web/app/components/chat-message.tsx
@@ -10,6 +10,7 @@ import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChainOfThought, type ChainPart } from "./chain-of-thought";
+import { isStatusReasoningText } from "./chat-stream-status";
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
import { splitDiffBlocks, hasDiffBlocks } from "@/lib/diff-blocks";
import type { ReportConfig } from "./charts/types";
@@ -55,13 +56,16 @@ type MessageSegment =
| { type: "subagent-card"; task: string; label?: string; sessionKey?: string; status: "running" | "done" | "error" };
/** Map AI SDK tool state string to a simplified status */
-function toolStatus(state: string): "running" | "done" | "error" {
- if (state === "output-available") {
- return "done";
- }
- if (state === "error") {
+function toolStatus(
+ state: string,
+ preliminary = false,
+): "running" | "done" | "error" {
+ if (state === "output-error" || state === "error") {
return "error";
}
+ if (state === "output-available" && !preliminary) {
+ return "done";
+ }
return "running";
}
@@ -115,18 +119,10 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
text: string;
state?: string;
};
- // Skip lifecycle/compaction status labels — they add noise
- // (e.g. "Preparing response...", "Optimizing session context...")
- const statusLabels = [
- "Preparing response...",
- "Optimizing session context...",
- "Waiting for subagent results...",
- "Waiting for subagents...",
- ];
- const isStatus = statusLabels.some((l) =>
- rp.text.startsWith(l),
- );
- if (!isStatus) {
+ // Skip lifecycle/compaction status labels in the thought body.
+ // The active stream row renders them separately so they stay visible
+ // without cluttering the permanent transcript.
+ if (!isStatusReasoningText(rp.text)) {
chain.push({
kind: "reasoning",
text: rp.text,
@@ -141,6 +137,7 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
state: string;
input?: unknown;
output?: unknown;
+ preliminary?: boolean;
};
if (tp.toolName === "sessions_spawn") {
flush(true);
@@ -149,13 +146,19 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
const task = typeof args?.task === "string" ? args.task : "Subagent task";
const label = typeof args?.label === "string" ? args.label : undefined;
const sessionKey = typeof out?.sessionKey === "string" ? out.sessionKey : undefined;
- segments.push({ type: "subagent-card", task, label, sessionKey, status: toolStatus(tp.state) });
+ segments.push({
+ type: "subagent-card",
+ task,
+ label,
+ sessionKey,
+ status: toolStatus(tp.state, tp.preliminary === true),
+ });
} else {
chain.push({
kind: "tool",
toolName: tp.toolName,
toolCallId: tp.toolCallId,
- status: toolStatus(tp.state),
+ status: toolStatus(tp.state, tp.preliminary === true),
args: asRecord(tp.input),
output: asRecord(tp.output),
});
@@ -175,6 +178,7 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
args?: unknown;
result?: unknown;
errorText?: string;
+ preliminary?: boolean;
};
const resolvedToolName = tp.title ?? tp.toolName ?? part.type.replace("tool-", "");
if (resolvedToolName === "sessions_spawn") {
@@ -186,19 +190,25 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
const sessionKey = typeof out?.sessionKey === "string" ? out.sessionKey : 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, sessionKey, status: toolStatus(resolvedState) });
+ (tp.errorText ? "output-error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available");
+ segments.push({
+ type: "subagent-card",
+ task,
+ label,
+ sessionKey,
+ status: toolStatus(resolvedState, tp.preliminary === true),
+ });
} else {
// Persisted tool-invocation parts have no state field but
// include result/output/errorText to indicate completion.
const resolvedState =
tp.state ??
- (tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available");
+ (tp.errorText ? "output-error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available");
chain.push({
kind: "tool",
toolName: resolvedToolName,
toolCallId: tp.toolCallId,
- status: toolStatus(resolvedState),
+ status: toolStatus(resolvedState, tp.preliminary === true),
args: asRecord(tp.input) ?? asRecord(tp.args),
output: asRecord(tp.output) ?? asRecord(tp.result),
});
@@ -784,7 +794,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
if (attachmentInfo) {
return (
- {!richHtml &&
}
+
{(attachmentInfo.message || richHtml) && (
{
]);
});
+ it("keeps partial tool output visible without marking the tool complete", () => {
+ const parser = createStreamParser();
+
+ parser.processEvent({
+ type: "tool-input-start",
+ toolCallId: "tool-1",
+ toolName: "readFile",
+ });
+ parser.processEvent({
+ type: "tool-output-partial",
+ toolCallId: "tool-1",
+ output: { text: "first chunk" },
+ });
+
+ expect(parser.getParts()).toEqual([
+ {
+ type: "dynamic-tool",
+ toolCallId: "tool-1",
+ toolName: "readFile",
+ state: "input-available",
+ input: {},
+ output: { text: "first chunk" },
+ preliminary: true,
+ },
+ ]);
+ });
+
it("closes reasoning state on reasoning-end to prevent stuck streaming badges", () => {
const parser = createStreamParser();
diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx
index 0850b0c107e..711d9f698be 100644
--- a/apps/web/app/components/chat-panel.tsx
+++ b/apps/web/app/components/chat-panel.tsx
@@ -29,6 +29,11 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { UnicodeSpinner } from "./unicode-spinner";
+import type { ChatPanelRuntimeState } from "@/lib/chat-session-registry";
+import {
+ getStreamActivityLabel,
+ hasAssistantText,
+} from "./chat-stream-status";
// ── Prompt suggestions for new chat hero ──
@@ -590,6 +595,7 @@ type ParsedPart =
state: string;
input?: Record
;
output?: Record;
+ preliminary?: boolean;
};
export function createStreamParser() {
@@ -685,6 +691,7 @@ export function createStreamParser() {
p.type === "dynamic-tool" &&
p.toolCallId === event.toolCallId
) {
+ p.preliminary = true;
p.output =
(event.output as Record<
string,
@@ -701,7 +708,13 @@ export function createStreamParser() {
p.type === "dynamic-tool" &&
p.toolCallId === event.toolCallId
) {
- p.state = "output-available";
+ if (event.preliminary === true) {
+ p.preliminary = true;
+ p.state = "input-available";
+ } else {
+ delete p.preliminary;
+ p.state = "output-available";
+ }
p.output =
(event.output as Record<
string,
@@ -814,6 +827,8 @@ type ChatPanelProps = {
onBack?: () => void;
/** Hide the header action buttons (when they're rendered elsewhere, e.g. tab bar). */
hideHeaderActions?: boolean;
+ /** Called whenever the panel's runtime state changes. */
+ onRuntimeStateChange?: (state: ChatPanelRuntimeState) => void;
};
export const ChatPanel = forwardRef(
@@ -836,6 +851,7 @@ export const ChatPanel = forwardRef(
subagentLabel,
onBack,
hideHeaderActions,
+ onRuntimeStateChange,
},
ref,
) {
@@ -962,6 +978,25 @@ export const ChatPanel = forwardRef(
status === "submitted" ||
isReconnecting;
+ useEffect(() => {
+ onRuntimeStateChange?.({
+ sessionId: currentSessionId,
+ sessionKey: subagentSessionKey ?? null,
+ isStreaming,
+ status,
+ isReconnecting,
+ loadingSession,
+ });
+ }, [
+ currentSessionId,
+ subagentSessionKey,
+ isStreaming,
+ status,
+ isReconnecting,
+ loadingSession,
+ onRuntimeStateChange,
+ ]);
+
// Stream stall detection: if we stay in "submitted" (no first
// token received) for too long, surface an error and reset.
const stallTimerRef = useRef | null>(null);
@@ -1955,29 +1990,18 @@ export const ChatPanel = forwardRef(
[],
);
- // ── Status label ──
+ // ── Active stream status row ──
- const _statusLabel = loadingSession
- ? "Loading session..."
- : isReconnecting
- ? "Resuming stream..."
- : status === "ready"
- ? "Ready"
- : status === "submitted"
- ? "Thinking..."
- : status === "streaming"
- ? (hasRunningSubagents ? "Waiting for subagents..." : "Streaming...")
- : status === "error"
- ? "Error"
- : status;
-
- // Show an inline Unicode spinner in the message flow when the AI
- // is thinking/streaming but hasn't produced visible text yet.
const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
- const lastAssistantHasText =
- lastMsg?.role === "assistant" &&
- lastMsg.parts.some((p) => p.type === "text" && (p as { text: string }).text.length > 0);
- const showInlineSpinner = isStreaming && !lastAssistantHasText;
+ const lastAssistantHasText = hasAssistantText(lastMsg);
+ const streamActivityLabel = getStreamActivityLabel({
+ loadingSession,
+ isReconnecting,
+ status,
+ hasRunningSubagents,
+ lastMessage: lastMsg,
+ });
+ const showStreamActivity = isStreaming && !!streamActivityLabel;
const showHeroState = messages.length === 0 && !compact && !isSubagentMode && !loadingSession;
@@ -2159,12 +2183,12 @@ export const ChatPanel = forwardRef(
return (
{/* Header — sticky glass bar */}
{isSubagentMode ? (
<>
@@ -2280,7 +2304,7 @@ export const ChatPanel = forwardRef(
{/* File-scoped session tabs (compact mode, not in subagent mode) */}
{!isSubagentMode && compact && fileContext && fileSessions.length > 0 && (
(
{/* Messages */}
@@ -2444,13 +2468,25 @@ export const ChatPanel = forwardRef
(
userHtmlMap={userHtmlMapRef.current}
/>
))}
- {showInlineSpinner && (
+ {showStreamActivity && (
-
+
+
+
+ {streamActivityLabel}
+
+
)}
@@ -2501,7 +2537,7 @@ export const ChatPanel = forwardRef(
{/* Input bar at bottom (hidden when hero state is active) */}
{!showHeroState && (
diff --git a/apps/web/app/components/chat-stream-status.test.ts b/apps/web/app/components/chat-stream-status.test.ts
new file mode 100644
index 00000000000..a02efb156ef
--- /dev/null
+++ b/apps/web/app/components/chat-stream-status.test.ts
@@ -0,0 +1,99 @@
+import type { UIMessage } from "ai";
+import { describe, expect, it } from "vitest";
+import {
+ getStreamActivityLabel,
+ hasAssistantText,
+ isStatusReasoningText,
+} from "./chat-stream-status";
+
+function assistantMessage(parts: UIMessage["parts"]): UIMessage {
+ return {
+ id: "assistant-1",
+ role: "assistant",
+ parts,
+ } as UIMessage;
+}
+
+describe("chat stream status helpers", () => {
+ it("detects status reasoning labels that should stay out of the transcript body", () => {
+ expect(isStatusReasoningText("Preparing response...")).toBe(true);
+ expect(
+ isStatusReasoningText(
+ "Optimizing session context...\nRetrying with compacted context...",
+ ),
+ ).toBe(true);
+ expect(isStatusReasoningText("Planning the requested changes")).toBe(false);
+ });
+
+ it("keeps the stream activity row visible after assistant text has started", () => {
+ const label = getStreamActivityLabel({
+ loadingSession: false,
+ isReconnecting: false,
+ status: "streaming",
+ hasRunningSubagents: false,
+ lastMessage: assistantMessage([
+ { type: "text", text: "Drafting the final answer now..." },
+ ] as UIMessage["parts"]),
+ });
+
+ expect(label).toBe("Still streaming...");
+ expect(
+ hasAssistantText(
+ assistantMessage([
+ { type: "text", text: "Drafting the final answer now..." },
+ ] as UIMessage["parts"]),
+ ),
+ ).toBe(true);
+ });
+
+ it("prefers gateway status reasoning over the generic streaming label", () => {
+ const label = getStreamActivityLabel({
+ loadingSession: false,
+ isReconnecting: false,
+ status: "streaming",
+ hasRunningSubagents: false,
+ lastMessage: assistantMessage([
+ {
+ type: "reasoning",
+ text: "Optimizing session context...\nRetrying with compacted context...",
+ },
+ ] as UIMessage["parts"]),
+ });
+
+ expect(label).toBe("Optimizing session context... Retrying with compacted context...");
+ });
+
+ it("surfaces the active tool name while a tool call is still running", () => {
+ const label = getStreamActivityLabel({
+ loadingSession: false,
+ isReconnecting: false,
+ status: "streaming",
+ hasRunningSubagents: false,
+ lastMessage: assistantMessage([
+ {
+ type: "dynamic-tool",
+ toolName: "read_file",
+ toolCallId: "tool-1",
+ state: "input-available",
+ input: {},
+ },
+ ] as UIMessage["parts"]),
+ });
+
+ expect(label).toBe("Running Read File...");
+ });
+
+ it("shows waiting for subagents as the top-priority active status", () => {
+ const label = getStreamActivityLabel({
+ loadingSession: false,
+ isReconnecting: false,
+ status: "streaming",
+ hasRunningSubagents: true,
+ lastMessage: assistantMessage([
+ { type: "text", text: "Initial draft is ready." },
+ ] as UIMessage["parts"]),
+ });
+
+ expect(label).toBe("Waiting for subagents...");
+ });
+});
diff --git a/apps/web/app/components/chat-stream-status.ts b/apps/web/app/components/chat-stream-status.ts
new file mode 100644
index 00000000000..33a92bc408f
--- /dev/null
+++ b/apps/web/app/components/chat-stream-status.ts
@@ -0,0 +1,195 @@
+import type { UIMessage } from "ai";
+
+export const STREAM_STATUS_REASONING_LABELS = [
+ "Preparing response...",
+ "Optimizing session context...",
+ "Waiting for subagent results...",
+ "Waiting for subagents...",
+] as const;
+
+type ChatStatus = "submitted" | "streaming" | "ready" | "error";
+type MessagePart = UIMessage["parts"][number];
+
+function collapseWhitespace(text: string): string {
+ return text.trim().replace(/\s+/g, " ");
+}
+
+function humanizeToolName(toolName: string): string {
+ const normalized = toolName
+ .replace(/^tool-/, "")
+ .replace(/[_-]+/g, " ")
+ .trim();
+
+ if (!normalized) {
+ return "tool";
+ }
+
+ return normalized.replace(/\b\w/g, (char) => char.toUpperCase());
+}
+
+function resolveToolName(part: MessagePart): string | null {
+ if (part.type === "dynamic-tool") {
+ return typeof part.toolName === "string" ? part.toolName : null;
+ }
+
+ if (!part.type.startsWith("tool-")) {
+ return null;
+ }
+
+ const toolPart = part as {
+ type: string;
+ title?: unknown;
+ toolName?: unknown;
+ };
+
+ if (typeof toolPart.title === "string" && toolPart.title.trim()) {
+ return toolPart.title;
+ }
+ if (typeof toolPart.toolName === "string" && toolPart.toolName.trim()) {
+ return toolPart.toolName;
+ }
+
+ return part.type.replace(/^tool-/, "");
+}
+
+function resolveToolState(part: MessagePart): string | null {
+ if (part.type === "dynamic-tool") {
+ return typeof part.state === "string"
+ ? part.state
+ : "input-available";
+ }
+
+ if (!part.type.startsWith("tool-")) {
+ return null;
+ }
+
+ const toolPart = part as {
+ state?: unknown;
+ errorText?: unknown;
+ output?: unknown;
+ result?: unknown;
+ };
+
+ if (typeof toolPart.state === "string") {
+ return toolPart.state;
+ }
+ if (typeof toolPart.errorText === "string" && toolPart.errorText.trim()) {
+ return "error";
+ }
+ if ("result" in toolPart || "output" in toolPart) {
+ return "output-available";
+ }
+
+ return "input-available";
+}
+
+export function hasAssistantText(message: UIMessage | null): boolean {
+ return Boolean(
+ message?.role === "assistant" &&
+ message.parts.some(
+ (part) =>
+ part.type === "text" &&
+ typeof (part as { text?: unknown }).text === "string" &&
+ (part as { text: string }).text.length > 0,
+ ),
+ );
+}
+
+export function isStatusReasoningText(text: string): boolean {
+ return STREAM_STATUS_REASONING_LABELS.some((label) =>
+ text.startsWith(label),
+ );
+}
+
+function getLatestStatusReasoning(parts: UIMessage["parts"]): string | null {
+ for (let i = parts.length - 1; i >= 0; i--) {
+ const part = parts[i];
+ if (part.type !== "reasoning") {
+ continue;
+ }
+
+ const text =
+ typeof (part as { text?: unknown }).text === "string"
+ ? collapseWhitespace((part as { text: string }).text)
+ : "";
+
+ if (text && isStatusReasoningText(text)) {
+ return text;
+ }
+ }
+
+ return null;
+}
+
+function getRunningToolLabel(parts: UIMessage["parts"]): string | null {
+ for (let i = parts.length - 1; i >= 0; i--) {
+ const part = parts[i];
+ const state = resolveToolState(part);
+ if (!state || state === "output-available" || state === "error") {
+ continue;
+ }
+
+ const toolName = resolveToolName(part);
+ if (!toolName) {
+ continue;
+ }
+
+ if (toolName === "sessions_spawn") {
+ return "Starting subagent...";
+ }
+
+ return `Running ${humanizeToolName(toolName)}...`;
+ }
+
+ return null;
+}
+
+export function getStreamActivityLabel({
+ loadingSession,
+ isReconnecting,
+ status,
+ hasRunningSubagents,
+ lastMessage,
+}: {
+ loadingSession: boolean;
+ isReconnecting: boolean;
+ status: ChatStatus;
+ hasRunningSubagents: boolean;
+ lastMessage: UIMessage | null;
+}): string | null {
+ if (loadingSession) {
+ return "Loading session...";
+ }
+
+ if (isReconnecting) {
+ return "Resuming stream...";
+ }
+
+ if (hasRunningSubagents) {
+ return "Waiting for subagents...";
+ }
+
+ if (lastMessage?.role === "assistant") {
+ const statusReasoning = getLatestStatusReasoning(lastMessage.parts);
+ if (statusReasoning) {
+ return statusReasoning;
+ }
+
+ const runningTool = getRunningToolLabel(lastMessage.parts);
+ if (runningTool) {
+ return runningTool;
+ }
+ }
+
+ if (status === "submitted") {
+ return "Thinking...";
+ }
+
+ if (status === "streaming") {
+ return hasAssistantText(lastMessage)
+ ? "Still streaming..."
+ : "Streaming...";
+ }
+
+ return null;
+}
diff --git a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx
index 9b63940d73e..8e1d6f12937 100644
--- a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx
+++ b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx
@@ -51,6 +51,10 @@ type ChatSessionsSidebarProps = {
onDeleteSession?: (sessionId: string) => void;
/** Called when the user renames a session from the sidebar menu. */
onRenameSession?: (sessionId: string, newTitle: string) => void;
+ /** Called when the user stops an actively running parent session. */
+ onStopSession?: (sessionId: string) => void;
+ /** Called when the user stops an actively running subagent session. */
+ onStopSubagent?: (sessionKey: string) => void;
/** Called when the user clicks the collapse/hide sidebar button. */
onCollapse?: () => void;
/** When true, show a loader instead of empty state (e.g. initial sessions fetch). */
@@ -149,6 +153,20 @@ function MoreHorizontalIcon() {
);
}
+function StopIcon() {
+ return (
+
+ );
+}
+
export function ChatSessionsSidebar({
sessions,
activeSessionId,
@@ -161,6 +179,8 @@ export function ChatSessionsSidebar({
onSelectSubagent,
onDeleteSession,
onRenameSession,
+ onStopSession,
+ onStopSubagent,
onCollapse,
mobile,
onClose,
@@ -286,8 +306,8 @@ export function ChatSessionsSidebar({
{group.sessions.map((session) => {
const isActive = session.id === activeSessionId && !activeSubagentKey;
const isHovered = session.id === hoveredId;
- const showMore = isHovered;
const isStreamingSession = streamingSessionIds?.has(session.id) ?? false;
+ const showMore = isHovered || isStreamingSession;
const sessionSubagents = subagentsByParent.get(session.id);
return (
)}
- {onDeleteSession && (
-
+
+ {isStreamingSession && onStopSession && (
+
+ )}
+ {onDeleteSession && (
e.stopPropagation()}
@@ -394,8 +429,8 @@ export function ChatSessionsSidebar({
-
- )}
+ )}
+
{/* Subagent sub-items */}
{sessionSubagents && sessionSubagents.length > 0 && (
@@ -406,38 +441,57 @@ export function ChatSessionsSidebar({
const subLabel = sa.label || sa.task;
const truncated = subLabel.length > 40 ? subLabel.slice(0, 40) + "..." : subLabel;
return (
-
+
+
+ )}
+
);
})}
diff --git a/apps/web/app/components/workspace/tab-bar.tsx b/apps/web/app/components/workspace/tab-bar.tsx
index ff89a1c1a16..ee1556f06f9 100644
--- a/apps/web/app/components/workspace/tab-bar.tsx
+++ b/apps/web/app/components/workspace/tab-bar.tsx
@@ -21,6 +21,8 @@ type TabBarProps = {
onCloseAll: () => void;
onReorder: (fromIndex: number, toIndex: number) => void;
onTogglePin: (tabId: string) => void;
+ liveChatTabIds?: Set;
+ onStopTab?: (tabId: string) => void;
onNewTab?: () => void;
leftContent?: React.ReactNode;
rightContent?: React.ReactNode;
@@ -32,10 +34,10 @@ type ContextMenuState = {
y: number;
} | null;
-function tabToFaviconClass(tab: Tab): string | undefined {
+function tabToFaviconClass(tab: Tab, isLive: boolean): string | undefined {
switch (tab.type) {
case "home": return "dench-favicon-home";
- case "chat": return "dench-favicon-chat";
+ case "chat": return isLive ? "dench-favicon-chat-live" : "dench-favicon-chat";
case "app": return "dench-favicon-app";
case "cron": return "dench-favicon-cron";
case "object": return "dench-favicon-object";
@@ -60,6 +62,8 @@ export function TabBar({
onCloseAll,
onReorder,
onTogglePin,
+ liveChatTabIds,
+ onStopTab,
onNewTab,
leftContent,
rightContent,
@@ -104,10 +108,10 @@ export function TabBar({
title: tab.title,
active: tab.id === activeTabId,
favicon: tabToFavicon(tab),
- faviconClass: tabToFaviconClass(tab),
+ faviconClass: tabToFaviconClass(tab, liveChatTabIds?.has(tab.id) ?? false),
isCloseIconVisible: !tab.pinned,
}));
- }, [nonHomeTabs, activeTabId]);
+ }, [nonHomeTabs, activeTabId, liveChatTabIds]);
const handleActive = useCallback((id: string) => onActivate(id), [onActivate]);
const handleClose = useCallback((id: string) => onClose(id), [onClose]);
@@ -177,6 +181,15 @@ export function TabBar({
label={contextTab.pinned ? "Unpin Tab" : "Pin Tab"}
onClick={() => { onTogglePin(contextMenu.tabId); setContextMenu(null); }}
/>
+ {contextTab.type === "chat" && liveChatTabIds?.has(contextMenu.tabId) && onStopTab && (
+ <>
+
+ { onStopTab(contextMenu.tabId); setContextMenu(null); }}
+ />
+ >
+ )}
(null);
- // Chat panel ref for session management
+ // Visible main chat panel ref for session management
const chatRef = useRef(null);
+ // Mounted main chat panels keyed by tab id so inactive tabs can keep streaming.
+ const chatPanelRefs = useRef>({});
// Compact (file-scoped) chat panel ref for sidebar drag-and-drop
const compactChatRef = useRef(null);
// Root layout ref for resize handle position (handle follows cursor)
@@ -442,53 +464,15 @@ function WorkspacePageInner() {
const [sessionsLoading, setSessionsLoading] = useState(true);
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
const [streamingSessionIds, setStreamingSessionIds] = useState>(new Set());
+ const [chatRuntimeSnapshots, setChatRuntimeSnapshots] = useState>({});
+ const [chatRunsSnapshot, setChatRunsSnapshot] = useState(() =>
+ createChatRunsSnapshot({ parentRuns: [], subagents: [] }),
+ );
// Subagent tracking
const [subagents, setSubagents] = useState([]);
const [activeSubagentKey, setActiveSubagentKey] = useState(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.
- // The identifier may be a childSessionKey (preferred) or a task label (legacy fallback).
- const handleSubagentClickFromChat = useCallback((identifier: string) => {
- const byKey = subagents.find((sa) => sa.childSessionKey === identifier);
- if (byKey) {
- setActiveSubagentKey(byKey.childSessionKey);
- return;
- }
- const byTask = subagents.find((sa) => sa.task === identifier);
- if (byTask) {
- setActiveSubagentKey(byTask.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([]);
@@ -531,15 +515,11 @@ function WorkspacePageInner() {
const loaded = loadTabs(key);
const hasNonHomeTabs = loaded.tabs.some((t) => t.id !== HOME_TAB_ID);
if (!hasNonHomeTabs) {
- const newTab: Tab = {
- id: generateTabId(),
- type: "chat",
- title: "New Chat",
- };
- setTabState(openTab(loaded, newTab));
+ setTabState(openTab(loaded, createBlankChatTab()));
} else {
setTabState(loaded);
}
+ setChatRuntimeSnapshots({});
}, [workspaceName]);
// Persist tabs to localStorage on change (only after initial load for this workspace)
@@ -549,8 +529,171 @@ function WorkspacePageInner() {
saveTabs(tabState, key);
}, [tabState, workspaceName]);
+ useEffect(() => {
+ const validTabIds = new Set(tabState.tabs.map((tab) => tab.id));
+ setChatRuntimeSnapshots((prev) => {
+ let next = prev;
+ for (const tabId of Object.keys(prev)) {
+ if (!validTabIds.has(tabId)) {
+ next = removeChatRuntimeSnapshot(next, tabId);
+ }
+ }
+ return next;
+ });
+ for (const tabId of Object.keys(chatPanelRefs.current)) {
+ if (!validTabIds.has(tabId)) {
+ delete chatPanelRefs.current[tabId];
+ }
+ }
+ }, [tabState.tabs]);
+
// Ref for the keyboard shortcut to close the active tab (avoids stale closure over loadContent)
const tabCloseActiveRef = useRef<(() => void) | null>(null);
+ const activeTab = useMemo(
+ () => tabState.tabs.find((tab) => tab.id === tabState.activeTabId) ?? HOME_TAB,
+ [tabState],
+ );
+ const mainChatTabs = useMemo(
+ () => tabState.tabs.filter((tab) => tab.id !== HOME_TAB_ID && isChatTab(tab)),
+ [tabState.tabs],
+ );
+
+ const openBlankChatTab = useCallback(() => {
+ const tab = createBlankChatTab();
+ setActivePath(null);
+ setContent({ kind: "none" });
+ setActiveSessionId(null);
+ setActiveSubagentKey(null);
+ setTabState((prev) => openTab(prev, tab));
+ return tab;
+ }, []);
+
+ const openSessionChatTab = useCallback((sessionId: string, title?: string) => {
+ setActivePath(null);
+ setContent({ kind: "none" });
+ setActiveSessionId(sessionId);
+ setActiveSubagentKey(null);
+ setTabState((prev) => openOrFocusParentChatTab(prev, { sessionId, title }));
+ }, []);
+
+ const openSubagentChatTab = useCallback((params: {
+ sessionKey: string;
+ parentSessionId: string;
+ title?: string;
+ }) => {
+ setActivePath(null);
+ setContent({ kind: "none" });
+ setActiveSessionId(params.parentSessionId);
+ setActiveSubagentKey(params.sessionKey);
+ setTabState((prev) => openOrFocusSubagentChatTab(prev, params));
+ }, []);
+
+ const visibleMainChatTabId = useMemo(() => {
+ if (isChatTab(activeTab)) {
+ return activeTab.id;
+ }
+ if (activeSubagentKey) {
+ const matchingSubagentTab = mainChatTabs.find((tab) => tab.sessionKey === activeSubagentKey);
+ if (matchingSubagentTab) {
+ return matchingSubagentTab.id;
+ }
+ }
+ if (activeSessionId) {
+ const matchingParentTab = mainChatTabs.find((tab) => tab.sessionId === activeSessionId);
+ if (matchingParentTab) {
+ return matchingParentTab.id;
+ }
+ }
+ return mainChatTabs[0]?.id ?? null;
+ }, [activeTab, activeSessionId, activeSubagentKey, mainChatTabs]);
+
+ useEffect(() => {
+ if (!isChatTab(activeTab)) {
+ return;
+ }
+ const identity = resolveChatIdentityForTab(activeTab);
+ setActiveSessionId((prev) => prev === identity.sessionId ? prev : identity.sessionId);
+ setActiveSubagentKey((prev) => prev === identity.subagentKey ? prev : identity.subagentKey);
+ }, [activeTab]);
+
+ const setMainChatPanelRef = useCallback((tabId: string, handle: ChatPanelHandle | null) => {
+ chatPanelRefs.current[tabId] = handle;
+ }, []);
+
+ useEffect(() => {
+ chatRef.current = visibleMainChatTabId ? chatPanelRefs.current[visibleMainChatTabId] ?? null : null;
+ }, [visibleMainChatTabId]);
+
+ const handleChatRuntimeStateChange = useCallback((tabId: string, runtime: ChatPanelRuntimeState) => {
+ setChatRuntimeSnapshots((prev) =>
+ mergeChatRuntimeSnapshot(prev, {
+ tabId,
+ ...runtime,
+ }),
+ );
+ }, []);
+
+ const handleChatTabSessionChange = useCallback((tabId: string, sessionId: string | null) => {
+ setTabState((prev) => bindParentSessionToChatTab(prev, tabId, sessionId));
+ if (tabState.activeTabId === tabId || visibleMainChatTabId === tabId) {
+ setActiveSessionId(sessionId);
+ setActiveSubagentKey(null);
+ }
+ }, [tabState.activeTabId, visibleMainChatTabId]);
+
+ const sendMessageInChatTab = useCallback((tabId: string, message: string) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ void chatPanelRefs.current[tabId]?.sendNewMessage(message);
+ });
+ });
+ }, []);
+
+ // Navigate to a subagent panel when its card is clicked in the chat.
+ // The identifier may be a childSessionKey (preferred) or a task label (legacy fallback).
+ const handleSubagentClickFromChat = useCallback((identifier: string) => {
+ const byKey = subagents.find((sa) => sa.childSessionKey === identifier);
+ if (byKey) {
+ openSubagentChatTab({
+ sessionKey: byKey.childSessionKey,
+ parentSessionId: byKey.parentSessionId,
+ title: byKey.label || byKey.task,
+ });
+ return;
+ }
+ const byTask = subagents.find((sa) => sa.task === identifier);
+ if (byTask) {
+ openSubagentChatTab({
+ sessionKey: byTask.childSessionKey,
+ parentSessionId: byTask.parentSessionId,
+ title: byTask.label || byTask.task,
+ });
+ }
+ }, [openSubagentChatTab, subagents]);
+
+ const handleSelectSubagent = useCallback((sessionKey: string) => {
+ const subagent = subagents.find((entry) => entry.childSessionKey === sessionKey);
+ if (!subagent) {
+ return;
+ }
+ openSubagentChatTab({
+ sessionKey,
+ parentSessionId: subagent.parentSessionId,
+ title: subagent.label || subagent.task,
+ });
+ }, [openSubagentChatTab, subagents]);
+
+ const handleBackFromSubagent = useCallback(() => {
+ if (!activeSubagentKey) {
+ return;
+ }
+ const activeChild = subagents.find((entry) => entry.childSessionKey === activeSubagentKey);
+ if (activeChild) {
+ openSessionChatTab(activeChild.parentSessionId);
+ return;
+ }
+ setActiveSubagentKey(null);
+ }, [activeSubagentKey, openSessionChatTab, subagents]);
const openTabForNode = useCallback((node: { path: string; name: string; type: string }) => {
const tab: Tab = {
@@ -700,7 +843,12 @@ function WorkspacePageInner() {
setActiveSessionId,
setActiveSubagentKey,
resetMainChat: () => {
- void chatRef.current?.newSession();
+ chatPanelRefs.current = {};
+ setChatRuntimeSnapshots({});
+ setChatRunsSnapshot(createChatRunsSnapshot({ parentRuns: [], subagents: [] }));
+ setStreamingSessionIds(new Set());
+ setSubagents([]);
+ setTabState({ tabs: [HOME_TAB], activeTabId: HOME_TAB_ID });
},
replaceUrlToRoot: () => {
// URL sync effect will write the correct URL after state is cleared
@@ -717,21 +865,37 @@ function WorkspacePageInner() {
async (sessionId: string) => {
const res = await fetch(`/api/web-sessions/${sessionId}`, { method: "DELETE" });
if (!res.ok) {return;}
+ const closedTabIds = new Set(
+ tabState.tabs
+ .filter((tab) => tab.type === "chat" && (tab.sessionId === sessionId || tab.parentSessionId === sessionId))
+ .map((tab) => tab.id),
+ );
+ setTabState((prev) => {
+ let next = closeChatTabsForSession(prev, sessionId);
+ const hasNonHomeTabs = next.tabs.some((tab) => tab.id !== HOME_TAB_ID);
+ if (!hasNonHomeTabs) {
+ next = openTab(next, createBlankChatTab());
+ }
+ return next;
+ });
+ setChatRuntimeSnapshots((prev) => {
+ let next = prev;
+ for (const tabId of closedTabIds) {
+ next = removeChatRuntimeSnapshot(next, tabId);
+ }
+ return next;
+ });
if (activeSessionId === sessionId) {
- setActiveSessionId(null);
- setActiveSubagentKey(null);
const remaining = sessions.filter((s) => s.id !== sessionId);
if (remaining.length > 0) {
- const next = remaining[0];
- setActiveSessionId(next.id);
- void chatRef.current?.loadSession(next.id);
+ openSessionChatTab(remaining[0].id, remaining[0].title);
} else {
- void chatRef.current?.newSession();
+ openBlankChatTab();
}
}
void fetchSessions();
},
- [activeSessionId, sessions, fetchSessions],
+ [activeSessionId, sessions, fetchSessions, openBlankChatTab, openSessionChatTab, tabState.tabs],
);
const handleRenameSession = useCallback(
@@ -746,15 +910,28 @@ function WorkspacePageInner() {
[fetchSessions],
);
- // Poll for active (streaming) agent runs so the sidebar can show indicators.
+ // Poll for parent/subagent run state so tabs and sidebars can reflect
+ // background activity across all open chats.
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
- const res = await fetch("/api/chat/active");
+ const res = await fetch("/api/chat/runs");
if (cancelled) {return;}
const data = await res.json();
- const ids: string[] = data.sessionIds ?? [];
+ const parentRuns: Array<{ sessionId: string; status: "running" | "waiting-for-subagents" | "completed" | "error" }> = data.parentRuns ?? [];
+ const nextSubagents: SubagentSpawnInfo[] = data.subagents ?? [];
+ const ids = parentRuns
+ .filter((run) => run.status === "running" || run.status === "waiting-for-subagents")
+ .map((run) => run.sessionId);
+ setChatRunsSnapshot(createChatRunsSnapshot({
+ parentRuns,
+ subagents: nextSubagents.map((subagent) => ({
+ childSessionKey: subagent.childSessionKey,
+ status: subagent.status ?? "completed",
+ })),
+ }));
+ setSubagents(nextSubagents);
setStreamingSessionIds((prev) => {
// Only update state if the set actually changed (avoid re-renders).
if (prev.size === ids.length && ids.every((id) => prev.has(id))) {return prev;}
@@ -924,7 +1101,7 @@ function WorkspacePageInner() {
setBrowseDir(null);
setActivePath(null);
setContent({ kind: "none" });
- void chatRef.current?.newSession();
+ openBlankChatTab();
return;
}
}
@@ -944,18 +1121,12 @@ function WorkspacePageInner() {
// Intercept chat folder item clicks
if (node.path.startsWith("~chats/")) {
const sessionId = node.path.slice("~chats/".length);
- setActivePath(null);
- setContent({ kind: "none" });
- setActiveSessionId(sessionId);
- void chatRef.current?.loadSession(sessionId);
- // URL is synced by the activeSessionId effect
+ openSessionChatTab(sessionId);
return;
}
// Clicking the Chats folder itself opens a new chat
if (node.path === "~chats") {
- setActivePath(null);
- setContent({ kind: "none" });
- void chatRef.current?.newSession();
+ openBlankChatTab();
return;
}
// Intercept cron job item clicks
@@ -977,15 +1148,44 @@ function WorkspacePageInner() {
openTabForNode(node);
void loadContent(node);
},
- [loadContent, openTabForNode, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
+ [loadContent, openBlankChatTab, openSessionChatTab, openTabForNode, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
);
+ const applyActivatedTab = useCallback((tab: Tab | undefined) => {
+ if (!tab || tab.id === HOME_TAB_ID) {
+ setActivePath(null);
+ setContent({ kind: "none" });
+ return;
+ }
+ if (tab.type === "chat") {
+ setActivePath(null);
+ setContent({ kind: "none" });
+ const identity = resolveChatIdentityForTab(tab);
+ setActiveSessionId(identity.sessionId);
+ setActiveSubagentKey(identity.subagentKey);
+ return;
+ }
+ if (tab.path) {
+ const node = resolveNode(tree, tab.path);
+ if (node) {
+ void loadContent(node);
+ } else if (tab.path === "~cron") {
+ setActivePath("~cron");
+ setContent({ kind: "cron-dashboard" });
+ } else if (tab.path.startsWith("~cron/")) {
+ setActivePath(tab.path);
+ const jobId = tab.path.slice("~cron/".length);
+ const job = cronJobs.find((j) => j.id === jobId);
+ if (job) setContent({ kind: "cron-job", jobId, job });
+ }
+ }
+ }, [tree, loadContent, cronJobs]);
+
// Tab handler callbacks (defined after loadContent is available)
const handleTabActivate = useCallback((tabId: string) => {
if (tabId === HOME_TAB_ID) {
- setActivePath(null);
- setContent({ kind: "none" });
setTabState((prev) => activateTab(prev, tabId));
+ applyActivatedTab(undefined);
return;
}
let tab: Tab | undefined;
@@ -995,81 +1195,36 @@ function WorkspacePageInner() {
return next;
});
requestAnimationFrame(() => {
- if (!tab) return;
- if (tab.type === "chat") {
- setActivePath(null);
- setContent({ kind: "none" });
- if (tab.sessionId) {
- setActiveSessionId(tab.sessionId);
- setActiveSubagentKey(null);
- void chatRef.current?.loadSession(tab.sessionId);
- } else {
- setActiveSessionId(null);
- setActiveSubagentKey(null);
- void chatRef.current?.newSession();
- }
- } else if (tab.path) {
- const node = resolveNode(tree, tab.path);
- if (node) {
- void loadContent(node);
- } else if (tab.path === "~cron") {
- setActivePath("~cron");
- setContent({ kind: "cron-dashboard" });
- } else if (tab.path.startsWith("~cron/")) {
- setActivePath(tab.path);
- const jobId = tab.path.slice("~cron/".length);
- const job = cronJobs.find((j) => j.id === jobId);
- if (job) setContent({ kind: "cron-job", jobId, job });
- }
- }
+ applyActivatedTab(tab);
});
- }, [tree, loadContent, cronJobs]);
+ }, [applyActivatedTab]);
const handleTabClose = useCallback((tabId: string) => {
const prev = tabState;
let next = closeTab(prev, tabId);
const hasNonHomeTabs = next.tabs.some((t) => t.id !== HOME_TAB_ID);
if (!hasNonHomeTabs) {
- const newTab: Tab = {
- id: generateTabId(),
- type: "chat",
- title: "New Chat",
- };
- next = openTab(next, newTab);
+ next = openTab(next, createBlankChatTab());
setTabState(next);
setActivePath(null);
setContent({ kind: "none" });
setActiveSessionId(null);
setActiveSubagentKey(null);
- requestAnimationFrame(() => {
- void chatRef.current?.newSession();
- });
return;
}
setTabState(next);
if (next.activeTabId !== prev.activeTabId) {
const newActive = next.tabs.find((t) => t.id === next.activeTabId);
if (!newActive || newActive.id === HOME_TAB_ID) {
- setActivePath(null);
- setContent({ kind: "none" });
- } else if (newActive.type === "chat") {
- setActivePath(null);
- setContent({ kind: "none" });
- if (newActive.sessionId) {
- setActiveSessionId(newActive.sessionId);
- setActiveSubagentKey(null);
- requestAnimationFrame(() => {
- void chatRef.current?.loadSession(newActive.sessionId!);
- });
- }
- } else if (newActive.path) {
- const node = resolveNode(tree, newActive.path);
- if (node) {
- void loadContent(node);
- }
+ const identity = resolveChatIdentityForTab(next.tabs.find((tab) => tab.type === "chat"));
+ setActiveSessionId(identity.sessionId);
+ setActiveSubagentKey(identity.subagentKey);
+ applyActivatedTab(undefined);
+ } else {
+ applyActivatedTab(newActive);
}
}
- }, [tree, loadContent, tabState]);
+ }, [applyActivatedTab, tabState]);
// Keep ref in sync so keyboard shortcut can close active tab
useEffect(() => {
@@ -1081,12 +1236,16 @@ function WorkspacePageInner() {
}, [tabState.activeTabId, handleTabClose]);
const handleTabCloseOthers = useCallback((tabId: string) => {
- setTabState((prev) => closeOtherTabs(prev, tabId));
- }, []);
+ const next = closeOtherTabs(tabState, tabId);
+ setTabState(next);
+ applyActivatedTab(next.tabs.find((tab) => tab.id === next.activeTabId));
+ }, [applyActivatedTab, tabState]);
const handleTabCloseToRight = useCallback((tabId: string) => {
- setTabState((prev) => closeTabsToRight(prev, tabId));
- }, []);
+ const next = closeTabsToRight(tabState, tabId);
+ setTabState(next);
+ applyActivatedTab(next.tabs.find((tab) => tab.id === next.activeTabId));
+ }, [applyActivatedTab, tabState]);
const handleTabCloseAll = useCallback(() => {
setTabState((prev) => {
@@ -1095,15 +1254,7 @@ function WorkspacePageInner() {
setContent({ kind: "none" });
setActiveSessionId(null);
setActiveSubagentKey(null);
- const newTab: Tab = {
- id: generateTabId(),
- type: "chat",
- title: "New Chat",
- };
- return openTab(closed, newTab);
- });
- requestAnimationFrame(() => {
- void chatRef.current?.newSession();
+ return openTab(closed, createBlankChatTab());
});
}, []);
@@ -1185,50 +1336,14 @@ function WorkspacePageInner() {
[],
);
- // Open inline file-path mentions from chat.
- // In chat mode, render a Dropbox-style preview in the right sidebar.
+ // Open inline file-path mentions from chat in a new workspace tab.
const handleFilePathClickFromChat = useCallback(
async (rawPath: string) => {
const inputPath = normalizeChatPath(rawPath);
if (!inputPath) {return false;}
- // Desktop behavior: always use right-sidebar preview for chat path clicks.
- const shouldPreviewInSidebar = !isMobile;
-
- const openNode = async (node: TreeNode) => {
- if (!shouldPreviewInSidebar) {
- handleNodeSelect(node);
- setShowChatSidebar(true);
- return true;
- }
-
- // Ensure we are in main-chat layout so the preview panel is visible.
- if (activePath || content.kind !== "none") {
- setActivePath(null);
- setContent({ kind: "none" });
- }
-
- setChatSidebarPreview({
- status: "loading",
- path: node.path,
- filename: node.name,
- });
- const previewContent = await loadSidebarPreviewFromNode(node);
- if (!previewContent) {
- setChatSidebarPreview({
- status: "error",
- path: node.path,
- filename: node.name,
- message: "Could not preview this file.",
- });
- return false;
- }
- setChatSidebarPreview({
- status: "ready",
- path: node.path,
- filename: node.name,
- content: previewContent,
- });
+ const openNode = (node: TreeNode) => {
+ handleNodeSelect(node);
return true;
};
@@ -1241,7 +1356,7 @@ function WorkspacePageInner() {
) {
const node = resolveNode(tree, inputPath);
if (node) {
- return await openNode(node);
+ return openNode(node);
}
}
@@ -1262,24 +1377,14 @@ function WorkspacePageInner() {
if (relPath) {
const node = resolveNode(tree, relPath);
if (node) {
- return await openNode(node);
+ return openNode(node);
}
}
}
if (info.type === "directory") {
const dirNode: TreeNode = { name: info.name, path: info.path, type: "folder" };
- if (shouldPreviewInSidebar) {
- return await openNode(dirNode);
- }
- setBrowseDir(info.path);
- setActivePath(info.path);
- setContent({
- kind: "directory",
- node: { name: info.name, path: info.path, type: "folder" },
- });
- setShowChatSidebar(true);
- return true;
+ return openNode(dirNode);
}
if (info.type === "file") {
@@ -1288,16 +1393,7 @@ function WorkspacePageInner() {
path: info.path,
type: inferNodeTypeFromFileName(info.name),
};
- if (shouldPreviewInSidebar) {
- return await openNode(fileNode);
- }
- const parentDir = info.path.split("/").slice(0, -1).join("/") || "/";
- if (isAbsolutePath(info.path)) {
- setBrowseDir(parentDir);
- }
- await loadContent(fileNode);
- setShowChatSidebar(true);
- return true;
+ return openNode(fileNode);
}
} catch {
// Ignore -- chat message bubble shows inline error state.
@@ -1305,7 +1401,7 @@ function WorkspacePageInner() {
return false;
},
- [activePath, content.kind, isMobile, tree, handleNodeSelect, workspaceRoot, loadSidebarPreviewFromNode, setBrowseDir, loadContent, router],
+ [tree, handleNodeSelect, workspaceRoot],
);
// Build the enhanced tree: real tree + Cron virtual folder at the bottom
@@ -1532,13 +1628,14 @@ function WorkspacePageInner() {
}
} else if (urlState.chat) {
initialPathHandled.current = true;
- setActiveSessionId(urlState.chat);
- setActivePath(null);
- setContent({ kind: "none" });
- void chatRef.current?.loadSession(urlState.chat);
-
if (urlState.subagent) {
- setActiveSubagentKey(urlState.subagent);
+ openSubagentChatTab({
+ sessionKey: urlState.subagent,
+ parentSessionId: urlState.chat,
+ title: "Subagent",
+ });
+ } else {
+ openSessionChatTab(urlState.chat);
}
} else {
// No path or chat param — mark hydration done (bare / or browse-only)
@@ -1611,12 +1708,15 @@ function WorkspacePageInner() {
}
setFileChatSessionId(urlState.fileChat);
} else if (urlState.chat) {
- setActiveSessionId(urlState.chat);
- setActivePath(null);
- setContent({ kind: "none" });
- void chatRef.current?.loadSession(urlState.chat);
- setActiveSubagentKey(urlState.subagent);
- setTabState((prev) => activateTab(prev, HOME_TAB_ID));
+ if (urlState.subagent) {
+ openSubagentChatTab({
+ sessionKey: urlState.subagent,
+ parentSessionId: urlState.chat,
+ title: "Subagent",
+ });
+ } else {
+ openSessionChatTab(urlState.chat);
+ }
} else {
setActivePath(null);
setContent({ kind: "none" });
@@ -1693,11 +1793,9 @@ function WorkspacePageInner() {
setActivePath(null);
setContent({ kind: "none" });
- // Give ChatPanel a frame to mount, then send the message
- requestAnimationFrame(() => {
- void chatRef.current?.sendNewMessage(sendParam);
- });
- }, [searchParams, router]);
+ const tab = openBlankChatTab();
+ sendMessageInChatTab(tab.id, sendParam);
+ }, [openBlankChatTab, searchParams, router, sendMessageInChatTab]);
const handleBreadcrumbNavigate = useCallback(
(path: string) => {
@@ -1844,10 +1942,9 @@ function WorkspacePageInner() {
const handleCronSendCommand = useCallback((message: string) => {
setActivePath(null);
setContent({ kind: "none" });
- requestAnimationFrame(() => {
- void chatRef.current?.sendNewMessage(message);
- });
- }, []);
+ const tab = openBlankChatTab();
+ sendMessageInChatTab(tab.id, message);
+ }, [openBlankChatTab, sendMessageInChatTab]);
// Derive the active session's title for the header / right sidebar
const activeSessionTitle = useMemo(() => {
@@ -1857,18 +1954,128 @@ function WorkspacePageInner() {
}, [activeSessionId, sessions]);
useEffect(() => {
- if (!activeSessionTitle) return;
setTabState((prev) => {
- const active = prev.tabs.find((t) => t.id === prev.activeTabId);
- if (active?.type === "chat" && active.title !== activeSessionTitle) {
- return updateTabTitle(prev, active.id, activeSessionTitle);
+ let next = syncParentChatTabTitles(prev, sessions);
+ next = syncSubagentChatTabTitles(next, subagents);
+ if (!activeSessionTitle) {
+ return next;
}
- return prev;
+ const active = next.tabs.find((t) => t.id === next.activeTabId);
+ if (active?.type === "chat" && active.title !== activeSessionTitle && !active.sessionKey) {
+ return updateChatTabTitle(next, active.id, activeSessionTitle);
+ }
+ return next;
});
- }, [activeSessionTitle]);
+ }, [activeSessionTitle, sessions, subagents]);
- // Whether to show the main ChatPanel (no file/content selected)
- const showMainChat = !activePath || content.kind === "none";
+ const runningSubagentKeys = useMemo(
+ () => new Set(subagents.filter((subagent) => subagent.status === "running").map((subagent) => subagent.childSessionKey)),
+ [subagents],
+ );
+
+ const liveChatTabIds = useMemo(() => {
+ const ids = new Set();
+ for (const tab of mainChatTabs) {
+ const runtime = chatRuntimeSnapshots[tab.id];
+ if (runtime?.isStreaming) {
+ ids.add(tab.id);
+ continue;
+ }
+ if (tab.sessionKey && (runningSubagentKeys.has(tab.sessionKey) || chatRunsSnapshot.subagentStatuses.get(tab.sessionKey) === "running")) {
+ ids.add(tab.id);
+ continue;
+ }
+ if (tab.sessionId && streamingSessionIds.has(tab.sessionId)) {
+ ids.add(tab.id);
+ }
+ }
+ return ids;
+ }, [chatRunsSnapshot.subagentStatuses, chatRuntimeSnapshots, mainChatTabs, runningSubagentKeys, streamingSessionIds]);
+
+ const optimisticallyStopParentSession = useCallback((sessionId: string) => {
+ setStreamingSessionIds((prev) => {
+ if (!prev.has(sessionId)) {
+ return prev;
+ }
+ const next = new Set(prev);
+ next.delete(sessionId);
+ return next;
+ });
+ setSubagents((prev) => prev.map((subagent) =>
+ subagent.parentSessionId === sessionId && subagent.status === "running"
+ ? { ...subagent, status: "completed" }
+ : subagent,
+ ));
+ setChatRuntimeSnapshots((prev) => {
+ const next: Record = {};
+ for (const [tabId, snapshot] of Object.entries(prev)) {
+ next[tabId] = snapshot.sessionId === sessionId
+ ? { ...snapshot, isStreaming: false, isReconnecting: false, status: "ready" }
+ : snapshot;
+ }
+ return next;
+ });
+ }, []);
+
+ const optimisticallyStopSubagent = useCallback((sessionKey: string) => {
+ setSubagents((prev) => prev.map((subagent) =>
+ subagent.childSessionKey === sessionKey && subagent.status === "running"
+ ? { ...subagent, status: "completed" }
+ : subagent,
+ ));
+ setChatRuntimeSnapshots((prev) => {
+ const next: Record = {};
+ for (const [tabId, snapshot] of Object.entries(prev)) {
+ next[tabId] = snapshot.sessionKey === sessionKey
+ ? { ...snapshot, isStreaming: false, isReconnecting: false, status: "ready" }
+ : snapshot;
+ }
+ return next;
+ });
+ }, []);
+
+ const stopParentSession = useCallback(async (sessionId: string) => {
+ optimisticallyStopParentSession(sessionId);
+ try {
+ await fetch("/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sessionId, cascadeChildren: true }),
+ });
+ } catch {
+ // Best-effort optimistic stop; polling will reconcile state.
+ }
+ }, [optimisticallyStopParentSession]);
+
+ const stopSubagentSession = useCallback(async (sessionKey: string) => {
+ optimisticallyStopSubagent(sessionKey);
+ try {
+ await fetch("/api/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sessionKey }),
+ });
+ } catch {
+ // Best-effort optimistic stop; polling will reconcile state.
+ }
+ }, [optimisticallyStopSubagent]);
+
+ const handleStopChatTab = useCallback((tabId: string) => {
+ const tab = tabState.tabs.find((entry) => entry.id === tabId);
+ if (!tab || tab.type !== "chat") {
+ return;
+ }
+ if (tab.sessionKey) {
+ void stopSubagentSession(tab.sessionKey);
+ return;
+ }
+ if (tab.sessionId) {
+ void stopParentSession(tab.sessionId);
+ }
+ }, [stopParentSession, stopSubagentSession, tabState.tabs]);
+
+ // Whether to show the main chat workspace instead of file/object content.
+ const showMainChat = activeTab.type === "chat" || activeTab.id === HOME_TAB_ID || (!activePath || content.kind === "none");
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
@@ -1908,15 +2115,12 @@ function WorkspacePageInner() {
chatActiveSubagentKey={activeSubagentKey}
chatSessionsLoading={sessionsLoading}
onSelectChatSession={(sessionId) => {
- setActiveSessionId(sessionId);
- setActiveSubagentKey(null);
- void chatRef.current?.loadSession(sessionId);
+ const session = sessions.find((entry) => entry.id === sessionId);
+ openSessionChatTab(sessionId, session?.title);
setSidebarOpen(false);
}}
onNewChatSession={() => {
- setActiveSessionId(null);
- setActiveSubagentKey(null);
- void chatRef.current?.newSession();
+ openBlankChatTab();
setSidebarOpen(false);
}}
onSelectChatSubagent={handleSelectSubagent}
@@ -1974,14 +2178,11 @@ function WorkspacePageInner() {
chatActiveSubagentKey={activeSubagentKey}
chatSessionsLoading={sessionsLoading}
onSelectChatSession={(sessionId) => {
- setActiveSessionId(sessionId);
- setActiveSubagentKey(null);
- void chatRef.current?.loadSession(sessionId);
+ const session = sessions.find((entry) => entry.id === sessionId);
+ openSessionChatTab(sessionId, session?.title);
}}
onNewChatSession={() => {
- setActiveSessionId(null);
- setActiveSubagentKey(null);
- void chatRef.current?.newSession();
+ openBlankChatTab();
}}
onSelectChatSubagent={handleSelectSubagent}
onDeleteChatSession={handleDeleteSession}
@@ -1997,7 +2198,7 @@ function WorkspacePageInner() {
{/* Main content */}
-
+
{/* Mobile top bar — always visible on mobile */}
{isMobile && (
{
- const newTab: Tab = {
- id: generateTabId(),
- type: "chat",
- title: "New Chat",
- };
- setActivePath(null);
- setContent({ kind: "none" });
- setActiveSessionId(null);
- setActiveSubagentKey(null);
- setTabState((prev) => openTab(prev, newTab));
- requestAnimationFrame(() => {
- void chatRef.current?.newSession();
- });
- }}
+ liveChatTabIds={liveChatTabIds}
+ onStopTab={handleStopChatTab}
+ onNewTab={openBlankChatTab}
rightContent={showMainChat ? (
<>
+ {visibleMainChatTabId && liveChatTabIds.has(visibleMainChatTabId) && (
+
handleStopChatTab(visibleMainChatTabId)}
+ className="p-1.5 rounded-lg cursor-pointer"
+ style={{ color: "var(--color-text-muted)" }}
+ title="Stop active chat"
+ >
+
+
+ )}
setChatSidebarOpen((v) => !v)}
@@ -2096,7 +2298,7 @@ function WorkspacePageInner() {
- {activeSessionId && (
+ {activeSessionId && !activeSubagentKey && (
- {showMainChat ? (
-
- {
- setActiveSessionId(id);
- setActiveSubagentKey(null);
- if (id) {
- setTabState((prev) => {
- const active = prev.tabs.find((t) => t.id === prev.activeTabId);
- if (active?.type === "chat" && !active.sessionId) {
- return {
- ...prev,
- tabs: prev.tabs.map((t) =>
- t.id === active.id ? { ...t, sessionId: id } : t,
- ),
- };
- }
- return prev;
- });
- }
- }}
- onSessionsChange={activeSubagent ? undefined : refreshSessions}
- onSubagentSpawned={activeSubagent ? undefined : handleSubagentSpawned}
- onSubagentClick={handleSubagentClickFromChat}
- onFilePathClick={handleFilePathClickFromChat}
- onDeleteSession={activeSubagent ? undefined : handleDeleteSession}
- onRenameSession={activeSubagent ? undefined : handleRenameSession}
- compact={isMobile}
- sessionKey={activeSubagent?.childSessionKey}
- subagentTask={activeSubagent?.task}
- subagentLabel={activeSubagent?.label}
- onBack={activeSubagent ? handleBackFromSubagent : undefined}
- hideHeaderActions={!isMobile}
- />
-
- ) : (
+
+ {mainChatTabs.map((tab) => {
+ const subagent = tab.sessionKey
+ ? subagents.find((entry) => entry.childSessionKey === tab.sessionKey)
+ : null;
+ const isVisible = tab.id === visibleMainChatTabId;
+ return (
+
+ setMainChatPanelRef(tab.id, handle)}
+ sessionTitle={tab.title}
+ initialSessionId={tab.sessionKey ? undefined : tab.sessionId ?? undefined}
+ onActiveSessionChange={tab.sessionKey ? undefined : (id) => handleChatTabSessionChange(tab.id, id)}
+ onSessionsChange={refreshSessions}
+ onSubagentClick={handleSubagentClickFromChat}
+ onFilePathClick={handleFilePathClickFromChat}
+ onDeleteSession={tab.sessionKey ? undefined : handleDeleteSession}
+ onRenameSession={tab.sessionKey ? undefined : handleRenameSession}
+ compact={isMobile}
+ sessionKey={tab.sessionKey ?? undefined}
+ subagentTask={subagent?.task}
+ subagentLabel={subagent?.label}
+ onBack={tab.sessionKey ? handleBackFromSubagent : undefined}
+ hideHeaderActions={!isMobile}
+ onRuntimeStateChange={(runtime) => handleChatRuntimeStateChange(tab.id, runtime)}
+ />
+
+ );
+ })}
+
+ {!showMainChat && (
-
+
{
- setActiveSessionId(sessionId);
- setActiveSubagentKey(null);
- void chatRef.current?.loadSession(sessionId);
+ const session = sessions.find((entry) => entry.id === sessionId);
+ openSessionChatTab(sessionId, session?.title);
}}
onNewSession={() => {
- setActiveSessionId(null);
- setActiveSubagentKey(null);
- void chatRef.current?.newSession();
+ openBlankChatTab();
}}
onSelectSubagent={handleSelectSubagent}
onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
+ onStopSession={(sessionId) => { void stopParentSession(sessionId); }}
+ onStopSubagent={(sessionKey) => { void stopSubagentSession(sessionKey); }}
embedded
/>
@@ -2304,7 +2502,7 @@ function WorkspacePageInner() {
transition: "width 200ms ease",
}}
>
-
+
;
+ subagentStatuses: Map;
+};
+
+export function mergeChatRuntimeSnapshot(
+ state: Record,
+ snapshot: ChatTabRuntimeSnapshot,
+): Record {
+ const current = state[snapshot.tabId];
+ if (
+ current &&
+ current.sessionId === snapshot.sessionId &&
+ current.sessionKey === snapshot.sessionKey &&
+ current.isStreaming === snapshot.isStreaming &&
+ current.status === snapshot.status &&
+ current.isReconnecting === snapshot.isReconnecting &&
+ current.loadingSession === snapshot.loadingSession
+ ) {
+ return state;
+ }
+ return {
+ ...state,
+ [snapshot.tabId]: snapshot,
+ };
+}
+
+export function removeChatRuntimeSnapshot(
+ state: Record,
+ tabId: string,
+): Record {
+ if (!(tabId in state)) {
+ return state;
+ }
+ const next = { ...state };
+ delete next[tabId];
+ return next;
+}
+
+export function createChatRunsSnapshot(params: {
+ parentRuns: Array<{ sessionId: string; status: "running" | "waiting-for-subagents" | "completed" | "error" }>;
+ subagents: Array<{ childSessionKey: string; status: "running" | "completed" | "error" }>;
+}): ChatRunsSnapshot {
+ return {
+ parentStatuses: new Map(params.parentRuns.map((run) => [run.sessionId, run.status])),
+ subagentStatuses: new Map(params.subagents.map((run) => [run.childSessionKey, run.status])),
+ };
+}
diff --git a/apps/web/lib/chat-tabs.test.ts b/apps/web/lib/chat-tabs.test.ts
new file mode 100644
index 00000000000..73da8ac8d1a
--- /dev/null
+++ b/apps/web/lib/chat-tabs.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+import { HOME_TAB, openTab, type TabState } from "./tab-state";
+import {
+ bindParentSessionToChatTab,
+ closeChatTabsForSession,
+ createBlankChatTab,
+ createParentChatTab,
+ createSubagentChatTab,
+ openOrFocusParentChatTab,
+ openOrFocusSubagentChatTab,
+ resolveChatIdentityForTab,
+ syncParentChatTabTitles,
+ syncSubagentChatTabTitles,
+} from "./chat-tabs";
+
+function baseState(): TabState {
+ return {
+ tabs: [HOME_TAB],
+ activeTabId: HOME_TAB.id,
+ };
+}
+
+describe("chat tab helpers", () => {
+ it("reuses an existing parent chat tab for the same session (prevents duplicate live tabs)", () => {
+ const existing = createParentChatTab({ sessionId: "parent-1", title: "Parent" });
+ const state = openTab(baseState(), existing);
+
+ const next = openOrFocusParentChatTab(state, { sessionId: "parent-1", title: "Renamed" });
+
+ expect(next.tabs.filter((tab) => tab.type === "chat")).toHaveLength(1);
+ expect(next.activeTabId).toBe(existing.id);
+ });
+
+ it("reuses an existing subagent tab for the same child session key (prevents duplicate child viewers)", () => {
+ const existing = createSubagentChatTab({
+ sessionKey: "agent:child-1:subagent:abc",
+ parentSessionId: "parent-1",
+ title: "Child",
+ });
+ const state = openTab(baseState(), existing);
+
+ const next = openOrFocusSubagentChatTab(state, {
+ sessionKey: "agent:child-1:subagent:abc",
+ parentSessionId: "parent-1",
+ title: "Child updated",
+ });
+
+ expect(next.tabs.filter((tab) => tab.type === "chat")).toHaveLength(1);
+ expect(next.activeTabId).toBe(existing.id);
+ });
+
+ it("binds a newly-created parent session id onto a draft chat tab without disturbing sibling tabs", () => {
+ const draft = createBlankChatTab();
+ const sibling = createParentChatTab({ sessionId: "existing-1", title: "Existing" });
+ const state = {
+ tabs: [HOME_TAB, draft, sibling],
+ activeTabId: draft.id,
+ } satisfies TabState;
+
+ const next = bindParentSessionToChatTab(state, draft.id, "new-session-1");
+
+ expect(next.tabs.find((tab) => tab.id === draft.id)?.sessionId).toBe("new-session-1");
+ expect(next.tabs.find((tab) => tab.id === sibling.id)?.sessionId).toBe("existing-1");
+ });
+
+ it("closes a deleted parent session and all of its subagent tabs (prevents orphan child tabs)", () => {
+ const parent = createParentChatTab({ sessionId: "parent-1", title: "Parent" });
+ const child = createSubagentChatTab({
+ sessionKey: "agent:child-1:subagent:abc",
+ parentSessionId: "parent-1",
+ title: "Child",
+ });
+ const unrelated = createParentChatTab({ sessionId: "parent-2", title: "Other" });
+ const state = {
+ tabs: [HOME_TAB, parent, child, unrelated],
+ activeTabId: child.id,
+ } satisfies TabState;
+
+ const next = closeChatTabsForSession(state, "parent-1");
+
+ expect(next.tabs.map((tab) => tab.id)).not.toContain(parent.id);
+ expect(next.tabs.map((tab) => tab.id)).not.toContain(child.id);
+ expect(next.tabs.map((tab) => tab.id)).toContain(unrelated.id);
+ });
+
+ it("syncs parent and subagent titles from persisted session metadata", () => {
+ const parent = createParentChatTab({ sessionId: "parent-1", title: "Draft title" });
+ const child = createSubagentChatTab({
+ sessionKey: "agent:child-1:subagent:abc",
+ parentSessionId: "parent-1",
+ title: "Child draft",
+ });
+ const state = {
+ tabs: [HOME_TAB, parent, child],
+ activeTabId: parent.id,
+ } satisfies TabState;
+
+ const parentSynced = syncParentChatTabTitles(state, [{ id: "parent-1", title: "Real title" }]);
+ const fullySynced = syncSubagentChatTabTitles(parentSynced, [
+ { childSessionKey: "agent:child-1:subagent:abc", task: "Long task", label: "Research branch" },
+ ]);
+
+ expect(fullySynced.tabs.find((tab) => tab.id === parent.id)?.title).toBe("Real title");
+ expect(fullySynced.tabs.find((tab) => tab.id === child.id)?.title).toBe("Research branch");
+ });
+
+ it("resolves chat identity for parent and subagent tabs", () => {
+ const parent = createParentChatTab({ sessionId: "parent-1", title: "Parent" });
+ const child = createSubagentChatTab({
+ sessionKey: "agent:child-1:subagent:abc",
+ parentSessionId: "parent-1",
+ title: "Child",
+ });
+
+ expect(resolveChatIdentityForTab(parent)).toEqual({
+ sessionId: "parent-1",
+ subagentKey: null,
+ });
+ expect(resolveChatIdentityForTab(child)).toEqual({
+ sessionId: "parent-1",
+ subagentKey: "agent:child-1:subagent:abc",
+ });
+ });
+});
diff --git a/apps/web/lib/chat-tabs.ts b/apps/web/lib/chat-tabs.ts
new file mode 100644
index 00000000000..00e9074de1d
--- /dev/null
+++ b/apps/web/lib/chat-tabs.ts
@@ -0,0 +1,178 @@
+import {
+ type Tab,
+ type TabState,
+ generateTabId,
+ openTab,
+} from "./tab-state";
+
+export function isChatTab(tab: Tab | undefined | null): tab is Tab {
+ return tab?.type === "chat";
+}
+
+export function isSubagentChatTab(tab: Tab | undefined | null): tab is Tab {
+ return Boolean(tab?.type === "chat" && tab.sessionKey);
+}
+
+export function createBlankChatTab(title = "New Chat"): Tab {
+ return {
+ id: generateTabId(),
+ type: "chat",
+ title,
+ };
+}
+
+export function createParentChatTab(params: {
+ sessionId: string;
+ title?: string;
+}): Tab {
+ return {
+ id: generateTabId(),
+ type: "chat",
+ title: params.title || "New Chat",
+ sessionId: params.sessionId,
+ };
+}
+
+export function createSubagentChatTab(params: {
+ sessionKey: string;
+ parentSessionId: string;
+ title?: string;
+}): Tab {
+ return {
+ id: generateTabId(),
+ type: "chat",
+ title: params.title || "Subagent",
+ sessionKey: params.sessionKey,
+ parentSessionId: params.parentSessionId,
+ };
+}
+
+export function bindParentSessionToChatTab(
+ state: TabState,
+ tabId: string,
+ sessionId: string | null,
+): TabState {
+ return {
+ ...state,
+ tabs: state.tabs.map((tab) =>
+ tab.id === tabId
+ ? {
+ ...tab,
+ sessionId: sessionId ?? undefined,
+ sessionKey: undefined,
+ }
+ : tab,
+ ),
+ };
+}
+
+export function updateChatTabTitle(
+ state: TabState,
+ tabId: string,
+ title: string,
+): TabState {
+ return {
+ ...state,
+ tabs: state.tabs.map((tab) =>
+ tab.id === tabId && tab.title !== title
+ ? { ...tab, title }
+ : tab,
+ ),
+ };
+}
+
+export function syncParentChatTabTitles(
+ state: TabState,
+ sessions: Array<{ id: string; title: string }>,
+): TabState {
+ const titleBySessionId = new Map(sessions.map((session) => [session.id, session.title]));
+ let changed = false;
+ const tabs = state.tabs.map((tab) => {
+ if (tab.type !== "chat" || !tab.sessionId) {
+ return tab;
+ }
+ const nextTitle = titleBySessionId.get(tab.sessionId);
+ if (!nextTitle || nextTitle === tab.title) {
+ return tab;
+ }
+ changed = true;
+ return { ...tab, title: nextTitle };
+ });
+ return changed ? { ...state, tabs } : state;
+}
+
+export function syncSubagentChatTabTitles(
+ state: TabState,
+ subagents: Array<{ childSessionKey: string; label?: string; task: string }>,
+): TabState {
+ const titleBySessionKey = new Map(
+ subagents.map((subagent) => [subagent.childSessionKey, subagent.label || subagent.task]),
+ );
+ let changed = false;
+ const tabs = state.tabs.map((tab) => {
+ if (tab.type !== "chat" || !tab.sessionKey) {
+ return tab;
+ }
+ const nextTitle = titleBySessionKey.get(tab.sessionKey);
+ if (!nextTitle || nextTitle === tab.title) {
+ return tab;
+ }
+ changed = true;
+ return { ...tab, title: nextTitle };
+ });
+ return changed ? { ...state, tabs } : state;
+}
+
+export function openOrFocusParentChatTab(
+ state: TabState,
+ params: { sessionId: string; title?: string },
+): TabState {
+ return openTab(state, createParentChatTab(params));
+}
+
+export function openOrFocusSubagentChatTab(
+ state: TabState,
+ params: { sessionKey: string; parentSessionId: string; title?: string },
+): TabState {
+ return openTab(state, createSubagentChatTab(params));
+}
+
+export function closeChatTabsForSession(
+ state: TabState,
+ sessionId: string,
+): TabState {
+ const tabs = state.tabs.filter((tab) => {
+ if (tab.pinned) {
+ return true;
+ }
+ if (tab.type !== "chat") {
+ return true;
+ }
+ return tab.sessionId !== sessionId && tab.parentSessionId !== sessionId;
+ });
+
+ const activeStillExists = tabs.some((tab) => tab.id === state.activeTabId);
+ return {
+ tabs,
+ activeTabId: activeStillExists ? state.activeTabId : tabs[tabs.length - 1]?.id ?? null,
+ };
+}
+
+export function resolveChatIdentityForTab(tab: Tab | undefined | null): {
+ sessionId: string | null;
+ subagentKey: string | null;
+} {
+ if (!tab || tab.type !== "chat") {
+ return { sessionId: null, subagentKey: null };
+ }
+ if (tab.sessionKey) {
+ return {
+ sessionId: tab.parentSessionId ?? null,
+ subagentKey: tab.sessionKey,
+ };
+ }
+ return {
+ sessionId: tab.sessionId ?? null,
+ subagentKey: null,
+ };
+}
diff --git a/apps/web/lib/subagent-registry.ts b/apps/web/lib/subagent-registry.ts
new file mode 100644
index 00000000000..eed8769d8b9
--- /dev/null
+++ b/apps/web/lib/subagent-registry.ts
@@ -0,0 +1,54 @@
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { resolveOpenClawStateDir } from "./workspace";
+
+export type SubagentRegistryEntry = {
+ runId: string;
+ childSessionKey: string;
+ requesterSessionKey: string;
+ task: string;
+ label?: string;
+ createdAt?: number;
+ endedAt?: number;
+ outcome?: { status: string; error?: string };
+};
+
+export function readSubagentRegistry(): SubagentRegistryEntry[] {
+ const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
+ if (!existsSync(registryPath)) {
+ return [];
+ }
+
+ try {
+ const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as {
+ runs?: Record;
+ };
+ return Object.values(raw.runs ?? {});
+ } catch {
+ return [];
+ }
+}
+
+export function resolveSubagentStatus(
+ entry: SubagentRegistryEntry,
+): "running" | "completed" | "error" {
+ if (typeof entry.endedAt !== "number") {
+ return "running";
+ }
+ if (entry.outcome?.status === "error") {
+ return "error";
+ }
+ return "completed";
+}
+
+export function listSubagentsForRequesterSession(
+ requesterSessionKey: string,
+): Array {
+ return readSubagentRegistry()
+ .filter((entry) => entry.requesterSessionKey === requesterSessionKey)
+ .map((entry) => ({
+ ...entry,
+ status: resolveSubagentStatus(entry),
+ }))
+ .toSorted((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
+}
diff --git a/apps/web/lib/tab-state.ts b/apps/web/lib/tab-state.ts
index 36c0416ad77..dc60d268aa3 100644
--- a/apps/web/lib/tab-state.ts
+++ b/apps/web/lib/tab-state.ts
@@ -23,6 +23,8 @@ export type Tab = {
icon?: string;
path?: string;
sessionId?: string;
+ sessionKey?: string;
+ parentSessionId?: string;
pinned?: boolean;
};
@@ -72,8 +74,8 @@ export function saveTabs(state: TabState, workspaceId?: string | null): void {
if (typeof window === "undefined") return;
try {
const serializable: TabState = {
- tabs: state.tabs.map(({ id, type, title, icon, path, sessionId, pinned }) => ({
- id, type, title, icon, path, sessionId, pinned,
+ tabs: state.tabs.map(({ id, type, title, icon, path, sessionId, sessionKey, parentSessionId, pinned }) => ({
+ id, type, title, icon, path, sessionId, sessionKey, parentSessionId, pinned,
})),
activeTabId: state.activeTabId,
};
@@ -91,12 +93,18 @@ export function findTabBySessionId(tabs: Tab[], sessionId: string): Tab | undefi
return tabs.find((t) => t.type === "chat" && t.sessionId === sessionId);
}
+export function findTabBySessionKey(tabs: Tab[], sessionKey: string): Tab | undefined {
+ return tabs.find((t) => t.type === "chat" && t.sessionKey === sessionKey);
+}
+
export function openTab(state: TabState, tab: Tab): TabState {
const existing = tab.path
? findTabByPath(state.tabs, tab.path)
- : tab.sessionId
- ? findTabBySessionId(state.tabs, tab.sessionId)
- : undefined;
+ : tab.sessionKey
+ ? findTabBySessionKey(state.tabs, tab.sessionKey)
+ : tab.sessionId
+ ? findTabBySessionId(state.tabs, tab.sessionId)
+ : undefined;
if (existing) {
return { ...state, activeTabId: existing.id };