fix control ui terminal run status recovery

This commit is contained in:
NianJiuZst
2026-05-19 16:45:42 +08:00
committed by clawsweeper
parent 4e60ad7212
commit 293cfb44fb
9 changed files with 104 additions and 9 deletions
+15
View File
@@ -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,
+2 -1
View File
@@ -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),
),
);
}
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -367,7 +367,7 @@ describe("loadSessions", () => {
key: "main",
kind: "direct",
updatedAt: 2,
hasActiveRun: false,
hasActiveRun: true,
status: "done",
},
],
+13
View File
@@ -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;
}
+45
View File
@@ -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", () => {
+9 -4
View File
@@ -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,
+15 -1
View File
@@ -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",
]);
});
+2 -1
View File
@@ -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) {