From e74acf333bbd72d41a0d6ab9065da04b31e65133 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 22:57:38 +0800 Subject: [PATCH] fix: preserve workbench project refresh path --- web/hwlab-cloud-web/index.html | 6 +- web/hwlab-cloud-web/scripts/check.ts | 2 + web/hwlab-cloud-web/scripts/tsc-check.ts | 1 + web/hwlab-cloud-web/src/App.tsx | 13 +++- web/hwlab-cloud-web/src/hooks/useAuth.ts | 30 ++++++--- web/hwlab-cloud-web/src/state/constants.ts | 46 +++++++++++++- web/hwlab-cloud-web/src/state/conversation.ts | 17 ++--- .../state/issue853-refresh-regression.test.ts | 2 +- .../src/state/runner-actions.ts | 5 +- .../src/state/runner-trace.test.ts | 45 ++++++++++++-- web/hwlab-cloud-web/src/state/runner-trace.ts | 25 +++++--- .../src/state/trace-reattach.ts | 10 +-- web/hwlab-cloud-web/src/state/workbench.ts | 62 +++++++++++-------- web/hwlab-cloud-web/src/types/config.ts | 1 + web/hwlab-cloud-web/src/types/domain.ts | 4 ++ 15 files changed, 202 insertions(+), 67 deletions(-) diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 635290c9..bf5116f8 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -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" } diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 8fee3417..583db03d 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -46,6 +46,8 @@ const css = readWeb("src/styles/workbench.css"); assert.match(html, /
<\/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`); diff --git a/web/hwlab-cloud-web/scripts/tsc-check.ts b/web/hwlab-cloud-web/scripts/tsc-check.ts index d748fde9..04d56d8f 100644 --- a/web/hwlab-cloud-web/scripts/tsc-check.ts +++ b/web/hwlab-cloud-web/scripts/tsc-check.ts @@ -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); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 0f3ca01b..7291b388 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -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 = { }; export function App(): ReactElement { - const auth = useAuth(); + const [projectId, setProjectId] = useState(() => resolveInitialWorkbenchProjectId()); + const auth = useAuth(projectId); const [route, setRoute] = useState(() => 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(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, diff --git a/web/hwlab-cloud-web/src/hooks/useAuth.ts b/web/hwlab-cloud-web/src/hooks/useAuth.ts index 7599e305..50d703ee 100644 --- a/web/hwlab-cloud-web/src/hooks/useAuth.ts +++ b/web/hwlab-cloud-web/src/hooks/useAuth.ts @@ -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; } -export function useAuth(): UseAuthResult { +export function useAuth(projectId: string = DEFAULT_WORKBENCH_PROJECT_ID): UseAuthResult { const [authState, setAuthState] = useState("checking"); const [session, setSession] = useState(null); const [error, setError] = useState(null); @@ -27,12 +27,12 @@ export function useAuth(): UseAuthResult { async function run(): Promise { 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 { - const early = await consumeEarlyWorkspaceBootstrap(); +async function resolveWorkspaceBootstrap(projectId: string): ReturnType { + 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> | null> { +async function consumeEarlyWorkspaceBootstrap(projectId: string): Promise> | 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, diff --git a/web/hwlab-cloud-web/src/state/constants.ts b/web/hwlab-cloud-web/src/state/constants.ts index 27f9330e..b19cb7a8 100644 --- a/web/hwlab-cloud-web/src/state/constants.ts +++ b/web/hwlab-cloud-web/src/state/constants.ts @@ -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; +} diff --git a/web/hwlab-cloud-web/src/state/conversation.ts b/web/hwlab-cloud-web/src/state/conversation.ts index 2fde7107..3c512508 100644 --- a/web/hwlab-cloud-web/src/state/conversation.ts +++ b/web/hwlab-cloud-web/src/state/conversation.ts @@ -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): boolean { return type === "assistant_message" && (event.status === "completed" || event.final === true || event.terminal === true); } -export async function ensureWorkspace(current: WorkspaceRecord | null): Promise { - 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 { + 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 { +export async function persistConversation(input: { workspace: WorkspaceRecord | null; projectId?: string; conversationId: string; sessionId: string | null; threadId: string | null; messages: ChatMessage[] }): Promise { + 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 { diff --git a/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts b/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts index 64d461f9..d3c2b0fa 100644 --- a/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts +++ b/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts @@ -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); diff --git a/web/hwlab-cloud-web/src/state/runner-actions.ts b/web/hwlab-cloud-web/src/state/runner-actions.ts index aa16fae2..9d860029 100644 --- a/web/hwlab-cloud-web/src/state/runner-actions.ts +++ b/web/hwlab-cloud-web/src/state/runner-actions.ts @@ -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 { 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; diff --git a/web/hwlab-cloud-web/src/state/runner-trace.test.ts b/web/hwlab-cloud-web/src/state/runner-trace.test.ts index 969e0c8c..5685c683 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts @@ -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[] = []; diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index d6021dde..48c6bda4 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -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): NonNullable { @@ -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 { 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 { +export async function fetchTraceSnapshot(traceId: string, traceUrlOrId: string, inactivityTimeoutMs: number, signal?: AbortSignal, projectId = WORKBENCH_PROJECT_ID): Promise { 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 { - 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 { + 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; diff --git a/web/hwlab-cloud-web/src/state/trace-reattach.ts b/web/hwlab-cloud-web/src/state/trace-reattach.ts index 0f2a4721..a14d4b0b 100644 --- a/web/hwlab-cloud-web/src/state/trace-reattach.ts +++ b/web/hwlab-cloud-web/src/state/trace-reattach.ts @@ -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[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]); } diff --git a/web/hwlab-cloud-web/src/state/workbench.ts b/web/hwlab-cloud-web/src/state/workbench.ts index 136054ea..18748245 100644 --- a/web/hwlab-cloud-web/src/state/workbench.ts +++ b/web/hwlab-cloud-web/src/state/workbench.ts @@ -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 => { 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" }), []); diff --git a/web/hwlab-cloud-web/src/types/config.ts b/web/hwlab-cloud-web/src/types/config.ts index 44dda899..4d390641 100644 --- a/web/hwlab-cloud-web/src/types/config.ts +++ b/web/hwlab-cloud-web/src/types/config.ts @@ -22,6 +22,7 @@ export interface WorkbenchTimeoutConfig { export interface WorkbenchConfig { auth?: WorkbenchAuthConfig; timeouts?: WorkbenchTimeoutConfig; + projectId?: string; } export interface WorkbenchWorkspaceBootstrap { diff --git a/web/hwlab-cloud-web/src/types/domain.ts b/web/hwlab-cloud-web/src/types/domain.ts index 47694e41..b77c59b8 100644 --- a/web/hwlab-cloud-web/src/types/domain.ts +++ b/web/hwlab-cloud-web/src/types/domain.ts @@ -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;