mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
d9080cfff8
commit
c5d00cb47d
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -708,7 +708,7 @@ describe("chat pane catalog session lifecycle", () => {
|
||||
render(
|
||||
pane.renderPaneHeader(
|
||||
createSessionWorkspaceProps(state),
|
||||
createBackgroundTasksProps(state, { onOpenSession: () => {} }),
|
||||
createBackgroundTasksProps(state),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user