diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index f5da56b0..9770ea87 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -1262,6 +1262,44 @@ test("hwlab-cli agent composer status detects Web unlocked steer mode", async () assert.equal(result.payload.route.path, "/v1/agent/chat/steer"); }); +test("hwlab-cli agent composer status ignores stale workspace activeTrace after selected conversation completed", async () => { + const result = await runHwlabCli([ + "client", + "agent", + "composer", + "status", + "--base-url", + "http://web.test", + "--cookie", + "hwlab_session=session-a" + ], { + fetchImpl: async () => new Response(JSON.stringify({ + ok: true, + workspace: { + workspaceId: "wsp_composer", + revision: 10, + selectedConversationId: "cnv_completed", + selectedAgentSessionId: "ses_completed", + activeTraceId: "trc_stale_running", + workspace: { + activeTraceId: "trc_stale_running", + threadId: "thread-composer", + sessionStatus: "running", + messages: [{ role: "agent", status: "completed", traceId: "trc_completed", conversationId: "cnv_completed" }] + } + } + }), { status: 200 }) + }); + + assert.equal(result.exitCode, 0); + assert.equal(result.payload.composer.locked, false); + assert.equal(result.payload.composer.disabled, false); + assert.equal(result.payload.composer.submitMode, "turn"); + assert.equal(result.payload.composer.route, "/v1/agent/chat"); + assert.equal(result.payload.composer.targetTraceId, null); + assert.equal(result.payload.composer.reason, "terminal-turn"); +}); + test("hwlab-cli agent composer submit auto routes active Web turn to steer", async () => { const calls: any[] = []; const result = await runHwlabCli([ diff --git a/tools/src/hwlab-cli/composer-policy.ts b/tools/src/hwlab-cli/composer-policy.ts index 25f5a194..8744f011 100644 --- a/tools/src/hwlab-cli/composer-policy.ts +++ b/tools/src/hwlab-cli/composer-policy.ts @@ -8,6 +8,8 @@ export function computeCodeAgentComposerState(input = {}) { const current = input.currentRequest && typeof input.currentRequest === "object" ? input.currentRequest : null; const workspace = input.workspace && typeof input.workspace === "object" ? input.workspace : null; const explicitStatus = normalizedStatus(input.status ?? input.sessionStatus ?? workspace?.sessionStatus); + const latestStatus = normalizedStatus(latest?.status); + const effectiveStatus = latestStatus || explicitStatus; const traceId = firstNonEmptyString( input.targetTraceId, current?.traceId, @@ -15,7 +17,7 @@ export function computeCodeAgentComposerState(input = {}) { workspace?.activeTraceId, latest?.status === "running" ? latest?.traceId : null ); - const active = Boolean(traceId) && ( + const active = Boolean(traceId) && !terminalStatus(effectiveStatus) && ( activeStatus(current?.status) || activeStatus(latest?.status) || activeStatus(explicitStatus) || @@ -51,7 +53,7 @@ export function computeCodeAgentComposerState(input = {}) { threadId: firstNonEmptyString(input.threadId, current?.threadId, latest?.threadId, workspace?.threadId), workspaceId: firstNonEmptyString(input.workspaceId, workspace?.workspaceId), workspaceRevision: integerOrNull(input.workspaceRevision ?? workspace?.revision), - reason: active ? "active-turn-steer" : disabledReason ?? (terminalStatus(explicitStatus) ? "terminal-turn" : "idle-turn") + reason: active ? "active-turn-steer" : disabledReason ?? (terminalStatus(effectiveStatus) ? "terminal-turn" : "idle-turn") }); } diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 0e1ae2c4..f7c7587a 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -139,7 +139,7 @@ export function App(): ReactElement { {route === "help" ? : null} {route === "settings" ? : null} - +
{ + const source = fs.readFileSync(sourcePath, "utf8"); + assert.match(source, /const submitted = props\.onSubmit\(next\);/u); + assert.match(source, /window\.setTimeout\(\(\) => setSubmitting\(false\), 0\);/u); + assert.doesNotMatch(source, /await props\.onSubmit\(next\)/u); + assert.doesNotMatch(source, /\.finally\(\(\) => setSubmitting\(false\)\)/u); +}); diff --git a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx index 1b8becd0..10115f20 100644 --- a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx +++ b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx @@ -5,6 +5,8 @@ import type { ProviderProfile } from "../../types/domain"; interface CommandBarProps { disabled: boolean; + submitMode: "turn" | "steer"; + targetTraceId: string | null; providerProfile: ProviderProfile; codeAgentTimeoutMs: number; gatewayShellTimeoutMs: number; @@ -29,7 +31,7 @@ interface CommandBarProps { export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, onTyping, ...props }: CommandBarProps): ReactElement { const [value, setValue] = useState(""); - const [pending, setPending] = useState(false); + const [submitting, setSubmitting] = useState(false); useEffect(() => { if (pickedDraft) { @@ -41,14 +43,12 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, async function submit(event: FormEvent): Promise { event.preventDefault(); const next = value.trim(); - if (!next || props.disabled || pending) return; - setPending(true); - try { - await props.onSubmit(next); - setValue(""); - } finally { - setPending(false); - } + if (!next || props.disabled || submitting) return; + setValue(""); + setSubmitting(true); + const submitted = props.onSubmit(next); + window.setTimeout(() => setSubmitting(false), 0); + void submitted.catch(() => setSubmitting(false)); } function keyDown(event: KeyboardEvent): void { @@ -58,7 +58,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, } return ( -
+