fix: preserve workbench project refresh path
This commit is contained in:
@@ -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" }
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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" }), []);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface WorkbenchTimeoutConfig {
|
||||
export interface WorkbenchConfig {
|
||||
auth?: WorkbenchAuthConfig;
|
||||
timeouts?: WorkbenchTimeoutConfig;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchWorkspaceBootstrap {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user