From 03d774d6d8be3e797bca1f024f27770548ad18d1 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 11:56:09 +0000 Subject: [PATCH] [codex] Fix Control UI terminal run status recovery (#84112) Summary: - Adds shared Control UI session-run active-state handling, applies terminal-status precedence in chat/session rendering and lifecycle recovery, and adds focused regressions plus a changelog entry. - Reproducibility: yes. Current main has a source-visible path where `status: "done"` plus stale `hasActiveRun ... eeps abort/in-progress UI alive, and the linked proof exercises the fixed stale-terminal state in Chromium. Automerge notes: - PR branch already contained follow-up commit before automerge: [codex] Fix Control UI terminal run status recovery Validation: - ClawSweeper review passed for head f9f503add0abb2a07e47224e863cd5f612658aec. - Required merge gates passed before the squash merge. Prepared head SHA: f9f503add0abb2a07e47224e863cd5f612658aec Review: https://github.com/openclaw/openclaw/pull/84112#issuecomment-4487409085 Co-authored-by: NianJiuZst <3235467914@qq.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + ui/src/ui/app-chat.test.ts | 15 ++++++++ ui/src/ui/app-chat.ts | 3 +- ui/src/ui/chat/run-lifecycle.ts | 3 +- ui/src/ui/controllers/sessions.test.ts | 2 +- ui/src/ui/session-run-state.ts | 13 +++++++ ui/src/ui/views/chat.test.ts | 45 +++++++++++++++++++++++ ui/src/ui/views/chat.ts | 13 +++++-- ui/src/ui/views/sessions.test.ts | 51 +++++++++++++++++++++++++- ui/src/ui/views/sessions.ts | 10 +++-- 10 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 ui/src/ui/session-run-state.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df090665335..fe0561359585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057) - CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe. - Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob. - Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering. diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index decd78ab79ac..a7521f066659 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -44,6 +44,7 @@ let handleSendChat: typeof import("./app-chat.ts").handleSendChat; let steerQueuedChatMessage: typeof import("./app-chat.ts").steerQueuedChatMessage; let navigateChatInputHistory: typeof import("./app-chat.ts").navigateChatInputHistory; let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat; +let hasAbortableSessionRun: typeof import("./app-chat.ts").hasAbortableSessionRun; let refreshChat: typeof import("./app-chat.ts").refreshChat; let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar; let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun; @@ -55,6 +56,7 @@ async function loadChatHelpers(): Promise { steerQueuedChatMessage, navigateChatInputHistory, handleAbortChat, + hasAbortableSessionRun, refreshChat, refreshChatAvatar, clearPendingQueueItemsForRun, @@ -1573,6 +1575,19 @@ describe("handleAbortChat", () => { expect(host.chatMessage).toBe(""); }); + it("ignores stale active-run flags once the current session is terminal", () => { + const host = makeHost({ + chatRunId: null, + sessionKey: "agent:main", + sessionsResult: createSessionsResult([ + row("agent:main", { hasActiveRun: true, status: "done" }), + row("agent:other", { hasActiveRun: true, status: "running" }), + ]), + }); + + expect(hasAbortableSessionRun(host)).toBe(false); + }); + it("keeps the draft when disconnected without an active run", async () => { const host = makeHost({ connected: false, diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 769036ab574f..18db6727c88e 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -35,6 +35,7 @@ import { loadSessions, type SessionsState } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; import { parseAgentSessionKey } from "./session-key.ts"; +import { isSessionRunActive } from "./session-run-state.ts"; import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { SessionsListResult } from "./types.ts"; @@ -108,7 +109,7 @@ export function hasAbortableSessionRun(host: { } return Boolean( host.sessionsResult?.sessions.some( - (session) => session.key === host.sessionKey && session.hasActiveRun === true, + (session) => session.key === host.sessionKey && isSessionRunActive(session), ), ); } diff --git a/ui/src/ui/chat/run-lifecycle.ts b/ui/src/ui/chat/run-lifecycle.ts index 9cf35852e6b2..6c1bc4eeaf63 100644 --- a/ui/src/ui/chat/run-lifecycle.ts +++ b/ui/src/ui/chat/run-lifecycle.ts @@ -1,4 +1,5 @@ import { resetToolStream, type CompactionStatus, type FallbackStatus } from "../app-tool-stream.ts"; +import { isSessionRunActive } from "../session-run-state.ts"; import type { SessionRunStatus, SessionsListResult } from "../types.ts"; export const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000; @@ -202,7 +203,7 @@ export function reconcileChatRunFromCurrentSessionRow(host: RunLifecycleHost): b if (!row) { return false; } - if (row.hasActiveRun === true || row.status === "running") { + if (isSessionRunActive(row)) { return false; } const terminalStatus = row.status !== undefined; diff --git a/ui/src/ui/controllers/sessions.test.ts b/ui/src/ui/controllers/sessions.test.ts index 4346d2101698..4213b669376e 100644 --- a/ui/src/ui/controllers/sessions.test.ts +++ b/ui/src/ui/controllers/sessions.test.ts @@ -367,7 +367,7 @@ describe("loadSessions", () => { key: "main", kind: "direct", updatedAt: 2, - hasActiveRun: false, + hasActiveRun: true, status: "done", }, ], diff --git a/ui/src/ui/session-run-state.ts b/ui/src/ui/session-run-state.ts new file mode 100644 index 000000000000..e26ddf7dc500 --- /dev/null +++ b/ui/src/ui/session-run-state.ts @@ -0,0 +1,13 @@ +import type { SessionRunStatus } from "./types.ts"; + +type SessionRunState = { + hasActiveRun?: boolean; + status?: SessionRunStatus; +}; + +export function isSessionRunActive(state: SessionRunState): boolean { + if (state.status) { + return state.status === "running"; + } + return state.hasActiveRun === true; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index e5921a6aafff..c7c0fc17c9d4 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -559,6 +559,51 @@ describe("chat loading skeleton", () => { expect(container.querySelector(".chat-loading-skeleton")).toBeNull(); expect(container.querySelectorAll(".chat-reading-indicator")).toHaveLength(1); }); + + it("lets terminal run status win over stale abortable session UI", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); + try { + const container = renderChatView({ + canAbort: true, + runStatus: { + phase: "done", + runId: "run-1", + sessionKey: "main", + occurredAt: 1_000, + }, + sessions: { + ts: 0, + path: "", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: 200_000 }, + sessions: [ + { + key: "main", + kind: "direct", + updatedAt: null, + hasActiveRun: true, + status: "done", + totalTokens: 190_000, + contextTokens: 200_000, + }, + ], + }, + onCompact: () => undefined, + }); + + expect(container.querySelector(".agent-chat__run-status--done")?.textContent).toContain( + "Done", + ); + expect(container.querySelector(".agent-chat__run-status--in-progress")).toBeNull(); + expect(container.querySelector(".chat-reading-indicator")).toBeNull(); + expect(container.querySelector(".chat-send-btn--stop")).toBeNull(); + expect(container.querySelector(".context-notice__action")?.disabled).toBe( + false, + ); + } finally { + nowSpy.mockRestore(); + } + }); }); describe("chat voice controls", () => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 85d7a8c5d27b..a6764c63ca33 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -71,6 +71,10 @@ const COMPOSER_CHROME_INTERACTIVE_SELECTOR = [ "[role='option']", ].join(","); +function hasTerminalRunStatus(status: ChatRunUiStatus | null | undefined): boolean { + return status?.phase === "done" || status?.phase === "interrupted"; +} + export type ChatProps = { sessionKey: string; onSessionKeyChange: (next: string) => void; @@ -979,7 +983,8 @@ export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; const canAbort = Boolean(props.canAbort && props.onAbort); - const composerRunStatus = canAbort ? { phase: "in-progress" as const } : props.runStatus; + const showAbortableUi = canAbort && !hasTerminalRunStatus(props.runStatus); + const composerRunStatus = showAbortableUi ? { phase: "in-progress" as const } : props.runStatus; const compactBusy = props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying"; const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey); @@ -1402,7 +1407,7 @@ export function renderChat(props: ChatProps) { ${renderChatQueue({ queue: props.queue, - canAbort: props.canAbort, + canAbort: showAbortableUi, onQueueSteer: props.onQueueSteer, onQueueRemove: props.onQueueRemove, })} @@ -1411,7 +1416,7 @@ export function renderChat(props: ChatProps) { ${renderCompactionIndicator(props.compactionStatus)} ${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, { compactBusy, - compactDisabled: !props.connected || isBusy || Boolean(props.canAbort), + compactDisabled: !props.connected || isBusy || showAbortableUi, onCompact: props.onCompact, })} ${props.showNewMessages @@ -1533,7 +1538,7 @@ export function renderChat(props: ChatProps) { ${renderChatRunControls({ - canAbort, + canAbort: showAbortableUi, connected: props.connected, draft: props.draft, hasMessages: props.messages.length > 0, diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index a91645a1f135..b74e593aa7a4 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -475,6 +475,13 @@ describe("sessions view", () => { updatedAt: 10, status: "failed", }, + { + key: "agent:main:done", + kind: "direct", + updatedAt: 5, + hasActiveRun: true, + status: "done", + }, ]), ), ), @@ -484,16 +491,23 @@ describe("sessions view", () => { expect(sessionTableHeaders(container)).toEqual(SESSION_TABLE_HEADERS); const badges = Array.from(container.querySelectorAll(".session-status-badge")); - expect(badges.map((badge) => badge.textContent?.trim())).toEqual(["Live", "Idle", "Failed"]); + expect(badges.map((badge) => badge.textContent?.trim())).toEqual([ + "Live", + "Idle", + "Failed", + "Done", + ]); expect(badges.map((badge) => [...badge.classList])).toEqual([ ["session-status-badge", "session-status-badge--live"], ["session-status-badge", "session-status-badge--idle"], ["session-status-badge", "session-status-badge--failed"], + ["session-status-badge", "session-status-badge--done"], ]); expect(badges.map((badge) => badge.getAttribute("aria-label"))).toEqual([ "Status: Live", "Status: Idle", "Status: Failed", + "Status: Done", ]); }); @@ -534,6 +548,41 @@ describe("sessions view", () => { ); }); + it("does not filter terminal sessions as live when active-run flags are stale", async () => { + const container = document.createElement("div"); + render( + renderSessions({ + ...buildProps( + buildMultiResult([ + { + key: "agent:main:done", + kind: "direct", + updatedAt: 20, + hasActiveRun: true, + status: "done", + }, + { + key: "agent:main:running", + kind: "direct", + updatedAt: 10, + hasActiveRun: true, + status: "running", + }, + ]), + ), + searchQuery: "live", + }), + container, + ); + await Promise.resolve(); + + const rows = container.querySelectorAll("tbody tr.session-data-row"); + expect(rows).toHaveLength(1); + expect(rows[0]?.querySelector(".session-key-cell")?.textContent?.trim()).toBe( + "agent:main:running", + ); + }); + it("keeps raw keys for inherited identity object properties", async () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 4532cf5d2766..3298d75c161b 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -4,6 +4,7 @@ import { formatRelativeTimestamp, parseSessionKeyParts } from "../format.ts"; import { icons } from "../icons.ts"; import { pathForTab } from "../navigation.ts"; import { formatSessionTokens } from "../presenter.ts"; +import { isSessionRunActive } from "../session-run-state.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; import { formatInheritedThinkingLabel, @@ -196,7 +197,7 @@ function resolveSessionStatusBadge(row: GatewaySessionRow): { label: string; tone: "live" | "idle" | "done" | "failed" | "muted"; } { - if (row.hasActiveRun === true || row.status === "running") { + if (isSessionRunActive(row)) { return { label: t("sessionsView.statusLive"), tone: "live" }; } if (row.status) { @@ -247,8 +248,11 @@ function filterRows( const displayName = normalizeLowercaseStringOrEmpty(row.displayName); const runtime = normalizeLowercaseStringOrEmpty(resolveAgentRuntimeLabel(row.agentRuntime)); const status = normalizeLowercaseStringOrEmpty(row.status); - const liveState = - row.hasActiveRun === true ? "live running" : row.hasActiveRun === false ? "idle" : ""; + const liveState = isSessionRunActive(row) + ? "live running" + : row.hasActiveRun === false + ? "idle" + : ""; if ( key.includes(q) || label.includes(q) ||