diff --git a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts index c3740b3e..7272195c 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts @@ -4,13 +4,15 @@ import test from "node:test"; import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; import { defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; -test("R2 composer ignores stale workspace activeTraceId without verified active message", () => { +test("R2 composer ignores stale workspace activeTraceId and workspace session without verified active projection", () => { const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }); const composer = resolveComposerState({ workspace, messages: [], conversations: [], activeConversationId: "cnv_r2", chatPending: false }); + assert.equal(composer.disabled, true); + assert.equal(composer.disabledReason, "session_required"); assert.equal(composer.submitMode, "turn"); assert.equal(composer.route, "/v1/agent/chat"); assert.equal(composer.targetTraceId, null); - assert.equal(composer.sessionId, "ses_r2"); + assert.equal(composer.sessionId, null); }); test("R2 composer steers only when an active agent message or status exists", () => { diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts new file mode 100644 index 00000000..64e8f27a --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; +import { initialWorkbenchConversationIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "../src/stores/workbench-projection.ts"; +import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveConversation, selectActiveMessages, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts"; + +test("R5 active conversation comes only from route or explicit UI selection", () => { + assert.equal(resolveWorkbenchActiveConversationId({ switchingConversationId: null, explicitConversationId: "cnv_route" }), "cnv_route"); + assert.equal(resolveWorkbenchActiveConversationId({ switchingConversationId: "cnv_switching", explicitConversationId: "cnv_route" }), "cnv_switching"); + assert.equal(resolveWorkbenchActiveConversationId({ switchingConversationId: null, explicitConversationId: null }), null); +}); + +test("R5 initial route parsing ignores workspace fallback", () => { + assert.equal(initialWorkbenchConversationIdFromLocation({ pathname: "/workbench/sessions/cnv_route" }), "cnv_route"); + assert.equal(initialWorkbenchConversationIdFromLocation({ pathname: "/workbench" }), null); + assert.equal(nextExplicitWorkbenchConversationId("cnv_existing", "not-a-conversation"), "cnv_existing"); +}); + +test("R5 late workspace and trace snapshots update cache without changing active projection", () => { + const workspace: WorkspaceRecord = { workspaceId: "wsp_r5", projectId: "prj_hwpod_workbench", selectedConversationId: "cnv_late" }; + const routeConversation: ConversationRecord = { conversationId: "cnv_route", sessionId: "ses_route", messages: [{ id: "msg_route", messageId: "msg_route", role: "user", title: "用户", text: "route prompt", status: "sent", createdAt: "2026-06-17T00:00:00.000Z", conversationId: "cnv_route" }] }; + const lateConversation: ConversationRecord = { conversationId: "cnv_late", sessionId: "ses_late", messages: [{ id: "msg_late", messageId: "msg_late", role: "agent", title: "Code Agent", text: "late reply", status: "completed", createdAt: "2026-06-17T00:00:01.000Z", conversationId: "cnv_late", traceId: "trc_late" }] }; + let state = createWorkbenchServerState(); + const active = "cnv_route"; + + state = reduceWorkbenchServerState(state, { type: "workspace.snapshot", projectId: "prj_hwpod_workbench", workspace }); + state = reduceWorkbenchServerState(state, { type: "conversation.detail", conversation: lateConversation }); + state = reduceWorkbenchServerState(state, { type: "trace.snapshot", traceId: "trc_late", trace: { traceId: "trc_late", status: "completed", events: [{ seq: 1, type: "late" }] } }); + + assert.equal(selectActiveConversation(state, active), null); + assert.deepEqual(selectActiveMessages(state, active), []); + assert.equal(selectTraceAuthorityById(state).trc_late?.traceId, "trc_late"); + + state = reduceWorkbenchServerState(state, { type: "conversation.detail", conversation: routeConversation }); + assert.equal(selectActiveConversation(state, active)?.conversationId, "cnv_route"); + assert.deepEqual(selectActiveMessages(state, active).map((message) => message.id), ["msg_route"]); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-projection.ts b/web/hwlab-cloud-web/src/stores/workbench-projection.ts new file mode 100644 index 00000000..31fb404e --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/workbench-projection.ts @@ -0,0 +1,28 @@ +// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0. +// Responsibility: Workbench UI projection helpers that keep route/explicit selection separate from server snapshots. + +import { firstNonEmptyString, normalizeWorkbenchConversationId } from "@/utils"; + +export interface WorkbenchActiveConversationInput { + switchingConversationId?: string | null; + explicitConversationId?: string | null; +} + +export function resolveWorkbenchActiveConversationId(input: WorkbenchActiveConversationInput): string | null { + return firstNonEmptyString(input.switchingConversationId, input.explicitConversationId); +} + +export function initialWorkbenchConversationIdFromLocation(location: Pick | null = typeof window === "undefined" ? null : window.location): string | null { + const pathname = location?.pathname ?? ""; + const match = /^\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u.exec(pathname); + if (!match?.[1]) return null; + try { + return normalizeWorkbenchConversationId(decodeURIComponent(match[1])); + } catch { + return normalizeWorkbenchConversationId(match[1]); + } +} + +export function nextExplicitWorkbenchConversationId(current: string | null, candidate: string | null | undefined): string | null { + return normalizeWorkbenchConversationId(candidate) ?? current; +} diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts new file mode 100644 index 00000000..2080ec18 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -0,0 +1,96 @@ +// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0. +// Responsibility: Normalized Workbench server-state reducer for workspace, conversation, session, turn, and trace caches. + +import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "@/types"; +import type { SessionStatusAuthority, TurnStatusAuthority } from "./workbench-session"; + +export interface WorkbenchServerState { + workspaceByProjectId: Record; + conversationsById: Record; + messagesByConversationId: Record; + sessionStatusById: Record; + turnStatusByTraceId: Record; + traceById: Record>; +} + +export type WorkbenchServerAction = + | { type: "workspace.snapshot"; projectId: string; workspace: WorkspaceRecord | null } + | { type: "conversation.list"; conversations: ConversationRecord[] } + | { type: "conversation.detail"; conversation: ConversationRecord | null | undefined } + | { type: "session.status"; session: SessionStatusAuthority } + | { type: "session.forget"; sessionId: string } + | { type: "turn.status"; turn: TurnStatusAuthority } + | { type: "turn.forget"; traceId: string } + | { type: "trace.snapshot"; traceId: string; trace: NonNullable }; + +export function createWorkbenchServerState(): WorkbenchServerState { + return { + workspaceByProjectId: {}, + conversationsById: {}, + messagesByConversationId: {}, + sessionStatusById: {}, + turnStatusByTraceId: {}, + traceById: {} + }; +} + +export function reduceWorkbenchServerState(state: WorkbenchServerState, action: WorkbenchServerAction): WorkbenchServerState { + switch (action.type) { + case "workspace.snapshot": + return { ...state, workspaceByProjectId: { ...state.workspaceByProjectId, [action.projectId]: action.workspace } }; + case "conversation.list": + return action.conversations.reduce((next, conversation) => reduceWorkbenchServerState(next, { type: "conversation.detail", conversation }), state); + case "conversation.detail": + return reduceConversationDetail(state, action.conversation); + case "session.status": + return { ...state, sessionStatusById: { ...state.sessionStatusById, [action.session.sessionId]: action.session } }; + case "session.forget": + return { ...state, sessionStatusById: withoutKey(state.sessionStatusById, action.sessionId) }; + case "turn.status": + return { ...state, turnStatusByTraceId: { ...state.turnStatusByTraceId, [action.turn.traceId]: action.turn } }; + case "turn.forget": + return { ...state, turnStatusByTraceId: withoutKey(state.turnStatusByTraceId, action.traceId) }; + case "trace.snapshot": + return { ...state, traceById: { ...state.traceById, [action.traceId]: action.trace } }; + } +} + +export function selectSessionStatusAuthority(state: WorkbenchServerState): Readonly> { + return state.sessionStatusById; +} + +export function selectTurnStatusAuthority(state: WorkbenchServerState): Readonly> { + return state.turnStatusByTraceId; +} + +export function selectTraceAuthorityById(state: WorkbenchServerState): Readonly>> { + return state.traceById; +} + +export function selectActiveConversation(state: WorkbenchServerState, conversationId: string | null): ConversationRecord | null { + return conversationId ? state.conversationsById[conversationId] ?? null : null; +} + +export function selectActiveMessages(state: WorkbenchServerState, conversationId: string | null): ChatMessage[] { + return conversationId ? state.messagesByConversationId[conversationId] ?? [] : []; +} + +function reduceConversationDetail(state: WorkbenchServerState, conversation: ConversationRecord | null | undefined): WorkbenchServerState { + const conversationId = conversation?.conversationId; + if (!conversationId || !conversation) return state; + return { + ...state, + conversationsById: { ...state.conversationsById, [conversationId]: conversation }, + messagesByConversationId: { + ...state.messagesByConversationId, + [conversationId]: Array.isArray(conversation.messages) ? conversation.messages : state.messagesByConversationId[conversationId] ?? [] + } + }; +} + +function withoutKey(source: Record, key: string): Record { + if (!(key in source)) return source; + const next = { ...source }; + delete next[key]; + return next; +} diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 723aa4e4..5866f940 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -71,20 +71,15 @@ const BUILTIN_PROVIDER_PROFILE_LABELS: Readonly> = Object export function resolveComposerState(input: { workspace: WorkspaceRecord | null; messages: ChatMessage[]; conversations?: ConversationRecord[]; activeConversationId: string | null; chatPending: boolean; currentRequest?: { traceId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null; turnStatusAuthority?: TurnStatusAuthorityMap }): ComposerState { const latestMessage = latestConversationMessage(input.messages, input.activeConversationId); const currentRequest = input.currentRequest && input.currentRequest.conversationId === input.activeConversationId ? input.currentRequest : null; - const workspaceTraceId = firstNonEmptyString(input.workspace?.activeTraceId, input.workspace?.workspace?.activeTraceId); const latestTraceId = firstNonEmptyString(latestMessage?.traceId, latestMessage?.runnerTrace?.traceId); - const activeTraceId = firstNonEmptyString(currentRequest?.traceId, workspaceTraceId, latestTraceId); + const activeTraceId = firstNonEmptyString(currentRequest?.traceId, latestTraceId); const turn = activeTraceId ? input.turnStatusAuthority?.[activeTraceId] : null; const activeByStatus = turn?.running === true || isActiveStatus(turn?.status); const terminal = turn?.terminal === true || isTerminalStatus(turn?.status); const conversationId = input.activeConversationId; const active = activeConversation(input.conversations ?? [], conversationId); - const workspaceConversationId = selectedConversationIdFromWorkspace(input.workspace); - const workspaceMatchesConversation = !conversationId || !workspaceConversationId || workspaceConversationId === conversationId; - const workspaceSessionId = workspaceMatchesConversation ? firstNonEmptyString(input.workspace?.selectedAgentSessionId, input.workspace?.workspace?.selectedAgentSessionId) : null; - const workspaceThreadId = workspaceMatchesConversation ? firstNonEmptyString(input.workspace?.workspace?.threadId) : null; - const sessionId = firstNonEmptyString(turn?.sessionId, currentRequest?.sessionId, active?.sessionId, workspaceSessionId); - const threadId = firstNonEmptyString(turn?.threadId, currentRequest?.threadId, active?.threadId, workspaceThreadId); + const sessionId = firstNonEmptyString(turn?.sessionId, currentRequest?.sessionId, active?.sessionId); + const threadId = firstNonEmptyString(turn?.threadId, currentRequest?.threadId, active?.threadId); const canSteer = Boolean(conversationId && sessionId && activeTraceId && activeByStatus && !terminal); if (canSteer) return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId, conversationId, sessionId, threadId }; if (!sessionId) return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId: null, threadId }; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index b4b254e5..c2c6cf39 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -8,7 +8,9 @@ import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealti import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription"; import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types"; import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils"; -import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session"; +import { initialWorkbenchConversationIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "./workbench-projection"; +import { createWorkbenchServerState, reduceWorkbenchServerState, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; @@ -40,10 +42,12 @@ export const useWorkbenchStore = defineStore("workbench", () => { const error = ref(null); const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null }); const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null); + const explicitConversationId = ref(initialWorkbenchConversationIdFromLocation()); const workspaceSelectionEpoch = ref(0); - const sessionStatusAuthority = ref>({}); - const turnStatusAuthority = ref>({}); - const traceAuthorityById = ref>>({}); + const serverState = ref(createWorkbenchServerState()); + const sessionStatusAuthority = computed(() => selectSessionStatusAuthority(serverState.value)); + const turnStatusAuthority = computed(() => selectTurnStatusAuthority(serverState.value)); + const traceAuthorityById = computed(() => selectTraceAuthorityById(serverState.value)); const traceHydrationInFlight = new Set(); const terminalRealtimeRefreshInFlight = new Set(); let realtimeStream: WorkbenchEventStream | null = null; @@ -51,10 +55,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { let realtimeGapTimer: number | null = null; const visibleConversations = computed(() => conversations.value); - const activeConversationId = computed(() => firstNonEmptyString(switchingConversationId.value, workspace.value?.selectedConversationId, workspace.value?.workspace?.selectedConversationId, messages.value.find((message) => message.conversationId)?.conversationId)); - const selectedSessionId = computed(() => firstNonEmptyString(workspace.value?.selectedAgentSessionId, workspace.value?.workspace?.selectedAgentSessionId, activeConversation.value?.sessionId)); - const selectedThreadId = computed(() => firstNonEmptyString(workspace.value?.workspace?.threadId, activeConversation.value?.threadId)); + const activeConversationId = computed(() => resolveWorkbenchActiveConversationId({ switchingConversationId: switchingConversationId.value, explicitConversationId: explicitConversationId.value })); const activeConversation = computed(() => visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null); + const selectedSessionId = computed(() => firstNonEmptyString(activeConversation.value?.sessionId)); + const selectedThreadId = computed(() => firstNonEmptyString(activeConversation.value?.threadId)); const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value, sessionStatusAuthority.value)); const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value })); const conversationDetailLoading = computed(() => Boolean(conversationDetailLoadingId.value || switchingConversationId.value || (loading.value && messages.value.length === 0))); @@ -67,9 +71,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function hydrate(options: HydrateOptions = {}): Promise { + const routeConversationId = normalizeWorkbenchConversationId(options.conversationId); + if (routeConversationId) setActiveConversationSelection(routeConversationId); const requestEpoch = workspaceSelectionEpoch.value; const previousConversationId = activeConversationId.value; - const routeConversationId = normalizeWorkbenchConversationId(options.conversationId); if (routeConversationId) conversationDetailLoadingId.value = routeConversationId; conversationsReady.value = conversations.value.length > 0; loading.value = true; @@ -83,13 +88,17 @@ export const useWorkbenchStore = defineStore("workbench", () => { } const nextWorkspace = workspaceResult.data?.workspace ?? null; const nextProjectId = workspaceProjectId(nextWorkspace, projectId.value); + rememberWorkspaceSnapshot(nextProjectId, nextWorkspace); projectId.value = nextProjectId; - const selectedConversationId = routeConversationId ?? selectedConversationIdFromWorkspace(nextWorkspace); + const selectedConversationId = routeConversationId ?? activeConversationId.value ?? selectedConversationIdFromWorkspace(nextWorkspace); + bootstrapActiveConversationSelection(selectedConversationId); const conversationsPromise = api.workbench.conversations(nextProjectId, { includeConversationId: selectedConversationId }); if (selectedConversationId) conversationDetailLoadingId.value = selectedConversationId; const selectedConversationResult = selectedConversationId ? await api.workbench.conversation(selectedConversationId, { projectId: nextProjectId }) : null; clearConversationDetailLoading(selectedConversationId); const selectedConversation = selectedConversationResult?.ok ? selectedConversationResult.data?.conversation ?? null : null; + rememberConversationDetail(selectedConversation); + if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation); if (selectedConversationId && selectedConversationResult && !selectedConversationResult.ok) error.value = selectedConversationResult.error ?? "conversation unavailable"; const workspaceSnapshot = selectedConversation ? workspaceWithSelectedConversation(nextWorkspace, selectedConversation, conversationProjectId(selectedConversation, nextProjectId)) @@ -98,6 +107,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { : nextWorkspace; if (shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: workspaceSnapshot })) { workspace.value = workspaceSnapshot; + rememberWorkspaceSnapshot(nextProjectId, workspaceSnapshot); providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value; rememberWorkbenchProjectId(workspaceProjectId(workspace.value, nextProjectId)); messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, previousConversationId, messages.value), messages.value); @@ -112,12 +122,14 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (conversationsResult.ok) { nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationId, selectedConversation); conversations.value = nextConversations; + rememberConversationList(nextConversations); conversationsReady.value = true; void refreshSessionStatusAuthority(nextConversations); } else { if (selectedConversation) { nextConversations = mergeConversationIntoList(nextConversations, selectedConversation); conversations.value = nextConversations; + rememberConversationDetail(selectedConversation); } conversationsReady.value = conversations.value.length > 0; error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable"; @@ -147,6 +159,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { loading.value = false; if (response.ok && isCurrentWorkspaceSelection(requestEpoch)) { workspace.value = response.data?.workspace ?? current; + rememberWorkspaceSnapshot(activeProjectId.value, workspace.value); + setActiveConversationSelection(selectedConversationIdFromWorkspace(workspace.value)); + rememberConversationDetail(workspace.value?.selectedConversation); messages.value = restoreMessagesTraceAuthority(messagesFromWorkspace(workspace.value), messages.value); currentRequest.value = null; void hydrateTurnStatusAuthority(messages.value); @@ -163,10 +178,13 @@ export const useWorkbenchStore = defineStore("workbench", () => { const requestEpoch = beginWorkspaceSelection(); const tabProjectId = conversation.projectId ?? activeProjectId.value; switchingConversationId.value = conversation.conversationId; + setActiveConversationSelection(conversation.conversationId); conversationDetailLoadingId.value = conversation.conversationId; conversations.value = mergeConversationIntoList(conversations.value, conversation); + rememberConversationDetail(conversation); conversationsReady.value = true; workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId); + rememberWorkspaceSnapshot(tabProjectId, workspace.value); void refreshSessionStatusById(sessionIdFromConversation(conversation)); loading.value = true; const persistPromise = api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" }); @@ -197,6 +215,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { error.value = "invalid session URL"; return false; } + setActiveConversationSelection(normalized); const current = await ensureWorkspace(); if (!current) return false; if (activeConversationId.value === normalized && messages.value.length > 0) return true; @@ -233,6 +252,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { const response = await api.workbench.deleteConversation(conversationId, { projectId: activeProjectId.value, workspaceId: workspace.value?.workspaceId, updatedByClient: "cloud-web-vue" }); if (response.ok) { workspace.value = response.data?.workspace ?? workspace.value; + rememberWorkspaceSnapshot(activeProjectId.value, workspace.value); + replaceActiveConversationSelection(selectedConversationIdFromWorkspace(workspace.value)); + rememberConversationDetail(workspace.value?.selectedConversation); messages.value = restoreMessagesTraceAuthority(messagesFromWorkspace(workspace.value), messages.value); currentRequest.value = null; void hydrateTurnStatusAuthority(messages.value); @@ -244,6 +266,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId }); if (response.ok) { conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId, activeConversation.value); + rememberConversationList(conversations.value); conversationsReady.value = true; await refreshSessionStatusAuthority(conversations.value); return; @@ -252,6 +275,34 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (conversations.value.length === 0) error.value = response.error ?? "session list unavailable"; } + function reduceServerState(action: WorkbenchServerAction): void { + serverState.value = reduceWorkbenchServerState(serverState.value, action); + } + + function rememberWorkspaceSnapshot(snapshotProjectId: string, snapshot: WorkspaceRecord | null): void { + reduceServerState({ type: "workspace.snapshot", projectId: snapshotProjectId, workspace: snapshot }); + } + + function rememberConversationDetail(conversation: ConversationRecord | null | undefined): void { + reduceServerState({ type: "conversation.detail", conversation }); + } + + function rememberConversationList(source: ConversationRecord[]): void { + reduceServerState({ type: "conversation.list", conversations: source }); + } + + function setActiveConversationSelection(conversationId: string | null | undefined): void { + explicitConversationId.value = nextExplicitWorkbenchConversationId(explicitConversationId.value, conversationId); + } + + function replaceActiveConversationSelection(conversationId: string | null | undefined): void { + explicitConversationId.value = normalizeWorkbenchConversationId(conversationId); + } + + function bootstrapActiveConversationSelection(conversationId: string | null | undefined): void { + if (!explicitConversationId.value) setActiveConversationSelection(conversationId); + } + async function refreshSessionStatusAuthority(source: ConversationRecord[] = conversations.value): Promise { await Promise.all(uniqueSessionIds(source).map((sessionId) => refreshSessionStatusById(sessionId))); } @@ -259,9 +310,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function refreshSelectedSessionStatus(fallbackConversation?: ConversationRecord | null): Promise { await refreshSessionStatusById(firstNonEmptyString( fallbackConversation ? sessionIdFromConversation(fallbackConversation) : null, - selectedSessionId.value, - workspace.value?.selectedAgentSessionId, - workspace.value?.workspace?.selectedAgentSessionId + selectedSessionId.value )); } @@ -271,23 +320,21 @@ export const useWorkbenchStore = defineStore("workbench", () => { const response = await api.agent.getAgentSession(id); const session = response.data?.session && typeof response.data.session === "object" ? response.data.session : null; if (!response.ok || !session) { - const next = { ...sessionStatusAuthority.value }; - delete next[id]; - sessionStatusAuthority.value = next; + reduceServerState({ type: "session.forget", sessionId: id }); return; } const returnedSessionId = firstNonEmptyString(session.sessionId, id); if (returnedSessionId !== id) return; - sessionStatusAuthority.value = { - ...sessionStatusAuthority.value, - [id]: { + reduceServerState({ + type: "session.status", + session: { sessionId: id, status: firstNonEmptyString(session.status) ?? null, updatedAt: firstNonEmptyString(session.updatedAt) ?? null, lastTraceId: firstNonEmptyString(session.lastTraceId) ?? null, loadedAt: new Date().toISOString() } - }; + }); } async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise { @@ -302,9 +349,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { applyTurnStatusSnapshot(id, response.data); return; } - const next = { ...turnStatusAuthority.value }; - delete next[id]; - turnStatusAuthority.value = next; + reduceServerState({ type: "turn.forget", traceId: id }); } function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void { @@ -318,9 +363,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { const status = normalizedStatusText(result.status) ?? null; const running = (result as AgentChatResultResponse).running === true || isTraceActiveStatus(status); const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status); - turnStatusAuthority.value = { - ...turnStatusAuthority.value, - [id]: { + reduceServerState({ + type: "turn.status", + turn: { traceId: id, status, running, @@ -331,7 +376,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { updatedAt: firstNonEmptyString((result as AgentChatResultResponse).updatedAt) ?? null, loadedAt: new Date().toISOString() } - }; + }); } function syncTurnStatusToMessage(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void { @@ -364,6 +409,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const traceId = steerMode && composer.value.targetTraceId ? composer.value.targetTraceId : nextProtocolId("trc"); const steerTraceId = steerMode ? nextProtocolId("trc_steer") : null; const conversationId = activeConversationId.value ?? nextProtocolId("cnv"); + setActiveConversationSelection(conversationId); const sessionId = composer.value.sessionId; const threadId = composer.value.threadId; const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" }); @@ -495,7 +541,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const existing = traceAuthorityById.value[traceId] ?? null; if (trace.eventSource !== "trace-api" && !traceHasEvents(trace) && !existing) return; const nextTrace = mergeRunnerTrace(existing, trace as NonNullable); - traceAuthorityById.value = { ...traceAuthorityById.value, [traceId]: nextTrace }; + reduceServerState({ type: "trace.snapshot", traceId, trace: nextTrace }); } function applyTraceHydrationResult(traceId: string, result: AgentChatResultResponse): void { @@ -568,7 +614,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { function clearConversation(): void { messages.value = []; currentRequest.value = null; - const activeTraceId = activeTraceIdFromWorkspace(workspace.value); + const activeTraceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value); if (activeTraceId) void clearActiveTrace(activeTraceId, "clear-conversation"); restartRealtime("clear-conversation"); } @@ -625,7 +671,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function realtimeTraceId(): string | null { - return firstNonEmptyString(currentRequest.value?.traceId, activeTraceIdFromMessages(messages.value, turnStatusAuthority.value), activeTraceIdFromWorkspace(workspace.value)); + return firstNonEmptyString(currentRequest.value?.traceId, activeTraceIdFromMessages(messages.value, turnStatusAuthority.value)); } function applyRealtimeEvent(event: WorkbenchRealtimeEvent, eventName: string): void { @@ -656,6 +702,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!shouldApplyWorkspaceSnapshot({ requestEpoch: workspaceSelectionEpoch.value, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: nextWorkspace })) return; const previousTraceId = realtimeTraceId(); workspace.value = nextWorkspace; + rememberWorkspaceSnapshot(workspaceProjectId(nextWorkspace, activeProjectId.value), nextWorkspace); providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value; rememberWorkbenchProjectId(workspaceProjectId(workspace.value, activeProjectId.value)); const nextTraceId = realtimeTraceId(); @@ -852,7 +899,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { workspace: current.workspace, updatedByClient: "cloud-web-vue-active-trace-repair" }); - if (response.ok && response.data?.workspace && shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: response.data.workspace })) workspace.value = response.data.workspace; + if (response.ok && response.data?.workspace && shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: response.data.workspace })) { + workspace.value = response.data.workspace; + rememberWorkspaceSnapshot(workspaceProjectId(workspace.value, activeProjectId.value), workspace.value); + } } function beginWorkspaceSelection(): number { @@ -875,7 +925,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function applySelectedConversationDetail(conversation: ConversationRecord, baseWorkspace: WorkspaceRecord | null, selectedProjectId: string, previousConversationId: string | null, previousMessages: ChatMessage[]): void { + setActiveConversationSelection(conversation.conversationId); workspace.value = workspaceWithSelectedConversation(baseWorkspace, conversation, selectedProjectId); + rememberWorkspaceSnapshot(selectedProjectId, workspace.value); + rememberConversationDetail(conversation); messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(conversation, conversation.conversationId, previousConversationId, previousMessages), previousMessages); conversations.value = mergeConversationIntoList(conversations.value, conversation); conversationsReady.value = true; @@ -904,6 +957,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { } if (!response.ok || !response.data?.workspace || activeConversationId.value !== conversationId) return; workspace.value = workspaceWithSelectedConversation(response.data.workspace, conversation, selectedProjectId); + rememberWorkspaceSnapshot(selectedProjectId, workspace.value); + rememberConversationDetail(conversation); } async function retrySelectConversationWithFreshWorkspace(conversation: ConversationRecord, tabProjectId: string, requestEpoch: number): Promise { @@ -915,7 +970,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { } const freshWorkspace = fresh.data?.workspace; if (!fresh.ok || !freshWorkspace?.workspaceId) return false; + setActiveConversationSelection(conversation.conversationId); workspace.value = optimisticWorkspaceSelection(freshWorkspace, conversation, tabProjectId); + rememberWorkspaceSnapshot(tabProjectId, workspace.value); + rememberConversationDetail(conversation); const [retried, detailResponse] = await Promise.all([ api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue-retry" }), api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId }) @@ -932,8 +990,13 @@ export const useWorkbenchStore = defineStore("workbench", () => { const selectedConversation = detailResponse.ok ? detailResponse.data?.conversation ?? null : null; if (!selectedConversation) error.value = detailResponse.error ?? "conversation unavailable"; workspace.value = workspaceWithSelectedConversation(retried.data?.workspace ?? workspace.value, selectedConversation ?? conversation, conversationProjectId(selectedConversation ?? conversation, tabProjectId)); + rememberWorkspaceSnapshot(tabProjectId, workspace.value); + rememberConversationDetail(selectedConversation ?? conversation); messages.value = messagesFromSelectedConversation(selectedConversation, conversation.conversationId, null, []); - if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation); + if (selectedConversation) { + conversations.value = mergeConversationIntoList(conversations.value, selectedConversation); + rememberConversationDetail(selectedConversation); + } void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); void hydrateTraceEvents(messages.value); @@ -950,7 +1013,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { function reattachRestoredActiveTrace(): void { void hydrateTraceEvents(messages.value); - const traceId = firstNonEmptyString(activeTraceIdFromMessages(messages.value, turnStatusAuthority.value), activeTraceIdFromWorkspace(workspace.value)); + const traceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value); if (traceId) void validateAndReattachTrace(traceId); }