diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index f2cc942b..e93217bc 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -5,16 +5,28 @@ export interface ConversationListOptions { includeConversationId?: string | null; } +export interface ConversationDetailOptions { + projectId?: string | null; +} + export const workbenchAPI = { workspace: (projectId: string): Promise> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }), updateWorkspace: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }), selectConversation: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }), conversations: (projectId: string, options: ConversationListOptions = {}): Promise> => fetchJson(conversationListPath(projectId, options), { timeoutMs: 8000, timeoutName: "conversations" }), - conversation: (conversationId: string): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { timeoutMs: 8000, timeoutName: "conversation" }), + conversation: (conversationId: string, options: ConversationDetailOptions = {}): Promise> => fetchJson(conversationDetailPath(conversationId, options), { timeoutMs: 8000, timeoutName: "conversation" }), deleteConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }), saveConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }) }; +function conversationDetailPath(conversationId: string, options: ConversationDetailOptions): string { + const params = new URLSearchParams(); + const projectId = typeof options.projectId === "string" ? options.projectId.trim() : ""; + if (projectId) params.set("projectId", projectId); + const query = params.toString(); + return `/v1/agent/conversations/${encodeURIComponent(conversationId)}${query ? `?${query}` : ""}`; +} + function conversationListPath(projectId: string, options: ConversationListOptions): string { const params = new URLSearchParams({ projectId }); const includeConversationId = typeof options.includeConversationId === "string" ? options.includeConversationId.trim() : ""; diff --git a/web/hwlab-cloud-web/src/router/guards.ts b/web/hwlab-cloud-web/src/router/guards.ts index 18e3999f..027f88b4 100644 --- a/web/hwlab-cloud-web/src/router/guards.ts +++ b/web/hwlab-cloud-web/src/router/guards.ts @@ -1,6 +1,7 @@ import type { Router } from "vue-router"; import { isNavigationFailure } from "vue-router"; import { useAuthStore } from "@/stores/auth"; +import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchProjectId } from "@/utils"; import { resolveDocumentTitle } from "./title"; const CHUNK_RELOAD_KEY = "hwlab:v03:chunk-reload-at"; @@ -29,7 +30,7 @@ export function installChunkRecovery(router: Router): void { export function installRouterGuards(router: Router): void { router.beforeEach(async (to) => { const auth = useAuthStore(); - if (!auth.checked) await auth.bootstrap(); + if (!auth.checked) await auth.bootstrap(projectIdFromRoute(to)); if (to.meta.requiresAuth !== false && auth.authState === "login") return { name: "Login", query: { redirect: to.fullPath } }; if (to.meta.requiresAdmin === true && !auth.isAdmin) return { name: "Dashboard", query: { blocked: "admin_required" } }; return true; @@ -42,3 +43,9 @@ export function installRouterGuards(router: Router): void { installChunkRecovery(router); } + +function projectIdFromRoute(to: { query?: Record }): string { + const raw = to.query?.projectId; + const value = Array.isArray(raw) ? raw[0] : raw; + return normalizeWorkbenchProjectId(value) ?? DEFAULT_WORKBENCH_PROJECT_ID; +} diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 53b79abf..7d8a2895 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -17,7 +17,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const projectId = ref(resolveInitialWorkbenchProjectId()); const workspace = ref(window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?.workspace ?? null); const conversations = ref([]); - const messages = ref(messagesFromWorkspace(workspace.value)); + const messages = ref([]); const providerProfile = ref(readString("hwlab.workbench.providerProfile.v1", "codex")); const providerOptions = ref(defaultProviderProfileOptions(providerProfile.value)); const recentDrafts = ref(readRecentDrafts()); @@ -54,6 +54,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function hydrate(): Promise { const requestEpoch = workspaceSelectionEpoch.value; + const previousConversationId = activeConversationId.value; conversationsReady.value = conversations.value.length > 0; loading.value = true; error.value = null; @@ -64,23 +65,36 @@ export const useWorkbenchStore = defineStore("workbench", () => { return; } const nextWorkspace = workspaceResult.data?.workspace ?? null; - const conversationsResult = await api.workbench.conversations(projectId.value, { includeConversationId: selectedConversationIdFromWorkspace(nextWorkspace) }); + const nextProjectId = workspaceProjectId(nextWorkspace, projectId.value); + projectId.value = nextProjectId; + const selectedConversationId = selectedConversationIdFromWorkspace(nextWorkspace); + const [conversationsResult, selectedConversationResult] = await Promise.all([ + api.workbench.conversations(nextProjectId, { includeConversationId: selectedConversationId }), + selectedConversationId ? api.workbench.conversation(selectedConversationId, { projectId: nextProjectId }) : Promise.resolve(null) + ]); loading.value = false; + const selectedConversation = selectedConversationResult?.ok ? selectedConversationResult.data?.conversation ?? null : null; let nextConversations = conversations.value; if (conversationsResult.ok) { - nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationIdFromWorkspace(nextWorkspace), selectedConversationFromWorkspace(nextWorkspace)); + nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationId, selectedConversation); conversations.value = nextConversations; conversationsReady.value = true; void refreshSessionStatusAuthority(nextConversations); } else { + if (selectedConversation) { + nextConversations = mergeConversationIntoList(nextConversations, selectedConversation); + conversations.value = nextConversations; + } conversationsReady.value = conversations.value.length > 0; error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable"; } - if (shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: nextWorkspace })) { - workspace.value = nextWorkspace; + if (selectedConversationId && selectedConversationResult && !selectedConversationResult.ok) error.value = selectedConversationResult.error ?? "conversation unavailable"; + const workspaceSnapshot = selectedConversation ? workspaceWithSelectedConversation(nextWorkspace, selectedConversation, conversationProjectId(selectedConversation, nextProjectId)) : nextWorkspace; + if (shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: workspaceSnapshot })) { + workspace.value = workspaceSnapshot; providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value; - rememberWorkbenchProjectId(workspaceProjectId(workspace.value, projectId.value)); - messages.value = messagesFromWorkspaceSelection(workspace.value, nextConversations); + rememberWorkbenchProjectId(workspaceProjectId(workspace.value, nextProjectId)); + messages.value = messagesFromSelectedConversation(selectedConversation, selectedConversationId, previousConversationId, messages.value); void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); void hydrateTraceEvents(messages.value); @@ -128,24 +142,28 @@ export const useWorkbenchStore = defineStore("workbench", () => { conversations.value = mergeConversationIntoList(conversations.value, conversation); conversationsReady.value = true; workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId); - messages.value = messagesFromConversation(conversation); - void hydrateTurnStatusAuthority(messages.value); - void hydrateTraceEvents(messages.value); void refreshSessionStatusById(sessionIdFromConversation(conversation)); loading.value = true; - const response = await api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" }); + const [response, detailResponse] = await Promise.all([ + api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" }), + api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId }) + ]); loading.value = false; clearSwitchingConversation(conversation.conversationId); if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return; if (response.ok) { - workspace.value = response.data?.workspace ?? current; - messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]); + const selectedConversation = detailResponse.ok ? detailResponse.data?.conversation ?? null : null; + if (!selectedConversation) error.value = detailResponse.error ?? "conversation unavailable"; + const selectedProjectId = conversationProjectId(selectedConversation ?? conversation, tabProjectId); + workspace.value = workspaceWithSelectedConversation(response.data?.workspace ?? current, selectedConversation ?? conversation, selectedProjectId); + messages.value = messagesFromSelectedConversation(selectedConversation, conversation.conversationId, null, []); + if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation); void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); void hydrateTraceEvents(messages.value); reattachRestoredActiveTrace(); currentRequest.value = null; - await refreshSelectedSessionStatus(conversation); + await refreshSelectedSessionStatus(selectedConversation ?? conversation); await refreshConversations(conversation.conversationId); return; } @@ -169,7 +187,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { await selectConversation(existing); return activeConversationId.value === normalized; } - const response = await api.workbench.conversation(normalized); + const response = await api.workbench.conversation(normalized, { projectId: activeProjectId.value }); const conversation = response.data?.conversation ?? null; if (!response.ok || !conversation) { if (isCurrentWorkspaceSelection(requestEpoch, normalized)) clearSwitchingConversation(normalized); @@ -197,7 +215,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise { const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId }); if (response.ok) { - conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId, activeConversation.value ?? selectedConversationFromWorkspace(workspace.value)); + conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId, activeConversation.value); conversationsReady.value = true; await refreshSessionStatusAuthority(conversations.value); return; @@ -628,15 +646,20 @@ export const useWorkbenchStore = defineStore("workbench", () => { const freshWorkspace = fresh.data?.workspace; if (!fresh.ok || !freshWorkspace?.workspaceId) return false; workspace.value = optimisticWorkspaceSelection(freshWorkspace, conversation, tabProjectId); - messages.value = messagesFromConversation(conversation); - const retried = await api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue-retry" }); + 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 }) + ]); if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return true; if (!retried.ok) { error.value = retried.error ?? "session switch failed"; return false; } - workspace.value = retried.data?.workspace ?? workspace.value; - messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]); + 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)); + messages.value = messagesFromSelectedConversation(selectedConversation, conversation.conversationId, null, []); + if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation); void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); void hydrateTraceEvents(messages.value); @@ -673,27 +696,57 @@ function uniqueTraceIds(messages: ChatMessage[]): string[] { } function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord { + const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId, current.selectedAgentSessionId, current.workspace?.selectedAgentSessionId); + const threadId = firstNonEmptyString(conversation.threadId, conversation.session?.threadId, current.workspace?.threadId); return { ...current, projectId, selectedConversationId: conversation.conversationId, - selectedAgentSessionId: conversation.sessionId ?? current.selectedAgentSessionId, + selectedAgentSessionId: sessionId, + workspace: { + ...(current.workspace ?? {}), + projectId, + selectedConversationId: conversation.conversationId, + selectedAgentSessionId: sessionId, + threadId + } + }; +} + +function workspaceWithSelectedConversation(current: WorkspaceRecord | null, conversation: ConversationRecord, projectId: string): WorkspaceRecord | null { + if (!current) return current; + const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId, current.selectedAgentSessionId, current.workspace?.selectedAgentSessionId); + const threadId = firstNonEmptyString(conversation.threadId, conversation.session?.threadId, current.workspace?.threadId); + return { + ...current, + projectId, + selectedConversationId: conversation.conversationId, + selectedAgentSessionId: sessionId, selectedConversation: conversation, workspace: { ...(current.workspace ?? {}), projectId, selectedConversationId: conversation.conversationId, - selectedAgentSessionId: conversation.sessionId ?? current.workspace?.selectedAgentSessionId, - threadId: conversation.threadId ?? current.workspace?.threadId, - messages: conversation.messages ?? [] + selectedAgentSessionId: sessionId, + threadId } }; } +function conversationProjectId(conversation: ConversationRecord | null | undefined, fallback: string): string { + return firstNonEmptyString(conversation?.projectId, fallback) ?? DEFAULT_WORKBENCH_PROJECT_ID; +} + function messagesFromConversation(conversation: ConversationRecord): ChatMessage[] { return (conversation.messages ?? []).map((message) => normalizeChatMessage(message)); } +function messagesFromSelectedConversation(conversation: ConversationRecord | null, selectedConversationId: string | null, previousConversationId: string | null, previousMessages: ChatMessage[]): ChatMessage[] { + if (conversation && (!selectedConversationId || conversation.conversationId === selectedConversationId)) return messagesFromConversation(conversation); + if (selectedConversationId && previousConversationId === selectedConversationId && previousMessages.length > 0) return previousMessages; + return []; +} + function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] { const selected = workspace?.selectedConversation?.messages; const embedded = workspace?.workspace?.messages; @@ -701,38 +754,6 @@ function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] return source.map((message) => normalizeChatMessage(message)); } -function messagesFromWorkspaceSelection(workspace: WorkspaceRecord | null, fallbackConversations: ConversationRecord[]): ChatMessage[] { - const workspaceMessages = messagesFromWorkspace(workspace); - if (workspaceMessages.length > 0) return workspaceMessages; - const selectedConversationId = selectedConversationIdFromWorkspace(workspace); - const fallback = selectedConversationId ? fallbackConversations.find((conversation) => conversation.conversationId === selectedConversationId) : null; - return fallback ? messagesFromConversation(fallback) : []; -} - -function selectedConversationFromWorkspace(workspace: WorkspaceRecord | null): ConversationRecord | null { - const selected = workspace?.selectedConversation; - if (selected?.conversationId) { - const messages = Array.isArray(selected.messages) && selected.messages.length > 0 ? selected.messages : messagesFromWorkspace(workspace); - return { ...selected, messages, messageCount: selected.messageCount ?? (messages.length || undefined) }; - } - const conversationId = selectedConversationIdFromWorkspace(workspace); - if (!conversationId) return null; - const messages = messagesFromWorkspace(workspace); - const firstUserMessage = messages.find((message) => message.role === "user"); - return { - conversationId, - projectId: firstNonEmptyString(workspace?.projectId, workspace?.workspace?.projectId), - sessionId: firstNonEmptyString(workspace?.selectedAgentSessionId, workspace?.workspace?.selectedAgentSessionId), - threadId: firstNonEmptyString(workspace?.workspace?.threadId), - status: firstNonEmptyString(workspace?.workspace?.sessionStatus), - updatedAt: firstNonEmptyString(workspace?.updatedAt, workspace?.workspace?.updatedAt), - lastTraceId: firstNonEmptyString(workspace?.activeTraceId, workspace?.workspace?.activeTraceId, workspace?.workspace?.lastTraceId), - firstUserMessagePreview: firstNonEmptyString(firstUserMessage?.text, firstUserMessage?.content), - messageCount: messages.length || undefined, - messages - }; -} - function normalizeChatMessage(message: ChatMessage): ChatMessage { const runnerTrace = normalizeMessageRunnerTrace(message); const error = normalizeAgentError(message.error ?? runnerTrace?.error);