fix(ui): keep task transcripts in the task sidebar (#120463)

* fix(ui): keep task transcripts in the task sidebar

* refactor(ui): split background task rail rendering

* fix(ui): reset stale task rail views

* fix(sessions): honor context usage provenance

Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>

* fix(qa): split suite runtime agent process below line cap

---------

Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
This commit is contained in:
Peter Steinberger
2026-08-08 01:45:44 -07:00
committed by GitHub
parent d9080cfff8
commit c5d00cb47d
39 changed files with 1251 additions and 750 deletions
+322
View File
@@ -0,0 +1,322 @@
// Qa Lab plugin module runs CLI processes and parses their structured output.
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
appendQaChildOutput,
appendQaChildOutputTail,
createQaChildOutputCapture,
createQaChildOutputTail,
formatQaChildOutputTail,
QA_CHILD_STDOUT_MAX_BYTES,
readQaChildOutput,
} from "./child-output.js";
import { QaSuiteInfraError } from "./errors.js";
import { resolveQaNodeExecPath } from "./node-exec.js";
import { createQaPosixCommandSettlement } from "./posix-command-settlement.js";
import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1B\[[0-?]*[ -/]*[@-~]`, "g");
function stripAnsiCodes(text: string) {
return text.replace(ANSI_ESCAPE_PATTERN, "");
}
function findBalancedJsonEnd(text: string, startIndex: number) {
const opening = text[startIndex];
const firstClosing = opening === "{" ? "}" : opening === "[" ? "]" : "";
if (!firstClosing) {
return -1;
}
const stack = [firstClosing];
let inString = false;
let escaping = false;
for (let index = startIndex + 1; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaping) {
escaping = false;
} else if (char === "\\") {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{" || char === "[") {
stack.push(char === "{" ? "}" : "]");
} else if (char === "}" || char === "]") {
if (stack.at(-1) !== char) {
return -1;
}
stack.pop();
if (stack.length === 0) {
return index;
}
}
}
return -1;
}
function parseBalancedJsonPayloadStart(text: string) {
const trimmedStart = text.search(/\S/u);
if (trimmedStart < 0) {
return undefined;
}
const char = text[trimmedStart];
if (char !== "{" && char !== "[") {
return undefined;
}
const end = findBalancedJsonEnd(text, trimmedStart);
if (end <= trimmedStart) {
return undefined;
}
try {
return JSON.parse(text.slice(trimmedStart, end + 1)) as unknown;
} catch {
return undefined;
}
}
function isJsonRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isStructuredDiagnosticJson(value: unknown) {
if (!isJsonRecord(value)) {
return false;
}
const level = value.level ?? value.logLevel ?? value.severity;
if (typeof level !== "string") {
return false;
}
return (
typeof value.message === "string" ||
typeof value.msg === "string" ||
typeof value.time === "string" ||
typeof value.timestamp === "string"
);
}
function isMemorySearchJsonPayload(value: unknown) {
return isJsonRecord(value) && Array.isArray(value.results);
}
function isMemoryStatusJsonPayload(value: unknown) {
if (Array.isArray(value)) {
return true;
}
return isJsonRecord(value) && value.command === "memory" && value.subcommand === "status";
}
function resolveQaCliJsonPayloadMatcher(args: readonly string[]) {
if (!args.includes("--json")) {
return undefined;
}
if (args[0] === "memory" && args[1] === "search") {
return isMemorySearchJsonPayload;
}
if (args[0] === "memory" && args[1] === "status") {
return isMemoryStatusJsonPayload;
}
return undefined;
}
function parseQaCliJsonOutput(text: string, args: readonly string[]) {
const cleaned = stripAnsiCodes(text).trim();
if (!cleaned) {
return {};
}
const matchesExpectedPayload = resolveQaCliJsonPayloadMatcher(args);
try {
return JSON.parse(cleaned) as unknown;
} catch {
// Some startup repair logs are emitted on stdout before command JSON.
const lines = cleaned.split(/\r?\n/);
const candidates: unknown[] = [];
for (const [index, line] of lines.entries()) {
const candidate = line.trimStart();
if (candidate !== line || (!candidate.startsWith("{") && !candidate.startsWith("["))) {
continue;
}
const jsonTail = lines.slice(index).join("\n");
try {
candidates.push(JSON.parse(jsonTail) as unknown);
} catch {
const balanced = parseBalancedJsonPayloadStart(jsonTail);
if (balanced !== undefined) {
candidates.push(balanced);
}
}
}
const expectedPayload = candidates.find((value) => matchesExpectedPayload?.(value) === true);
if (expectedPayload !== undefined) {
return expectedPayload;
}
const payload = candidates.toReversed().find((value) => !isStructuredDiagnosticJson(value));
if (payload !== undefined) {
return payload;
}
const diagnosticOnly = candidates.at(-1);
if (diagnosticOnly !== undefined) {
return diagnosticOnly;
}
// Keep a line-oriented fallback for compact payloads followed by diagnostics.
for (const line of lines.toReversed()) {
const candidate = line.trim();
if (!candidate.startsWith("{") && !candidate.startsWith("[")) {
continue;
}
try {
return JSON.parse(candidate) as unknown;
} catch {
// Keep looking for the actual payload line.
}
}
throw new Error(`qa cli returned non-JSON stdout: ${truncateUtf16Safe(cleaned, 240)}`);
}
}
function killQaCliWindowsProcessTree(child: Pick<ChildProcessWithoutNullStreams, "kill" | "pid">) {
if (child.pid) {
const result = spawnSync(
resolveQaWindowsSystem32ExePath("taskkill.exe"),
["/PID", String(child.pid), "/T", "/F"],
{
stdio: "ignore",
windowsHide: true,
timeout: 5_000,
},
);
if (!result.error && result.status === 0) {
return;
}
}
child.kill("SIGKILL");
}
async function runQaCli(
env: Pick<
QaSuiteRuntimeEnv,
"gateway" | "repoRoot" | "primaryModel" | "alternateModel" | "providerMode"
>,
args: string[],
opts?: { timeoutMs?: number; json?: boolean; env?: NodeJS.ProcessEnv },
) {
const stdout = createQaChildOutputCapture();
const stdoutTail = createQaChildOutputTail();
const stderr = createQaChildOutputTail();
const distEntryPath = path.join(env.repoRoot, "dist", "index.js");
const nodeExecPath = await resolveQaNodeExecPath();
await new Promise<void>((resolve, reject) => {
const child = spawn(nodeExecPath, [distEntryPath, ...args], {
cwd: env.gateway.tempRoot,
env: {
...env.gateway.runtimeEnv,
...opts?.env,
},
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
const timeoutMs = resolveTimerTimeoutMs(opts?.timeoutMs, 60_000);
const rejectTimeout = () => {
const stdoutText = formatQaChildOutputTail(stdoutTail, "qa cli stdout");
const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr");
const diagnostics = [
stdoutText ? `stdout:\n${stdoutText}` : "",
stderrText ? `stderr:\n${stderrText}` : "",
]
.filter(Boolean)
.join("\n");
return new QaSuiteInfraError(
"qa_cli_timeout",
`qa cli timed out: openclaw ${args.join(" ")}${diagnostics ? `\n${diagnostics}` : ""}`,
);
};
const getExitError = (code: number | null) => {
if (code === 0) {
if (stdout.exceeded) {
return new Error(
`qa cli stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`,
);
}
return undefined;
}
const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr");
return new Error(`qa cli failed (${code ?? "unknown"}): ${stderrText}`);
};
if (process.platform !== "win32") {
createQaPosixCommandSettlement({
child,
settlementFailureMessage: "qa cli settlement failed",
executionTimeoutMs: timeoutMs,
forceKillAfterMs: 0,
initialSignal: "SIGKILL",
onSettled: (outcome) => {
const primary = outcome.primary;
const primaryError =
primary.type === "spawn-error" || primary.type === "stream-error"
? primary.error
: primary.type === "timeout"
? rejectTimeout()
: getExitError(primary.type === "exit" ? primary.exitCode : 1);
if (outcome.settlementFailure) {
reject(
primaryError
? new AggregateError(
[primaryError, outcome.settlementFailure],
"qa cli command and settlement failed",
)
: outcome.settlementFailure,
);
return;
}
if (primaryError) {
reject(primaryError);
return;
}
resolve();
},
onStderrData: (chunk) => appendQaChildOutputTail(stderr, chunk),
onStdoutData: (chunk) => {
appendQaChildOutput(stdout, chunk);
appendQaChildOutputTail(stdoutTail, chunk);
},
processGroupId: child.pid,
verifyAfterMs: 500,
});
return;
}
const timeout = setTimeout(() => {
killQaCliWindowsProcessTree(child);
reject(rejectTimeout());
}, timeoutMs);
child.stdout.on("data", (chunk) => {
appendQaChildOutput(stdout, chunk);
appendQaChildOutputTail(stdoutTail, chunk);
});
child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk));
child.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.once("close", (code) => {
clearTimeout(timeout);
const error = getExitError(code);
return error ? reject(error) : resolve();
});
});
const text = readQaChildOutput(stdout).trim();
if (!opts?.json) {
return text;
}
return parseQaCliJsonOutput(text, args);
}
export { runQaCli };
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { runQaCli } from "./suite-runtime-agent-process.js";
import { runQaCli } from "./qa-cli-process.js";
const cleanups: Array<() => Promise<void>> = [];
@@ -30,12 +30,12 @@ vi.mock("./suite-runtime-agent-session.js", () => ({
}));
import { QA_CHILD_STDERR_TAIL_BYTES, QA_CHILD_STDOUT_MAX_BYTES } from "./child-output.js";
import { runQaCli } from "./qa-cli-process.js";
import {
findManagedDreamingCronJob,
listCronJobs,
readDoctorMemoryStatus,
runAgentPrompt,
runQaCli,
startAgentRun,
waitForAgentRun,
waitForAgentHistoryReply,
@@ -1,31 +1,17 @@
// Qa Lab plugin module implements suite runtime agent process behavior.
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
appendQaChildOutput,
appendQaChildOutputTail,
createQaChildOutputCapture,
createQaChildOutputTail,
formatQaChildOutputTail,
QA_CHILD_STDOUT_MAX_BYTES,
readQaChildOutput,
} from "./child-output.js";
import { QaSuiteInfraError } from "./errors.js";
import { extractGatewayMessageText } from "./gateway-log-sentinel.js";
import { resolveQaNodeExecPath } from "./node-exec.js";
import { createQaPosixCommandSettlement } from "./posix-command-settlement.js";
import { runQaCli } from "./qa-cli-process.js";
import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js";
import { readSessionTranscriptSummary } from "./suite-runtime-agent-session.js";
import { waitForGatewayHealthy, waitForTransportReady } from "./suite-runtime-gateway.js";
import type { QaDreamingStatus, QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
import { resolveQaGatewayTimeoutWithGraceMs } from "./timer-timeouts.js";
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
type QaMemorySearchResult = {
results?: Array<{ snippet?: string; text?: string; path?: string }>;
@@ -62,7 +48,6 @@ type QaAgentWaitResult = {
terminalReply?: QaAgentTerminalReply;
};
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1B\[[0-?]*[ -/]*[@-~]`, "g");
const MANAGED_DREAMING_CRON_MARKER = "[managed-by=memory-core.short-term-promotion]";
const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DREAMING_PROMPT = "__openclaw_memory_core_short_term_promotion_dream__";
@@ -72,305 +57,6 @@ const QA_HISTORY_RETRY_MAX_MS = 5_000;
const QA_TRANSCRIPT_EVIDENCE_TIMEOUT_MS = 5_000;
const QA_TRANSCRIPT_EVIDENCE_POLL_MS = 50;
function stripAnsiCodes(text: string) {
return text.replace(ANSI_ESCAPE_PATTERN, "");
}
function findBalancedJsonEnd(text: string, startIndex: number) {
const opening = text[startIndex];
const firstClosing = opening === "{" ? "}" : opening === "[" ? "]" : "";
if (!firstClosing) {
return -1;
}
const stack = [firstClosing];
let inString = false;
let escaping = false;
for (let index = startIndex + 1; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaping) {
escaping = false;
} else if (char === "\\") {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{" || char === "[") {
stack.push(char === "{" ? "}" : "]");
} else if (char === "}" || char === "]") {
if (stack.at(-1) !== char) {
return -1;
}
stack.pop();
if (stack.length === 0) {
return index;
}
}
}
return -1;
}
function parseBalancedJsonPayloadStart(text: string) {
const trimmedStart = text.search(/\S/u);
if (trimmedStart < 0) {
return undefined;
}
const char = text[trimmedStart];
if (char !== "{" && char !== "[") {
return undefined;
}
const end = findBalancedJsonEnd(text, trimmedStart);
if (end <= trimmedStart) {
return undefined;
}
try {
return JSON.parse(text.slice(trimmedStart, end + 1)) as unknown;
} catch {
return undefined;
}
}
function isJsonRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isStructuredDiagnosticJson(value: unknown) {
if (!isJsonRecord(value)) {
return false;
}
const level = value.level ?? value.logLevel ?? value.severity;
if (typeof level !== "string") {
return false;
}
return (
typeof value.message === "string" ||
typeof value.msg === "string" ||
typeof value.time === "string" ||
typeof value.timestamp === "string"
);
}
function isMemorySearchJsonPayload(value: unknown) {
return isJsonRecord(value) && Array.isArray(value.results);
}
function isMemoryStatusJsonPayload(value: unknown) {
if (Array.isArray(value)) {
return true;
}
return isJsonRecord(value) && value.command === "memory" && value.subcommand === "status";
}
function resolveQaCliJsonPayloadMatcher(args: readonly string[]) {
if (!args.includes("--json")) {
return undefined;
}
if (args[0] === "memory" && args[1] === "search") {
return isMemorySearchJsonPayload;
}
if (args[0] === "memory" && args[1] === "status") {
return isMemoryStatusJsonPayload;
}
return undefined;
}
function parseQaCliJsonOutput(text: string, args: readonly string[]) {
const cleaned = stripAnsiCodes(text).trim();
if (!cleaned) {
return {};
}
const matchesExpectedPayload = resolveQaCliJsonPayloadMatcher(args);
try {
return JSON.parse(cleaned) as unknown;
} catch {
// Some startup repair logs are emitted on stdout before command JSON.
const lines = cleaned.split(/\r?\n/);
const candidates: unknown[] = [];
for (const [index, line] of lines.entries()) {
const candidate = line.trimStart();
if (candidate !== line || (!candidate.startsWith("{") && !candidate.startsWith("["))) {
continue;
}
const jsonTail = lines.slice(index).join("\n");
try {
candidates.push(JSON.parse(jsonTail) as unknown);
} catch {
const balanced = parseBalancedJsonPayloadStart(jsonTail);
if (balanced !== undefined) {
candidates.push(balanced);
}
}
}
const expectedPayload = candidates.find((value) => matchesExpectedPayload?.(value) === true);
if (expectedPayload !== undefined) {
return expectedPayload;
}
const payload = candidates.toReversed().find((value) => !isStructuredDiagnosticJson(value));
if (payload !== undefined) {
return payload;
}
const diagnosticOnly = candidates.at(-1);
if (diagnosticOnly !== undefined) {
return diagnosticOnly;
}
// Keep a line-oriented fallback for compact payloads followed by diagnostics.
for (const line of lines.toReversed()) {
const candidate = line.trim();
if (!candidate.startsWith("{") && !candidate.startsWith("[")) {
continue;
}
try {
return JSON.parse(candidate) as unknown;
} catch {
// Keep looking for the actual payload line.
}
}
throw new Error(`qa cli returned non-JSON stdout: ${truncateUtf16Safe(cleaned, 240)}`);
}
}
function killQaCliWindowsProcessTree(child: Pick<ChildProcessWithoutNullStreams, "kill" | "pid">) {
if (child.pid) {
const result = spawnSync(
resolveQaWindowsSystem32ExePath("taskkill.exe"),
["/PID", String(child.pid), "/T", "/F"],
{
stdio: "ignore",
windowsHide: true,
timeout: 5_000,
},
);
if (!result.error && result.status === 0) {
return;
}
}
child.kill("SIGKILL");
}
async function runQaCli(
env: Pick<
QaSuiteRuntimeEnv,
"gateway" | "repoRoot" | "primaryModel" | "alternateModel" | "providerMode"
>,
args: string[],
opts?: { timeoutMs?: number; json?: boolean; env?: NodeJS.ProcessEnv },
) {
const stdout = createQaChildOutputCapture();
const stdoutTail = createQaChildOutputTail();
const stderr = createQaChildOutputTail();
const distEntryPath = path.join(env.repoRoot, "dist", "index.js");
const nodeExecPath = await resolveQaNodeExecPath();
await new Promise<void>((resolve, reject) => {
const child = spawn(nodeExecPath, [distEntryPath, ...args], {
cwd: env.gateway.tempRoot,
env: {
...env.gateway.runtimeEnv,
...opts?.env,
},
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
const timeoutMs = resolveTimerTimeoutMs(opts?.timeoutMs, 60_000);
const rejectTimeout = () => {
const stdoutText = formatQaChildOutputTail(stdoutTail, "qa cli stdout");
const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr");
const diagnostics = [
stdoutText ? `stdout:\n${stdoutText}` : "",
stderrText ? `stderr:\n${stderrText}` : "",
]
.filter(Boolean)
.join("\n");
return new QaSuiteInfraError(
"qa_cli_timeout",
`qa cli timed out: openclaw ${args.join(" ")}${diagnostics ? `\n${diagnostics}` : ""}`,
);
};
const getExitError = (code: number | null) => {
if (code === 0) {
if (stdout.exceeded) {
return new Error(
`qa cli stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`,
);
}
return undefined;
}
const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr");
return new Error(`qa cli failed (${code ?? "unknown"}): ${stderrText}`);
};
if (process.platform !== "win32") {
createQaPosixCommandSettlement({
child,
settlementFailureMessage: "qa cli settlement failed",
executionTimeoutMs: timeoutMs,
forceKillAfterMs: 0,
initialSignal: "SIGKILL",
onSettled: (outcome) => {
const primary = outcome.primary;
const primaryError =
primary.type === "spawn-error" || primary.type === "stream-error"
? primary.error
: primary.type === "timeout"
? rejectTimeout()
: getExitError(primary.type === "exit" ? primary.exitCode : 1);
if (outcome.settlementFailure) {
reject(
primaryError
? new AggregateError(
[primaryError, outcome.settlementFailure],
"qa cli command and settlement failed",
)
: outcome.settlementFailure,
);
return;
}
if (primaryError) {
reject(primaryError);
return;
}
resolve();
},
onStderrData: (chunk) => appendQaChildOutputTail(stderr, chunk),
onStdoutData: (chunk) => {
appendQaChildOutput(stdout, chunk);
appendQaChildOutputTail(stdoutTail, chunk);
},
processGroupId: child.pid,
verifyAfterMs: 500,
});
return;
}
const timeout = setTimeout(() => {
killQaCliWindowsProcessTree(child);
reject(rejectTimeout());
}, timeoutMs);
child.stdout.on("data", (chunk) => {
appendQaChildOutput(stdout, chunk);
appendQaChildOutputTail(stdoutTail, chunk);
});
child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk));
child.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.once("close", (code) => {
clearTimeout(timeout);
const error = getExitError(code);
return error ? reject(error) : resolve();
});
});
const text = readQaChildOutput(stdout).trim();
if (!opts?.json) {
return text;
}
return parseQaCliJsonOutput(text, args);
}
async function startAgentRun(
env: Pick<QaSuiteRuntimeEnv, "gateway" | "transport">,
params: {
@@ -741,7 +427,6 @@ export {
listCronJobs,
readDoctorMemoryStatus,
runAgentPrompt,
runQaCli,
startAgentRun,
waitForAgentHistoryReply,
waitForAgentRun,
+1 -1
View File
@@ -13,11 +13,11 @@ export {
listCronJobs,
readDoctorMemoryStatus,
runAgentPrompt,
runQaCli,
startAgentRun,
waitForAgentHistoryReply,
waitForAgentRun,
} from "./suite-runtime-agent-process.js";
export { runQaCli } from "./qa-cli-process.js";
export {
ensureImageGenerationConfigured,
extractMediaPathFromText,
@@ -164,7 +164,9 @@ export async function normalizeEmbeddedRunAttempt(input: {
const promptCacheLastCallUsage = normalizeUsage(attempt.promptCache?.lastCallUsage as UsageLike);
const callUsage = resolveLatestCallUsage({
currentAttemptCandidates: [currentAttemptAssistantUsage, promptCacheLastCallUsage],
carriedCandidates: [input.lastRunPromptUsage, lastAssistantUsage],
// The latest assistant sentinel must invalidate stale carried usage; reversing this order
// would resurrect a prior exact value after the runtime reports context as unavailable.
carriedCandidates: [lastAssistantUsage, input.lastRunPromptUsage],
});
const attemptUsage = attempt.attemptUsage ?? callUsage.currentAttempt;
mergeUsageIntoAccumulator(input.usageAccumulator, attemptUsage);
@@ -14,17 +14,19 @@ const streamMocks = vi.hoisted(() => ({
import type { AgentTool } from "../runtime/index.js";
import { agentSessionAutomaticCompaction } from "./agent-session-compaction.js";
import {
createCompactionHandlers,
createResourceLoader,
} from "./agent-session-loop-resource-loader.test-support.js";
import type { AgentSessionEvent } from "./agent-session-types.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import { createExtensionRuntime } from "./extensions/loader.js";
import type { LoadExtensionsResult, ToolDefinition } from "./extensions/types.js";
import type { ToolDefinition } from "./extensions/types.js";
import { ModelRegistry } from "./model-registry.js";
import type { ResourceLoader } from "./resource-loader.js";
import { createAgentSession, createAgentSessionForEmbeddedRunner } from "./sdk.js";
import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { createSyntheticSourceInfo } from "./source-info.js";
const testModel: Model = {
id: "test-model",
@@ -114,68 +116,6 @@ function mockInvalidThenTextSummary(recoveredText: string) {
return () => requests;
}
function createResourceLoader(
handlers: Map<string, Array<(...args: unknown[]) => Promise<unknown>>> = new Map(),
): ResourceLoader {
const extensionsResult: LoadExtensionsResult = {
extensions:
handlers.size > 0
? [
{
path: "<test-extension>",
resolvedPath: "<test-extension>",
sourceInfo: createSyntheticSourceInfo("<test-extension>", {
source: "temporary",
}),
handlers,
tools: new Map(),
messageRenderers: new Map(),
commands: new Map(),
flags: new Map(),
shortcuts: new Map(),
},
]
: [],
errors: [],
runtime: createExtensionRuntime(),
};
return {
getExtensions: () => extensionsResult,
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
extendResources: () => {},
reload: async () => {},
};
}
function createCompactionHandlers() {
return new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
[
"session_before_compact",
[
async (event: unknown) => {
const preparation = (
event as {
preparation: { firstKeptEntryId: string; tokensBefore: number };
}
).preparation;
return {
compaction: {
summary: "condensed history",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
},
};
},
],
],
]);
}
async function createTestSession(
options: {
model?: Model;
@@ -0,0 +1,66 @@
import { createExtensionRuntime } from "./extensions/loader.js";
import type { LoadExtensionsResult } from "./extensions/types.js";
import type { ResourceLoader } from "./resource-loader.js";
import { createSyntheticSourceInfo } from "./source-info.js";
export function createResourceLoader(
handlers: Map<string, Array<(...args: unknown[]) => Promise<unknown>>> = new Map(),
): ResourceLoader {
const extensionsResult: LoadExtensionsResult = {
extensions:
handlers.size > 0
? [
{
path: "<test-extension>",
resolvedPath: "<test-extension>",
sourceInfo: createSyntheticSourceInfo("<test-extension>", {
source: "temporary",
}),
handlers,
tools: new Map(),
messageRenderers: new Map(),
commands: new Map(),
flags: new Map(),
shortcuts: new Map(),
},
]
: [],
errors: [],
runtime: createExtensionRuntime(),
};
return {
getExtensions: () => extensionsResult,
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
extendResources: () => {},
reload: async () => {},
};
}
export function createCompactionHandlers() {
return new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
[
"session_before_compact",
[
async (event: unknown) => {
const preparation = (
event as {
preparation: { firstKeptEntryId: string; tokensBefore: number };
}
).preparation;
return {
compaction: {
summary: "condensed history",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
},
};
},
],
],
]);
}
@@ -557,6 +557,8 @@ describe("runReplyAgent auto-compaction token update", () => {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 200_000,
totalTokensFresh: true,
totalTokensVersion: 1 as const,
};
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
compactState.compactEmbeddedAgentSessionMock.mockRejectedValueOnce(new GatewayDrainingError());
@@ -2779,6 +2781,8 @@ describe("runReplyAgent fallback reasoning tags", () => {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 1_000_000,
totalTokensFresh: true,
totalTokensVersion: 1 as const,
compactionCount: 0,
},
});
@@ -51,6 +51,7 @@ function makeParams(
...(options?.sessionId ? { sessionId: options.sessionId } : {}),
totalTokens: options?.totalTokens ?? 123,
totalTokensFresh: options?.totalTokensFresh ?? true,
totalTokensVersion: 1 as const,
inputTokens: 100,
outputTokens: 23,
systemPromptReport: {
+6 -2
View File
@@ -715,7 +715,7 @@ describe("buildStatusReply subagent summary", () => {
model: "kimi-k2.7-code",
totalTokens: 0,
totalTokensFresh: true,
totalTokensVersion: 1,
totalTokensVersion: 1 as const,
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
@@ -1405,7 +1405,7 @@ describe("buildStatusReply subagent summary", () => {
},
totalTokens: 49_000,
totalTokensFresh: true,
totalTokensVersion: 1,
totalTokensVersion: 1 as const,
contextTokens: 1_048_576,
},
sessionKey: "agent:main:main",
@@ -2027,6 +2027,8 @@ describe("buildStatusReply subagent summary", () => {
sessionId: "sess-status-codex-context",
updatedAt: 0,
totalTokens: 25_000,
totalTokensFresh: true,
totalTokensVersion: 1,
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
@@ -2073,6 +2075,8 @@ describe("buildStatusReply subagent summary", () => {
sessionId: "sess-status-codex-stale-context",
updatedAt: 0,
totalTokens: 181_000,
totalTokensFresh: true,
totalTokensVersion: 1,
contextTokens: 400_000,
},
sessionKey: "agent:main:main",
@@ -436,6 +436,7 @@ describe("buildInboundUserContextPrefix", () => {
const entry = createGoalSessionEntry("active");
entry.totalTokens = 10;
entry.totalTokensFresh = true;
entry.totalTokensVersion = 1;
entry.goal = { ...entry.goal!, tokenBudget: 10 };
expect(buildInboundUserContextPrefix({} as TemplateContext, undefined, entry)).toBe("");
+3 -3
View File
@@ -92,7 +92,7 @@ export function resolveResponsesServerCompactionThreshold(params: {
}
function resolveMemoryFlushGateState<
TEntry extends Pick<SessionEntry, "totalTokens" | "totalTokensFresh">,
TEntry extends Pick<SessionEntry, "totalTokens" | "totalTokensFresh" | "totalTokensVersion">,
>(params: {
entry?: TEntry;
tokenCount?: number;
@@ -129,7 +129,7 @@ function resolveMemoryFlushGateState<
export function shouldRunMemoryFlush(params: {
entry?: Pick<
SessionEntry,
"totalTokens" | "totalTokensFresh" | "compactionCount" | "memoryFlush"
"totalTokens" | "totalTokensFresh" | "totalTokensVersion" | "compactionCount" | "memoryFlush"
>;
/**
* Optional token count override for flush gating. When provided, this value is
@@ -154,7 +154,7 @@ export function shouldRunMemoryFlush(params: {
}
export function shouldRunPreflightCompaction(params: {
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh">;
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh" | "totalTokensVersion">;
/**
* Optional projected token count override for pre-run compaction gating.
* When provided, this value is treated as a fresh estimate and used instead
+21 -5
View File
@@ -262,7 +262,7 @@ describe("shouldRunMemoryFlush", () => {
it("requires totalTokens and threshold", () => {
expect(
shouldRunMemoryFlush({
entry: { totalTokens: 0 },
entry: { totalTokens: 0, totalTokensFresh: true, totalTokensVersion: 1 },
contextWindowTokens: 16_000,
reserveTokensFloor: 20_000,
softThresholdTokens: 4_000,
@@ -284,7 +284,7 @@ describe("shouldRunMemoryFlush", () => {
it("skips when under threshold", () => {
expect(
shouldRunMemoryFlush({
entry: { totalTokens: 10_000 },
entry: { totalTokens: 10_000, totalTokensFresh: true, totalTokensVersion: 1 },
contextWindowTokens: 100_000,
reserveTokensFloor: 20_000,
softThresholdTokens: 10_000,
@@ -295,7 +295,7 @@ describe("shouldRunMemoryFlush", () => {
it("triggers at the threshold boundary", () => {
expect(
shouldRunMemoryFlush({
entry: { totalTokens: 85 },
entry: { totalTokens: 85, totalTokensFresh: true, totalTokensVersion: 1 },
contextWindowTokens: 100,
reserveTokensFloor: 10,
softThresholdTokens: 5,
@@ -308,6 +308,8 @@ describe("shouldRunMemoryFlush", () => {
shouldRunMemoryFlush({
entry: {
totalTokens: 90_000,
totalTokensFresh: true,
totalTokensVersion: 1,
compactionCount: 2,
memoryFlush: { kind: "succeeded", compactionCount: 2 },
},
@@ -321,7 +323,12 @@ describe("shouldRunMemoryFlush", () => {
it("runs when above threshold and not flushed", () => {
expect(
shouldRunMemoryFlush({
entry: { totalTokens: 96_000, compactionCount: 1 },
entry: {
totalTokens: 96_000,
totalTokensFresh: true,
totalTokensVersion: 1,
compactionCount: 1,
},
contextWindowTokens: 100_000,
reserveTokensFloor: 5_000,
softThresholdTokens: 2_000,
@@ -337,14 +344,23 @@ describe("shouldRunMemoryFlush", () => {
};
for (const entry of [
{ totalTokens: 95_000, compactionCount: 1 },
{
totalTokens: 95_000,
totalTokensFresh: true,
totalTokensVersion: 1 as const,
compactionCount: 1,
},
{
totalTokens: 95_000,
totalTokensFresh: true,
totalTokensVersion: 1 as const,
compactionCount: 2,
memoryFlush: { kind: "succeeded" as const, compactionCount: 1 },
},
{
totalTokens: 95_000,
totalTokensFresh: true,
totalTokensVersion: 1 as const,
compactionCount: 3,
memoryFlush: { kind: "succeeded" as const, compactionCount: 2 },
},
+2 -1
View File
@@ -247,6 +247,7 @@ describe("forkSessionEntryFromParent", () => {
sessionId: "parent-session",
totalTokens: 150_000,
totalTokensFresh: true,
totalTokensVersion: 1 as const,
updatedAt: 1,
},
);
@@ -371,7 +372,7 @@ describe("forkSessionEntryFromParent", () => {
await expect(resolveParentForkDecision({ parentEntry, storePath })).resolves.toMatchObject({
status: "fork",
parentTokens: 4_567,
parentTokens: 67,
});
});
+2 -2
View File
@@ -19,7 +19,7 @@ import {
import { resolveRuntimePolicySessionKey } from "../auto-reply/reply/runtime-policy-session-key.js";
import { normalizeChatType } from "../channels/chat-type.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveFreshSessionTotalTokens } from "../config/sessions.js";
import { resolveFreshSessionTotalTokens, resolveSessionTotalTokens } from "../config/sessions.js";
import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { SessionEntry } from "../config/sessions/types.js";
@@ -460,7 +460,7 @@ export async function sessionsCommand(
const modelRef = row.displayModelRef;
return {
...r,
totalTokens: resolveFreshSessionTotalTokens(r) ?? null,
totalTokens: resolveSessionTotalTokens(r) ?? null,
totalTokensFresh: resolveFreshSessionTotalTokens(r) !== undefined,
// Prefer row-level context tokens, then config/model lookup, so JSON
// mirrors the terminal percentage calculation.
+22
View File
@@ -555,6 +555,28 @@ describe("getStatusSummary", () => {
).toBe(false);
});
it("keeps stale totals visible without deriving context utilization", async () => {
statusSummaryMocks.listSessionEntries.mockReturnValue(
toSessionEntrySummaries({
"agent:main:main": {
sessionId: "stale-total",
updatedAt: Date.now(),
totalTokens: 50_000,
totalTokensFresh: false,
},
}),
);
const summary = await getStatusSummary();
expect(summary.sessions.recent[0]).toMatchObject({
totalTokens: 50_000,
totalTokensFresh: false,
remainingTokens: null,
percentUsed: null,
});
});
it("uses bundled provider static catalogs for cold status context", async () => {
vi.mocked(statusSummaryRuntime.resolveConfiguredStatusModelRef).mockReturnValue({
provider: "google",
+11 -5
View File
@@ -231,6 +231,8 @@ function createSessionStatusRows() {
const recent = Object.entries(store).map(([key, entry]) => {
const contextTokens = typeof entry.contextTokens === "number" ? entry.contextTokens : null;
const total = typeof entry.totalTokens === "number" ? entry.totalTokens : null;
const freshTotal =
total !== null && entry.totalTokensFresh && entry.totalTokensVersion === 1 ? total : null;
return {
agentId: agent.id,
key,
@@ -243,13 +245,17 @@ function createSessionStatusRows() {
inputTokens: entry.inputTokens,
outputTokens: entry.outputTokens,
totalTokens: total,
totalTokensFresh: typeof entry.totalTokens === "number" ? entry.totalTokensFresh : false,
totalTokensFresh: freshTotal !== null,
cacheRead: entry.cacheRead,
cacheWrite: entry.cacheWrite,
remainingTokens:
total !== null && contextTokens !== null ? Math.max(0, contextTokens - total) : null,
freshTotal !== null && contextTokens !== null
? Math.max(0, contextTokens - freshTotal)
: null,
percentUsed:
total !== null && contextTokens ? Math.round((total / contextTokens) * 100) : null,
freshTotal !== null && contextTokens
? Math.round((freshTotal / contextTokens) * 100)
: null,
model: typeof entry.model === "string" ? entry.model : null,
contextTokens,
flags: [
@@ -1139,8 +1145,8 @@ describe("statusCommand", () => {
const payload = JSON.parse(getLastRuntimeLog());
expect(payload.sessions.recent[0].totalTokens).toBe(5000);
expect(payload.sessions.recent[0].totalTokensFresh).toBe(false);
expect(payload.sessions.recent[0].percentUsed).toBe(50);
expect(payload.sessions.recent[0].remainingTokens).toBe(5000);
expect(payload.sessions.recent[0].percentUsed).toBeNull();
expect(payload.sessions.recent[0].remainingTokens).toBeNull();
});
it("prints formatted lines with verbose cache details", async () => {
+2 -2
View File
@@ -800,7 +800,7 @@ export function mergeSessionEntryPreserveActivity(
});
}
function resolveSessionTotalTokensValue(entry?: Pick<SessionEntry, "totalTokens"> | null) {
export function resolveSessionTotalTokens(entry?: Pick<SessionEntry, "totalTokens"> | null) {
const total = entry?.totalTokens;
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) {
return undefined;
@@ -811,7 +811,7 @@ function resolveSessionTotalTokensValue(entry?: Pick<SessionEntry, "totalTokens"
export function resolveFreshSessionTotalTokens(
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh" | "totalTokensVersion"> | null,
): number | undefined {
const total = resolveSessionTotalTokensValue(entry);
const total = resolveSessionTotalTokens(entry);
if (total === undefined) {
return undefined;
}
+13 -6
View File
@@ -11,7 +11,11 @@ import {
} from "../config/sessions/model-override-provenance.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js";
import { resolveFreshSessionTotalTokens, type SessionEntry } from "../config/sessions/types.js";
import {
resolveFreshSessionTotalTokens,
resolveSessionTotalTokens,
type SessionEntry,
} from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.js";
import { listGatewayAgentsBasic } from "../gateway/agent-list.js";
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js";
@@ -457,13 +461,16 @@ export async function getStatusSummary(
fallbackContextTokens: configContextTokens ?? undefined,
allowAsyncLoad: false,
}) ?? null;
const total = resolveFreshSessionTotalTokens(entry);
const totalTokensFresh = total !== undefined;
const total = resolveSessionTotalTokens(entry);
const freshTotal = resolveFreshSessionTotalTokens(entry);
const totalTokensFresh = freshTotal !== undefined;
const remaining =
contextTokens != null && total !== undefined ? Math.max(0, contextTokens - total) : null;
contextTokens != null && freshTotal !== undefined
? Math.max(0, contextTokens - freshTotal)
: null;
const pct =
contextTokens && contextTokens > 0 && total !== undefined
? Math.min(999, Math.round((total / contextTokens) * 100))
contextTokens && contextTokens > 0 && freshTotal !== undefined
? Math.min(999, Math.round((freshTotal / contextTokens) * 100))
: null;
const runtime = resolveSessionRuntimeLabel({
cfg,
+5
View File
@@ -5177,9 +5177,14 @@ export const en: TranslationMap = {
toolUseMany: "{count} tool uses",
expandTask: "Show details for {title}",
detailTitle: "Task details",
transcriptTitle: "Task transcript",
backToTasks: "Back to background tasks",
backToDetail: "Back to task details",
detailLoading: "Loading task details…",
detailFailed: "Could not load task details.",
transcriptLoading: "Loading task transcript…",
transcriptEmpty: "No transcript messages yet.",
transcriptFailed: "Could not load task transcript.",
prompt: "Prompt",
output: "Output",
promptUnavailable: "Prompt unavailable.",
+102 -10
View File
@@ -2,7 +2,7 @@ import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eSuite } from "../../e2e/control-ui-e2e-suite.test-support.ts";
import { installMockGateway } from "../../test-helpers/control-ui-e2e.ts";
import { installMockGateway, type MockGatewayRequest } from "../../test-helpers/control-ui-e2e.ts";
const suite = createControlUiE2eSuite({
name: "Control UI chat background-tasks rail mocked Gateway E2E",
@@ -15,6 +15,19 @@ const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-
const baseTime = Date.now();
const chatSessionKey = "agent:main:main";
function requestSessionKey(request: MockGatewayRequest): string | undefined {
const { params } = request;
if (
typeof params !== "object" ||
params === null ||
!("sessionKey" in params) ||
typeof params.sessionKey !== "string"
) {
return undefined;
}
return params.sessionKey;
}
const runningSubagent = {
id: "task-subagent",
taskId: "task-subagent",
@@ -113,14 +126,46 @@ suite.define(() => {
thinkingLevel: null,
},
},
{
match: { sessionKey: finishedCli.sessionKey },
response: {
messages: [
{
content: [
{ type: "text", text: "CLI transcript stayed in the task rail." },
],
role: "assistant",
timestamp: Date.now(),
},
],
sessionId: "cli-task-transcript",
thinkingLevel: null,
},
},
],
},
"tasks.list": { tasks: [runningSubagent, queuedCron, finishedCli] },
"tasks.get": {
task: {
...runningSubagent,
prompt: "Trace model routing across provider and session boundaries.",
},
cases: [
{
match: { taskId: runningSubagent.id },
response: {
task: {
...runningSubagent,
prompt: "Trace model routing across provider and session boundaries.",
},
},
},
{
match: { taskId: finishedCli.id },
response: {
task: {
...finishedCli,
prompt: "Generate a searchable media index.",
},
},
},
],
},
"tasks.cancel": {
found: true,
@@ -218,15 +263,62 @@ suite.define(() => {
expect(page.url()).toBe(chatUrl);
expect(
(await gateway.getRequests("chat.history")).some(
(request) =>
(request.params as { sessionKey?: string }).sessionKey ===
runningSubagent.childSessionKey,
(request) => requestSessionKey(request) === runningSubagent.childSessionKey,
),
).toBe(false);
await page.screenshot({
path: path.join(artifactDir, "04-back-to-list.png"),
fullPage: true,
});
const mainTranscript = page.locator(".chat-main .chat-thread");
const mainTranscriptBefore = await mainTranscript.textContent();
await rail
.locator('[data-task-id="task-cli"]')
.getByRole("button", { name: "Show details for Generate media index" })
.click();
await rail.getByText("Generate a searchable media index.").waitFor();
await page.screenshot({
path: path.join(artifactDir, "05-cli-task-detail.png"),
fullPage: true,
});
await rail.getByRole("button", { name: "View transcript" }).click();
await expect
.poll(async () =>
(await gateway.getRequests("chat.history")).some(
(request) => requestSessionKey(request) === finishedCli.sessionKey,
),
)
.toBe(true);
const transcriptRequest = (await gateway.getRequests("chat.history")).find(
(request) => requestSessionKey(request) === finishedCli.sessionKey,
);
expect(transcriptRequest?.params).toEqual({
sessionKey: finishedCli.sessionKey,
limit: 100,
});
await rail.getByText("CLI transcript stayed in the task rail.").waitFor();
expect(await rail.locator(".chat-thread").textContent()).toContain(
"CLI transcript stayed in the task rail.",
);
expect(page.url()).toBe(chatUrl);
expect(await mainTranscript.textContent()).toBe(mainTranscriptBefore);
expect(await rail.getByRole("button", { name: "Back to task details" }).count()).toBe(1);
await page.screenshot({
path: path.join(artifactDir, "06-cli-task-transcript.png"),
fullPage: true,
});
await rail.getByRole("button", { name: "Back to task details" }).click();
await rail.locator('[data-task-detail="task-cli"]').waitFor({ state: "visible" });
await rail.getByText("Generate a searchable media index.").waitFor();
expect(page.url()).toBe(chatUrl);
expect(await mainTranscript.textContent()).toBe(mainTranscriptBefore);
await page.screenshot({
path: path.join(artifactDir, "07-cli-task-detail-restored.png"),
fullPage: true,
});
},
);
});
@@ -279,7 +371,7 @@ suite.define(() => {
expect(Math.abs(previewCenter - linkCenter)).toBeLessThanOrEqual(2);
expect(previewBox.y + previewBox.height).toBeLessThanOrEqual(linkBox.y);
await page.screenshot({
path: path.join(artifactDir, "05-running-task-popover-centered.png"),
path: path.join(artifactDir, "08-running-task-popover-centered.png"),
fullPage: true,
});
@@ -289,7 +381,7 @@ suite.define(() => {
expect(await row.textContent()).toContain("CLI command");
expect(await row.textContent()).toContain("Command running");
await page.screenshot({
path: path.join(artifactDir, "06-one-background-exec.png"),
path: path.join(artifactDir, "09-one-background-exec.png"),
fullPage: true,
});
},
+11 -7
View File
@@ -90,7 +90,7 @@ import { handleAgentEvent, normalizePlanSnapshot, type PlanStatus } from "./tool
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
const CHAT_HISTORY_REQUEST_LIMIT = 100;
export const CHAT_HISTORY_REQUEST_LIMIT = 100;
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
const SESSION_MESSAGE_RELEASE_RETRY_MS = 250;
const MAX_SESSION_MESSAGE_RELEASE_ATTEMPTS = 3;
@@ -232,6 +232,12 @@ function shouldHideHistoryMessage(message: unknown): boolean {
);
}
export function visibleChatHistoryMessages(messages: unknown): unknown[] {
return Array.isArray(messages)
? messages.filter((message) => !shouldHideHistoryMessage(message))
: [];
}
export function materializeVisibleAssistantStreamMessages(
messages: unknown[],
state: ChatState,
@@ -1433,7 +1439,7 @@ export async function loadChatHistory(
opts.startup === true && startupAdvertised !== false ? "chat.startup" : "chat.history";
const client = state.client;
const connectionEpoch = state.connectionEpoch;
const requestKey = `${connectionEpoch}\0${method}\0${sessionKey}\0${requestAgentId ?? ""}\0${CHAT_HISTORY_REQUEST_LIMIT}`;
const requestKey = `${connectionEpoch}\u0000${method}\u0000${sessionKey}\u0000${requestAgentId ?? ""}\u0000${CHAT_HISTORY_REQUEST_LIMIT}`;
const requests = getChatHistoryPaneRequests(state);
const inFlight = requests.inFlightHistory;
// Live events replace the rendered array while their snapshot is pending;
@@ -1503,9 +1509,7 @@ export async function loadOlderChatHistoryPage(
}
return {
...result,
messages: (Array.isArray(result.messages) ? result.messages : []).filter(
(message) => !shouldHideHistoryMessage(message),
),
messages: visibleChatHistoryMessages(result.messages),
};
}
@@ -1573,7 +1577,7 @@ async function loadChatHistoryUncached(
state.chatLoading = true;
setChatError(state, null);
try {
const requestKey = `${connectionEpoch}\0${method}\0${sessionKey}\0${requestAgentId ?? ""}\0${CHAT_HISTORY_REQUEST_LIMIT}`;
const requestKey = `${connectionEpoch}\u0000${method}\u0000${sessionKey}\u0000${requestAgentId ?? ""}\u0000${CHAT_HISTORY_REQUEST_LIMIT}`;
const res = await requestSharedChatHistory(
client,
requestKey,
@@ -1606,7 +1610,7 @@ async function loadChatHistoryUncached(
const nextPagination = resolveChatHistoryPagination(res);
const nextSessionId = resolveChatHistorySessionId(res);
applyChatAgentsList(state, res.agentsList, client);
const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message));
const visibleMessages = visibleChatHistoryMessages(messages);
const previousTerminalMessages = reconcileAuthoritativeTerminalHistory({
host: state,
previousMessages,
+1
View File
@@ -109,6 +109,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
this.requestUpdate(),
);
protected readonly transcript = new ChatTranscriptController(this);
protected readonly backgroundTaskTranscript = new ChatTranscriptController(this);
protected readonly questionPromptState = createQuestionPromptState(() => {
this.questionPrompts = listQuestionPrompts(this.questionPromptState);
this.requestUpdate();
+2 -4
View File
@@ -25,10 +25,8 @@ import { headerPlatformByClient } from "./chat-pane-shared.ts";
import { readChatSessionActionAccess } from "./chat-session-action-access.ts";
import { patchChatSessionLabel } from "./chat-state-route.ts";
import { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts";
import {
renderBackgroundTasksToggle,
type BackgroundTasksProps,
} from "./components/chat-background-tasks.ts";
import { renderBackgroundTasksToggle } from "./components/chat-background-tasks-render.ts";
import type { BackgroundTasksProps } from "./components/chat-background-tasks.types.ts";
import { isChatRunWorking } from "./components/chat-composer.ts";
import {
type ChatPaneHeaderAction,
+1 -3
View File
@@ -213,9 +213,6 @@ export class ChatPane extends ChatPaneHeader {
narrowLayout:
chatLayoutWidth <
WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH + (railSideDocked ? WORKSPACE_RAIL_MAX_WIDTH : 0),
onOpenSession: (sessionKey) => {
this.onPaneSessionChange?.(this.paneId, sessionKey);
},
});
const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout;
// Only side-docked rails narrow the conversation region.
@@ -253,6 +250,7 @@ export class ChatPane extends ChatPaneHeader {
});
const props: ChatProps = {
transcript: this.transcript,
backgroundTaskTranscript: this.backgroundTaskTranscript,
paneId: this.paneId,
sessionKey: state.sessionKey,
announceTranscript: this.active,
@@ -179,7 +179,7 @@ describe("chat pane session access", () => {
render(
pane.renderPaneHeader(
createSessionWorkspaceProps(state),
createBackgroundTasksProps(state, { onOpenSession: () => {} }),
createBackgroundTasksProps(state),
session,
false,
undefined,
@@ -213,7 +213,7 @@ describe("chat pane session access", () => {
render(
pane.renderPaneHeader(
createSessionWorkspaceProps(state),
createBackgroundTasksProps(state, { onOpenSession: () => {} }),
createBackgroundTasksProps(state),
session,
false,
undefined,
+1 -1
View File
@@ -708,7 +708,7 @@ describe("chat pane catalog session lifecycle", () => {
render(
pane.renderPaneHeader(
createSessionWorkspaceProps(state),
createBackgroundTasksProps(state, { onOpenSession: () => {} }),
createBackgroundTasksProps(state),
undefined,
true,
undefined,
+3 -3
View File
@@ -776,9 +776,9 @@ function createBackgroundTasks(
loading: false,
error: null,
tasks: [],
view: { kind: "list" },
cancellingTaskIds: new Set<string>(),
finishedCollapsed: false,
selectedTaskId: null,
taskDetails: new Map(),
taskDetailErrors: new Map(),
taskDetailLoadingIds: new Set<string>(),
@@ -787,8 +787,8 @@ function createBackgroundTasks(
onRefresh: () => undefined,
onCancel: () => undefined,
onSelectTask: () => undefined,
onBackToList: () => undefined,
onOpenSession: () => undefined,
onBack: () => undefined,
onOpenTranscript: () => undefined,
...overrides,
};
}
+54 -5
View File
@@ -38,10 +38,8 @@ import type { ChatRunStartupStatus } from "./chat-run-startup.ts";
import type { ChatSessionCompanionThread } from "./chat-session-companion.ts";
import { renderChatViewNotices } from "./chat-view-notices.ts";
import { createChatAttachmentDropHandlers } from "./components/chat-attachments.ts";
import {
renderBackgroundTasksRail,
type BackgroundTasksProps,
} from "./components/chat-background-tasks.ts";
import { renderBackgroundTasksRail } from "./components/chat-background-tasks-render.ts";
import type { BackgroundTasksProps } from "./components/chat-background-tasks.types.ts";
import type {
CapabilityMenuProps,
ChatComposerDisabledBanner,
@@ -85,6 +83,7 @@ type ChatReplyTarget = {
export type ChatProps = {
transcript: ChatTranscriptController;
backgroundTaskTranscript?: ChatTranscriptController;
paneId: string;
sessionKey: string;
announceTranscript?: boolean;
@@ -390,6 +389,56 @@ export function renderChat(props: ChatProps) {
},
props.transcript,
);
const backgroundTaskView = props.backgroundTasks?.view;
const backgroundTaskTranscript = props.backgroundTaskTranscript;
const backgroundTaskThread =
backgroundTaskTranscript &&
backgroundTaskView?.kind === "transcript" &&
backgroundTaskView.load.status === "loaded" &&
backgroundTaskView.load.messages.length > 0
? renderChatThread(
{
paneId: `${props.paneId}:background-task-transcript`,
sessionKey: backgroundTaskView.sessionKey,
announceTranscript: false,
loading: false,
messages: backgroundTaskView.load.messages,
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
runId: null,
queue: [],
showThinking: props.showThinking,
showToolCalls: props.showToolCalls,
persistCommentary: props.persistCommentary,
readOnly: true,
sessions: props.sessions,
sessionHost: props.sessionHost,
gatewayUrl: props.gatewayUrl,
assistantName: props.assistantName,
assistantAvatar: props.assistantAvatar,
assistantAvatarUrl: props.assistantAvatarUrl,
userId: props.userId,
userName: props.userName,
userAvatar: props.userAvatar,
basePath: props.basePath,
fullMessageAgentId: props.fullMessageAgentId,
loadFullAssistantMessage: props.loadFullAssistantMessage,
localMediaPreviewRoots: props.localMediaPreviewRoots,
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken,
resolveArtifactDownload: props.resolveArtifactDownload,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode,
allowExternalEmbedUrls: props.allowExternalEmbedUrls,
autoExpandToolCalls: props.autoExpandToolCalls,
onRequestUpdate: requestUpdate,
onDraftChange: () => undefined,
onSend: () => undefined,
},
backgroundTaskTranscript,
)
: nothing;
const chatColumnFooter = renderChatComposer({
paneId: props.paneId,
@@ -547,7 +596,7 @@ export function renderChat(props: ChatProps) {
: ""} ${tasksDockBottom ? "chat-workbench--tasks-dock-bottom" : ""}"
>
${renderSessionWorkspaceRail(props.sessionWorkspace)}
${renderBackgroundTasksRail(props.backgroundTasks)}
${renderBackgroundTasksRail(props.backgroundTasks, backgroundTaskThread)}
${props.sessionWorkspace?.dockDragging
? html`
<div class="chat-workbench__dock-zones" aria-hidden="true">
@@ -51,6 +51,7 @@ function renderTaskMeta(
task: TaskSummary,
props: BackgroundTasksProps,
facts: TaskDisplayFacts,
transcriptReturnTo: "list" | "detail",
): TemplateResult {
const tone = STATUS_TONES[task.status];
const showTranscript = task.runtime !== "subagent" && facts.transcriptSessionKey;
@@ -98,7 +99,7 @@ function renderTaskMeta(
type="button"
@click=${(event: MouseEvent) => {
event.stopPropagation();
props.onOpenSession(facts.transcriptSessionKey!);
props.onOpenTranscript(task, transcriptReturnTo);
}}
>
${t("chat.backgroundTasks.viewTranscript")}
@@ -164,7 +165,7 @@ export function renderTaskRow(task: TaskSummary, props: BackgroundTasksProps): T
`
: nothing}
</div>
${renderTaskMeta(task, props, facts)}
${renderTaskMeta(task, props, facts, "list")}
${detail ? html`<div class="chat-tasks-rail__task-detail">${detail}</div>` : nothing}
</div>
`;
@@ -185,7 +186,7 @@ export function renderTaskDetail(task: TaskSummary, props: BackgroundTasksProps)
${newest.status === "running"
? html`<span class="chat-tasks-rail__task-pulse" aria-hidden="true"></span>`
: nothing}
${renderTaskMeta(newest, props, facts)}
${renderTaskMeta(newest, props, facts, "detail")}
</div>
${facts.active && props.canCancel
? html`
@@ -7,8 +7,6 @@ import {
type BackgroundTasksHost,
} from "./chat-background-tasks.ts";
const openSession = { onOpenSession: () => {} };
function flushAsync() {
return new Promise<void>((resolve) => {
setTimeout(resolve, 0);
@@ -70,11 +68,11 @@ async function refreshingHost(tasks: TaskSummary[], initial = false) {
hello: null,
requestUpdate: vi.fn(),
};
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
if (!initial) {
await flushAsync();
deferRefresh = true;
createBackgroundTasksProps(host, openSession).onRefresh();
createBackgroundTasksProps(host).onRefresh();
}
return {
host,
@@ -124,7 +122,7 @@ describe("background tasks concurrent snapshots", () => {
])("replays $name during the initial load without a stale second load", async (test) => {
const refresh = await refreshingHost(test.stale, true);
expect(refresh.request).toHaveBeenCalledTimes(2);
expect(createBackgroundTasksProps(refresh.host, openSession).tasks).toBeNull();
expect(createBackgroundTasksProps(refresh.host).tasks).toBeNull();
handleBackgroundTasksEvent(refresh.host, test.event);
handleBackgroundTasksEvent(refresh.host, {
@@ -142,10 +140,7 @@ describe("background tasks concurrent snapshots", () => {
expect(refresh.request).toHaveBeenCalledTimes(2);
expect(
createBackgroundTasksProps(refresh.host, openSession).tasks?.map((task) => [
task.id,
task.status,
]),
createBackgroundTasksProps(refresh.host).tasks?.map((task) => [task.id, task.status]),
).toEqual(test.expected);
});
@@ -166,7 +161,7 @@ describe("background tasks concurrent snapshots", () => {
refresh.resolveRecent({ tasks: stale });
await flushAsync();
const props = createBackgroundTasksProps(refresh.host, openSession);
const props = createBackgroundTasksProps(refresh.host);
expect(props.error).toBe("Initial task snapshot unavailable");
expect(props.tasks?.map((task) => [task.id, task.status])).toEqual([
["task-snapshot-failed", "completed"],
@@ -193,7 +188,7 @@ describe("background tasks concurrent snapshots", () => {
refresh.resolveRecent({ tasks });
await flushAsync();
const props = createBackgroundTasksProps(refresh.host, openSession);
const props = createBackgroundTasksProps(refresh.host);
expect(props.tasks).toHaveLength(10);
expect(new Set(props.tasks?.map((task) => task.id)).size).toBe(10);
expect(props.tasks?.every((task) => task.status === "completed")).toBe(true);
@@ -207,19 +202,15 @@ describe("background tasks concurrent snapshots", () => {
const tasks = [makeTask({ id: "task-cancelled" })];
const refresh = await refreshingHost(tasks);
createBackgroundTasksProps(refresh.host, openSession).onCancel("task-cancelled");
createBackgroundTasksProps(refresh.host).onCancel("task-cancelled");
await flushAsync();
expect(createBackgroundTasksProps(refresh.host, openSession).tasks?.[0]?.status).toBe(
"cancelled",
);
expect(createBackgroundTasksProps(refresh.host).tasks?.[0]?.status).toBe("cancelled");
refresh.resolveActive({ tasks });
refresh.resolveRecent({ tasks });
await flushAsync();
expect(createBackgroundTasksProps(refresh.host, openSession).tasks?.[0]?.status).toBe(
"cancelled",
);
expect(createBackgroundTasksProps(refresh.host).tasks?.[0]?.status).toBe("cancelled");
});
it("discards an in-flight snapshot after a same-client connection-epoch change", async () => {
@@ -229,19 +220,19 @@ describe("background tasks concurrent snapshots", () => {
refresh.setFallbackTasks(replacement);
refresh.host.connectionEpoch = 2;
createBackgroundTasksProps(refresh.host, openSession);
createBackgroundTasksProps(refresh.host);
await flushAsync();
expect(
createBackgroundTasksProps(refresh.host, openSession).tasks?.map((task) => task.id),
).toEqual(["task-new-account"]);
expect(createBackgroundTasksProps(refresh.host).tasks?.map((task) => task.id)).toEqual([
"task-new-account",
]);
refresh.resolveActive({ tasks: stale });
refresh.resolveRecent({ tasks: stale });
await flushAsync();
expect(
createBackgroundTasksProps(refresh.host, openSession).tasks?.map((task) => task.id),
).toEqual(["task-new-account"]);
expect(createBackgroundTasksProps(refresh.host).tasks?.map((task) => task.id)).toEqual([
"task-new-account",
]);
expect(refresh.request).toHaveBeenCalledTimes(4);
});
});
@@ -0,0 +1,218 @@
import { html, nothing, type TemplateResult } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import { isActiveTask, partitionTasks, taskTitle } from "../../../lib/tasks/data.ts";
import type { TaskSummary } from "../../../lib/tasks/task-summary.ts";
import { renderTaskDetail, renderTaskRow } from "./chat-background-task-row.ts";
import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
/** Active-count badge shown on the collapsed-rail toggles; 0 until the task
* list has loaded for the pane's session. */
function backgroundTasksActiveCount(props: BackgroundTasksProps | undefined): number {
return props?.tasks?.filter(isActiveTask).length ?? 0;
}
export function renderBackgroundTasksToggle(
backgroundTasks: BackgroundTasksProps | undefined,
): TemplateResult | typeof nothing {
if (!backgroundTasks) {
return nothing;
}
const expanded = !backgroundTasks.collapsed;
const label = expanded ? t("chat.backgroundTasks.collapse") : t("chat.backgroundTasks.show");
const activeCount = backgroundTasksActiveCount(backgroundTasks);
return html`
<openclaw-tooltip .content=${label}>
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-tasks-toggle"
type="button"
aria-label=${label}
aria-expanded=${String(expanded)}
@click=${backgroundTasks.onToggleCollapsed}
>
${icons.activity}
${!expanded && activeCount > 0
? html`<span class="chat-tasks-toggle__badge" aria-hidden="true">${activeCount}</span>`
: nothing}
</button>
</openclaw-tooltip>
`;
}
function renderTaskRows(
tasks: readonly TaskSummary[],
props: BackgroundTasksProps,
): TemplateResult {
return html`
<div class="chat-tasks-rail__list" role="list">
${repeat(
tasks,
(task) => task.id,
(task) => renderTaskRow(task, props),
)}
</div>
`;
}
export function renderBackgroundTasksRail(
backgroundTasks: BackgroundTasksProps | undefined,
transcript: TemplateResult | typeof nothing = nothing,
): TemplateResult | typeof nothing {
// Collapsed rails render nothing at all — no icon strip. Reopening happens
// through renderBackgroundTasksToggle.
if (!backgroundTasks || backgroundTasks.collapsed) {
return nothing;
}
const view = backgroundTasks.view;
const viewedTask =
view.kind === "list"
? undefined
: backgroundTasks.tasks?.find((task) => task.id === view.taskId);
const selectedTask = view.kind === "detail" ? viewedTask : undefined;
const { active, recent } = partitionTasks(backgroundTasks.tasks ?? []);
const loaded = backgroundTasks.tasks !== null;
const empty = loaded && active.length === 0 && recent.length === 0;
const collapseButton = html`
<openclaw-tooltip .content=${t("chat.backgroundTasks.collapse")}>
<button
type="button"
class="nav-collapse-toggle chat-tasks-rail__collapse-toggle"
aria-label=${t("chat.backgroundTasks.collapse")}
aria-expanded="true"
@click=${backgroundTasks.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${backgroundTasks.narrowLayout ? icons.panelBottomClose : icons.panelRightClose}</span
>
</button>
</openclaw-tooltip>
`;
return html`
<aside
id=${`${backgroundTasks.statusRowId}-rail`}
class="chat-tasks-rail"
aria-label=${t("chat.backgroundTasks.label")}
>
<div class="chat-tasks-rail__header">
${view.kind !== "list" && viewedTask
? html`
<button
class="btn btn--ghost btn--sm chat-tasks-rail__back"
type="button"
aria-label=${view.kind === "transcript" && view.returnTo === "detail"
? t("chat.backgroundTasks.backToDetail")
: t("chat.backgroundTasks.backToTasks")}
@click=${backgroundTasks.onBack}
>
${icons.arrowLeft}
</button>
<div class="chat-tasks-rail__title">
<span class="chat-tasks-rail__eyebrow"
>${view.kind === "transcript"
? t("chat.backgroundTasks.transcriptTitle")
: t("chat.backgroundTasks.detailTitle")}</span
>
<strong title=${taskTitle(viewedTask)}>${taskTitle(viewedTask)}</strong>
</div>
<div class="chat-tasks-rail__actions">${collapseButton}</div>
`
: html`
<div class="chat-tasks-rail__title">
<span class="chat-tasks-rail__eyebrow">${backgroundTasks.sessionKey}</span>
<strong>${t("chat.backgroundTasks.title")}</strong>
</div>
<div class="chat-tasks-rail__actions">
<openclaw-tooltip .content=${t("chat.backgroundTasks.refresh")}>
<button
class="btn btn--ghost btn--sm chat-tasks-rail__refresh"
type="button"
aria-label=${t("chat.backgroundTasks.refresh")}
?disabled=${backgroundTasks.loading || !backgroundTasks.connected}
@click=${backgroundTasks.onRefresh}
>
${icons.refresh}
</button>
</openclaw-tooltip>
${collapseButton}
</div>
`}
</div>
${!backgroundTasks.connected
? html`<div class="chat-tasks-rail__state">${t("tasksPage.disconnected")}</div>`
: nothing}
${backgroundTasks.error
? html`<div class="chat-tasks-rail__state chat-tasks-rail__state--error">
${backgroundTasks.error}
</div>`
: nothing}
${view.kind === "transcript"
? html`<div class="chat-tasks-rail__transcript" data-task-transcript=${view.taskId}>
${view.load.status === "loading"
? html`<div class="chat-tasks-rail__state">
${t("chat.backgroundTasks.transcriptLoading")}
</div>`
: view.load.status === "error"
? html`<div class="chat-tasks-rail__state chat-tasks-rail__state--error">
${t("chat.backgroundTasks.transcriptFailed")}
</div>`
: view.load.messages.length === 0
? html`<div class="chat-tasks-rail__state">
${t("chat.backgroundTasks.transcriptEmpty")}
</div>`
: transcript}
</div>`
: selectedTask
? html`<div class="chat-tasks-rail__scroll">
${renderTaskDetail(selectedTask, backgroundTasks)}
</div>`
: html`
${backgroundTasks.loading && !loaded
? html`<div class="chat-tasks-rail__state">
${t("chat.backgroundTasks.loading")}
</div>`
: nothing}
${empty
? html`<div class="chat-tasks-rail__state">${t("chat.backgroundTasks.empty")}</div>`
: nothing}
<div class="chat-tasks-rail__scroll chat-tasks-rail__scroll--split">
${active.length > 0
? html`
<section class="chat-tasks-rail__section" data-tasks-section="running">
<div class="chat-tasks-rail__section-title">
${t("chat.backgroundTasks.running", { count: String(active.length) })}
</div>
${renderTaskRows(active, backgroundTasks)}
</section>
`
: nothing}
${recent.length > 0
? html`
<section class="chat-tasks-rail__section" data-tasks-section="finished">
<button
class="chat-tasks-rail__section-toggle"
type="button"
aria-expanded=${String(!backgroundTasks.finishedCollapsed)}
@click=${backgroundTasks.onToggleFinished}
>
<span class="chat-tasks-rail__section-title">
${t("chat.backgroundTasks.finished", { count: String(recent.length) })}
</span>
<span class="chat-tasks-rail__section-chevron" aria-hidden="true">
${backgroundTasks.finishedCollapsed
? icons.chevronRight
: icons.chevronDown}
</span>
</button>
${backgroundTasks.finishedCollapsed
? nothing
: renderTaskRows(recent, backgroundTasks)}
</section>
`
: nothing}
</div>
`}
</aside>
`;
}
@@ -12,7 +12,8 @@ import {
taskTitle,
} from "../../../lib/tasks/data.ts";
import type { TaskSummary } from "../../../lib/tasks/task-summary.ts";
import { STATUS_TONES, type BackgroundTasksProps } from "./chat-background-tasks.ts";
import { STATUS_TONES } from "./chat-background-tasks-shared.ts";
import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
type BackgroundTasksStatus = { count: number; startedMs: number | null };
@@ -2,14 +2,15 @@ import { html, render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../../api/gateway.ts";
import type { TaskSummary } from "../../../lib/tasks/task-summary.ts";
import { CHAT_HISTORY_REQUEST_LIMIT } from "../chat-history.ts";
import { renderBackgroundTasksRail } from "./chat-background-tasks-render.ts";
import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts";
import {
createBackgroundTasksProps,
handleBackgroundTasksEvent,
renderBackgroundTasksRail,
type BackgroundTasksHost,
type BackgroundTasksProps,
} from "./chat-background-tasks.ts";
import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
function flushAsync() {
return new Promise((resolve) => {
@@ -66,8 +67,6 @@ function createHost(options?: {
return { host, request, requestUpdate };
}
const openSession = { onOpenSession: () => {} };
function makeProps(overrides: Partial<BackgroundTasksProps> = {}): BackgroundTasksProps {
return {
sessionKey: "agent:main:current",
@@ -79,9 +78,9 @@ function makeProps(overrides: Partial<BackgroundTasksProps> = {}): BackgroundTas
loading: false,
error: null,
tasks: null,
view: { kind: "list" },
cancellingTaskIds: new Set(),
finishedCollapsed: false,
selectedTaskId: null,
taskDetails: new Map(),
taskDetailErrors: new Map(),
taskDetailLoadingIds: new Set(),
@@ -90,8 +89,8 @@ function makeProps(overrides: Partial<BackgroundTasksProps> = {}): BackgroundTas
onRefresh: () => {},
onCancel: () => {},
onSelectTask: () => {},
onBackToList: () => {},
onOpenSession: () => {},
onBack: () => {},
onOpenTranscript: () => {},
...overrides,
};
}
@@ -134,10 +133,10 @@ describe("background tasks rail state", () => {
},
});
expect(createBackgroundTasksProps(host, openSession).collapsed).toBe(true);
expect(createBackgroundTasksProps(host).collapsed).toBe(true);
await flushAsync();
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.collapsed).toBe(true);
expect(props.finishedCollapsed).toBe(true);
expect(request).toHaveBeenCalledTimes(2);
@@ -165,12 +164,12 @@ describe("background tasks rail state", () => {
},
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
await flushAsync();
expect(request.mock.calls[0]?.[1]).toMatchObject({ status: ["queued", "running"] });
expect(request.mock.calls[1]?.[1]).not.toHaveProperty("status");
expect(createBackgroundTasksProps(host, openSession).tasks).toEqual([recent]);
expect(createBackgroundTasksProps(host).tasks).toEqual([recent]);
});
it("loads the snapshot when a task event arrives before any load", async () => {
@@ -178,7 +177,7 @@ describe("background tasks rail state", () => {
connected: false,
request: () => Promise.resolve({ tasks: [makeTask({ id: "task-1" })] }),
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
expect(request).not.toHaveBeenCalled();
host.connected = true;
@@ -189,18 +188,18 @@ describe("background tasks rail state", () => {
await flushAsync();
expect(request).toHaveBeenCalledTimes(2);
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.tasks?.map((task) => task.id)).toEqual(["task-1"]);
});
it("keeps expansion across session switches and reloads the new scope", async () => {
const { host, request } = createHost();
createBackgroundTasksProps(host, openSession).onToggleCollapsed();
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host).onToggleCollapsed();
createBackgroundTasksProps(host);
await flushAsync();
host.sessionKey = "agent:main:another-thread";
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.collapsed).toBe(false);
expect(props.sessionKey).toBe("agent:main:another-thread");
expect(props.tasks).toBeNull();
@@ -220,13 +219,13 @@ describe("background tasks rail state", () => {
});
const auth = { role: "operator" as const, scopes: ["operator.write"] };
host.hello = { type: "hello-ok", protocol: 4, auth };
createBackgroundTasksProps(host, openSession).onToggleCollapsed();
createBackgroundTasksProps(host).onToggleCollapsed();
await flushAsync();
createBackgroundTasksProps(host, openSession).onCancel("task-1");
createBackgroundTasksProps(host).onCancel("task-1");
await flushAsync();
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.error).toBe("already finished");
expect(props.cancellingTaskIds.has("task-1")).toBe(false);
});
@@ -243,21 +242,119 @@ describe("background tasks rail state", () => {
? Promise.resolve({ task: { ...running, prompt: "Audit the background task UI" } })
: Promise.resolve({ tasks: [running] }),
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
await flushAsync();
createBackgroundTasksProps(host, openSession).onSelectTask(running);
createBackgroundTasksProps(host).onSelectTask(running);
await flushAsync();
expect(request).toHaveBeenCalledWith("tasks.get", { taskId: "task-1" });
const props = createBackgroundTasksProps(host, openSession);
expect(props.selectedTaskId).toBe("task-1");
const props = createBackgroundTasksProps(host);
expect(props.view).toEqual({ kind: "detail", taskId: "task-1" });
expect(props.taskDetails.get("task-1")?.prompt).toBe("Audit the background task UI");
props.onBackToList();
expect(createBackgroundTasksProps(host, openSession).selectedTaskId).toBeNull();
props.onBack();
expect(createBackgroundTasksProps(host).view).toEqual({ kind: "list" });
});
it("loads a task transcript in the rail and returns to its originating detail", async () => {
const task = makeTask({
id: "task-1",
runtime: "cli",
sessionKey: "agent:main:cli:fallback",
childSessionKey: "agent:main:cli:child",
});
const { host, request } = createHost({
request: (method) => {
if (method === "chat.history") {
return Promise.resolve({
messages: [
{ role: "assistant", content: [{ type: "text", text: "CLI result" }] },
{ role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
],
});
}
return Promise.resolve({ tasks: [task] });
},
});
createBackgroundTasksProps(host);
await flushAsync();
const props = createBackgroundTasksProps(host);
props.onSelectTask(task);
props.onOpenTranscript(task, "detail");
expect(createBackgroundTasksProps(host).view).toMatchObject({
kind: "transcript",
taskId: "task-1",
sessionKey: "agent:main:cli:child",
returnTo: "detail",
load: { status: "loading" },
});
await flushAsync();
expect(request).toHaveBeenCalledWith("chat.history", {
sessionKey: "agent:main:cli:child",
limit: CHAT_HISTORY_REQUEST_LIMIT,
});
const loaded = createBackgroundTasksProps(host).view;
expect(loaded.kind).toBe("transcript");
if (loaded.kind !== "transcript") {
throw new Error("expected transcript view");
}
expect(loaded.load).toMatchObject({ status: "loaded" });
expect(loaded.load.status === "loaded" ? loaded.load.messages : []).toHaveLength(1);
createBackgroundTasksProps(host).onBack();
expect(createBackgroundTasksProps(host).view).toEqual({ kind: "detail", taskId: "task-1" });
});
it.each(["detail", "transcript"] as const)(
"returns a stale %s view to the list when refresh omits its task",
async (view) => {
const task = makeTask({
id: "task-1",
runtime: "cli",
sessionKey: "agent:main:cli:late",
});
let listCall = 0;
let resolveView: ((value: unknown) => void) | undefined;
const viewResponse = new Promise<unknown>((resolve) => {
resolveView = resolve;
});
const { host } = createHost({
request: (method) => {
if (method === "tasks.get" || method === "chat.history") {
return viewResponse;
}
listCall += 1;
return Promise.resolve({ tasks: listCall <= 2 ? [task] : [] });
},
});
createBackgroundTasksProps(host);
await flushAsync();
const props = createBackgroundTasksProps(host);
if (view === "detail") {
props.onSelectTask(task);
} else {
props.onOpenTranscript(task, "list");
}
createBackgroundTasksProps(host).onRefresh();
await flushAsync();
expect(createBackgroundTasksProps(host).tasks).toEqual([]);
expect(createBackgroundTasksProps(host).view).toEqual({ kind: "list" });
resolveView?.(
view === "detail"
? { task: { ...task, prompt: "Late task detail" } }
: { messages: [{ role: "assistant", content: "Late transcript" }] },
);
await flushAsync();
expect(createBackgroundTasksProps(host).view).toEqual({ kind: "list" });
},
);
it("moves focus into task details and restores it to the selected row", async () => {
const running = makeTask({ id: "task-1", progressSummary: "Reading files" });
const completed = makeTask({
@@ -273,21 +370,18 @@ describe("background tasks rail state", () => {
? Promise.resolve({ task: completed })
: Promise.resolve({ tasks: [running] }),
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
await flushAsync();
const container = document.createElement("div");
document.body.append(container);
const renderRail = () => {
render(
html`${renderBackgroundTasksRail(createBackgroundTasksProps(host, openSession))}`,
container,
);
render(html`${renderBackgroundTasksRail(createBackgroundTasksProps(host))}`, container);
};
host.requestUpdate = renderRail;
// finishedCollapsed defaults to true; back-navigation from a finished
// detail must expand the section so the returned-to row stays visible.
const initialProps = createBackgroundTasksProps(host, openSession);
const initialProps = createBackgroundTasksProps(host);
initialProps.onToggleCollapsed();
renderRail();
@@ -303,7 +397,7 @@ describe("background tasks rail state", () => {
back?.click();
await flushAnimationFrame();
expect(createBackgroundTasksProps(host, openSession).finishedCollapsed).toBe(false);
expect(createBackgroundTasksProps(host).finishedCollapsed).toBe(false);
expect(
container.querySelector('[data-tasks-section="finished"] [data-task-id="task-1"]'),
).not.toBeNull();
@@ -327,13 +421,13 @@ describe("background tasks rail state", () => {
? Promise.resolve({ task: completed })
: Promise.resolve({ tasks: [running] }),
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
await flushAsync();
createBackgroundTasksProps(host, openSession).onSelectTask(running);
createBackgroundTasksProps(host).onSelectTask(running);
await flushAsync();
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.tasks?.map((task) => [task.id, task.status])).toEqual([["task-1", "completed"]]);
expect(props.taskDetails.get("task-1")?.terminalSummary).toBe("Finished in lookup");
});
@@ -368,17 +462,17 @@ describe("background tasks rail state", () => {
return listCall === 3 ? active : recent;
},
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
await flushAsync();
createBackgroundTasksProps(host, openSession).onRefresh();
createBackgroundTasksProps(host, openSession).onSelectTask(running);
createBackgroundTasksProps(host).onRefresh();
createBackgroundTasksProps(host).onSelectTask(running);
await flushAsync();
resolveActive?.({ tasks: [running] });
resolveRecent?.({ tasks: [running] });
await flushAsync();
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.tasks?.map((task) => [task.id, task.status])).toEqual([["task-1", "completed"]]);
expect(props.taskDetails.get("task-1")).toMatchObject({
status: "completed",
@@ -397,17 +491,17 @@ describe("background tasks rail state", () => {
request: (method) =>
method === "tasks.get" ? detail : Promise.resolve({ tasks: [running] }),
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
await flushAsync();
createBackgroundTasksProps(host, openSession).onSelectTask(running);
createBackgroundTasksProps(host).onSelectTask(running);
handleBackgroundTasksEvent(host, { action: "deleted", taskId: "task-1" });
resolveDetail?.({ task: { ...running, prompt: "Deleted task prompt" } });
await flushAsync();
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.tasks).toEqual([]);
expect(props.selectedTaskId).toBeNull();
expect(props.view).toEqual({ kind: "list" });
expect(props.taskDetails.has("task-1")).toBe(false);
});
});
@@ -417,7 +511,7 @@ describe("background tasks rail events", () => {
const { host, request } = createHost({
request: () => Promise.resolve({ tasks }),
});
createBackgroundTasksProps(host, openSession).onToggleCollapsed();
createBackgroundTasksProps(host).onToggleCollapsed();
await flushAsync();
return { host, request };
}
@@ -429,11 +523,11 @@ describe("background tasks rail events", () => {
action: "upserted",
task: makeTask({ id: "task-2", status: "completed", updatedAt: 9_000 }),
});
let props = createBackgroundTasksProps(host, openSession);
let props = createBackgroundTasksProps(host);
expect(props.tasks?.map((task) => task.id)).toEqual(["task-2", "task-1"]);
handleBackgroundTasksEvent(host, { action: "deleted", taskId: "task-1" });
props = createBackgroundTasksProps(host, openSession);
props = createBackgroundTasksProps(host);
expect(props.tasks?.map((task) => task.id)).toEqual(["task-2"]);
});
@@ -454,7 +548,7 @@ describe("background tasks rail events", () => {
handleBackgroundTasksEvent(host, { action: "upserted", task: correction });
expect(createBackgroundTasksProps(host, openSession).tasks).toEqual([correction]);
expect(createBackgroundTasksProps(host).tasks).toEqual([correction]);
});
it("does not roll back running tool activity from an equally current event", async () => {
@@ -474,7 +568,7 @@ describe("background tasks rail events", () => {
handleBackgroundTasksEvent(host, { action: "upserted", task: stale });
expect(createBackgroundTasksProps(host, openSession).tasks).toEqual([progress]);
expect(createBackgroundTasksProps(host).tasks).toEqual([progress]);
});
it("preserves an opened prompt when a terminal event corrects its output", async () => {
@@ -497,14 +591,14 @@ describe("background tasks rail events", () => {
? Promise.resolve({ task: { ...completed, prompt } })
: Promise.resolve({ tasks: [completed] }),
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
await flushAsync();
createBackgroundTasksProps(host, openSession).onSelectTask(completed);
createBackgroundTasksProps(host).onSelectTask(completed);
await flushAsync();
handleBackgroundTasksEvent(host, { action: "upserted", task: correction });
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.tasks?.[0]?.terminalSummary).toBe("Authoritative terminal details");
expect(props.taskDetails.get("task-1")).toMatchObject({
prompt,
@@ -520,7 +614,7 @@ describe("background tasks rail events", () => {
task: makeTask({ id: "task-2", sessionKey: "agent:main:another-thread" }),
});
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.tasks?.map((task) => task.id)).toEqual(["task-1"]);
});
@@ -536,7 +630,7 @@ describe("background tasks rail events", () => {
},
});
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.tasks?.map((task) => task.id)).toEqual(["task-owner", "task-1"]);
});
@@ -565,9 +659,9 @@ describe("background tasks rail events", () => {
? Promise.resolve({ task: completed })
: Promise.resolve({ tasks: [running] }),
});
createBackgroundTasksProps(host, openSession);
createBackgroundTasksProps(host);
await flushAsync();
createBackgroundTasksProps(host, openSession).onSelectTask(running);
createBackgroundTasksProps(host).onSelectTask(running);
await flushAsync();
handleBackgroundTasksEvent(host, {
@@ -575,7 +669,7 @@ describe("background tasks rail events", () => {
task: makeTask({ id: "task-1", status: "running", updatedAt: 2_000 }),
});
const props = createBackgroundTasksProps(host, openSession);
const props = createBackgroundTasksProps(host);
expect(props.tasks?.[0]?.status).toBe("completed");
expect(props.taskDetails.get("task-1")).toMatchObject({
status: "completed",
@@ -586,9 +680,42 @@ describe("background tasks rail events", () => {
});
describe("background tasks rail rendering", () => {
it("renders explicit transcript loading, empty, and error states", () => {
const task = makeTask({ id: "task-1", runtime: "cli" });
const baseView = {
kind: "transcript" as const,
taskId: "task-1",
sessionKey: "agent:main:cli:task",
returnTo: "detail" as const,
};
const loading = renderTaskRail({
tasks: [task],
view: { ...baseView, load: { status: "loading" } },
});
expect(loading.textContent).toContain("Loading task transcript…");
expect(loading.querySelector(".chat-tasks-rail__back")?.getAttribute("aria-label")).toBe(
"Back to task details",
);
const empty = renderTaskRail({
tasks: [task],
view: { ...baseView, load: { status: "loaded", messages: [] } },
});
expect(empty.textContent).toContain("No transcript messages yet.");
const failed = renderTaskRail({
tasks: [task],
view: {
...baseView,
load: { status: "error" },
},
});
expect(failed.textContent).toContain("Could not load task transcript.");
});
it("keeps subagents in the rail and preserves linked sessions for other runtimes", () => {
const onCancel = vi.fn();
const onOpenSession = vi.fn();
const onOpenTranscript = vi.fn();
const onSelectTask = vi.fn();
const container = renderTaskRail({
canCancel: true,
@@ -608,7 +735,7 @@ describe("background tasks rail rendering", () => {
],
onCancel,
onSelectTask,
onOpenSession,
onOpenTranscript,
});
const rows = container.querySelectorAll(".chat-tasks-rail__task");
@@ -629,7 +756,10 @@ describe("background tasks rail rendering", () => {
);
expect(transcript).not.toBeNull();
transcript?.click();
expect(onOpenSession).toHaveBeenCalledWith("agent:main:cli:finished");
expect(onOpenTranscript).toHaveBeenCalledWith(
expect.objectContaining({ id: "task-2" }),
"list",
);
expect(onSelectTask).not.toHaveBeenCalled();
});
@@ -660,22 +790,26 @@ describe("background tasks rail rendering", () => {
});
it("opens a compact task detail view with prompt, output, and back navigation", () => {
const onBackToList = vi.fn();
const onBack = vi.fn();
const onOpenTranscript = vi.fn();
const task = makeTask({
id: "task-1",
runtime: "cli",
sessionKey: "agent:main:cli:audit",
status: "completed",
terminalSummary: "Audit complete",
});
const container = renderTaskRail({
tasks: [task],
selectedTaskId: "task-1",
view: { kind: "detail", taskId: "task-1" },
taskDetails: new Map([
[
"task-1",
{ ...task, terminalSummary: "Stale running progress", prompt: "Review running tasks" },
],
]),
onBackToList,
onBack,
onOpenTranscript,
});
const detail = container.querySelector('[data-task-detail="task-1"]');
@@ -683,11 +817,20 @@ describe("background tasks rail rendering", () => {
expect(detail?.textContent).toContain("Audit complete");
expect(detail?.textContent).not.toContain("Stale running progress");
expect(container.querySelector(".chat-tasks-rail__task")).toBeNull();
const transcript = container.querySelector<HTMLButtonElement>(
".chat-tasks-rail__task-transcript",
);
expect(transcript).not.toBeNull();
transcript?.click();
expect(onOpenTranscript).toHaveBeenCalledWith(
expect.objectContaining({ id: "task-1" }),
"detail",
);
const back = container.querySelector<HTMLButtonElement>(".chat-tasks-rail__back");
expect(back?.getAttribute("aria-label")).toBe("Back to background tasks");
back?.click();
expect(onBackToList).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
});
it("uses a newer lookup snapshot for output", () => {
@@ -706,7 +849,7 @@ describe("background tasks rail rendering", () => {
});
const container = renderTaskRail({
tasks: [listTask],
selectedTaskId: "task-1",
view: { kind: "detail", taskId: "task-1" },
taskDetails: new Map([["task-1", lookupTask]]),
});
@@ -1,9 +1,5 @@
import { html, nothing, type TemplateResult } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../../api/gateway.ts";
import { hasOperatorWriteAccess } from "../../../app/operator-access.ts";
import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import type { SessionScopeHost } from "../../../lib/sessions/index.ts";
import { canonicalUiSessionKeyForPersistence } from "../../../lib/sessions/session-key.ts";
@@ -16,17 +12,19 @@ import {
normalizeTasksCancelResult,
normalizeTasksGetResult,
normalizeTasksListResult,
partitionTasks,
sortTasks,
taskTitle,
} from "../../../lib/tasks/data.ts";
import type { TaskSummary } from "../../../lib/tasks/task-summary.ts";
import { renderTaskDetail, renderTaskRow } from "./chat-background-task-row.ts";
import {
CHAT_HISTORY_REQUEST_LIMIT,
type ChatHistoryResult,
visibleChatHistoryMessages,
} from "../chat-history.ts";
import { newestTaskSnapshot } from "./chat-background-tasks-shared.ts";
import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
export { STATUS_TONES } from "./chat-background-tasks-shared.ts";
export type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
import type {
BackgroundTasksProps,
BackgroundTasksRailView,
} from "./chat-background-tasks.types.ts";
type BackgroundTaskLoadEvent = NonNullable<ReturnType<typeof normalizeTaskEventPayload>>;
@@ -57,7 +55,7 @@ type BackgroundTasksState = {
// per pane: two panes on the same agent would otherwise cross-anchor.
statusRowId: string;
tasks: TaskSummary[] | null;
selectedTaskId: string | null;
view: BackgroundTasksRailView;
taskDetails: Map<string, TaskSummary>;
taskDetailErrors: Map<string, string>;
taskDetailLoadingIds: Set<string>;
@@ -117,7 +115,7 @@ function getBackgroundTasksState(host: BackgroundTasksHost): BackgroundTasksStat
sessionKey,
statusRowId: `chat-tasks-status-${nextStatusRowId}`,
tasks: null,
selectedTaskId: null,
view: { kind: "list" },
taskDetails: new Map(),
taskDetailErrors: new Map(),
taskDetailLoadingIds: new Set(),
@@ -184,6 +182,12 @@ function loadBackgroundTasks(
current.tasks = sortTasks(
merged.map((task) => newestTaskSnapshot(task, current.taskDetails.get(task.id))),
);
const viewedTaskId = current.view.kind === "list" ? null : current.view.taskId;
// Detail and transcript navigation depend on the authoritative list;
// a bounded refresh may legitimately omit the previously viewed task.
if (viewedTaskId && !current.tasks.some((task) => task.id === viewedTaskId)) {
current.view = { kind: "list" };
}
current.loadedClient = client;
} catch (error) {
const current = getBackgroundTasksState(host);
@@ -291,8 +295,8 @@ export function handleBackgroundTasksEvent(host: BackgroundTasksHost, payload: u
return;
}
state.tasks = state.tasks.filter((task) => task.id !== event.taskId);
if (state.selectedTaskId === event.taskId) {
state.selectedTaskId = null;
if (state.view.kind !== "list" && state.view.taskId === event.taskId) {
state.view = { kind: "list" };
}
state.taskDetails.delete(event.taskId);
state.taskDetailErrors.delete(event.taskId);
@@ -400,14 +404,14 @@ function selectBackgroundTaskDetail(
state: BackgroundTasksState,
task: TaskSummary,
) {
state.selectedTaskId = task.id;
state.view = { kind: "detail", taskId: task.id };
host.requestUpdate?.();
focusBackgroundTaskControl(state, "back");
void loadBackgroundTaskDetail(host, state, task);
}
function showBackgroundTaskList(host: BackgroundTasksHost, state: BackgroundTasksState) {
const taskId = state.selectedTaskId;
const taskId = state.view.kind === "detail" ? state.view.taskId : null;
const listedTask = state.tasks?.find((task) => task.id === taskId);
const detailedTask = taskId ? state.taskDetails.get(taskId) : undefined;
const selectedTask = listedTask ? newestTaskSnapshot(listedTask, detailedTask) : detailedTask;
@@ -420,13 +424,84 @@ function showBackgroundTaskList(host: BackgroundTasksHost, state: BackgroundTask
state.finishedCollapsed = false;
}
}
state.selectedTaskId = null;
state.view = { kind: "list" };
host.requestUpdate?.();
if (taskId) {
focusBackgroundTaskControl(state, { taskId });
}
}
function openBackgroundTaskTranscript(
host: BackgroundTasksHost,
state: BackgroundTasksState,
task: TaskSummary,
returnTo: "list" | "detail",
) {
const sessionKey = normalizeOptionalString(task.childSessionKey ?? task.sessionKey);
const client = host.client;
const pendingView: BackgroundTasksRailView = {
kind: "transcript",
taskId: task.id,
sessionKey: sessionKey ?? "",
returnTo,
load: { status: "loading" },
};
state.view = pendingView;
host.requestUpdate?.();
focusBackgroundTaskControl(state, "back");
if (!client || !host.connected || !sessionKey) {
state.view = {
...pendingView,
load: { status: "error" },
};
host.requestUpdate?.();
return;
}
void (async () => {
let load: Extract<BackgroundTasksRailView, { kind: "transcript" }>["load"];
try {
const result = await client.request<ChatHistoryResult>("chat.history", {
sessionKey,
limit: CHAT_HISTORY_REQUEST_LIMIT,
});
load = { status: "loaded", messages: visibleChatHistoryMessages(result.messages) };
} catch {
load = { status: "error" };
}
const current = getBackgroundTasksState(host);
if (current !== state || current.view !== pendingView) {
return;
}
current.view = { ...pendingView, load };
host.requestUpdate?.();
})();
}
function showPreviousBackgroundTaskView(host: BackgroundTasksHost, state: BackgroundTasksState) {
if (state.view.kind === "detail") {
showBackgroundTaskList(host, state);
return;
}
if (state.view.kind !== "transcript") {
return;
}
const { returnTo, taskId } = state.view;
if (returnTo === "detail" && state.tasks?.some((task) => task.id === taskId)) {
state.view = { kind: "detail", taskId };
host.requestUpdate?.();
window.requestAnimationFrame(() => {
document
.getElementById(`${state.statusRowId}-rail`)
?.querySelector<HTMLElement>(".chat-tasks-rail__task-transcript")
?.focus();
});
return;
}
state.view = { kind: "list" };
host.requestUpdate?.();
focusBackgroundTaskControl(state, { taskId });
}
async function cancelBackgroundTask(
host: BackgroundTasksHost,
state: BackgroundTasksState,
@@ -488,7 +563,7 @@ function toggleBackgroundTasks(host: BackgroundTasksHost) {
export function createBackgroundTasksProps(
host: BackgroundTasksHost,
opts: { narrowLayout?: boolean; onOpenSession: (sessionKey: string) => void },
opts: { narrowLayout?: boolean } = {},
): BackgroundTasksProps {
const state = getBackgroundTasksState(host);
if (!host.connected) {
@@ -517,7 +592,7 @@ export function createBackgroundTasksProps(
loading: state.loading,
error: state.error,
tasks: state.tasks,
selectedTaskId: state.selectedTaskId,
view: state.view,
taskDetails: state.taskDetails,
taskDetailErrors: state.taskDetailErrors,
taskDetailLoadingIds: state.taskDetailLoadingIds,
@@ -531,190 +606,7 @@ export function createBackgroundTasksProps(
onRefresh: () => loadBackgroundTasks(host, state, true),
onCancel: (taskId) => void cancelBackgroundTask(host, state, taskId),
onSelectTask: (task) => selectBackgroundTaskDetail(host, state, task),
onBackToList: () => showBackgroundTaskList(host, state),
onOpenSession: opts.onOpenSession,
onBack: () => showPreviousBackgroundTaskView(host, state),
onOpenTranscript: (task, returnTo) => openBackgroundTaskTranscript(host, state, task, returnTo),
};
}
/** Active-count badge shown on the collapsed-rail toggles; 0 until the task
* list has loaded for the pane's session. */
function backgroundTasksActiveCount(props: BackgroundTasksProps | undefined): number {
return props?.tasks?.filter(isActiveTask).length ?? 0;
}
export function renderBackgroundTasksToggle(
backgroundTasks: BackgroundTasksProps | undefined,
): TemplateResult | typeof nothing {
if (!backgroundTasks) {
return nothing;
}
const expanded = !backgroundTasks.collapsed;
const label = expanded ? t("chat.backgroundTasks.collapse") : t("chat.backgroundTasks.show");
const activeCount = backgroundTasksActiveCount(backgroundTasks);
return html`
<openclaw-tooltip .content=${label}>
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-tasks-toggle"
type="button"
aria-label=${label}
aria-expanded=${String(expanded)}
@click=${backgroundTasks.onToggleCollapsed}
>
${icons.activity}
${!expanded && activeCount > 0
? html`<span class="chat-tasks-toggle__badge" aria-hidden="true">${activeCount}</span>`
: nothing}
</button>
</openclaw-tooltip>
`;
}
function renderTaskRows(
tasks: readonly TaskSummary[],
props: BackgroundTasksProps,
): TemplateResult {
return html`
<div class="chat-tasks-rail__list" role="list">
${repeat(
tasks,
(task) => task.id,
(task) => renderTaskRow(task, props),
)}
</div>
`;
}
export function renderBackgroundTasksRail(
backgroundTasks: BackgroundTasksProps | undefined,
): TemplateResult | typeof nothing {
// Collapsed rails render nothing at all — no icon strip. Reopening happens
// through renderBackgroundTasksToggle.
if (!backgroundTasks || backgroundTasks.collapsed) {
return nothing;
}
const selectedTask = backgroundTasks.tasks?.find(
(task) => task.id === backgroundTasks.selectedTaskId,
);
const { active, recent } = partitionTasks(backgroundTasks.tasks ?? []);
const loaded = backgroundTasks.tasks !== null;
const empty = loaded && active.length === 0 && recent.length === 0;
const collapseButton = html`
<openclaw-tooltip .content=${t("chat.backgroundTasks.collapse")}>
<button
type="button"
class="nav-collapse-toggle chat-tasks-rail__collapse-toggle"
aria-label=${t("chat.backgroundTasks.collapse")}
aria-expanded="true"
@click=${backgroundTasks.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${backgroundTasks.narrowLayout ? icons.panelBottomClose : icons.panelRightClose}</span
>
</button>
</openclaw-tooltip>
`;
return html`
<aside
id=${`${backgroundTasks.statusRowId}-rail`}
class="chat-tasks-rail"
aria-label=${t("chat.backgroundTasks.label")}
>
<div class="chat-tasks-rail__header">
${selectedTask
? html`
<button
class="btn btn--ghost btn--sm chat-tasks-rail__back"
type="button"
aria-label=${t("chat.backgroundTasks.backToTasks")}
@click=${backgroundTasks.onBackToList}
>
${icons.arrowLeft}
</button>
<div class="chat-tasks-rail__title">
<span class="chat-tasks-rail__eyebrow"
>${t("chat.backgroundTasks.detailTitle")}</span
>
<strong title=${taskTitle(selectedTask)}>${taskTitle(selectedTask)}</strong>
</div>
<div class="chat-tasks-rail__actions">${collapseButton}</div>
`
: html`
<div class="chat-tasks-rail__title">
<span class="chat-tasks-rail__eyebrow">${backgroundTasks.sessionKey}</span>
<strong>${t("chat.backgroundTasks.title")}</strong>
</div>
<div class="chat-tasks-rail__actions">
<openclaw-tooltip .content=${t("chat.backgroundTasks.refresh")}>
<button
class="btn btn--ghost btn--sm chat-tasks-rail__refresh"
type="button"
aria-label=${t("chat.backgroundTasks.refresh")}
?disabled=${backgroundTasks.loading || !backgroundTasks.connected}
@click=${backgroundTasks.onRefresh}
>
${icons.refresh}
</button>
</openclaw-tooltip>
${collapseButton}
</div>
`}
</div>
${!backgroundTasks.connected
? html`<div class="chat-tasks-rail__state">${t("tasksPage.disconnected")}</div>`
: nothing}
${backgroundTasks.error
? html`<div class="chat-tasks-rail__state chat-tasks-rail__state--error">
${backgroundTasks.error}
</div>`
: nothing}
${selectedTask
? html`<div class="chat-tasks-rail__scroll">
${renderTaskDetail(selectedTask, backgroundTasks)}
</div>`
: html`
${backgroundTasks.loading && !loaded
? html`<div class="chat-tasks-rail__state">${t("chat.backgroundTasks.loading")}</div>`
: nothing}
${empty
? html`<div class="chat-tasks-rail__state">${t("chat.backgroundTasks.empty")}</div>`
: nothing}
<div class="chat-tasks-rail__scroll chat-tasks-rail__scroll--split">
${active.length > 0
? html`
<section class="chat-tasks-rail__section" data-tasks-section="running">
<div class="chat-tasks-rail__section-title">
${t("chat.backgroundTasks.running", { count: String(active.length) })}
</div>
${renderTaskRows(active, backgroundTasks)}
</section>
`
: nothing}
${recent.length > 0
? html`
<section class="chat-tasks-rail__section" data-tasks-section="finished">
<button
class="chat-tasks-rail__section-toggle"
type="button"
aria-expanded=${String(!backgroundTasks.finishedCollapsed)}
@click=${backgroundTasks.onToggleFinished}
>
<span class="chat-tasks-rail__section-title">
${t("chat.backgroundTasks.finished", { count: String(recent.length) })}
</span>
<span class="chat-tasks-rail__section-chevron" aria-hidden="true">
${backgroundTasks.finishedCollapsed
? icons.chevronRight
: icons.chevronDown}
</span>
</button>
${backgroundTasks.finishedCollapsed
? nothing
: renderTaskRows(recent, backgroundTasks)}
</section>
`
: nothing}
</div>
`}
</aside>
`;
}
@@ -1,5 +1,16 @@
import type { TaskSummary } from "../../../lib/tasks/task-summary.ts";
export type BackgroundTasksRailView =
| { kind: "list" }
| { kind: "detail"; taskId: string }
| {
kind: "transcript";
taskId: string;
sessionKey: string;
returnTo: "list" | "detail";
load: { status: "loading" } | { status: "loaded"; messages: unknown[] } | { status: "error" };
};
export type BackgroundTasksProps = {
sessionKey: string;
statusRowId: string;
@@ -13,7 +24,7 @@ export type BackgroundTasksProps = {
error: string | null;
/** null until the first load for this session finished. */
tasks: TaskSummary[] | null;
selectedTaskId: string | null;
view: BackgroundTasksRailView;
taskDetails: ReadonlyMap<string, TaskSummary>;
taskDetailErrors: ReadonlyMap<string, string>;
taskDetailLoadingIds: ReadonlySet<string>;
@@ -24,6 +35,6 @@ export type BackgroundTasksProps = {
onRefresh: () => void;
onCancel: (taskId: string) => void;
onSelectTask: (task: TaskSummary) => void;
onBackToList: () => void;
onOpenSession: (sessionKey: string) => void;
onBack: () => void;
onOpenTranscript: (task: TaskSummary, returnTo: "list" | "detail") => void;
};
+6 -3
View File
@@ -80,7 +80,7 @@ import { getOrCreateSessionCacheValue } from "../session-cache.ts";
import type { PlanStatus } from "../tool-stream.ts";
import { getToolTitlesVersion } from "../tool-titles.ts";
import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts";
import type { BackgroundTasksProps } from "./chat-background-tasks.ts";
import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
import { renderChatDivider, renderChatNotice } from "./chat-divider.ts";
import type { ArtifactDownloadResolver } from "./chat-message-media.ts";
import {
@@ -133,6 +133,8 @@ type ChatThreadProps = {
showThinking: boolean;
showToolCalls: boolean;
persistCommentary?: boolean;
/** Suppresses transcript mutations while preserving read-only presentation controls. */
readOnly?: boolean;
/** True while the session has an abortable live run (marks running tool rows). */
runActive?: boolean;
/** True while the agent is visibly working (isChatRunWorking); shows the working spark. */
@@ -1158,7 +1160,7 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
const copyButton = actionOwner?.querySelector<HTMLButtonElement>(".chat-copy-btn");
const canReply = Boolean(text && props.onSetReply);
const canRewind = isUserMessage && typeof props.onRewindMessage === "function";
const canHide = groupKeys.length > 0;
const canHide = !props.readOnly && groupKeys.length > 0;
const canCopy = Boolean(copyButton);
const canFork = isUserMessage && typeof props.onForkMessage === "function";
if (!canReply && !canRewind && !canHide && !canCopy && !canFork) {
@@ -1656,7 +1658,7 @@ function renderChatThreadContents(
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
contextWindow: threadContextWindow,
onReply: props.onSetReply,
onDelete,
onDelete: props.readOnly ? undefined : onDelete,
onRewind:
rewindEntryId && props.onRewindMessage
? () => {
@@ -1884,6 +1886,7 @@ function renderChatThreadContents(
props.planStatus,
props.questionPrompts,
Boolean(props.autoExpandToolCalls),
Boolean(props.readOnly),
props.assistantName,
assistantIdentity.avatar,
props.userId,
+17
View File
@@ -906,6 +906,23 @@ openclaw-chat-sidebar-region,
overflow: auto;
}
.chat-tasks-rail__transcript {
display: flex;
flex: 1 1 0;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.chat-tasks-rail__transcript > .chat-thread {
padding: 12px 0 6px;
border-radius: 0;
}
.chat-tasks-rail__transcript .chat-thread-inner {
width: calc(100% - 16px);
}
/* List mode splits scrolling per section: running work owns the rail and the
finished history pins to the bottom, so a long history can never clip the
active tasks (each section scrolls on its own instead of the container). */