Skip to content

Commit 03d774d

Browse files
clawsweeper[bot]NianJiuZstTakhoffman
authored
[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 f9f503a. - Required merge gates passed before the squash merge. Prepared head SHA: f9f503a Review: #84112 (comment) 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>
1 parent 4e60ad7 commit 03d774d

10 files changed

Lines changed: 145 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010

1111
### Fixes
1212

13+
- Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)
1314
- CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.
1415
- 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.
1516
- 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.

ui/src/ui/app-chat.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ let handleSendChat: typeof import("./app-chat.ts").handleSendChat;
4444
let steerQueuedChatMessage: typeof import("./app-chat.ts").steerQueuedChatMessage;
4545
let navigateChatInputHistory: typeof import("./app-chat.ts").navigateChatInputHistory;
4646
let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat;
47+
let hasAbortableSessionRun: typeof import("./app-chat.ts").hasAbortableSessionRun;
4748
let refreshChat: typeof import("./app-chat.ts").refreshChat;
4849
let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar;
4950
let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun;
@@ -55,6 +56,7 @@ async function loadChatHelpers(): Promise<void> {
5556
steerQueuedChatMessage,
5657
navigateChatInputHistory,
5758
handleAbortChat,
59+
hasAbortableSessionRun,
5860
refreshChat,
5961
refreshChatAvatar,
6062
clearPendingQueueItemsForRun,
@@ -1573,6 +1575,19 @@ describe("handleAbortChat", () => {
15731575
expect(host.chatMessage).toBe("");
15741576
});
15751577

1578+
it("ignores stale active-run flags once the current session is terminal", () => {
1579+
const host = makeHost({
1580+
chatRunId: null,
1581+
sessionKey: "agent:main",
1582+
sessionsResult: createSessionsResult([
1583+
row("agent:main", { hasActiveRun: true, status: "done" }),
1584+
row("agent:other", { hasActiveRun: true, status: "running" }),
1585+
]),
1586+
});
1587+
1588+
expect(hasAbortableSessionRun(host)).toBe(false);
1589+
});
1590+
15761591
it("keeps the draft when disconnected without an active run", async () => {
15771592
const host = makeHost({
15781593
connected: false,

ui/src/ui/app-chat.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { loadSessions, type SessionsState } from "./controllers/sessions.ts";
3535
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
3636
import { normalizeBasePath } from "./navigation.ts";
3737
import { parseAgentSessionKey } from "./session-key.ts";
38+
import { isSessionRunActive } from "./session-run-state.ts";
3839
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts";
3940
import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts";
4041
import type { SessionsListResult } from "./types.ts";
@@ -108,7 +109,7 @@ export function hasAbortableSessionRun(host: {
108109
}
109110
return Boolean(
110111
host.sessionsResult?.sessions.some(
111-
(session) => session.key === host.sessionKey && session.hasActiveRun === true,
112+
(session) => session.key === host.sessionKey && isSessionRunActive(session),
112113
),
113114
);
114115
}

ui/src/ui/chat/run-lifecycle.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { resetToolStream, type CompactionStatus, type FallbackStatus } from "../app-tool-stream.ts";
2+
import { isSessionRunActive } from "../session-run-state.ts";
23
import type { SessionRunStatus, SessionsListResult } from "../types.ts";
34

45
export const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000;
@@ -202,7 +203,7 @@ export function reconcileChatRunFromCurrentSessionRow(host: RunLifecycleHost): b
202203
if (!row) {
203204
return false;
204205
}
205-
if (row.hasActiveRun === true || row.status === "running") {
206+
if (isSessionRunActive(row)) {
206207
return false;
207208
}
208209
const terminalStatus = row.status !== undefined;

ui/src/ui/controllers/sessions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ describe("loadSessions", () => {
367367
key: "main",
368368
kind: "direct",
369369
updatedAt: 2,
370-
hasActiveRun: false,
370+
hasActiveRun: true,
371371
status: "done",
372372
},
373373
],

ui/src/ui/session-run-state.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { SessionRunStatus } from "./types.ts";
2+
3+
type SessionRunState = {
4+
hasActiveRun?: boolean;
5+
status?: SessionRunStatus;
6+
};
7+
8+
export function isSessionRunActive(state: SessionRunState): boolean {
9+
if (state.status) {
10+
return state.status === "running";
11+
}
12+
return state.hasActiveRun === true;
13+
}

ui/src/ui/views/chat.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,51 @@ describe("chat loading skeleton", () => {
559559
expect(container.querySelector(".chat-loading-skeleton")).toBeNull();
560560
expect(container.querySelectorAll(".chat-reading-indicator")).toHaveLength(1);
561561
});
562+
563+
it("lets terminal run status win over stale abortable session UI", () => {
564+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
565+
try {
566+
const container = renderChatView({
567+
canAbort: true,
568+
runStatus: {
569+
phase: "done",
570+
runId: "run-1",
571+
sessionKey: "main",
572+
occurredAt: 1_000,
573+
},
574+
sessions: {
575+
ts: 0,
576+
path: "",
577+
count: 1,
578+
defaults: { modelProvider: null, model: null, contextTokens: 200_000 },
579+
sessions: [
580+
{
581+
key: "main",
582+
kind: "direct",
583+
updatedAt: null,
584+
hasActiveRun: true,
585+
status: "done",
586+
totalTokens: 190_000,
587+
contextTokens: 200_000,
588+
},
589+
],
590+
},
591+
onCompact: () => undefined,
592+
});
593+
594+
expect(container.querySelector(".agent-chat__run-status--done")?.textContent).toContain(
595+
"Done",
596+
);
597+
expect(container.querySelector(".agent-chat__run-status--in-progress")).toBeNull();
598+
expect(container.querySelector(".chat-reading-indicator")).toBeNull();
599+
expect(container.querySelector(".chat-send-btn--stop")).toBeNull();
600+
expect(container.querySelector<HTMLButtonElement>(".context-notice__action")?.disabled).toBe(
601+
false,
602+
);
603+
} finally {
604+
nowSpy.mockRestore();
605+
}
606+
});
562607
});
563608

564609
describe("chat voice controls", () => {

ui/src/ui/views/chat.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ const COMPOSER_CHROME_INTERACTIVE_SELECTOR = [
7171
"[role='option']",
7272
].join(",");
7373

74+
function hasTerminalRunStatus(status: ChatRunUiStatus | null | undefined): boolean {
75+
return status?.phase === "done" || status?.phase === "interrupted";
76+
}
77+
7478
export type ChatProps = {
7579
sessionKey: string;
7680
onSessionKeyChange: (next: string) => void;
@@ -979,7 +983,8 @@ export function renderChat(props: ChatProps) {
979983
const canCompose = props.connected;
980984
const isBusy = props.sending || props.stream !== null;
981985
const canAbort = Boolean(props.canAbort && props.onAbort);
982-
const composerRunStatus = canAbort ? { phase: "in-progress" as const } : props.runStatus;
986+
const showAbortableUi = canAbort && !hasTerminalRunStatus(props.runStatus);
987+
const composerRunStatus = showAbortableUi ? { phase: "in-progress" as const } : props.runStatus;
983988
const compactBusy =
984989
props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying";
985990
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
@@ -1402,7 +1407,7 @@ export function renderChat(props: ChatProps) {
14021407
14031408
${renderChatQueue({
14041409
queue: props.queue,
1405-
canAbort: props.canAbort,
1410+
canAbort: showAbortableUi,
14061411
onQueueSteer: props.onQueueSteer,
14071412
onQueueRemove: props.onQueueRemove,
14081413
})}
@@ -1411,7 +1416,7 @@ export function renderChat(props: ChatProps) {
14111416
${renderCompactionIndicator(props.compactionStatus)}
14121417
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, {
14131418
compactBusy,
1414-
compactDisabled: !props.connected || isBusy || Boolean(props.canAbort),
1419+
compactDisabled: !props.connected || isBusy || showAbortableUi,
14151420
onCompact: props.onCompact,
14161421
})}
14171422
${props.showNewMessages
@@ -1533,7 +1538,7 @@ export function renderChat(props: ChatProps) {
15331538
</div>
15341539
15351540
${renderChatRunControls({
1536-
canAbort,
1541+
canAbort: showAbortableUi,
15371542
connected: props.connected,
15381543
draft: props.draft,
15391544
hasMessages: props.messages.length > 0,

ui/src/ui/views/sessions.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,13 @@ describe("sessions view", () => {
475475
updatedAt: 10,
476476
status: "failed",
477477
},
478+
{
479+
key: "agent:main:done",
480+
kind: "direct",
481+
updatedAt: 5,
482+
hasActiveRun: true,
483+
status: "done",
484+
},
478485
]),
479486
),
480487
),
@@ -484,16 +491,23 @@ describe("sessions view", () => {
484491

485492
expect(sessionTableHeaders(container)).toEqual(SESSION_TABLE_HEADERS);
486493
const badges = Array.from(container.querySelectorAll(".session-status-badge"));
487-
expect(badges.map((badge) => badge.textContent?.trim())).toEqual(["Live", "Idle", "Failed"]);
494+
expect(badges.map((badge) => badge.textContent?.trim())).toEqual([
495+
"Live",
496+
"Idle",
497+
"Failed",
498+
"Done",
499+
]);
488500
expect(badges.map((badge) => [...badge.classList])).toEqual([
489501
["session-status-badge", "session-status-badge--live"],
490502
["session-status-badge", "session-status-badge--idle"],
491503
["session-status-badge", "session-status-badge--failed"],
504+
["session-status-badge", "session-status-badge--done"],
492505
]);
493506
expect(badges.map((badge) => badge.getAttribute("aria-label"))).toEqual([
494507
"Status: Live",
495508
"Status: Idle",
496509
"Status: Failed",
510+
"Status: Done",
497511
]);
498512
});
499513

@@ -534,6 +548,41 @@ describe("sessions view", () => {
534548
);
535549
});
536550

551+
it("does not filter terminal sessions as live when active-run flags are stale", async () => {
552+
const container = document.createElement("div");
553+
render(
554+
renderSessions({
555+
...buildProps(
556+
buildMultiResult([
557+
{
558+
key: "agent:main:done",
559+
kind: "direct",
560+
updatedAt: 20,
561+
hasActiveRun: true,
562+
status: "done",
563+
},
564+
{
565+
key: "agent:main:running",
566+
kind: "direct",
567+
updatedAt: 10,
568+
hasActiveRun: true,
569+
status: "running",
570+
},
571+
]),
572+
),
573+
searchQuery: "live",
574+
}),
575+
container,
576+
);
577+
await Promise.resolve();
578+
579+
const rows = container.querySelectorAll("tbody tr.session-data-row");
580+
expect(rows).toHaveLength(1);
581+
expect(rows[0]?.querySelector(".session-key-cell")?.textContent?.trim()).toBe(
582+
"agent:main:running",
583+
);
584+
});
585+
537586
it("keeps raw keys for inherited identity object properties", async () => {
538587
const container = document.createElement("div");
539588
render(

ui/src/ui/views/sessions.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { formatRelativeTimestamp, parseSessionKeyParts } from "../format.ts";
44
import { icons } from "../icons.ts";
55
import { pathForTab } from "../navigation.ts";
66
import { formatSessionTokens } from "../presenter.ts";
7+
import { isSessionRunActive } from "../session-run-state.ts";
78
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
89
import {
910
formatInheritedThinkingLabel,
@@ -196,7 +197,7 @@ function resolveSessionStatusBadge(row: GatewaySessionRow): {
196197
label: string;
197198
tone: "live" | "idle" | "done" | "failed" | "muted";
198199
} {
199-
if (row.hasActiveRun === true || row.status === "running") {
200+
if (isSessionRunActive(row)) {
200201
return { label: t("sessionsView.statusLive"), tone: "live" };
201202
}
202203
if (row.status) {
@@ -247,8 +248,11 @@ function filterRows(
247248
const displayName = normalizeLowercaseStringOrEmpty(row.displayName);
248249
const runtime = normalizeLowercaseStringOrEmpty(resolveAgentRuntimeLabel(row.agentRuntime));
249250
const status = normalizeLowercaseStringOrEmpty(row.status);
250-
const liveState =
251-
row.hasActiveRun === true ? "live running" : row.hasActiveRun === false ? "idle" : "";
251+
const liveState = isSessionRunActive(row)
252+
? "live running"
253+
: row.hasActiveRun === false
254+
? "idle"
255+
: "";
252256
if (
253257
key.includes(q) ||
254258
label.includes(q) ||

0 commit comments

Comments
 (0)