mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
[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 headf9f503add0. - Required merge gates passed before the squash merge. Prepared head SHA:f9f503add0Review: 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>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -367,7 +367,7 @@ describe("loadSessions", () => {
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: 2,
|
||||
hasActiveRun: false,
|
||||
hasActiveRun: true,
|
||||
status: "done",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(".context-notice__action")?.disabled).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat voice controls", () => {
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
|
||||
${renderChatRunControls({
|
||||
canAbort,
|
||||
canAbort: showAbortableUi,
|
||||
connected: props.connected,
|
||||
draft: props.draft,
|
||||
hasMessages: props.messages.length > 0,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
Reference in New Issue
Block a user