Skip to content

Commit 4935e24

Browse files
authored
fix: reconcile control ui run status cleanup
Fix stale Control UI active-run cleanup across terminal, reconnect, reset, and session-switch paths. Adds shared run lifecycle cleanup, stale compaction/fallback reconciliation, focused tests, and the compact composer run-status chip. Fixes #76874 and #64220; refs #71630. Validated with green PR CI on head 141f071 and focused local UI tests.
1 parent aac216d commit 4935e24

19 files changed

Lines changed: 829 additions & 33 deletions

CHANGELOG.md

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

1212
### Fixes
1313

14+
- Control UI/chat: reconcile terminal and reconnect run cleanup with cached session activity, stale compaction/fallback indicators, and a compact composer run-status chip so completed or interrupted turns do not leave Stop active. Fixes #76874 and #64220; refs #71630. Thanks @BunsDev.
1415
- iOS/chat: resize PhotosPicker image attachments to capped JPEGs before staging and sending, stripping source metadata and keeping oversized camera photos under the chat upload budget. Fixes #68524. Thanks @BunsDev.
1516
- Codex harness: classify native app-server token-refresh logout and relogin failures as authentication refresh errors, so users get re-authentication guidance instead of a raw runtime failure.
1617
- Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly.

ui/src/styles/chat/layout.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,65 @@
737737
align-self: center;
738738
}
739739

740+
.agent-chat__run-status {
741+
display: inline-flex;
742+
align-items: center;
743+
gap: 5px;
744+
height: 24px;
745+
max-width: 132px;
746+
padding: 0 8px;
747+
border-radius: var(--radius-full);
748+
border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
749+
color: var(--muted);
750+
font-size: 0.72rem;
751+
line-height: 1;
752+
white-space: nowrap;
753+
overflow: hidden;
754+
flex-shrink: 0;
755+
user-select: none;
756+
}
757+
758+
.agent-chat__run-status svg {
759+
width: 13px;
760+
height: 13px;
761+
stroke: currentColor;
762+
fill: none;
763+
stroke-width: 1.6px;
764+
stroke-linecap: round;
765+
stroke-linejoin: round;
766+
flex-shrink: 0;
767+
}
768+
769+
.agent-chat__run-status-label {
770+
overflow: hidden;
771+
text-overflow: ellipsis;
772+
}
773+
774+
.agent-chat__run-status--in-progress {
775+
color: var(--info);
776+
border-color: color-mix(in srgb, var(--info) 32%, transparent);
777+
}
778+
779+
.agent-chat__run-status--in-progress svg {
780+
animation: chat-run-status-spin 1s linear infinite;
781+
}
782+
783+
.agent-chat__run-status--done {
784+
color: var(--ok);
785+
border-color: color-mix(in srgb, var(--ok) 32%, transparent);
786+
}
787+
788+
.agent-chat__run-status--interrupted {
789+
color: var(--warn);
790+
border-color: color-mix(in srgb, var(--warn) 36%, transparent);
791+
}
792+
793+
@keyframes chat-run-status-spin {
794+
to {
795+
transform: rotate(360deg);
796+
}
797+
}
798+
740799
.chat-send-btn {
741800
display: inline-flex;
742801
align-items: center;
@@ -755,6 +814,18 @@
755814
padding: 0;
756815
}
757816

817+
@media (max-width: 560px) {
818+
.agent-chat__run-status {
819+
width: 24px;
820+
padding: 0;
821+
justify-content: center;
822+
}
823+
824+
.agent-chat__run-status-label {
825+
display: none;
826+
}
827+
}
828+
758829
.chat-send-btn svg {
759830
width: 15px;
760831
height: 15px;

ui/src/ui/app-chat.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type ChatInputHistoryKeyResult,
1818
type ChatInputHistoryState,
1919
} from "./chat/input-history.ts";
20+
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
2021
import type { ChatSideResult } from "./chat/side-result.ts";
2122
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
2223
import { parseSlashCommand, refreshSlashCommands } from "./chat/slash-commands.ts";
@@ -731,13 +732,22 @@ async function clearChatHistory(host: ChatHost) {
731732
if (!host.client || !host.connected) {
732733
return;
733734
}
735+
const hadActiveRun = hasAbortableSessionRun(host);
734736
try {
735737
await host.client.request("sessions.reset", { key: host.sessionKey });
736738
host.chatMessages = [];
737739
host.chatSideResult = null;
738-
host.chatSideResultTerminalRuns?.clear();
739-
host.chatStream = null;
740-
host.chatRunId = null;
740+
reconcileChatRunLifecycle(host as unknown as Parameters<typeof reconcileChatRunLifecycle>[0], {
741+
outcome: hadActiveRun ? "interrupted" : undefined,
742+
sessionStatus: "killed",
743+
runId: host.chatRunId,
744+
sessionKey: host.sessionKey,
745+
clearLocalRun: true,
746+
clearChatStream: true,
747+
clearToolStream: true,
748+
clearSideResultTerminalRuns: true,
749+
clearRunStatus: !hadActiveRun,
750+
});
741751
await loadChatHistory(host as unknown as ChatState);
742752
} catch (err) {
743753
host.lastError = String(err);

ui/src/ui/app-gateway.node.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,89 @@ describe("connectGateway", () => {
268268
expect(host.eventLogBuffer[0]?.event).toBe("presence");
269269
});
270270

271+
it("marks orphaned run state interrupted after reconnect hello", () => {
272+
vi.useFakeTimers();
273+
try {
274+
const host = createHost() as TestGatewayHost & {
275+
chatRunStatus?: unknown;
276+
chatStreamStartedAt?: number | null;
277+
compactionStatus?: unknown;
278+
compactionClearTimer?: ReturnType<typeof setTimeout> | null;
279+
fallbackStatus?: unknown;
280+
fallbackClearTimer?: ReturnType<typeof setTimeout> | null;
281+
sessionsResult?: {
282+
ts: number;
283+
path: string;
284+
count: number;
285+
defaults: Record<string, unknown>;
286+
sessions: Array<Record<string, unknown>>;
287+
};
288+
};
289+
host.chatRunId = "run-1";
290+
host.chatStream = "Working...";
291+
host.chatStreamStartedAt = 100;
292+
host.compactionStatus = {
293+
phase: "active",
294+
runId: "run-1",
295+
startedAt: 100,
296+
completedAt: null,
297+
};
298+
host.compactionClearTimer = setTimeout(() => undefined, 1_000);
299+
host.fallbackStatus = {
300+
selected: "openai/gpt-5.5",
301+
active: "anthropic/claude-sonnet-4-6",
302+
attempts: [],
303+
occurredAt: 100,
304+
};
305+
host.fallbackClearTimer = setTimeout(() => undefined, 1_000);
306+
host.toolStreamById.set("tool-1", {} as never);
307+
host.toolStreamOrder = ["tool-1"];
308+
host.chatToolMessages = [{ role: "assistant" }];
309+
host.sessionsResult = {
310+
ts: 0,
311+
path: "",
312+
count: 1,
313+
defaults: {},
314+
sessions: [
315+
{
316+
key: "main",
317+
kind: "direct",
318+
updatedAt: 1,
319+
hasActiveRun: true,
320+
status: "running",
321+
startedAt: 100,
322+
},
323+
],
324+
};
325+
326+
connectGateway(host);
327+
const client = requireGatewayClient();
328+
client.emitHello();
329+
330+
expect(host.chatRunId).toBeNull();
331+
expect(host.chatStream).toBeNull();
332+
expect(host.chatStreamStartedAt).toBeNull();
333+
expect(host.compactionStatus).toBeNull();
334+
expect(host.compactionClearTimer).toBeNull();
335+
expect(host.fallbackStatus).toBeNull();
336+
expect(host.fallbackClearTimer).toBeNull();
337+
expect(host.toolStreamOrder).toStrictEqual([]);
338+
expect(host.chatToolMessages).toStrictEqual([]);
339+
expect(host.chatRunStatus).toMatchObject({
340+
phase: "interrupted",
341+
runId: "run-1",
342+
sessionKey: "main",
343+
});
344+
expect(host.sessionsResult.sessions[0]).toMatchObject({
345+
hasActiveRun: false,
346+
status: "killed",
347+
abortedLastRun: true,
348+
});
349+
} finally {
350+
vi.useRealTimers();
351+
}
352+
});
353+
271354
it("applies update.available only from active client", () => {
272355
const host = createHost();
273356

ui/src/ui/app-gateway.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from "./app-settings.ts";
2121
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts";
2222
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
23+
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
2324
import { parseChatSideResult, type ChatSideResult } from "./chat/side-result.ts";
2425
import { formatConnectError } from "./connect-error.ts";
2526
import { recordControlUiRpcTiming } from "./control-ui-performance.ts";
@@ -551,11 +552,24 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
551552
}
552553
// Reset orphaned chat run state from before disconnect.
553554
// Any in-flight run's final event was lost during the disconnect window.
554-
host.chatRunId = null;
555-
(host as unknown as { chatStream: string | null }).chatStream = null;
556-
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
557-
(host as GatewayHostWithSideResults).chatSideResultTerminalRuns?.clear();
558-
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
555+
const orphanedRunId = host.chatRunId;
556+
const hadOrphanedRun =
557+
Boolean(orphanedRunId) ||
558+
(host as unknown as { chatStream?: string | null }).chatStream != null;
559+
reconcileChatRunLifecycle(
560+
host as unknown as Parameters<typeof reconcileChatRunLifecycle>[0],
561+
{
562+
outcome: hadOrphanedRun ? "interrupted" : undefined,
563+
sessionStatus: "killed",
564+
runId: orphanedRunId,
565+
sessionKey: host.sessionKey,
566+
clearLocalRun: true,
567+
clearChatStream: true,
568+
clearToolStream: true,
569+
clearSideResultTerminalRuns: true,
570+
clearRunStatus: !hadOrphanedRun,
571+
},
572+
);
559573
if (shutdownHost.resumeChatQueueAfterReconnect) {
560574
// The interrupted run will never emit its terminal event now that the
561575
// old client is gone, so resume any deferred commands after hello.

ui/src/ui/app-render.helpers.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "./app-chat.ts";
99
import { syncUrlWithSessionKey } from "./app-settings.ts";
1010
import type { AppViewState } from "./app-view-state.ts";
11+
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
1112
import {
1213
isCronSessionKey,
1314
parseSessionKey,
@@ -145,18 +146,20 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
145146
state.chatStream = null;
146147
state.chatSideResult = null;
147148
state.lastError = null;
148-
state.compactionStatus = null;
149-
state.fallbackStatus = null;
150149
state.chatAvatarUrl = null;
151150
state.chatAvatarSource = null;
152151
state.chatAvatarStatus = null;
153152
state.chatAvatarReason = null;
154153
state.chatQueue = restoreChatQueueForSession(state, sessionKey);
155154
host.resetChatInputHistoryNavigation();
156155
host.chatStreamStartedAt = null;
157-
state.chatRunId = null;
158-
host.chatSideResultTerminalRuns.clear();
159-
host.resetToolStream();
156+
reconcileChatRunLifecycle(state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0], {
157+
clearLocalRun: true,
158+
clearChatStream: true,
159+
clearToolStream: true,
160+
clearSideResultTerminalRuns: true,
161+
clearRunStatus: true,
162+
});
160163
host.resetChatScroll();
161164
state.applySettings({
162165
...state.settings,

ui/src/ui/app-render.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "./app-render.helpers.ts";
2626
import { warnQueryToken } from "./app-settings.ts";
2727
import type { AppViewState } from "./app-view-state.ts";
28+
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
2829
import {
2930
controlUiNowMs,
3031
recordControlUiRenderTiming,
@@ -2494,6 +2495,7 @@ export function renderApp(state: AppViewState) {
24942495
canSend: state.connected,
24952496
disabledReason: chatDisabledReason,
24962497
error: state.lastError,
2498+
runStatus: state.chatRunStatus,
24972499
onDismissError: () => dismissChatError(state),
24982500
sessions: state.sessionsResult,
24992501
focusMode: chatFocus,
@@ -2548,12 +2550,25 @@ export function renderApp(state: AppViewState) {
25482550
if (!state.client || !state.connected) {
25492551
return;
25502552
}
2553+
const hadActiveRun = hasAbortableSessionRun(state);
25512554
try {
25522555
await state.client.request("sessions.reset", { key: state.sessionKey });
25532556
state.chatMessages = [];
25542557
state.chatSideResult = null;
2555-
state.chatStream = null;
2556-
state.chatRunId = null;
2558+
reconcileChatRunLifecycle(
2559+
state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0],
2560+
{
2561+
outcome: hadActiveRun ? "interrupted" : undefined,
2562+
sessionStatus: "killed",
2563+
runId: state.chatRunId,
2564+
sessionKey: state.sessionKey,
2565+
clearLocalRun: true,
2566+
clearChatStream: true,
2567+
clearToolStream: true,
2568+
clearSideResultTerminalRuns: true,
2569+
clearRunStatus: !hadActiveRun,
2570+
},
2571+
);
25572572
await loadChatHistory(state);
25582573
} catch (err) {
25592574
state.lastError = String(err);

ui/src/ui/app-tool-stream.node.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ describe("app-tool-stream fallback lifecycle handling", () => {
307307
startedAt: TOOL_STREAM_TEST_NOW,
308308
completedAt: null,
309309
});
310-
expect(host.compactionClearTimer).toBeNull();
310+
expect(host.compactionClearTimer).not.toBeNull();
311311

312312
handleAgentEvent(host, agentEvent("run-2", 3, "lifecycle", { phase: "end" }));
313313

@@ -325,6 +325,34 @@ describe("app-tool-stream fallback lifecycle handling", () => {
325325
vi.useRealTimers();
326326
});
327327

328+
it("auto-clears active compaction after the stale timeout", () => {
329+
useToolStreamFakeTimers();
330+
const host = createHost();
331+
332+
handleAgentEvent(host, agentEvent("run-1", 1, "compaction", { phase: "start" }));
333+
334+
expect(host.compactionStatus).toEqual({
335+
phase: "active",
336+
runId: "run-1",
337+
startedAt: TOOL_STREAM_TEST_NOW,
338+
completedAt: null,
339+
});
340+
vi.advanceTimersByTime(5 * 60_000 - 1);
341+
expect(host.compactionStatus).toEqual({
342+
phase: "active",
343+
runId: "run-1",
344+
startedAt: TOOL_STREAM_TEST_NOW,
345+
completedAt: null,
346+
});
347+
348+
vi.advanceTimersByTime(1);
349+
350+
expect(host.compactionStatus).toBeNull();
351+
expect(host.compactionClearTimer).toBeNull();
352+
353+
vi.useRealTimers();
354+
});
355+
328356
it("treats lifecycle error as terminal for retry-pending compaction", () => {
329357
useToolStreamFakeTimers();
330358
const host = createHost();

0 commit comments

Comments
 (0)