diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index b8ea7bb9..53d5363a 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -38,6 +38,7 @@ const requiredFiles = Object.freeze([ "src/stores/app.ts", "src/stores/auth.ts", "src/stores/workbench.ts", + "src/stores/workbench-event-reducer.ts", "src/stores/workbench-timeline-model.ts", "src/stores/workbench-session-cache.ts", "src/composables/useWorkbenchScrollRuntime.ts", @@ -78,6 +79,12 @@ const pkg = JSON.parse(readWeb("package.json")) as { dependencies?: Record { assert.deepEqual(splitWorkbenchScopedKey(workbenchRealtimeScopeKey("ses|one", "trc/two")), ["workbench.realtime", "ses|one", "trc/two"]); @@ -152,9 +154,24 @@ test("timeline model deduplicates authoritative messages with stable row keys", ]); assert.equal(rows.length, 2); - assert.deepEqual(rows.map((row) => `${row.role}:${row.message.text}`), ["user:hi", "agent:done"]); + const messageRows = rows.flatMap((row) => row.message ? [row] : []); + assert.deepEqual(messageRows.map((row) => `${row.role}:${row.message.text}`), ["user:hi", "agent:done"]); assert.match(workbenchTimelineSignature(rows), /completed:4/u); - assert.deepEqual(normalizeWorkbenchTimelineMessages(rows.map((row) => row.message)).map((message) => message.messageId), ["msg_user", "msg_agent"]); + assert.deepEqual(normalizeWorkbenchTimelineMessages(messageRows.map((row) => row.message)).map((message) => message.messageId), ["msg_user", "msg_agent"]); +}); + +test("timeline model preserves OpenCode turn gap, thinking, retry and error row parity", () => { + const rows = buildWorkbenchTimelineRows([ + { id: "msg_user_1", messageId: "msg_user_1", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-06-30T00:00:00.000Z", sessionId: "ses_a", traceId: "trc_a" }, + { id: "msg_agent_1", messageId: "msg_agent_1", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a" }, + { id: "msg_user_2", messageId: "msg_user_2", role: "user", title: "用户", text: "again", status: "sent", createdAt: "2026-06-30T00:00:02.000Z", sessionId: "ses_a", traceId: "trc_b" }, + { id: "msg_agent_2", messageId: "msg_agent_2", role: "agent", title: "Code Agent", text: "", status: "retry" as never, createdAt: "2026-06-30T00:00:03.000Z", sessionId: "ses_a", traceId: "trc_b", error: { message: "Error: {\"error\":{\"type\":\"Provider\",\"message\":\"offline\"}}" } } + ]); + + assert.ok(rows.some((row) => row.type === "TurnGap")); + assert.ok(rows.some((row) => row.type === "Thinking")); + assert.ok(rows.some((row) => row.type === "Retry")); + assert.ok(rows.some((row) => row.type === "Error" && row.text === "Provider: offline")); }); test("session cache trim keeps retained sessions and trims messages by explicit policy", () => { @@ -180,6 +197,49 @@ test("session cache trim keeps retained sessions and trims messages by explicit assert.deepEqual(Object.keys(result.state.sessionsById).sort(), ["ses_1", "ses_3"]); }); +test("session cache cleanup drops stale session/message caches with OpenCode cleanup parity", () => { + const result = cleanupDroppedWorkbenchSessionCaches({ + sessionOrder: ["ses_1", "ses_2"], + sessionsById: { ses_1: { sessionId: "ses_1" }, ses_2: { sessionId: "ses_2" } }, + messagesBySessionId: { ses_1: [], ses_2: [] } + }, ["ses_2"]); + + assert.deepEqual(result.droppedSessionIds, ["ses_1"]); + assert.deepEqual(result.state.sessionOrder, ["ses_2"]); + assert.deepEqual(Object.keys(result.state.sessionsById), ["ses_2"]); +}); + +test("server-state cleanup removes dropped session trace and turn authority", () => { + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId: "ses_1", messages: [{ id: "m1", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:00.000Z", traceId: "trc_1", runnerTrace: { traceId: "trc_1", sessionId: "ses_1", events: [] } }] } }); + state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId: "trc_1", status: "running", running: true, terminal: false, sessionId: "ses_1" } }); + state = reduceWorkbenchServerState(state, { type: "trace.snapshot", traceId: "trc_1", trace: { traceId: "trc_1", sessionId: "ses_1", events: [] } }); + + const cleaned = cleanupWorkbenchServerStateDroppedSessions(state, []); + assert.deepEqual(Object.keys(cleaned.messagesBySessionId), []); + assert.deepEqual(Object.keys(cleaned.turnStatusByTraceId), []); + assert.deepEqual(Object.keys(cleaned.traceById), []); + + const cleanedById = cleanupWorkbenchServerStateSessions(state, ["ses_1"]); + assert.deepEqual(Object.keys(cleanedById.messagesBySessionId), []); + assert.deepEqual(Object.keys(cleanedById.turnStatusByTraceId), []); + assert.deepEqual(Object.keys(cleanedById.traceById), []); +}); + +test("realtime event reducer classifies SSE payloads before store side effects", () => { + const trace = reduceWorkbenchRealtimeEvent({ type: "trace.event", traceId: "trc_1", event: { traceId: "trc_1", label: "delta" }, snapshot: { traceId: "trc_1", status: "running" } }, "workbench.trace.event"); + assert.equal(trace.activityLabel, "realtime:trace.event"); + assert.equal(trace.action.type, "trace.event"); + assert.equal(trace.diagnostic.module, "workbench-event-reducer"); + + const emptyMessage = reduceWorkbenchRealtimeEvent({ type: "message.snapshot" }, "workbench.message.snapshot"); + assert.deepEqual(emptyMessage.action, { type: "ignore", reason: "message.snapshot.missing-message" }); + + const error = reduceWorkbenchRealtimeEvent({ type: "error", traceId: "trc_2", error: { message: "offline" } }, "workbench.error"); + assert.equal(error.action.type, "projection.error"); + assert.equal(error.diagnostic.traceId, "trc_2"); +}); + test("health probe cache records ok and unavailable states", async () => { const cache = createWorkbenchHealthProbeCache({ cacheMs: 100 }); const ok = await cache.probe({ key: "workbench", fetcher: async () => ({ ready: true }), classify: (value) => value.ready ? "ok" : "degraded" }); diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index e137f262..6bd14225 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -26,7 +26,8 @@ import { traceLifecycleExpanded } from "./trace-lifecycle"; const workbench = useWorkbenchStore(); const clipboard = useClipboard(); const panelRef = ref(null); -const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(panelRef, { threshold: 56 }); +const scrollSessionKey = computed(() => workbench.activeSessionId ?? "workbench"); +const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(panelRef, { threshold: 56, sessionKey: scrollSessionKey, tabKey: "conversation" }); const detailMessageId = ref(null); const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages); const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value)); @@ -282,34 +283,41 @@ function formatDuration(ms: number): string {