diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 899efd04..0ea23cec 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -15,6 +15,8 @@ Workbench 页面和组件必须明确区分“未加载完成”和“已加载但为空”。未加载完成时不得渲染由 workspace、localStorage、默认对象或 stub 记录拼出来的伪数据;这类数据只能作为内部恢复线索,不能在列表、表格、卡片或状态栏中冒充真实加载结果。已经确认后端返回且集合为空时,才显示空态文案。 +Workbench 状态对象必须服从 UniDesk OA Web SPEC 中已经定下来的单一权威 API 绑定。Session rail 的会话集合只允许消费 `/v1/agent/conversations` 成功返回的 conversation 集合;当前 selected conversation 如果需要出现在左侧列表中,必须通过显式 query/path/body 传入稳定 conversation id,由该列表 API 在同一响应中返回,后端不得隐藏读取 workspace selected、localStorage、Web snapshot 或上一轮页面状态。前端不得用 workspace selected snapshot、stub、localStorage 或 route 状态补出 session tab,也不得把前端拼接出的 status、final response、markdown、running 动效或 stub 传回后端变成事实。Session 运行状态必须按 `sessionId` 绑定到单一 session 状态 API;Code Agent turn、trace 阅读和 final response 必须按 `traceId` 绑定到单一 trace/result snapshot API。任何权威 API 失败时只能保留上一份成功结果或显示未加载/错误态,不得切换到另一条 fallback 路径形成一条会话、旧 running 态或劣化 markdown。 + Cloud Web 的通用加载态使用 `web/hwlab-cloud-web/src/components/common/LoadingState.vue`。新增或修复页面加载态时优先复用该组件,并通过明确的 ready/loading 状态控制展示;不要在每个组件里重新实现一套 spinner、点状动画或默认占位数据。紧凑区域可以使用组件的 compact 形态,文案默认保持“加载中”。 Session rail 是该规则的高频区域。`/v1/agent/conversations` 还未返回时,即使 workspace 中已有 `selectedConversationId`、sessionId、traceId 或 selected conversation snapshot,也不能把选中 session stub 渲染成单条 `.session-tab`,更不能让它占满整个 session 列表高度。加载窗口应只显示 `LoadingState`,并隐藏当前 trace 元信息、复制/删除等依赖真实 active tab 的动作;待 conversations ready 后再渲染真实 session tabs,或在真实空集合时显示空态。 diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index c8d1551f..0b14ae4a 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1104,6 +1104,69 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac } }); +test("cloud api includes an explicitly requested conversation in the conversations list API", async () => { + let now = "2026-06-16T08:00:00.000Z"; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + now: () => now + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue1338", password: "alice-pass" }, adminLogin.cookie); + assert.equal(aliceCreate.status, 201); + const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue1338", password: "alice-pass" }); + + const selected = await putJson(port, "/v1/agent/conversations/cnv_issue1338_selected", { + projectId: "prj_hwpod_workbench", + sessionId: "ses_issue1338_selected", + threadId: "thread-issue1338-selected", + sessionStatus: "completed", + messages: [ + { id: "msg_issue1338_selected_user", role: "user", title: "用户", text: "被选中的旧会话", status: "sent", conversationId: "cnv_issue1338_selected", sessionId: "ses_issue1338_selected", threadId: "thread-issue1338-selected" } + ] + }, aliceLogin.cookie); + assert.equal(selected.status, 200); + + now = "2026-06-16T08:05:00.000Z"; + const recent = await putJson(port, "/v1/agent/conversations/cnv_issue1338_recent", { + projectId: "prj_hwpod_workbench", + sessionId: "ses_issue1338_recent", + threadId: "thread-issue1338-recent", + sessionStatus: "completed", + messages: [ + { id: "msg_issue1338_recent_user", role: "user", title: "用户", text: "列表窗口内较新的会话", status: "sent", conversationId: "cnv_issue1338_recent", sessionId: "ses_issue1338_recent", threadId: "thread-issue1338-recent" } + ] + }, aliceLogin.cookie); + assert.equal(recent.status, 200); + + const limited = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench&limit=1", aliceLogin.cookie); + assert.equal(limited.status, 200); + assert.deepEqual(limited.body.conversations.map((conversation) => conversation.conversationId), ["cnv_issue1338_recent"]); + assert.equal(limited.body.count, 1); + + const included = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench&limit=1&includeConversationId=cnv_issue1338_selected", aliceLogin.cookie); + assert.equal(included.status, 200); + assert.deepEqual(included.body.conversations.map((conversation) => conversation.conversationId), ["cnv_issue1338_selected", "cnv_issue1338_recent"]); + assert.equal(included.body.defaultConversation.conversationId, "cnv_issue1338_selected"); + assert.equal(included.body.count, 2); + + const invalidInclude = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench&includeConversationId=bad", aliceLogin.cookie); + assert.equal(invalidInclude.status, 400); + assert.equal(invalidInclude.body.error.code, "invalid_include_conversation_id"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api exposes terminal conversation status when stored session status is stale running", async () => { const server = createCloudApiServer({ env: { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 16ceaba4..03d93ff1 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1023,6 +1023,10 @@ class AccessController { if (!auth.ok) return sendJson(response, auth.status, auth); const projectId = textOr(url.searchParams.get("projectId"), ""); const limit = boundedListLimit(url.searchParams.get("limit")); + const includeConversationId = textOr(url.searchParams.get("includeConversationId"), ""); + if (includeConversationId && !safeConversationIdLocal(includeConversationId)) { + return sendJson(response, 400, errorPayload("invalid_include_conversation_id", "includeConversationId must start with cnv_", 400)); + } const sessions = await this.store.listAgentSessionsForUser?.({ ownerUserId: auth.actor.id, actorRole: auth.actor.role, @@ -1032,12 +1036,13 @@ class AccessController { includeArchived: false, now: this.now() }) ?? []; - const conversations = await this.repairVisibleConversationsIfNeeded( + let conversations = await this.repairVisibleConversationsIfNeeded( auth.actor, conversationsFromAgentSessions(sessions), projectId, { deadlineMs: 1500 } ); + conversations = includeConversation(conversations, includeConversationId ? await this.visibleConversationForActor(auth.actor, includeConversationId, projectId) : null); return sendJson(response, 200, { ok: true, status: "succeeded", @@ -2693,6 +2698,12 @@ function conversationsFromAgentSessions(sessions = []) { } return [...byConversation.values()].sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))); } +function includeConversation(conversations = [], included = null) { + const includedConversationId = textOr(included?.conversationId, ""); + if (!includedConversationId) return conversations; + if (conversations.some((conversation) => conversation.conversationId === includedConversationId)) return conversations; + return [included, ...conversations]; +} function publicAgentConversation(session) { const snapshot = normalizeObject(session.session); const rawMessages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; 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 299194d2..e0fcef45 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 @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; -import { defaultProviderProfileOptions, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; +import { defaultProviderProfileOptions, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; test("R2 composer ignores stale workspace activeTraceId without verified active message", () => { const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }); @@ -93,6 +93,11 @@ test("R2 session list shows loading until conversations are ready", () => { assert.equal(shouldShowSessionListLoading({ loading: false, conversationsReady: false }), false); }); +test("R2 session tabs render only conversations supplied by the list API", () => { + const tabs = sortSessionTabs([], "cnv_selected_only"); + assert.equal(tabs.length, 0); +}); + test("R2 drafts keep recent unique values and normalize corrupt storage", () => { const next = recordRecentDraft([{ text: "旧输入", ts: "2026-01-01T00:00:00.000Z" }], "新输入", "2026-01-02T00:00:00.000Z"); assert.deepEqual(next.map((item) => item.text), ["新输入", "旧输入"]); @@ -163,35 +168,12 @@ test("R2 session tabs do not infer running state from workspace stubs", () => { agentMessage({ conversationId: "cnv_r2", status: "running", updatedAt: "2026-01-04T00:01:00.000Z" }) ] }]; - const workspace = workspaceRecord({ activeTraceId: null, sessionStatus: "idle" }); - const merged = mergeSelectedConversation(conversations, workspace); - const tabs = sortSessionTabs(merged, "cnv_r2"); + const tabs = sortSessionTabs(conversations, "cnv_r2"); assert.equal(tabs[0]?.status, "unknown"); assert.equal(tabs[0]?.running, false); assert.equal(tabs[0]?.messageCount, 2); assert.equal(tabs[0]?.label, "保持原始标题"); - const authorityTabs = sortSessionTabs(merged, "cnv_r2", { ses_r2: { sessionId: "ses_r2", status: "running" } }); - assert.equal(authorityTabs[0]?.status, "running"); - assert.equal(authorityTabs[0]?.running, true); -}); - -test("R2 active in-flight turn updates tab content but not status without session authority", () => { - const messages: ChatMessage[] = [ - userMessage({ conversationId: "cnv_live", sessionId: "ses_live", text: "立即显示左侧运行态", createdAt: "2026-01-04T00:00:00.000Z" }), - agentMessage({ conversationId: "cnv_live", sessionId: "ses_live", status: "running", traceId: "trc_live", updatedAt: "2026-01-04T00:00:01.000Z" }) - ]; - const merged = mergeActiveConversationActivity([{ conversationId: "cnv_live", sessionId: "ses_live", status: "active", messageCount: 0 }], { - activeConversationId: "cnv_live", - messages, - currentRequest: { conversationId: "cnv_live", sessionId: "ses_live", traceId: "trc_live", status: "running" }, - chatPending: true - }); - const tabs = sortSessionTabs(merged, "cnv_live"); - assert.equal(tabs[0]?.status, "unknown"); - assert.equal(tabs[0]?.running, false); - assert.equal(tabs[0]?.label, "立即显示左侧运行态"); - assert.equal(tabs[0]?.messageCount, 2); - const authorityTabs = sortSessionTabs(merged, "cnv_live", { ses_live: { sessionId: "ses_live", status: "running" } }); + const authorityTabs = sortSessionTabs(conversations, "cnv_r2", { ses_r2: { sessionId: "ses_r2", status: "running" } }); assert.equal(authorityTabs[0]?.status, "running"); assert.equal(authorityTabs[0]?.running, true); }); @@ -221,32 +203,6 @@ test("R2 agent message titles drop transient running wording", () => { assert.equal(normalizeWorkbenchMessageTitle("user", "用户"), "用户"); }); -test("R2 session tabs keep the workspace-selected conversation when the list window omits it", () => { - const workspace: WorkspaceRecord = { - ...workspaceRecord({ activeTraceId: null, sessionStatus: "completed" }), - selectedConversationId: "cnv_selected_omitted", - selectedAgentSessionId: "ses_selected_omitted", - selectedConversation: { - conversationId: "cnv_selected_omitted", - sessionId: "ses_selected_omitted", - firstUserMessagePreview: "被列表窗口省略的当前会话", - updatedAt: "2026-01-05T00:00:00.000Z", - messages: [agentMessage({ conversationId: "cnv_selected_omitted", sessionId: "ses_selected_omitted", status: "completed", updatedAt: "2026-01-05T00:00:00.000Z" })] - }, - workspace: { - selectedConversationId: "cnv_selected_omitted", - selectedAgentSessionId: "ses_selected_omitted", - sessionStatus: "completed" - } - }; - const conversations: ConversationRecord[] = [{ conversationId: "cnv_other", sessionId: "ses_other", firstUserMessagePreview: "列表里较新的其他会话", updatedAt: "2026-01-06T00:00:00.000Z" }]; - const merged = mergeSelectedConversation(conversations, workspace); - assert.equal(merged.some((conversation) => conversation.conversationId === "cnv_selected_omitted"), true); - const tabs = sortSessionTabs(merged, "cnv_selected_omitted"); - assert.equal(tabs.find((tab) => tab.conversationId === "cnv_selected_omitted")?.active, true); - assert.equal(tabs.find((tab) => tab.conversationId === "cnv_selected_omitted")?.label, "被列表窗口省略的当前会话"); -}); - test("R2 session tab labels normalize object previews and reject object placeholders", () => { const tabs = sortSessionTabs([ { conversationId: "cnv_object", sessionId: "ses_object", firstUserMessagePreview: { text: "对象预览标题" } as unknown as string, updatedAt: "2026-01-03T00:00:00.000Z" }, diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index adb980b2..f2cc942b 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -1,12 +1,23 @@ import { fetchJson } from "./client"; import type { ApiResult, ConversationRecord, WorkspaceRecord } from "@/types"; +export interface ConversationListOptions { + includeConversationId?: 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): Promise> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }), + 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" }), 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 conversationListPath(projectId: string, options: ConversationListOptions): string { + const params = new URLSearchParams({ projectId }); + const includeConversationId = typeof options.includeConversationId === "string" ? options.includeConversationId.trim() : ""; + if (includeConversationId) params.set("includeConversationId", includeConversationId); + return `/v1/agent/conversations?${params.toString()}`; +} diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 55769c11..343f5ae7 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -41,13 +41,6 @@ export interface ProviderProfileOption { configured?: boolean; } -export interface ActiveConversationActivityInput { - activeConversationId: string | null; - messages: ChatMessage[]; - currentRequest?: { traceId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null; - chatPending: boolean; -} - export const RECENT_DRAFTS_STORAGE_KEY = "hwlab.workbench.recentDrafts.v1"; export const RECENT_DRAFTS_LIMIT = 8; const OBJECT_OBJECT_TEXT = "[object Object]"; @@ -116,20 +109,6 @@ export function shouldShowSessionListLoading(input: { loading: boolean; conversa return input.loading && !input.conversationsReady; } -export function mergeSelectedConversation(conversations: ConversationRecord[], workspace: WorkspaceRecord | null): ConversationRecord[] { - const selectedConversationId = selectedConversationIdFromWorkspace(workspace); - if (!selectedConversationId) return conversations; - const selected = selectedConversationFromWorkspace(workspace, selectedConversationId); - if (!selected) return conversations; - const existingIndex = conversations.findIndex((conversation) => conversation.conversationId === selectedConversationId); - if (existingIndex < 0) return [selected, ...conversations]; - const existing = conversations[existingIndex]; - if (!existing) return [selected, ...conversations]; - const next = conversations.slice(); - next[existingIndex] = mergeConversationRecords(existing, selected); - return next; -} - export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | null, traceId: string, reason: string, sessionStatus?: string | null): WorkspaceRecord | null { if (!workspace || !traceId) return workspace; const activeTraceId = activeTraceIdFromWorkspace(workspace); @@ -239,44 +218,6 @@ export function sortSessionTabs(conversations: ConversationRecord[], activeConve return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId, sessionStatusAuthority)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt)); } -export function mergeActiveConversationActivity(conversations: ConversationRecord[], input: ActiveConversationActivityInput): ConversationRecord[] { - const conversationId = firstNonEmptyString(input.activeConversationId, input.currentRequest?.conversationId, input.messages.find((message) => message.conversationId)?.conversationId); - if (!conversationId) return conversations; - const activityMessages = input.messages.filter((message) => !message.conversationId || message.conversationId === conversationId); - const latestAgent = latestAgentMessage(activityMessages); - const requestMatches = !input.currentRequest?.conversationId || input.currentRequest.conversationId === conversationId; - const liveStatus = requestMatches && isActiveStatus(input.currentRequest?.status) - ? input.currentRequest?.status - : input.chatPending && isActiveStatus(latestAgent?.status) - ? latestAgent?.status - : null; - if (!liveStatus || activityMessages.length === 0) return conversations; - const existing = conversations.find((conversation) => conversation.conversationId === conversationId) ?? { conversationId }; - const userMessage = activityMessages.find((message) => message.role === "user"); - const lastUserMessageAt = latestUserMessageAtFromMessages(activityMessages) ?? firstNonEmptyString(existing.lastUserMessageAt, existing.snapshot?.lastUserMessageAt) ?? null; - const updatedAt = lastUserMessageAt ?? conversationDisplayUpdatedAt(existing) ?? new Date().toISOString(); - const next: ConversationRecord = { - ...existing, - conversationId, - sessionId: firstNonEmptyString(input.currentRequest?.sessionId, latestAgent?.sessionId, existing.sessionId, existing.session?.sessionId) ?? null, - threadId: firstNonEmptyString(input.currentRequest?.threadId, latestAgent?.threadId, existing.threadId, existing.session?.threadId) ?? null, - status: liveStatus, - lastTraceId: firstNonEmptyString(input.currentRequest?.traceId, latestAgent?.traceId, latestAgent?.runnerTrace?.traceId, existing.lastTraceId) ?? null, - firstUserMessagePreview: firstNonEmptyString(existing.firstUserMessagePreview, existing.snapshot?.firstUserMessagePreview, userMessage?.text, userMessage?.content) ?? null, - messages: activityMessages, - messageCount: activityMessages.length, - lastUserMessageAt, - updatedAt, - snapshot: { - ...(existing.snapshot ?? {}), - sessionStatus: liveStatus, - ...(lastUserMessageAt ? { lastUserMessageAt } : {}), - updatedAt - } - }; - return [next, ...conversations.filter((conversation) => conversation.conversationId !== conversationId)]; -} - export function recordRecentDraft(existing: DraftEntry[], text: string, now = new Date().toISOString()): DraftEntry[] { const trimmed = text.trim(); if (!trimmed) return existing; @@ -307,72 +248,6 @@ function activeConversation(conversations: ConversationRecord[], conversationId: return conversations.find((item) => item.conversationId === conversationId) ?? null; } -function selectedConversationFromWorkspace(workspace: WorkspaceRecord | null, selectedConversationId: string): ConversationRecord | null { - const selected = workspace?.selectedConversation; - const stub = selectedConversationStub(workspace, selectedConversationId); - if (selected?.conversationId === selectedConversationId) return stub ? mergeConversationRecords(selected, stub) : selected; - return stub; -} - -function selectedConversationStub(workspace: WorkspaceRecord | null, selectedConversationId: string): ConversationRecord | null { - if (!workspace) return null; - const messages = Array.isArray(workspace.workspace?.messages) ? workspace.workspace.messages : []; - const sessionId = firstNonEmptyString(workspace.selectedAgentSessionId, workspace.workspace?.selectedAgentSessionId, messages.find((message) => message.sessionId)?.sessionId) ?? null; - const threadId = firstNonEmptyString(workspace.workspace?.threadId, messages.find((message) => message.threadId)?.threadId) ?? null; - const lastTraceId = firstNonEmptyString([...messages].reverse().find((message) => message.traceId)?.traceId, workspace.workspace?.lastTraceId, workspace.activeTraceId, workspace.workspace?.activeTraceId) ?? null; - const lastUserMessageAt = latestUserMessageAtFromMessages(messages); - const latestAgent = latestAgentMessage(messages); - const stub: ConversationRecord = { - conversationId: selectedConversationId, - projectId: firstNonEmptyString(workspace.projectId, workspace.workspace?.projectId) ?? null, - sessionId, - threadId, - status: firstNonEmptyString(workspace.workspace?.sessionStatus, latestAgent?.status, latestAgent?.runnerTrace?.status, messages.at(-1)?.status) ?? null, - updatedAt: lastUserMessageAt ?? firstNonEmptyString(workspace.createdAt, workspace.updatedAt, workspace.workspace?.updatedAt), - lastUserMessageAt, - lastTraceId, - messageCount: messages.length, - messages - }; - return { ...stub, status: resolveConversationSessionStatus(stub) }; -} - -function mergeConversationRecords(existing: ConversationRecord, selected: ConversationRecord): ConversationRecord { - const primary = existing; - const supplemental = selected; - const primaryMessages = primary.messages ?? []; - const supplementalMessages = supplemental.messages ?? []; - const messages = supplementalMessages.length > 0 ? supplementalMessages : primaryMessages; - const lastUserMessageAt = firstNonEmptyString(primary.lastUserMessageAt, primary.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(primaryMessages), supplemental.lastUserMessageAt, supplemental.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(supplementalMessages)) ?? null; - const status = resolveConversationSessionStatus({ ...primary, messages }, [ - supplemental.status, - supplemental.session?.status, - supplemental.snapshot?.sessionStatus, - supplemental.snapshot?.status, - statusFromMessages(messages) - ]); - return { - ...supplemental, - ...primary, - conversationId: primary.conversationId, - projectId: firstNonEmptyString(primary.projectId, supplemental.projectId) ?? null, - sessionId: firstNonEmptyString(primary.sessionId, primary.session?.sessionId, supplemental.sessionId, supplemental.session?.sessionId) ?? null, - threadId: firstNonEmptyString(primary.threadId, primary.session?.threadId, supplemental.threadId, supplemental.session?.threadId) ?? null, - status, - lastTraceId: firstNonEmptyString(latestAgentMessage(messages)?.traceId, messages.at(-1)?.traceId, primary.lastTraceId, supplemental.lastTraceId) ?? null, - firstUserMessagePreview: firstNonEmptyString(primary.firstUserMessagePreview, primary.snapshot?.firstUserMessagePreview, supplemental.firstUserMessagePreview, supplemental.snapshot?.firstUserMessagePreview) ?? null, - messageCount: messages.length > 0 ? messages.length : primary.messageCount ?? supplemental.messageCount ?? primaryMessages.length, - messages: messages.length > 0 ? messages : primary.messages, - lastUserMessageAt, - updatedAt: lastUserMessageAt ?? firstNonEmptyString(primary.startedAt, primary.updatedAt, primary.snapshot?.updatedAt, supplemental.startedAt, supplemental.updatedAt, supplemental.snapshot?.updatedAt) - }; -} - -function statusFromMessages(messages: ChatMessage[]): string | null { - const latestAgent = latestAgentMessage(messages); - return firstNonEmptyString(latestAgent?.status, latestAgent?.runnerTrace?.status, messages.at(-1)?.status) ?? null; -} - function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage | null { return [...(messages ?? [])].reverse().find((message) => message.role === "agent") ?? null; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 54ea991b..332fa8af 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,7 +4,7 @@ import { api } from "@/api"; import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, 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, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority } from "./workbench-session"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; @@ -31,7 +31,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const workspaceSelectionEpoch = ref(0); const sessionStatusAuthority = ref>({}); - const visibleConversations = computed(() => mergeSelectedConversation(conversations.value, workspace.value)); + 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)); @@ -51,17 +51,24 @@ export const useWorkbenchStore = defineStore("workbench", () => { conversationsReady.value = conversations.value.length > 0; loading.value = true; error.value = null; - const [workspaceResult, conversationsResult] = await Promise.all([api.workbench.workspace(projectId.value), api.workbench.conversations(projectId.value)]); - loading.value = false; - conversationsReady.value = true; + const workspaceResult = await api.workbench.workspace(projectId.value); if (!workspaceResult.ok) { + loading.value = false; error.value = workspaceResult.error ?? "workspace unavailable"; return; } const nextWorkspace = workspaceResult.data?.workspace ?? null; - const nextConversations = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : []; - conversations.value = nextConversations; - void refreshSessionStatusAuthority(mergeSelectedConversation(nextConversations, nextWorkspace)); + const conversationsResult = await api.workbench.conversations(projectId.value, { includeConversationId: selectedConversationIdFromWorkspace(nextWorkspace) }); + loading.value = false; + const nextConversations = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : conversations.value; + if (conversationsResult.ok) { + conversations.value = nextConversations; + conversationsReady.value = true; + void refreshSessionStatusAuthority(nextConversations); + } else { + 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; providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value; @@ -98,7 +105,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { messages.value = messagesFromWorkspace(workspace.value); currentRequest.value = null; await refreshSelectedSessionStatus(); - await refreshConversations(); + await refreshConversations(selectedConversationIdFromWorkspace(workspace.value)); } } @@ -123,6 +130,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { reattachRestoredActiveTrace(); currentRequest.value = null; await refreshSelectedSessionStatus(conversation); + await refreshConversations(conversation.conversationId); return; } if (response.status === 404 && await retrySelectConversationWithFreshWorkspace(conversation, tabProjectId, requestEpoch)) return; @@ -152,7 +160,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { error.value = response.error ?? "session URL not found"; return false; } - conversations.value = [conversation, ...conversations.value.filter((item) => item.conversationId !== normalized)]; void refreshSessionStatusById(sessionIdFromConversation(conversation)); await selectConversation(conversation); return activeConversationId.value === normalized; @@ -166,20 +173,23 @@ export const useWorkbenchStore = defineStore("workbench", () => { workspace.value = response.data?.workspace ?? workspace.value; messages.value = messagesFromWorkspace(workspace.value); currentRequest.value = null; - await refreshConversations(); + await refreshConversations(selectedConversationIdFromWorkspace(workspace.value)); } } - async function refreshConversations(): Promise { - const response = await api.workbench.conversations(activeProjectId.value); - conversationsReady.value = true; + async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise { + const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId }); if (response.ok) { conversations.value = response.data?.conversations ?? []; - await refreshSessionStatusAuthority(visibleConversations.value); + conversationsReady.value = true; + await refreshSessionStatusAuthority(conversations.value); + return; } + conversationsReady.value = conversations.value.length > 0; + if (conversations.value.length === 0) error.value = response.error ?? "session list unavailable"; } - async function refreshSessionStatusAuthority(source: ConversationRecord[] = visibleConversations.value): Promise { + async function refreshSessionStatusAuthority(source: ConversationRecord[] = conversations.value): Promise { await Promise.all(uniqueSessionIds(source).map((sessionId) => refreshSessionStatusById(sessionId))); } @@ -217,46 +227,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { }; } - function bumpConversationActivity(input: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; traceId?: string | null; status?: string | null; updatedAt?: string | null }): void { - const traceMessage = input.traceId ? messages.value.find((message) => message.traceId === input.traceId) : null; - const conversationId = firstNonEmptyString(input.conversationId, traceMessage?.conversationId, activeConversationId.value); - if (!conversationId) return; - const existing = conversations.value.find((conversation) => conversation.conversationId === conversationId) ?? null; - const activityMessages = activeConversationId.value === conversationId ? messages.value : messages.value.filter((message) => message.conversationId === conversationId); - const latestAgent = [...activityMessages].reverse().find((message) => message.role === "agent"); - const latestTrace = [...activityMessages].reverse().find((message) => message.traceId); - const userMessage = activityMessages.find((message) => message.role === "user"); - const status = firstNonEmptyString(input.status, latestAgent?.status, existing?.status) ?? existing?.status ?? null; - const sessionId = firstNonEmptyString(input.sessionId, traceMessage?.sessionId, latestAgent?.sessionId, existing?.sessionId, existing?.session?.sessionId, selectedSessionId.value) ?? null; - const threadId = firstNonEmptyString(input.threadId, traceMessage?.threadId, latestAgent?.threadId, existing?.threadId, existing?.session?.threadId, selectedThreadId.value) ?? null; - const lastTraceId = firstNonEmptyString(input.traceId, latestTrace?.traceId, existing?.lastTraceId) ?? null; - const nextMessages = activityMessages.length > 0 ? activityMessages : existing?.messages ?? []; - const lastUserMessageAt = latestUserMessageAtFromMessages(nextMessages) ?? firstNonEmptyString(existing?.lastUserMessageAt, existing?.snapshot?.lastUserMessageAt) ?? null; - const updatedAt = lastUserMessageAt ?? conversationDisplayUpdatedAt(existing ?? { conversationId }) ?? firstNonEmptyString(input.updatedAt, traceMessage?.updatedAt, latestAgent?.updatedAt, latestAgent?.runnerTrace?.updatedAt, latestTrace?.updatedAt, new Date().toISOString()) ?? new Date().toISOString(); - const firstUserMessagePreview = firstNonEmptyString(existing?.firstUserMessagePreview, existing?.snapshot?.firstUserMessagePreview, userMessage?.text) ?? null; - const next: ConversationRecord = { - ...(existing ?? { conversationId }), - conversationId, - projectId: existing?.projectId ?? activeProjectId.value, - sessionId, - threadId, - status, - lastTraceId, - updatedAt, - lastUserMessageAt, - messages: nextMessages, - messageCount: nextMessages.length || existing?.messageCount, - firstUserMessagePreview, - snapshot: { - ...(existing?.snapshot ?? {}), - ...(status ? { sessionStatus: status } : {}), - ...(lastUserMessageAt ? { lastUserMessageAt } : {}), - updatedAt - } - }; - conversations.value = [next, ...conversations.value.filter((conversation) => conversation.conversationId !== conversationId)]; - } - async function submitMessage(text: string): Promise { const value = text.trim(); if (!value) return; @@ -274,7 +244,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" }); const pending = makeMessage("agent", "", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent", retryInput: value }); messages.value.push(user, pending); - bumpConversationActivity({ conversationId, sessionId, threadId, traceId, status: "running" }); void refreshSessionStatusById(sessionId); chatPending.value = true; currentRequest.value = { traceId, conversationId, sessionId, threadId, status: "running" }; @@ -292,6 +261,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return; } void refreshSessionStatusById(sessionId); + void refreshConversations(conversationId); subscribe(traceId, response.data); } @@ -423,7 +393,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void { const trace = snapshotToRunnerTrace(snapshot); messages.value = messages.value.map((message) => message.traceId === traceId ? { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, trace), updatedAt: new Date().toISOString() } : message); - bumpConversationActivity({ traceId, sessionId: trace.sessionId, threadId: trace.threadId, status: firstNonEmptyString(trace.status), updatedAt: firstNonEmptyString(trace.updatedAt) }); void refreshSessionStatusById(trace.sessionId ?? selectedSessionId.value); } @@ -437,11 +406,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); return { ...message, status: terminalStatus, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); - bumpConversationActivity({ conversationId: firstNonEmptyString((result as Record).conversationId), sessionId: result.sessionId, threadId: result.threadId, traceId, status: terminalStatus, updatedAt: resultActivityUpdatedAt(result) }); chatPending.value = false; currentRequest.value = null; void clearActiveTrace(traceId, "trace-terminal", workspaceSessionStatusFromChatStatus(terminalStatus)); void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value); + void refreshConversations(firstNonEmptyString((result as Record).conversationId, activeConversationId.value)); } async function hydrateTerminalMessageDiagnostics(): Promise { @@ -464,8 +433,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { const status = statusFromResult(result.status); return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); - bumpConversationActivity({ conversationId: firstNonEmptyString((result as Record).conversationId), sessionId: result.sessionId, threadId: result.threadId, traceId, status: statusFromResult(result.status), updatedAt: resultActivityUpdatedAt(result) }); void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value); + void refreshConversations(firstNonEmptyString((result as Record).conversationId, activeConversationId.value)); } function failTrace(traceId: string, message: string): void { @@ -478,8 +447,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { function markMessage(traceId: string, patch: Partial): void { messages.value = messages.value.map((message) => message.traceId === traceId && message.role === "agent" ? { ...message, ...patch, updatedAt: new Date().toISOString() } : message); - const updated = messages.value.find((message) => message.traceId === traceId && message.role === "agent"); - bumpConversationActivity({ conversationId: updated?.conversationId, sessionId: updated?.sessionId, threadId: updated?.threadId, traceId, status: patch.status ?? updated?.status, updatedAt: updated?.updatedAt }); } async function ensureWorkspace(): Promise { @@ -540,9 +507,14 @@ export const useWorkbenchStore = defineStore("workbench", () => { reattachRestoredActiveTrace(); currentRequest.value = null; await refreshSelectedSessionStatus(conversation); + await refreshConversations(conversation.conversationId); return true; } + function currentListIncludeConversationId(): string | null { + return firstNonEmptyString(switchingConversationId.value, activeConversationId.value, selectedConversationIdFromWorkspace(workspace.value)); + } + function reattachRestoredActiveTrace(): void { const traceId = firstNonEmptyString(activeTraceIdFromMessages(messages.value), activeTraceIdFromWorkspace(workspace.value)); if (traceId) void validateAndReattachTrace(traceId); @@ -737,12 +709,6 @@ function recordValue(value: unknown): Record | null { return value && typeof value === "object" ? value as Record : null; } -function resultActivityUpdatedAt(result: AgentChatResultResponse): string | null { - const finalResponse = recordValue(result.finalResponse); - const reply = recordValue(result.reply); - return firstNonEmptyString(result.updatedAt, finalResponse?.updatedAt, reply?.updatedAt, finalResponse?.createdAt, reply?.createdAt); -} - function firstArray(...values: unknown[]): TraceEvent[] { for (const value of values) { if (Array.isArray(value)) return value as TraceEvent[];