The React migration (#756) and the 7-round #775 patch left the `fetchJson` inactivity-timeout surface half-wired: `client.ts` and the `api.*` helpers accepted an `ActivityRef`, but `state/workbench.ts` `submitMessage` and `state/runner-trace.ts` `waitForAgentResult` / `pollRunnerTrace` never built or forwarded one. The Web UI was therefore falling back to a **total** timeout: a long-running Code Agent operation (e.g. `bootsharp` that finished in ~9.7s but the assistant message chain took ~37s) was killed at `codeAgentTimeoutMs` even while trace events kept flowing, producing "Code Agent 在超时内未返回可显示正文". User-facing symptom in #795 is exactly this: the 4th turn on `D601-F103-V2 bootshar[p]` timed out in the Web UI while the backend completed the work and emitted `agentrun:result:completed`. Changes - `state/workbench.ts` adds a `useRef<number>`-backed `lastActivityAtRef`, `updateActivity`, `buildActivityRef` and exposes `recordActivity` on the store. `submitMessage` now bumps activity on the initial submit, on every trace snapshot, and threads a single `submitActivityRef` through `api.sendAgentMessage` / `api.steerAgentMessage`, `pollRunnerTrace`, and `waitForAgentResult`. - `state/runner-trace.ts` `waitForAgentResult` and `pollRunnerTrace` accept an `ActivityRefSource`, pass it to `api.getAgentChatResult` so each per-poll `fetchJson` uses the inactivity-timeout branch, and `pollRunnerTrace` mutates the ref's `lastActivityAt` / `lastEventLabel` / `waitingFor` whenever a fresh snapshot arrives. The outer `while`-loop hard ceiling becomes `max(totalTimeoutMs * 4, totalTimeoutMs + 60s)` as a safety net for runaway operations; the per-request inactivity-timeout (driven by `activityRef`) is the primary abort signal, matching the pre-React `app-device-pod.ts:fetchJson` contract. - `components/command-bar/CommandBar.tsx` adds an `onTyping` prop and fires it from the textarea `onChange` so user-typing resets the inactivity clock. - `App.tsx` adds a stable `noteActivity` (`useCallback`) wired to `store.recordActivity` and passes it as `onTyping` to `<CommandBar>`. Test - `scripts/fetchJson-inactivity.test.ts` adds three cases: 1. `fetchJson` without `activityRef` honours the total window. 2. `fetchJson` with 100ms-cadence activity survives the configured window and aborts ~1-2s after activity stops with the "无新活动" error (the HWLAB #795 regression assertion). 3. `fetchJson` with 1.5s-cadence activity (the actual `pollRunnerTrace` interval) survives past the 5s window and only aborts after activity stops at 6.5s. Verification - `bun run scripts/tsc-check.ts` passes (strict React TSX, 0 explicit any) - `bun run check` 5 pass / 0 fail (3 new + 2 dist-contract) - `bun run build` 9 dist files verified fresh; `app.js` 234676 → 238740 B - orphan-listener check (per HWLAB #748): no `el.X.addEventListener` in `*.tsx` whose X is not in the `el` literal. Refs - HWLAB #795 (this issue) - HWLAB #775 round 1 #777 added the `fetchJson` `activityRef` plumbing in `client.ts` but stopped short of the workbench / runner-trace layer - HWLAB #756 React migration baseline Co-authored-by: Codex <codex@local>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ActivityRef } from "../src/services/api/client.ts";
|
||||
|
||||
const win = globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } };
|
||||
if (!win.window) {
|
||||
win.window = {
|
||||
setTimeout: (handler, delay) => setTimeout(handler, delay),
|
||||
clearTimeout: (timer) => clearTimeout(timer as unknown as ReturnType<typeof setTimeout>)
|
||||
};
|
||||
}
|
||||
|
||||
function bindSignalFetch(): typeof fetch {
|
||||
return (async (_input, init) => {
|
||||
return await new Promise<Response>((_, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal) {
|
||||
if (signal.aborted) reject(new DOMException("aborted", "AbortError"));
|
||||
else signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
|
||||
}
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function buildMutableActivityRef(initial: number): { ref: ActivityRef; update: (at: number) => void } {
|
||||
const ref: ActivityRef = {
|
||||
lastActivityAt: initial,
|
||||
lastActivityIso: new Date(initial).toISOString(),
|
||||
waitingFor: "test",
|
||||
lastEventLabel: null
|
||||
};
|
||||
return {
|
||||
ref,
|
||||
update: (at: number) => {
|
||||
ref.lastActivityAt = at;
|
||||
ref.lastActivityIso = new Date(at).toISOString();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test("fetchJson 总超时:未传 activityRef 时 timeoutMs 走总窗口", { timeout: 5000 }, async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = bindSignalFetch();
|
||||
try {
|
||||
const { fetchJson } = await import("../src/services/api/client.ts");
|
||||
const startedAt = Date.now();
|
||||
const result = await fetchJson("/v1/agent/chat", { timeoutMs: 200, timeoutName: "test-total" });
|
||||
const elapsed = Date.now() - startedAt;
|
||||
assert.equal(result.ok, false, "fetch should fail when no activity is supplied");
|
||||
assert.match(result.error ?? "", /超时/u, `expected timeout error, got: ${result.error}`);
|
||||
assert.ok(elapsed < 1500, `fetchJson should abort near 200ms but took ${elapsed}ms`);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("fetchJson inactivity:activityRef 每 100ms 推一次活动,stop 后 ~2s abort", { timeout: 15000 }, async () => {
|
||||
const { ref, update } = buildMutableActivityRef(Date.now());
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = bindSignalFetch();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
update(Date.now());
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
const { fetchJson } = await import("../src/services/api/client.ts");
|
||||
const startedAt = Date.now();
|
||||
const promise = fetchJson("/v1/agent/chat", { timeoutMs: 2000, timeoutName: "test-inactivity", activityRef: () => ref });
|
||||
setTimeout(() => clearInterval(interval), 1800);
|
||||
const result = await promise;
|
||||
const elapsed = Date.now() - startedAt;
|
||||
assert.equal(result.ok, false, "fetch should fail once activity stops");
|
||||
assert.match(result.error ?? "", /无新活动/u, `expected inactivity-timeout error, got: ${result.error}`);
|
||||
assert.ok(elapsed >= 1800, `should not abort before 1800ms with continuous activity, got ${elapsed}ms`);
|
||||
assert.ok(elapsed < 6000, `should abort ~1-2s after activity stops, got ${elapsed}ms`);
|
||||
} finally {
|
||||
clearInterval(interval);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("fetchJson inactivity:trace poll 风格的 1.5s 间隔在 6.5s 内不会触发总超时", { timeout: 20000 }, async () => {
|
||||
// HWLAB #795 regression: the runner-trace poll loop used a total timeout
|
||||
// equal to the user-configured codeAgentTimeoutMs, so an operation that
|
||||
// kept emitting events every 1.5s would still be killed by the outer
|
||||
// total cap. With activityRef, the inactivity window resets on every
|
||||
// snapshot so the request only aborts after timeoutMs of NO activity.
|
||||
const { ref, update } = buildMutableActivityRef(Date.now());
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = bindSignalFetch();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
update(Date.now());
|
||||
}, 1500);
|
||||
|
||||
try {
|
||||
const { fetchJson } = await import("../src/services/api/client.ts");
|
||||
const startedAt = Date.now();
|
||||
const promise = fetchJson("/v1/agent/chat/result/abc", { timeoutMs: 5000, timeoutName: "trace-poll", activityRef: () => ref });
|
||||
setTimeout(() => clearInterval(interval), 6500);
|
||||
const result = await promise;
|
||||
const elapsed = Date.now() - startedAt;
|
||||
assert.equal(result.ok, false, "fetch should fail once activity stops");
|
||||
assert.ok(elapsed >= 6000, `continuous 1.5s activity should keep the request alive past the per-poll inactivity window; got ${elapsed}ms`);
|
||||
assert.match(result.error ?? "", /无新活动/u, `expected inactivity-timeout error, got: ${result.error}`);
|
||||
} finally {
|
||||
clearInterval(interval);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useAuth } from "./hooks/useAuth";
|
||||
import { useSidebarResize } from "./hooks/useSidebarResize";
|
||||
@@ -70,6 +71,16 @@ export function App(): ReactElement {
|
||||
setPickedDraft(null);
|
||||
}
|
||||
|
||||
// HWLAB #795: wire the command bar's onTyping to the workbench activity
|
||||
// clock so the Code Agent inactivity-timeout (driven by activityRef in
|
||||
// state/workbench.ts) does not falsely fire while the user is reading the
|
||||
// trace / composing a follow-up. The bar only emits onTyping on actual
|
||||
// input changes; we keep this stable via useCallback so the CommandBar
|
||||
// does not re-render on every keystroke.
|
||||
const noteActivity = useCallback(() => {
|
||||
store.recordActivity();
|
||||
}, [store]);
|
||||
|
||||
async function submitWithDraftRecord(value: string): Promise<void> {
|
||||
recordDraft(value);
|
||||
await store.submitMessage(value);
|
||||
@@ -128,7 +139,7 @@ export function App(): ReactElement {
|
||||
{route === "help" ? <HelpView help={help} /> : null}
|
||||
{route === "settings" ? <SettingsView onLogout={auth.logout} /> : null}
|
||||
<DraftsList onPick={pickDraft} />
|
||||
<CommandBar disabled={store.composer.disabled} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} />
|
||||
<CommandBar disabled={store.composer.disabled} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} onTyping={noteActivity} />
|
||||
</section>
|
||||
<div
|
||||
className="resize-handle right-sidebar-resize"
|
||||
|
||||
@@ -16,9 +16,18 @@ interface CommandBarProps {
|
||||
pickedDraft: string | null;
|
||||
onPickedDraftConsumed(): void;
|
||||
disabledReason: string | null;
|
||||
/**
|
||||
* Notify the workbench that the user is actively typing. Wired by App.tsx
|
||||
* to `store.recordActivity` so the Code Agent inactivity-timeout (driven
|
||||
* by the activityRef plumbed in workbench.ts) treats a user who is reading
|
||||
* or composing a follow-up as "active". This avoids HWLAB #795 style
|
||||
* false-positive timeouts where the user is at the keyboard but the
|
||||
* backend has not yet emitted the next trace event.
|
||||
*/
|
||||
onTyping(): void;
|
||||
}
|
||||
|
||||
export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, ...props }: CommandBarProps): ReactElement {
|
||||
export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, onTyping, ...props }: CommandBarProps): ReactElement {
|
||||
const [value, setValue] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
@@ -82,7 +91,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
|
||||
</label>
|
||||
<div className="input-shell">
|
||||
<span className="prompt-mark">hwlab</span>
|
||||
<textarea id="command-input" value={value} placeholder="输入任务,Enter 发送,Shift+Enter 换行" rows={1} onChange={(event) => setValue(event.currentTarget.value)} onKeyDown={keyDown} />
|
||||
<textarea id="command-input" value={value} placeholder="输入任务,Enter 发送,Shift+Enter 换行" rows={1} onChange={(event) => { setValue(event.currentTarget.value); onTyping(); }} onKeyDown={keyDown} />
|
||||
</div>
|
||||
<button className="command-button" id="command-send" type="submit" disabled={props.disabled || pending}>{pending ? "发送中" : "发送"}</button>
|
||||
<button className="command-button secondary" id="command-clear" type="button" onClick={() => { setValue(""); props.onClear(); }}>清空</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
TraceEvent
|
||||
} from "../types/domain";
|
||||
import { api } from "../services/api/client";
|
||||
import type { ActivityRef, ActivityRefSource } from "../services/api/client";
|
||||
|
||||
export interface TraceSnapshot {
|
||||
traceId?: string;
|
||||
@@ -89,18 +90,30 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
|
||||
} as AgentChatResultResponse;
|
||||
}
|
||||
|
||||
export async function waitForAgentResult(initial: AgentChatResponse, totalTimeoutMs: number): Promise<AgentChatResultResponse | null> {
|
||||
export async function waitForAgentResult(
|
||||
initial: AgentChatResponse,
|
||||
totalTimeoutMs: number,
|
||||
activityRef?: ActivityRefSource
|
||||
): Promise<AgentChatResultResponse | null> {
|
||||
if (!isResultUrlStatus(initial) || !initial.resultUrl) {
|
||||
return isTerminalStatus(initial.status) ? initial : null;
|
||||
}
|
||||
if (isTerminalStatus(initial.status)) return initial;
|
||||
const startedAt = Date.now();
|
||||
// Hard ceiling is a safety net for runaway operations; the per-poll
|
||||
// inactivity-timeout in fetchJson (driven by activityRef) is the primary
|
||||
// abort signal so active sessions are not falsely killed.
|
||||
const hardCapMs = Math.max(totalTimeoutMs * 4, totalTimeoutMs + 60_000);
|
||||
let attempt = 0;
|
||||
while (Date.now() - startedAt < totalTimeoutMs) {
|
||||
while (Date.now() - startedAt < hardCapMs) {
|
||||
attempt += 1;
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, TRACE_POLL_INTERVAL_MS));
|
||||
if (Date.now() - startedAt >= totalTimeoutMs) break;
|
||||
const polled = await api.getAgentChatResult(initial.resultUrl, Math.min(8000, Math.max(4000, totalTimeoutMs - (Date.now() - startedAt))));
|
||||
if (Date.now() - startedAt >= hardCapMs) break;
|
||||
const polled = await api.getAgentChatResult(
|
||||
initial.resultUrl,
|
||||
Math.min(8000, Math.max(4000, totalTimeoutMs - (Date.now() - startedAt))),
|
||||
activityRef ?? null
|
||||
);
|
||||
if (polled.ok && polled.data) {
|
||||
if (isTerminalStatus(polled.data.status)) return polled.data;
|
||||
if (attempt > TRACE_HARD_CAP_ATTEMPTS) return null;
|
||||
@@ -113,17 +126,28 @@ export async function waitForAgentResult(initial: AgentChatResponse, totalTimeou
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function pollRunnerTrace(resultUrl: string, traceId: string, onSnapshot: (snapshot: TraceSnapshot) => void, totalTimeoutMs: number): Promise<TraceSnapshot | null> {
|
||||
export async function pollRunnerTrace(
|
||||
resultUrl: string,
|
||||
traceId: string,
|
||||
onSnapshot: (snapshot: TraceSnapshot) => void,
|
||||
totalTimeoutMs: number,
|
||||
activityRef?: ActivityRefSource
|
||||
): Promise<TraceSnapshot | null> {
|
||||
const startedAt = Date.now();
|
||||
const hardCapMs = Math.max(totalTimeoutMs * 4, totalTimeoutMs + 60_000);
|
||||
const traceUrl = traceUrlFromResultUrl(resultUrl, traceId);
|
||||
let lastEventCount = -1;
|
||||
let lastStatus: string | null = null;
|
||||
let lastUpdatedAt: string | null = null;
|
||||
while (Date.now() - startedAt < totalTimeoutMs) {
|
||||
while (Date.now() - startedAt < hardCapMs) {
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, TRACE_POLL_INTERVAL_MS));
|
||||
if (Date.now() - startedAt >= totalTimeoutMs) break;
|
||||
if (Date.now() - startedAt >= hardCapMs) break;
|
||||
const remaining = totalTimeoutMs - (Date.now() - startedAt);
|
||||
const polled = await api.getAgentChatResult(traceUrl, Math.min(8000, Math.max(3000, remaining)));
|
||||
const polled = await api.getAgentChatResult(
|
||||
traceUrl,
|
||||
Math.min(8000, Math.max(3000, remaining)),
|
||||
activityRef ?? null
|
||||
);
|
||||
if (!polled.ok || !polled.data) continue;
|
||||
const events = Array.isArray(polled.data.events) ? polled.data.events : [];
|
||||
const status = String(polled.data.status ?? "");
|
||||
@@ -134,6 +158,17 @@ export async function pollRunnerTrace(resultUrl: string, traceId: string, onSnap
|
||||
lastStatus = status;
|
||||
lastUpdatedAt = updatedAt;
|
||||
const lastEvent = events[events.length - 1];
|
||||
// Note activity whenever a fresh snapshot arrives so the inactivity clock
|
||||
// resets; this is what makes the user-configured codeAgentTimeoutMs an
|
||||
// inactivity budget instead of a total budget (HWLAB #795 regression).
|
||||
if (activityRef) {
|
||||
const ref: ActivityRef | null = activityRef();
|
||||
if (ref && ref.lastActivityAt > 0) {
|
||||
ref.lastActivityAt = Date.now();
|
||||
ref.lastEventLabel = String(lastEvent?.label ?? lastEvent?.type ?? polled.data.lastEventLabel ?? "");
|
||||
ref.waitingFor = String(polled.data.waitingFor ?? ref.waitingFor ?? null);
|
||||
}
|
||||
}
|
||||
const snapshot: TraceSnapshot = {
|
||||
traceId,
|
||||
status: status || undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
|
||||
import { api } from "../services/api/client";
|
||||
import type {
|
||||
@@ -83,6 +83,18 @@ export interface WorkbenchStore {
|
||||
retryAgentMessage(messageId: string): Promise<void>;
|
||||
replayAgentTrace(messageId: string): Promise<void>;
|
||||
clearConversation(): void;
|
||||
/**
|
||||
* Note recent user / system activity so the inactivity-timeout in fetchJson
|
||||
* (services/api/client.ts) treats the user-configured codeAgentTimeoutMs as
|
||||
* an inactivity budget rather than a total budget. Hooked to the Web
|
||||
* composer onTyping callback in App.tsx; also called internally on submit
|
||||
* and trace snapshot updates. This is the core of the HWLAB #795
|
||||
* regression fix: the React migration removed the pre-React
|
||||
* `app-device-pod.ts:fetchJson` activityRef wiring, so the Web UI was
|
||||
* falling back to a total-timeout that killed long-running Code Agent
|
||||
* operations even while trace events kept flowing.
|
||||
*/
|
||||
recordActivity(): void;
|
||||
setProviderProfile(profile: ProviderProfile): void;
|
||||
setCodeAgentTimeoutMs(value: number): void;
|
||||
setGatewayShellTimeoutMs(value: number): void;
|
||||
@@ -91,6 +103,28 @@ export interface WorkbenchStore {
|
||||
|
||||
export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
const [state, dispatch] = useReducer(workbenchReducer, initialState);
|
||||
// Single shared activity clock so submit, trace polling, and user-typing
|
||||
// all reset the same inactivity window. Kept in a ref (not state) so
|
||||
// updates never trigger re-renders.
|
||||
const lastActivityAtRef = useRef<number>(Date.now());
|
||||
const updateActivity = useCallback(() => {
|
||||
lastActivityAtRef.current = Date.now();
|
||||
}, []);
|
||||
const buildActivityRef = useCallback(
|
||||
(waitingFor: string, lastEventLabel: string | null): (() => import("../services/api/client").ActivityRef | null) => {
|
||||
return () => {
|
||||
const at = lastActivityAtRef.current;
|
||||
if (!Number.isFinite(at) || at <= 0) return null;
|
||||
return {
|
||||
lastActivityAt: at,
|
||||
lastActivityIso: new Date(at).toISOString(),
|
||||
waitingFor,
|
||||
lastEventLabel
|
||||
};
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const activeConversationId = firstNonEmptyString(
|
||||
state.workspace?.selectedConversationId,
|
||||
@@ -204,6 +238,10 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
const pendingWithRetry: ChatMessage = { ...pending, retryInput: value };
|
||||
dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } });
|
||||
const route = composer.submitMode === "steer" ? api.steerAgentMessage : api.sendAgentMessage;
|
||||
// The submit call itself is a "kicked off" event; treat it as activity
|
||||
// so the initial POST has a fresh inactivity window.
|
||||
updateActivity();
|
||||
const submitActivityRef = buildActivityRef("code-agent-submit", null);
|
||||
const response = await route({
|
||||
projectId: WORKBENCH_PROJECT_ID,
|
||||
message: value,
|
||||
@@ -217,7 +255,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
workspaceId: state.workspace?.workspaceId,
|
||||
workspaceRevision: state.workspace?.revision,
|
||||
targetTraceId: composer.targetTraceId
|
||||
}, state.codeAgentTimeoutMs);
|
||||
}, state.codeAgentTimeoutMs, submitActivityRef);
|
||||
const finalize = async (result: AgentChatResultResponse, availability: CodeAgentAvailability | null): Promise<void> => {
|
||||
const completed = messageFromAgentResponse(pending.id, pending, result);
|
||||
dispatch({ type: "message:complete", messageId: pending.id, message: completed, availability: availability ?? result.availability ?? null });
|
||||
@@ -228,10 +266,13 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
if (response.ok && response.data) {
|
||||
const subscribeTrace = (response.data.resultUrl && isResultUrlStatus(response.data))
|
||||
? pollRunnerTrace(response.data.resultUrl, traceId, (snapshot) => {
|
||||
// Trace snapshot update counts as activity; pollRunnerTrace also
|
||||
// bumps the ref internally when a fresh snapshot arrives.
|
||||
updateActivity();
|
||||
dispatch({ type: "message:trace", messageId: pending.id, trace: snapshotToRunnerTrace(snapshot) });
|
||||
}, state.codeAgentTimeoutMs)
|
||||
}, state.codeAgentTimeoutMs, submitActivityRef)
|
||||
: Promise.resolve<TraceSnapshot | null>(null);
|
||||
const terminal = await waitForAgentResult(response.data, state.codeAgentTimeoutMs);
|
||||
const terminal = await waitForAgentResult(response.data, state.codeAgentTimeoutMs, submitActivityRef);
|
||||
const traceTerminal = await subscribeTrace;
|
||||
if (terminal) {
|
||||
await finalize(mergeTraceResults(terminal, traceTerminal), response.data.availability ?? null);
|
||||
@@ -288,6 +329,11 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
}, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
|
||||
|
||||
const clearConversation = useCallback(() => dispatch({ type: "conversation:clear" }), []);
|
||||
|
||||
const recordActivity = useCallback(() => {
|
||||
updateActivity();
|
||||
}, [updateActivity]);
|
||||
|
||||
const setProviderProfile = useCallback((profile: ProviderProfile) => {
|
||||
window.localStorage.setItem("hwlab.workbench.codeAgentProviderProfile.v1", profile);
|
||||
dispatch({ type: "profile:set", profile });
|
||||
@@ -306,7 +352,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
void refreshLive(devicePodId);
|
||||
}, [refreshLive]);
|
||||
|
||||
return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, cancelAgentMessage, retryAgentMessage, replayAgentTrace, clearConversation, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod };
|
||||
return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, cancelAgentMessage, retryAgentMessage, replayAgentTrace, clearConversation, recordActivity, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod };
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user