Merge remote-tracking branch 'origin/bp/3-stream-status'
This commit is contained in:
commit
23aa21c4d2
@ -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");
|
||||
|
||||
@ -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 */ }
|
||||
},
|
||||
|
||||
107
apps/web/app/api/chat/runs/route.test.ts
Normal file
107
apps/web/app/api/chat/runs/route.test.ts
Normal file
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
47
apps/web/app/api/chat/runs/route.ts
Normal file
47
apps/web/app/api/chat/runs/route.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
113
apps/web/app/api/chat/stop/route.test.ts
Normal file
113
apps/web/app/api/chat/stop/route.test.ts
Normal file
@ -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 });
|
||||
});
|
||||
});
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
<div className="flex flex-col items-end gap-1.5 py-2">
|
||||
{!richHtml && <AttachedFilesCard paths={attachmentInfo.paths} />}
|
||||
<AttachedFilesCard paths={attachmentInfo.paths} />
|
||||
{(attachmentInfo.message || richHtml) && (
|
||||
<div
|
||||
className="max-w-[80%] w-fit rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 break-words chat-message-font"
|
||||
|
||||
@ -72,6 +72,33 @@ describe("createStreamParser", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@ -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<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
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<ChatPanelHandle, ChatPanelProps>(
|
||||
@ -836,6 +851,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
subagentLabel,
|
||||
onBack,
|
||||
hideHeaderActions,
|
||||
onRuntimeStateChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@ -962,6 +978,25 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
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<ReturnType<typeof setTimeout> | null>(null);
|
||||
@ -1955,29 +1990,18 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
[],
|
||||
);
|
||||
|
||||
// ── 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<ChatPanelHandle, ChatPanelProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col"
|
||||
className="h-full min-h-0 flex flex-col overflow-hidden"
|
||||
style={{ background: "var(--color-main-bg)" }}
|
||||
>
|
||||
{/* Header — sticky glass bar */}
|
||||
<header
|
||||
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex items-center ${isSubagentMode ? "gap-3" : "justify-between"} z-20`}
|
||||
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex shrink-0 items-center ${isSubagentMode ? "gap-3" : "justify-between"} z-20`}
|
||||
>
|
||||
{isSubagentMode ? (
|
||||
<>
|
||||
@ -2280,7 +2304,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
{/* File-scoped session tabs (compact mode, not in subagent mode) */}
|
||||
{!isSubagentMode && compact && fileContext && fileSessions.length > 0 && (
|
||||
<div
|
||||
className="px-2 py-1.5 border-b flex gap-1 overflow-x-auto z-20"
|
||||
className="px-2 py-1.5 border-b flex shrink-0 gap-1 overflow-x-auto z-20"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-bg-glass)",
|
||||
@ -2319,7 +2343,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto min-h-0"
|
||||
className="min-h-0 min-w-0 flex-1 overflow-y-auto"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
>
|
||||
{/* Messages */}
|
||||
@ -2444,13 +2468,25 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
userHtmlMap={userHtmlMapRef.current}
|
||||
/>
|
||||
))}
|
||||
{showInlineSpinner && (
|
||||
{showStreamActivity && (
|
||||
<div className="py-3 min-w-0">
|
||||
<UnicodeSpinner
|
||||
name="pulse"
|
||||
className="text-base"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<div
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className={`text-sm ${lastAssistantHasText ? "" : "opacity-90"}`}
|
||||
style={{ color: "inherit" }}
|
||||
/>
|
||||
<span className="text-xs truncate">
|
||||
{streamActivityLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
@ -2501,7 +2537,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
{/* Input bar at bottom (hidden when hero state is active) */}
|
||||
{!showHeroState && (
|
||||
<div
|
||||
className={`${compact ? "px-3 py-2" : "px-3 pb-3 pt-0 md:px-6 md:pb-5"} z-20`}
|
||||
className={`${compact ? "px-3 py-2" : "px-3 pb-3 pt-0 md:px-6 md:pb-5"} shrink-0 z-20`}
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<div className={compact ? "" : "max-w-[720px] mx-auto"}>
|
||||
|
||||
99
apps/web/app/components/chat-stream-status.test.ts
Normal file
99
apps/web/app/components/chat-stream-status.test.ts
Normal file
@ -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...");
|
||||
});
|
||||
});
|
||||
195
apps/web/app/components/chat-stream-status.ts
Normal file
195
apps/web/app/components/chat-stream-status.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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 (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
@ -366,8 +386,23 @@ export function ChatSessionsSidebar({
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{onDeleteSession && (
|
||||
<div className={`shrink-0 flex items-center pr-1 transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}>
|
||||
<div className={`shrink-0 flex items-center pr-1 gap-0.5 transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}>
|
||||
{isStreamingSession && onStopSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStopSession(session.id);
|
||||
}}
|
||||
className="flex items-center justify-center w-6 h-6 rounded-md transition-colors hover:bg-black/5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Stop chat"
|
||||
aria-label="Stop chat"
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
)}
|
||||
{onDeleteSession && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@ -394,8 +429,8 @@ export function ChatSessionsSidebar({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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 (
|
||||
<button
|
||||
<div
|
||||
key={sa.childSessionKey}
|
||||
type="button"
|
||||
onClick={() => handleSelectSubagentItem(sa.childSessionKey)}
|
||||
className="w-full text-left pl-3 pr-2 py-1.5 rounded-r-lg transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: isSubActive
|
||||
? "var(--color-chat-sidebar-active-bg)"
|
||||
: "transparent",
|
||||
}}
|
||||
className="flex items-center"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isSubRunning && (
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-[9px] flex-shrink-0"
|
||||
style={{ color: "var(--color-chat-sidebar-muted)" }}
|
||||
/>
|
||||
)}
|
||||
<SubagentIcon />
|
||||
<span
|
||||
className="text-[11px] truncate"
|
||||
style={{
|
||||
color: isSubActive
|
||||
? "var(--color-chat-sidebar-active-text)"
|
||||
: "var(--color-text-muted)",
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectSubagentItem(sa.childSessionKey)}
|
||||
className="flex-1 text-left pl-3 pr-2 py-1.5 rounded-r-lg transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: isSubActive
|
||||
? "var(--color-chat-sidebar-active-bg)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isSubRunning && (
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-[9px] flex-shrink-0"
|
||||
style={{ color: "var(--color-chat-sidebar-muted)" }}
|
||||
/>
|
||||
)}
|
||||
<SubagentIcon />
|
||||
<span
|
||||
className="text-[11px] truncate"
|
||||
style={{
|
||||
color: isSubActive
|
||||
? "var(--color-chat-sidebar-active-text)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{truncated}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{isSubRunning && onStopSubagent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStopSubagent(sa.childSessionKey);
|
||||
}}
|
||||
className="shrink-0 flex items-center justify-center w-6 h-6 rounded-md mr-1 transition-colors hover:bg-black/5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Stop subagent"
|
||||
aria-label="Stop subagent"
|
||||
>
|
||||
{truncated}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<StopIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -21,6 +21,8 @@ type TabBarProps = {
|
||||
onCloseAll: () => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
onTogglePin: (tabId: string) => void;
|
||||
liveChatTabIds?: Set<string>;
|
||||
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 && (
|
||||
<>
|
||||
<div className="h-px my-0.5 mx-1 bg-neutral-400/15" />
|
||||
<ContextMenuItem
|
||||
label="Stop Session"
|
||||
onClick={() => { onStopTab(contextMenu.tabId); setContextMenu(null); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="h-px my-0.5 mx-1 bg-neutral-400/15" />
|
||||
<ContextMenuItem
|
||||
label="Close"
|
||||
|
||||
@ -1974,6 +1974,7 @@ body {
|
||||
/* Favicon icon classes (SVG data-uri with currentColor replaced by hex) */
|
||||
.dench-favicon-home,
|
||||
.dench-favicon-chat,
|
||||
.dench-favicon-chat-live,
|
||||
.dench-favicon-file,
|
||||
.dench-favicon-app,
|
||||
.dench-favicon-cron,
|
||||
@ -1989,6 +1990,10 @@ body {
|
||||
.dench-favicon-chat {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
.dench-favicon-chat-live {
|
||||
opacity: 0.9;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3Ccircle cx='18' cy='18' r='3' fill='%2310b981' stroke='none'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
.dench-favicon-file {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
61
apps/web/lib/chat-session-registry.ts
Normal file
61
apps/web/lib/chat-session-registry.ts
Normal file
@ -0,0 +1,61 @@
|
||||
export type ChatPanelRuntimeState = {
|
||||
sessionId: string | null;
|
||||
sessionKey: string | null;
|
||||
isStreaming: boolean;
|
||||
status: string;
|
||||
isReconnecting: boolean;
|
||||
loadingSession: boolean;
|
||||
};
|
||||
|
||||
export type ChatTabRuntimeSnapshot = ChatPanelRuntimeState & {
|
||||
tabId: string;
|
||||
};
|
||||
|
||||
export type ChatRunsSnapshot = {
|
||||
parentStatuses: Map<string, "running" | "waiting-for-subagents" | "completed" | "error">;
|
||||
subagentStatuses: Map<string, "running" | "completed" | "error">;
|
||||
};
|
||||
|
||||
export function mergeChatRuntimeSnapshot(
|
||||
state: Record<string, ChatTabRuntimeSnapshot>,
|
||||
snapshot: ChatTabRuntimeSnapshot,
|
||||
): Record<string, ChatTabRuntimeSnapshot> {
|
||||
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<string, ChatTabRuntimeSnapshot>,
|
||||
tabId: string,
|
||||
): Record<string, ChatTabRuntimeSnapshot> {
|
||||
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])),
|
||||
};
|
||||
}
|
||||
124
apps/web/lib/chat-tabs.test.ts
Normal file
124
apps/web/lib/chat-tabs.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
178
apps/web/lib/chat-tabs.ts
Normal file
178
apps/web/lib/chat-tabs.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
54
apps/web/lib/subagent-registry.ts
Normal file
54
apps/web/lib/subagent-registry.ts
Normal file
@ -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<string, SubagentRegistryEntry>;
|
||||
};
|
||||
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<SubagentRegistryEntry & { status: "running" | "completed" | "error" }> {
|
||||
return readSubagentRegistry()
|
||||
.filter((entry) => entry.requesterSessionKey === requesterSessionKey)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
status: resolveSubagentStatus(entry),
|
||||
}))
|
||||
.toSorted((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||
}
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user