Merge pull request #1387 from pikasTech/codex/1378-workbench-server-state

[1371 R5] 归一 Workbench 前端投影状态
This commit is contained in:
Lyon
2026-06-17 14:15:01 +08:00
committed by GitHub
6 changed files with 264 additions and 42 deletions
@@ -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", () => {
@@ -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"]);
});
@@ -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<Location, "pathname"> | 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;
}
@@ -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<string, WorkspaceRecord | null>;
conversationsById: Record<string, ConversationRecord>;
messagesByConversationId: Record<string, ChatMessage[]>;
sessionStatusById: Record<string, SessionStatusAuthority>;
turnStatusByTraceId: Record<string, TurnStatusAuthority>;
traceById: Record<string, NonNullable<ChatMessage["runnerTrace"]>>;
}
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<ChatMessage["runnerTrace"]> };
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<Record<string, SessionStatusAuthority>> {
return state.sessionStatusById;
}
export function selectTurnStatusAuthority(state: WorkbenchServerState): Readonly<Record<string, TurnStatusAuthority>> {
return state.turnStatusByTraceId;
}
export function selectTraceAuthorityById(state: WorkbenchServerState): Readonly<Record<string, NonNullable<ChatMessage["runnerTrace"]>>> {
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<T>(source: Record<string, T>, key: string): Record<string, T> {
if (!(key in source)) return source;
const next = { ...source };
delete next[key];
return next;
}
@@ -71,20 +71,15 @@ const BUILTIN_PROVIDER_PROFILE_LABELS: Readonly<Record<string, string>> = 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 };
+95 -32
View File
@@ -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<string | null>(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<string | null>(initialWorkbenchConversationIdFromLocation());
const workspaceSelectionEpoch = ref(0);
const sessionStatusAuthority = ref<Record<string, SessionStatusAuthority>>({});
const turnStatusAuthority = ref<Record<string, TurnStatusAuthority>>({});
const traceAuthorityById = ref<Record<string, NonNullable<ChatMessage["runnerTrace"]>>>({});
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<string>();
const terminalRealtimeRefreshInFlight = new Set<string>();
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<void> {
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<void> {
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<void> {
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<void> {
@@ -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<ChatMessage["runnerTrace"]>);
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<boolean> {
@@ -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);
}