fix: preserve workbench project refresh path

This commit is contained in:
Codex Agent
2026-06-05 22:57:38 +08:00
parent b31c37f437
commit e74acf333b
15 changed files with 202 additions and 67 deletions
+5 -1
View File
@@ -11,7 +11,11 @@
var path = window.location.pathname.replace(/\/+$/g, "") || "/";
if (path !== "/" && path !== "/workspace") return;
if (hash && hash !== "#/workspace" && hash !== "#/") return;
var projectId = "prj_device_pod_workbench";
var projectId = new URLSearchParams(window.location.search).get("projectId");
if (!projectId) {
try { projectId = window.localStorage.getItem("hwlab.workbench.projectId.v1"); } catch (_) { projectId = null; }
}
if (!/^prj_[A-Za-z0-9_.:-]{3,96}$/.test(projectId || "")) projectId = "prj_hwpod_workbench";
window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP = fetch("/auth/workspace-bootstrap?projectId=" + encodeURIComponent(projectId), {
credentials: "same-origin",
headers: { accept: "application/json" }
+2
View File
@@ -46,6 +46,8 @@ const css = readWeb("src/styles/workbench.css");
assert.match(html, /<div id="root"><\/div>/u, "index.html must only expose React mount root");
assert.match(html, /src="\/src\/main\.tsx"/u, "index.html must load React TSX entry");
assert.match(html, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "workspace bootstrap must start during HTML parse for the workspace first screen");
assert.match(html, /new URLSearchParams\(window\.location\.search\)\.get\("projectId"\)/u, "workspace bootstrap must honor the URL projectId before the React bundle loads");
assert.doesNotMatch(html, /prj_device_pod_workbench/u, "workspace bootstrap must not hard-code the retired device-pod project id");
assert.doesNotMatch(html, /workbench-shell|hwpod-node-sidebar|command-input|conversation-list|session-tabs/u, "index.html must not contain business DOM shell");
for (const removed of ["app.ts", "app-conversation.ts", "app-helpers.ts", "app-skills.ts", "styles.css", "web-types.d.ts"]) {
assert.equal(fs.existsSync(path.join(rootDir, removed)), false, `${removed} must not remain as a legacy runtime path`);
+1
View File
@@ -30,6 +30,7 @@ const tscArgs = ["--noEmit", "--project", path.join(rootDir, "tsconfig.json"), "
const tscResult = spawnSync(tscBin, tscArgs, { cwd: rootDir, encoding: "utf8" });
if (tscResult.stdout) process.stdout.write(tscResult.stdout);
if (tscResult.stderr) process.stderr.write(tscResult.stderr);
if (tscResult.error) console.error(`hwlab-cloud-web tsc-check: failed to start ${tscBin}: ${tscResult.error.message}`);
if (tscResult.status !== 0) {
console.error(`hwlab-cloud-web tsc-check: strict TypeScript failed with exit ${tscResult.status ?? "unknown"}`);
process.exit(tscResult.status ?? 2);
+11 -2
View File
@@ -16,6 +16,7 @@ import { WorkbenchBuildSummary } from "./components/workbench/WorkbenchBuildSumm
import { WorkbenchProbe } from "./components/workbench/WorkbenchProbe";
import { fetchText } from "./services/api/client";
import { installWebPerformanceTelemetry } from "./services/performance/rum";
import { rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "./state/constants";
import { firstNonEmptyString } from "./utils";
const AccessView = lazy(() => import("./components/access/AccessView").then((module) => ({ default: module.AccessView })));
@@ -46,13 +47,14 @@ const routeLabels: Record<RouteId, string> = {
};
export function App(): ReactElement {
const auth = useAuth();
const [projectId, setProjectId] = useState(() => resolveInitialWorkbenchProjectId());
const auth = useAuth(projectId);
const [route, setRoute] = useState<RouteId>(() => routeFromLocation());
const workspaceRoute = route === "workspace";
const authReady = auth.authState === "authenticated";
const authChecking = auth.authState === "checking";
const workspaceShellPendingAuth = authChecking && workspaceRoute;
const store = useWorkbenchStore(authReady && workspaceRoute);
const store = useWorkbenchStore(authReady && workspaceRoute, projectId);
const [pickedDraft, setPickedDraft] = useState<string | null>(null);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
@@ -64,6 +66,13 @@ export function App(): ReactElement {
installWebPerformanceTelemetry();
}, []);
useEffect(() => {
const next = workspaceProjectId(store.state.workspace, projectId);
if (next === projectId) return;
rememberWorkbenchProjectId(next);
setProjectId(next);
}, [projectId, store.state.workspace]);
const sessionResize = useSidebarResize({
storageKey: SESSION_SIDEBAR_STORAGE_KEY,
defaultWidth: SESSION_SIDEBAR_DEFAULT_WIDTH,
+20 -10
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "../services/api/client";
import { WORKBENCH_PROJECT_ID } from "../state/constants";
import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchProjectId } from "../state/constants";
import type { AuthSession, AuthState, AuthUser, WorkspaceRecord } from "../types/domain";
import type { WorkbenchEarlyWorkspaceBootstrap } from "../types/config";
import { asRecord, nonEmptyString } from "../utils";
@@ -17,7 +17,7 @@ export interface UseAuthResult {
logout(): Promise<void>;
}
export function useAuth(): UseAuthResult {
export function useAuth(projectId: string = DEFAULT_WORKBENCH_PROJECT_ID): UseAuthResult {
const [authState, setAuthState] = useState<AuthState>("checking");
const [session, setSession] = useState<AuthSession | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -27,12 +27,12 @@ export function useAuth(): UseAuthResult {
async function run(): Promise<void> {
const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
const mode = config?.mode ?? "auto";
const current = mode === "auto" ? await resolveWorkspaceBootstrap() : await api.session();
const current = mode === "auto" ? await resolveWorkspaceBootstrap(projectId) : await api.session();
if (canceled) return;
if (current.ok && current.data?.authenticated === true) {
const workspace = workspaceFromPayload(current.data);
if (workspace) {
window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP = { projectId: WORKBENCH_PROJECT_ID, workspace };
window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP = { projectId: payloadProjectId(current.data, workspace, projectId), workspace };
}
const next = sessionFromPayload(current.data);
setSession(next);
@@ -56,7 +56,7 @@ export function useAuth(): UseAuthResult {
return () => {
canceled = true;
};
}, []);
}, [projectId]);
useEffect(() => {
document.body.dataset.authState = authState;
@@ -83,18 +83,19 @@ export function useAuth(): UseAuthResult {
return useMemo(() => ({ authState, session, error, login, logout }), [authState, session, error, login, logout]);
}
async function resolveWorkspaceBootstrap(): ReturnType<typeof api.workspaceBootstrap> {
const early = await consumeEarlyWorkspaceBootstrap();
async function resolveWorkspaceBootstrap(projectId: string): ReturnType<typeof api.workspaceBootstrap> {
const normalizedProjectId = normalizeWorkbenchProjectId(projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID;
const early = await consumeEarlyWorkspaceBootstrap(normalizedProjectId);
if (early) return early;
return api.workspaceBootstrap(WORKBENCH_PROJECT_ID);
return api.workspaceBootstrap(normalizedProjectId);
}
async function consumeEarlyWorkspaceBootstrap(): Promise<Awaited<ReturnType<typeof api.workspaceBootstrap>> | null> {
async function consumeEarlyWorkspaceBootstrap(projectId: string): Promise<Awaited<ReturnType<typeof api.workspaceBootstrap>> | null> {
const pending = window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP;
if (!pending) return null;
window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP = undefined;
const early = await pending;
if (early.projectId !== WORKBENCH_PROJECT_ID) return null;
if ((normalizeWorkbenchProjectId(early.projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID) !== projectId) return null;
return normalizeEarlyWorkspaceBootstrap(early);
}
@@ -114,6 +115,15 @@ function workspaceFromPayload(payload: unknown): WorkspaceRecord | null {
return typeof workspace?.workspaceId === "string" && workspace.workspaceId.trim() ? workspace as unknown as WorkspaceRecord : null;
}
function payloadProjectId(payload: unknown, workspace: WorkspaceRecord | null, fallback: string): string {
const record = asRecord(payload);
return normalizeWorkbenchProjectId(record?.projectId)
?? normalizeWorkbenchProjectId(workspace?.projectId)
?? normalizeWorkbenchProjectId(workspace?.workspace?.projectId)
?? normalizeWorkbenchProjectId(fallback)
?? DEFAULT_WORKBENCH_PROJECT_ID;
}
function sessionFromPayload(payload: { authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }): AuthSession {
return {
authenticated: payload.authenticated === true,
+45 -1
View File
@@ -1 +1,45 @@
export const WORKBENCH_PROJECT_ID = "prj_hwpod_workbench";
export const DEFAULT_WORKBENCH_PROJECT_ID = "prj_hwpod_workbench";
export const WORKBENCH_PROJECT_ID = DEFAULT_WORKBENCH_PROJECT_ID;
export const WORKBENCH_PROJECT_ID_STORAGE_KEY = "hwlab.workbench.projectId.v1";
const WORKBENCH_PROJECT_ID_PATTERN = /^prj_[A-Za-z0-9_.:-]{3,96}$/u;
export function normalizeWorkbenchProjectId(value: unknown): string | null {
if (typeof value !== "string") return null;
const text = value.trim();
return WORKBENCH_PROJECT_ID_PATTERN.test(text) ? text : null;
}
export function resolveInitialWorkbenchProjectId(): string {
if (typeof window === "undefined") return DEFAULT_WORKBENCH_PROJECT_ID;
const fromUrl = normalizeWorkbenchProjectId(new URLSearchParams(window.location.search).get("projectId"));
const fromBootstrap = normalizeWorkbenchProjectId(window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?.projectId);
let fromStorage: string | null = null;
try {
fromStorage = normalizeWorkbenchProjectId(window.localStorage.getItem(WORKBENCH_PROJECT_ID_STORAGE_KEY));
} catch {
fromStorage = null;
}
return fromUrl ?? fromBootstrap ?? fromStorage ?? DEFAULT_WORKBENCH_PROJECT_ID;
}
export function rememberWorkbenchProjectId(projectId: string): void {
const normalized = normalizeWorkbenchProjectId(projectId);
if (!normalized || typeof window === "undefined") return;
try {
window.localStorage.setItem(WORKBENCH_PROJECT_ID_STORAGE_KEY, normalized);
} catch {
// localStorage may be unavailable in private / embedded contexts.
}
}
export function workspaceProjectId(value: unknown, fallback = DEFAULT_WORKBENCH_PROJECT_ID): string {
if (value && typeof value === "object") {
const record = value as { projectId?: unknown; workspace?: { projectId?: unknown } | null; selectedConversation?: { projectId?: unknown } | null };
return normalizeWorkbenchProjectId(record.projectId)
?? normalizeWorkbenchProjectId(record.workspace?.projectId)
?? normalizeWorkbenchProjectId(record.selectedConversation?.projectId)
?? fallback;
}
return fallback;
}
@@ -1,7 +1,7 @@
import { api } from "../services/api/client";
import type { AgentChatReply, AgentChatResponse, AgentChatResultResponse, ChatMessage, ConversationRecord, SessionTab, WorkspaceRecord } from "../types/domain";
import { firstNonEmptyString, nextProtocolId, nonEmptyString, relativeUpdatedLabel, shortToken } from "../utils";
import { WORKBENCH_PROJECT_ID } from "./constants";
import { WORKBENCH_PROJECT_ID, workspaceProjectId } from "./constants";
export function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] {
const selected = workspace?.selectedConversation?.messages;
@@ -119,17 +119,18 @@ function isTerminalAssistantEvent(event: Record<string, unknown>): boolean {
return type === "assistant_message" && (event.status === "completed" || event.final === true || event.terminal === true);
}
export async function ensureWorkspace(current: WorkspaceRecord | null): Promise<WorkspaceRecord | null> {
if (current?.workspaceId) return current;
const response = await api.workspace(WORKBENCH_PROJECT_ID);
export async function ensureWorkspace(current: WorkspaceRecord | null, projectId = WORKBENCH_PROJECT_ID): Promise<WorkspaceRecord | null> {
if (current?.workspaceId && workspaceProjectId(current, projectId) === projectId) return current;
const response = await api.workspace(projectId);
return response.ok ? response.data?.workspace ?? null : null;
}
export async function persistConversation(input: { workspace: WorkspaceRecord | null; conversationId: string; sessionId: string | null; threadId: string | null; messages: ChatMessage[] }): Promise<WorkspaceRecord | null> {
export async function persistConversation(input: { workspace: WorkspaceRecord | null; projectId?: string; conversationId: string; sessionId: string | null; threadId: string | null; messages: ChatMessage[] }): Promise<WorkspaceRecord | null> {
const projectId = workspaceProjectId(input.workspace, input.projectId ?? WORKBENCH_PROJECT_ID);
const messages = mergeConversationMessages(input.messages);
const latest = messages.at(-1);
await api.saveConversation(input.conversationId, {
projectId: WORKBENCH_PROJECT_ID,
projectId,
conversationId: input.conversationId,
sessionId: input.sessionId,
threadId: input.threadId,
@@ -140,7 +141,7 @@ export async function persistConversation(input: { workspace: WorkspaceRecord |
});
if (input.workspace?.workspaceId) {
const response = await api.updateWorkspace(input.workspace.workspaceId, {
projectId: WORKBENCH_PROJECT_ID,
projectId,
expectedRevision: input.workspace.revision,
selectedConversationId: input.conversationId,
selectedAgentSessionId: input.sessionId,
@@ -166,7 +167,7 @@ function conversationToTab(conversation: ConversationRecord, activeConversationI
const status = firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, conversation.messages?.at(-1)?.status) ?? "source";
const trace = firstNonEmptyString(conversation.lastTraceId, conversation.messages?.at(-1)?.traceId);
const preview = firstNonEmptyString(conversation.firstUserMessagePreview, conversation.snapshot?.firstUserMessagePreview, conversation.messages?.find((message) => message.role === "user")?.text);
return { key: sessionId ?? conversationId, label: truncateLabel(preview) ?? `Session ${shortToken(sessionId ?? conversationId, 8)}`, subtitle: `${relativeUpdatedLabel(timestampMs(updatedAt))} · ${status === "running" ? "处理中" : trace ? `trace ${shortToken(trace, 8)}` : "未开始"}`, conversationId, sessionId, threadId: firstNonEmptyString(conversation.threadId, conversation.session?.threadId), lastTraceId: trace, status, messageCount: Number.isFinite(Number(conversation.messageCount)) ? Number(conversation.messageCount) : conversation.messages?.length ?? 0, active: conversationId === activeConversationId, updatedAt, userPreview: preview };
return { key: sessionId ?? conversationId, label: truncateLabel(preview) ?? `Session ${shortToken(sessionId ?? conversationId, 8)}`, subtitle: `${relativeUpdatedLabel(timestampMs(updatedAt))} · ${status === "running" ? "处理中" : trace ? `trace ${shortToken(trace, 8)}` : "未开始"}`, projectId: conversation.projectId, conversationId, sessionId, threadId: firstNonEmptyString(conversation.threadId, conversation.session?.threadId), lastTraceId: trace, status, messageCount: Number.isFinite(Number(conversation.messageCount)) ? Number(conversation.messageCount) : conversation.messages?.length ?? 0, active: conversationId === activeConversationId, updatedAt, userPreview: preview };
}
function truncateLabel(value: string | null): string | null {
@@ -19,7 +19,7 @@ test("issue 853 persists submitted user and running agent messages before long C
test("issue 853 submit failure persists the same trace before refresh", () => {
const source = fs.readFileSync(path.join(srcRoot, "state/workbench.ts"), "utf8");
const failureIndex = source.indexOf('const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, Math.max(8000, state.codeAgentTimeoutMs));');
const failureIndex = source.indexOf('const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, Math.max(8000, state.codeAgentTimeoutMs), undefined, activeProjectId);');
const failedWithTraceIndex = source.indexOf('const failedWithTrace = runnerTrace ? { ...failed, runnerTrace } : failed;', failureIndex);
const persistIndex = source.indexOf('messages: [...state.messages, user, failedWithTrace]', failedWithTraceIndex);
assert.ok(failureIndex > 0);
@@ -2,9 +2,11 @@ import { api } from "../services/api/client";
import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../types/domain";
import { firstNonEmptyString } from "../utils";
import { replayFullTrace, snapshotToRunnerTrace } from "./runner-trace";
import { WORKBENCH_PROJECT_ID, workspaceProjectId } from "./constants";
export interface RunnerActionsOptions {
activeConversationId: string | null;
projectId?: string;
workspace: WorkspaceRecord | null;
messages: ChatMessage[];
codeAgentTimeoutMs: number;
@@ -21,6 +23,7 @@ export async function cancelAgentMessageAction(
const message = options.messages.find((item) => item.id === messageId);
if (!message?.traceId) return;
const response = await api.cancelAgentMessage({
projectId: workspaceProjectId(options.workspace, options.projectId ?? WORKBENCH_PROJECT_ID),
traceId: message.traceId,
conversationId: message.conversationId ?? options.activeConversationId ?? undefined,
sessionId: message.sessionId
@@ -74,7 +77,7 @@ export async function replayAgentTraceAction(
): Promise<void> {
const message = options.messages.find((item) => item.id === messageId);
if (!message?.traceId) return;
const snapshot = await replayFullTrace(message.traceId, Math.max(8000, options.codeAgentTimeoutMs));
const snapshot = await replayFullTrace(message.traceId, Math.max(8000, options.codeAgentTimeoutMs), workspaceProjectId(options.workspace, options.projectId ?? WORKBENCH_PROJECT_ID));
if (!snapshot) {
options.onTraceStatus(messageId, "trace 回放失败");
return;
@@ -125,9 +125,9 @@ test("subscribeToTrace keeps polling result when trace terminal lacks final resp
assert.equal(resultPolls, 2);
assert.deepEqual(fetches, [
"/v1/agent/chat/result/trc_terminal_first",
"/v1/agent/chat/trace/trc_terminal_first",
"/v1/agent/chat/trace/trc_terminal_first?projectId=prj_hwpod_workbench",
"/v1/agent/chat/result/trc_terminal_first",
"/v1/agent/chat/trace/trc_terminal_first"
"/v1/agent/chat/trace/trc_terminal_first?projectId=prj_hwpod_workbench"
]);
} finally {
globalThis.fetch = originalFetch;
@@ -184,7 +184,7 @@ test("subscribeToTrace preserves trace when terminal result with text arrives be
assert.equal(completed.traceEvents?.length, 3);
assert.deepEqual(fetches, [
"/v1/agent/chat/result/trc_failed_fast",
"/v1/agent/chat/trace/trc_failed_fast"
"/v1/agent/chat/trace/trc_failed_fast?projectId=prj_hwpod_workbench"
]);
} finally {
globalThis.fetch = originalFetch;
@@ -280,7 +280,7 @@ test("subscribeToTrace merges terminal result without text with trace assistant
assert.equal(completed.runnerTrace?.events?.length, 3);
assert.deepEqual(fetches, [
"/v1/agent/chat/result/trc_result_first",
"/v1/agent/chat/trace/trc_result_first"
"/v1/agent/chat/trace/trc_result_first?projectId=prj_hwpod_workbench"
]);
} finally {
globalThis.fetch = originalFetch;
@@ -379,6 +379,43 @@ test("fetchTraceSnapshot retries until fast-fail trace events are readable", asy
}
});
test("fetchTraceSnapshot and replayFullTrace use the active workbench project id", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.window?.setTimeout;
const originalClearTimeout = globalThis.window?.clearTimeout;
const fetches: string[] = [];
globalThis.window = {
...(globalThis.window ?? {}),
setTimeout: ((callback: () => void) => { callback(); return 1; }) as typeof window.setTimeout,
clearTimeout: (() => undefined) as typeof window.clearTimeout
} as typeof window;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
fetches.push(url);
return jsonResponse({
status: "completed",
traceId: url.includes("custom_replay") ? "trc_custom_replay" : "trc_custom_snapshot",
events: [{ seq: 1, label: "agentrun:result:completed", status: "completed" }],
eventCount: 1
});
}) as typeof fetch;
try {
await fetchTraceSnapshot("trc_custom_snapshot", "trc_custom_snapshot", 1000, undefined, "prj_v02_code_agent");
await replayFullTrace("trc_custom_replay", 1000, "prj_v02_code_agent");
assert.deepEqual(fetches, [
"/v1/agent/chat/trace/trc_custom_snapshot?projectId=prj_v02_code_agent",
"/v1/agent/chat/trace/trc_custom_replay?projectId=prj_v02_code_agent"
]);
} finally {
globalThis.fetch = originalFetch;
if (globalThis.window) {
globalThis.window.setTimeout = originalSetTimeout ?? globalThis.window.setTimeout;
globalThis.window.clearTimeout = originalClearTimeout ?? globalThis.window.clearTimeout;
}
}
});
test("fetchTraceSnapshot accepts expired trace fallback without waiting for raw events", async () => {
const originalFetch = globalThis.fetch;
const fetches: string[] = [];
+17 -8
View File
@@ -7,6 +7,7 @@ import type {
} from "../types/domain";
import { api } from "../services/api/client";
import type { ActivityRef, ActivityRefSource } from "../services/api/client";
import { WORKBENCH_PROJECT_ID, normalizeWorkbenchProjectId } from "./constants";
export interface TraceSnapshot {
traceId?: string;
@@ -41,8 +42,10 @@ export function isResultUrlStatus(response: AgentChatResponse): boolean {
return Boolean(response.resultUrl) && (response.status === "running" || response.status === "accepted" || isTerminalStatus(response.status));
}
function traceUrlFromResultUrl(resultUrl: string, traceId: string): string {
return resultUrl.replace(/\/v1\/agent\/chat\/result\/[^/?#]+/u, `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`);
function traceUrlFromResultUrl(resultUrl: string, traceId: string, projectId = WORKBENCH_PROJECT_ID): string {
const url = resultUrl.replace(/\/v1\/agent\/chat\/result\/[^/?#]+/u, `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`);
if (/[?&]projectId=/u.test(url)) return url;
return `${url}${url.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(normalizeWorkbenchProjectId(projectId) ?? WORKBENCH_PROJECT_ID)}`;
}
export function mergeRunnerTrace(previous: ChatMessage["runnerTrace"], next: NonNullable<ChatMessage["runnerTrace"]>): NonNullable<ChatMessage["runnerTrace"]> {
@@ -190,6 +193,7 @@ function firstNonEmptyString(...values: unknown[]): string | null {
*/
export interface TraceSubscriptionConfig {
traceId: string;
projectId?: string;
initial: AgentChatResponse;
onActivity: () => void;
onSnapshot: (snapshot: TraceSnapshot) => void;
@@ -201,12 +205,13 @@ export interface TraceSubscriptionConfig {
export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise<void> {
const { traceId, initial, onActivity, onSnapshot, onComplete, onInfrastructureError, signal, inactivityTimeoutMs } = config;
const projectId = normalizeWorkbenchProjectId(config.projectId) ?? WORKBENCH_PROJECT_ID;
// If the initial response is already terminal, still fetch the trace once.
// Provider fast-fail responses can include final text immediately while the
// trace endpoint already has the event list needed for refresh persistence.
if (isTerminalStatus(initial.status)) {
const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, inactivityTimeoutMs, signal);
const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, inactivityTimeoutMs, signal, projectId);
if (signal.aborted) return;
if (traceSnapshot) onSnapshot(traceSnapshot);
onComplete(mergeTraceResults(initial as AgentChatResultResponse, traceSnapshot));
@@ -218,7 +223,7 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
}
const resultUrl = initial.resultUrl;
const traceUrl = traceUrlFromResultUrl(resultUrl, traceId);
const traceUrl = traceUrlFromResultUrl(resultUrl, traceId, projectId);
let lastEventCount = -1;
let lastStatus: string | null = null;
let lastUpdatedAt: string | null = null;
@@ -336,9 +341,9 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
}
}
export async function fetchTraceSnapshot(traceId: string, traceUrlOrId: string, inactivityTimeoutMs: number, signal?: AbortSignal): Promise<TraceSnapshot | null> {
export async function fetchTraceSnapshot(traceId: string, traceUrlOrId: string, inactivityTimeoutMs: number, signal?: AbortSignal, projectId = WORKBENCH_PROJECT_ID): Promise<TraceSnapshot | null> {
if (signal?.aborted) return null;
const url = traceUrlOrId.startsWith("/") ? traceUrlOrId : `/v1/agent/chat/trace/${encodeURIComponent(traceUrlOrId)}?projectId=prj_hwpod_workbench`;
const url = traceUrlOrId.startsWith("/") ? traceUrlOrId : traceUrl(traceUrlOrId, projectId);
const totalTimeoutMs = Math.max(1000, Math.min(inactivityTimeoutMs, TRACE_SNAPSHOT_MAX_TIMEOUT_MS));
const startedAt = Date.now();
const maxAttempts = Math.max(1, Math.ceil(totalTimeoutMs / TRACE_SNAPSHOT_RETRY_INTERVAL_MS));
@@ -406,8 +411,8 @@ function snapshotToTraceSnapshot(result: AgentChatResultResponse): TraceSnapshot
};
}
export async function replayFullTrace(traceId: string, totalTimeoutMs = 15000): Promise<TraceSnapshot | null> {
const url = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}?projectId=prj_hwpod_workbench`;
export async function replayFullTrace(traceId: string, totalTimeoutMs = 15000, projectId = WORKBENCH_PROJECT_ID): Promise<TraceSnapshot | null> {
const url = traceUrl(traceId, projectId);
const startedAt = Date.now();
let polled = await api.getAgentChatResult(url, totalTimeoutMs);
while ((!polled.ok || !polled.data) && Date.now() - startedAt < totalTimeoutMs) {
@@ -418,6 +423,10 @@ export async function replayFullTrace(traceId: string, totalTimeoutMs = 15000):
return { ...snapshotFromTracePoll(traceId, polled.data), eventsCompacted: true };
}
function traceUrl(traceId: string, projectId: string): string {
return `/v1/agent/chat/trace/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(normalizeWorkbenchProjectId(projectId) ?? WORKBENCH_PROJECT_ID)}`;
}
function traceSnapshotEventCount(result: AgentChatResultResponse, events: TraceEvent[]): number {
const explicit = positiveNumber(result.eventCount);
if (explicit > 0 || events.length > 0) return explicit || events.length;
@@ -8,6 +8,7 @@ import type { AgentChatResultResponse, ChatMessage, WorkspaceRecord } from "../t
export interface UseTraceReattachConfig {
enabled: boolean;
projectId: string;
activeTraceId: string | null;
codeAgentTimeoutMs: number;
chatPending: boolean;
@@ -19,7 +20,7 @@ export interface UseTraceReattachConfig {
}
export function useTraceReattach(config: UseTraceReattachConfig): void {
const { enabled, activeTraceId, codeAgentTimeoutMs, chatPending, workspace, messages, onActivity, onDispatch, onPersist } = config;
const { enabled, projectId, activeTraceId, codeAgentTimeoutMs, chatPending, workspace, messages, onActivity, onDispatch, onPersist } = config;
useEffect(() => {
if (!enabled || !activeTraceId || chatPending) return;
const resultUrl = `/v1/agent/chat/result/${encodeURIComponent(activeTraceId)}`;
@@ -38,6 +39,7 @@ export function useTraceReattach(config: UseTraceReattachConfig): void {
const ws: WorkspaceRecord | null = workspace;
void subscribeToTrace({
traceId: activeTraceId,
projectId,
initial: { status: "running", resultUrl, traceId: activeTraceId } as Parameters<typeof subscribeToTrace>[0]["initial"],
inactivityTimeoutMs: codeAgentTimeoutMs,
signal: controller.signal,
@@ -50,16 +52,16 @@ export function useTraceReattach(config: UseTraceReattachConfig): void {
const stub = messages.find((m) => m.id === placeholderId) ?? ({ id: placeholderId, role: "agent" as const, text: "", status: "running" as const, traceId: activeTraceId, conversationId, sessionId, threadId, createdAt: new Date().toISOString() } as ChatMessage);
const completed = messageFromAgentResponse(placeholderId, stub, mergeTraceResults(result, lastSnapshot));
onDispatch({ type: "message:complete", messageId: placeholderId, message: completed, availability: result.availability ?? null, workspace: ws ?? undefined });
void onPersist({ workspace: ws, conversationId, sessionId: result.sessionId ?? sessionId, threadId: result.threadId ?? threadId, messages: mergeConversationMessages(messages, [completed]) });
void onPersist({ workspace: ws, projectId, conversationId, sessionId: result.sessionId ?? sessionId, threadId: result.threadId ?? threadId, messages: mergeConversationMessages(messages, [completed]) });
controller.abort();
},
onInfrastructureError: (error) => {
const failed = makeMessage("agent", `Code Agent re-attach ${codeAgentTimeoutMs}ms 无活动(${error}`, "failed", { traceId: activeTraceId, conversationId, sessionId, threadId, title: "Code Agent 超时" });
onDispatch({ type: "message:fail", messageId: placeholderId, message: failed });
void onPersist({ workspace: ws, conversationId, sessionId, threadId, messages: mergeConversationMessages(messages, [failed]) });
void onPersist({ workspace: ws, projectId, conversationId, sessionId, threadId, messages: mergeConversationMessages(messages, [failed]) });
controller.abort();
}
}).finally(() => controller.abort());
return () => controller.abort();
}, [enabled, activeTraceId, chatPending, codeAgentTimeoutMs, workspace, messages, onActivity, onDispatch, onPersist]);
}, [enabled, projectId, activeTraceId, chatPending, codeAgentTimeoutMs, workspace, messages, onActivity, onDispatch, onPersist]);
}
+35 -27
View File
@@ -14,7 +14,7 @@ import type {
WorkspaceRecord
} from "../types/domain";
import { firstNonEmptyString, nextProtocolId } from "../utils";
import { WORKBENCH_PROJECT_ID } from "./constants";
import { WORKBENCH_PROJECT_ID, rememberWorkbenchProjectId, workspaceProjectId } from "./constants";
import {
availabilityFromLive,
composerFromState,
@@ -91,8 +91,9 @@ export interface WorkbenchStore {
setGatewayShellTimeoutMs(value: number): void;
}
export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJECT_ID): WorkbenchStore {
const [state, dispatch] = useReducer(workbenchReducer, initialState);
const activeProjectId = workspaceProjectId(state.workspace, projectId);
// 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.
@@ -129,14 +130,15 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const hydrate = useCallback(async () => {
dispatch({ type: "hydrate:start" });
const [workspaceResult, conversationsResult] = await Promise.all([
api.workspace(WORKBENCH_PROJECT_ID),
api.conversations(WORKBENCH_PROJECT_ID)
api.workspace(projectId),
api.conversations(projectId)
]);
if (!workspaceResult.ok) {
dispatch({ type: "hydrate:error", error: workspaceResult.error ?? "workspace unavailable" });
return;
}
const workspace = workspaceResult.data?.workspace ?? null;
rememberWorkbenchProjectId(workspaceProjectId(workspace, projectId));
const conversations = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : [];
dispatch({
type: "hydrate:done",
@@ -144,7 +146,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
conversations,
messages: messagesFromWorkspace(workspace)
});
}, []);
}, [projectId]);
const refreshLive = useCallback(async () => {
const [healthLive, health, restIndex, adapter, hwpodNodeOps, liveBuilds] = await Promise.all([
@@ -172,25 +174,26 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
}, [enabled, refreshLive]);
// HWLAB #802: re-attach to a running trace on hydrate.
useTraceReattach({ enabled, activeTraceId: firstNonEmptyString(state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId), codeAgentTimeoutMs: state.codeAgentTimeoutMs, chatPending: state.chatPending, workspace: state.workspace, messages: state.messages, onActivity: updateActivity, onDispatch: dispatch, onPersist: persistConversation });
useTraceReattach({ enabled, projectId: activeProjectId, activeTraceId: firstNonEmptyString(state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId), codeAgentTimeoutMs: state.codeAgentTimeoutMs, chatPending: state.chatPending, workspace: state.workspace, messages: state.messages, onActivity: updateActivity, onDispatch: dispatch, onPersist: persistConversation });
const createSession = useCallback(async () => {
const workspace = await ensureWorkspace(state.workspace);
const workspace = await ensureWorkspace(state.workspace, activeProjectId);
if (!workspace) return;
const response = await api.selectConversation(workspace.workspaceId, { projectId: WORKBENCH_PROJECT_ID, create: true, updatedByClient: "cloud-web-react" });
const response = await api.selectConversation(workspace.workspaceId, { projectId: activeProjectId, create: true, updatedByClient: "cloud-web-react" });
if (response.ok) {
const nextWorkspace = response.data?.workspace ?? workspace;
dispatch({ type: "conversation:select", conversation: nextWorkspace.selectedConversation ?? null, workspace: nextWorkspace });
const conversations = await api.conversations(WORKBENCH_PROJECT_ID);
const conversations = await api.conversations(workspaceProjectId(nextWorkspace, activeProjectId));
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
}
}, [state.workspace]);
}, [activeProjectId, state.workspace]);
const selectConversation = useCallback(async (tab: SessionTab) => {
const workspace = await ensureWorkspace(state.workspace);
const tabProjectId = tab.projectId ?? activeProjectId;
const workspace = await ensureWorkspace(state.workspace, tabProjectId);
if (!workspace || !tab.conversationId) return;
const response = await api.selectConversation(workspace.workspaceId, {
projectId: WORKBENCH_PROJECT_ID,
projectId: tabProjectId,
conversationId: tab.conversationId,
sessionId: tab.sessionId,
threadId: tab.threadId,
@@ -198,21 +201,22 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
});
if (response.ok) {
const nextWorkspace = response.data?.workspace ?? workspace;
rememberWorkbenchProjectId(workspaceProjectId(nextWorkspace, tabProjectId));
dispatch({ type: "conversation:select", conversation: nextWorkspace.selectedConversation ?? conversationFromTab(tab), workspace: nextWorkspace });
}
}, [state.workspace]);
}, [activeProjectId, state.workspace]);
const deleteCurrentSession = useCallback(async () => {
const conversationId = activeConversationId;
if (!conversationId) return;
const workspaceId = state.workspace?.workspaceId;
const response = await api.deleteConversation(conversationId, { projectId: WORKBENCH_PROJECT_ID, workspaceId, updatedByClient: "cloud-web-react" });
const response = await api.deleteConversation(conversationId, { projectId: activeProjectId, workspaceId, updatedByClient: "cloud-web-react" });
if (response.ok) {
dispatch({ type: "conversation:select", conversation: response.data?.workspace?.selectedConversation ?? null, workspace: response.data?.workspace ?? state.workspace });
const conversations = await api.conversations(WORKBENCH_PROJECT_ID);
const conversations = await api.conversations(activeProjectId);
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
}
}, [activeConversationId, state.workspace]);
}, [activeConversationId, activeProjectId, state.workspace]);
const submitMessage = useCallback(async (text: string) => {
const value = text.trim();
@@ -227,13 +231,13 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const pending = makeMessage("agent", "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent 处理中" });
const pendingWithRetry: ChatMessage = { ...pending, retryInput: value };
dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } });
let currentWorkspace = await persistConversation({ workspace: state.workspace, conversationId, sessionId, threadId, messages: [...state.messages, user, pendingWithRetry] });
let currentWorkspace = await persistConversation({ workspace: state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, pendingWithRetry] });
if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage;
updateActivity();
const submitActivityRef = buildActivityRef("code-agent-submit", null);
const response = await route({
projectId: WORKBENCH_PROJECT_ID,
projectId: activeProjectId,
message: value,
prompt: value,
conversationId,
@@ -250,9 +254,9 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
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 });
currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, conversationId, sessionId: completed.sessionId ?? sessionId, threadId: completed.threadId ?? threadId, messages: [...state.messages, user, completed] });
currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId: completed.sessionId ?? sessionId, threadId: completed.threadId ?? threadId, messages: [...state.messages, user, completed] });
if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
const conversations = await api.conversations(WORKBENCH_PROJECT_ID);
const conversations = await api.conversations(activeProjectId);
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
};
if (response.ok && response.data) {
@@ -262,6 +266,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
try {
await subscribeToTrace({
traceId,
projectId: activeProjectId,
initial: response.data,
inactivityTimeoutMs: state.codeAgentTimeoutMs,
signal: subscriptionController.signal,
@@ -278,7 +283,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
// the frontend-reported failure, not a silent "running" state.
const failed = makeMessage("agent", `Code Agent 在 ${state.codeAgentTimeoutMs}ms 无活动后中断(${error});可以继续 steer 或查看 trace。`, "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 超时" });
dispatch({ type: "message:fail", messageId: pending.id, message: failed });
void persistConversation({ workspace: currentWorkspace ?? state.workspace, conversationId, sessionId, threadId, messages: [...state.messages, user, failed] })
void persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, failed] })
.then((workspace) => { if (workspace) dispatch({ type: "workspace:sync", workspace }); });
subscriptionController.abort();
}
@@ -294,21 +299,22 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
await finalize(mergeTraceResults(response.data, traceTerminal), response.data.availability ?? null);
}
} else {
const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, Math.max(8000, state.codeAgentTimeoutMs));
const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, Math.max(8000, state.codeAgentTimeoutMs), undefined, activeProjectId);
const failed = makeMessage("agent", response.error ?? "Code Agent 请求失败", "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 请求失败" });
const runnerTrace = traceSnapshot ? snapshotToRunnerTrace(traceSnapshot) : null;
const failedWithTrace = runnerTrace ? { ...failed, runnerTrace } : failed;
if (runnerTrace) dispatch({ type: "message:trace", messageId: pending.id, trace: runnerTrace });
dispatch({ type: "message:fail", messageId: pending.id, message: failedWithTrace });
currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, conversationId, sessionId, threadId, messages: [...state.messages, user, failedWithTrace] });
currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, failedWithTrace] });
if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
}
dispatch({ type: "chat:done" });
}, [activeConversationId, composer.conversationId, composer.sessionId, composer.submitMode, composer.targetTraceId, composer.threadId, state.codeAgentTimeoutMs, state.gatewayShellTimeoutMs, state.messages, state.providerProfile, state.workspace]);
}, [activeConversationId, activeProjectId, composer.conversationId, composer.sessionId, composer.submitMode, composer.targetTraceId, composer.threadId, state.codeAgentTimeoutMs, state.gatewayShellTimeoutMs, state.messages, state.providerProfile, state.workspace]);
const cancelAgentMessage = useCallback(async (messageId: string) => {
return cancelAgentMessageAction(messageId, {
activeConversationId,
projectId: activeProjectId,
workspace: state.workspace,
messages: state.messages,
codeAgentTimeoutMs: state.codeAgentTimeoutMs,
@@ -317,11 +323,12 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }),
submitMessage
});
}, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
}, [activeConversationId, activeProjectId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
const retryAgentMessage = useCallback(async (messageId: string) => {
return retryAgentMessageAction(messageId, {
activeConversationId,
projectId: activeProjectId,
workspace: state.workspace,
messages: state.messages,
codeAgentTimeoutMs: state.codeAgentTimeoutMs,
@@ -330,11 +337,12 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }),
submitMessage
});
}, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
}, [activeConversationId, activeProjectId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
const replayAgentTrace = useCallback(async (messageId: string) => {
return replayAgentTraceAction(messageId, {
activeConversationId,
projectId: activeProjectId,
workspace: state.workspace,
messages: state.messages,
codeAgentTimeoutMs: state.codeAgentTimeoutMs,
@@ -343,7 +351,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }),
submitMessage
});
}, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
}, [activeConversationId, activeProjectId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
const clearConversation = useCallback(() => dispatch({ type: "conversation:clear" }), []);
+1
View File
@@ -22,6 +22,7 @@ export interface WorkbenchTimeoutConfig {
export interface WorkbenchConfig {
auth?: WorkbenchAuthConfig;
timeouts?: WorkbenchTimeoutConfig;
projectId?: string;
}
export interface WorkbenchWorkspaceBootstrap {
+4
View File
@@ -109,12 +109,14 @@ export interface CodeAgentAvailability {
export interface WorkspaceRecord {
workspaceId: string;
projectId?: string | null;
revision?: number;
updatedAt?: string;
selectedConversationId?: string | null;
selectedAgentSessionId?: string | null;
activeTraceId?: string | null;
workspace?: {
projectId?: string | null;
selectedConversationId?: string | null;
selectedAgentSessionId?: string | null;
threadId?: string | null;
@@ -128,6 +130,7 @@ export interface WorkspaceRecord {
export interface ConversationRecord {
conversationId: string;
projectId?: string | null;
sessionId?: string | null;
threadId?: string | null;
status?: string | null;
@@ -373,6 +376,7 @@ export interface SessionTab {
key: string;
label: string;
subtitle: string;
projectId?: string | null;
conversationId?: string | null;
sessionId?: string | null;
threadId?: string | null;