diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 7303736f..2fd21d3a 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -151,6 +151,9 @@ assert.doesNotMatch(workbenchStoreSource, /function applyRealtimeEvent[\s\S]{0,9 assertIncludes(workbenchTimelineRuntimeSource, "TurnGap", "Timeline runtime must preserve OpenCode TurnGap row parity"); assertIncludes(workbenchTimelineRuntimeSource, "AssistantPart", "Timeline runtime must preserve OpenCode AssistantPart row parity"); assertIncludes(workbenchTimelineRuntimeSource, "unwrapErrorMessage", "Timeline runtime must preserve OpenCode readable error row behavior"); +assertIncludes(workbenchTimelineRuntimeSource, "WORKBENCH_TIMELINE_OPENCODE_PARITY", "Timeline runtime must carry an explicit OpenCode row parity table"); +assertIncludes(workbenchTimelineRuntimeSource, "not-applicable", "Timeline parity table must document HWLAB-not-applicable OpenCode rows"); +assertIncludes(workbenchTimelineRuntimeSource, "Permission", "Timeline parity table must explicitly account for OpenCode permission rows"); assertIncludes(workbenchScrollRuntimeSource, "createWorkbenchScrollPersistenceRuntime", "Scroll runtime must preserve OpenCode session/tab scroll persistence"); assertIncludes(conversationPanelSource, "sessionKey: scrollSessionKey", "ConversationPanel must use session-scoped scroll persistence in production"); assertIncludes(workbenchErrorRuntimeSource, "formatServerError", "Error runtime must preserve OpenCode readable server error formatting"); diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index 04b0c3e6..fdc58300 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -15,7 +15,7 @@ import { composeWorkbenchScopedKey, splitWorkbenchScopedKey, workbenchPathKey, w import { createSafeStorageRuntime, isStorageQuotaError, migrateLegacyStorage, normalizePersistedValue, readJsonStorage, removePersistedTarget, removeStorageKey, writeJsonStorage, type StorageLike } from "../src/utils/safe-storage.ts"; import { checkWorkbenchHealth, createWorkbenchHealthProbeCache } from "../src/utils/workbench-health.ts"; import { messageDiagnosticView } from "../src/utils/workbench-error-runtime.ts"; -import { buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts"; +import { WORKBENCH_TIMELINE_OPENCODE_PARITY, buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts"; import { reduceWorkbenchRealtimeEvent } from "../src/stores/workbench-event-reducer.ts"; import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery } from "../src/stores/workbench-realtime-plan.ts"; import { cleanupWorkbenchServerStateDroppedSessions, cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState } from "../src/stores/workbench-server-state.ts"; @@ -215,6 +215,21 @@ test("timeline model preserves OpenCode turn gap, thinking, retry and error row assert.ok(rows.some((row) => row.type === "Error" && row.text === "Provider: offline")); }); +test("timeline model documents OpenCode row parity and preserves comment/diff rows", () => { + assert.ok(WORKBENCH_TIMELINE_OPENCODE_PARITY.some((row) => row.openCodeRows === "Permission" && row.status === "not-applicable")); + assert.ok(WORKBENCH_TIMELINE_OPENCODE_PARITY.some((row) => row.openCodeRows === "Tool" && row.status === "adapted")); + + const rows = buildWorkbenchTimelineRows([ + { id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "review", status: "sent", createdAt: "2026-06-30T00:00:00.000Z", sessionId: "ses_a", traceId: "trc_a", comments: [{ text: "note" }] } as ChatMessage, + { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "done", status: "completed", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a", summary: { diffs: [{ file: "a.ts" }, { file: "a.ts" }, { file: "b.ts" }] } } as ChatMessage + ]); + + assert.ok(rows.some((row) => row.type === "CommentStrip")); + const diff = rows.find((row) => row.type === "DiffSummary"); + if (!diff || diff.type !== "DiffSummary") throw new Error("missing DiffSummary row"); + assert.equal(diff?.diffs?.length, 2); +}); + test("session cache trim keeps retained sessions and trims messages by explicit policy", () => { const result = trimWorkbenchSessionCache({ sessionOrder: ["ses_1", "ses_2", "ses_3"], diff --git a/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts b/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts index 7b649c7c..088eb47c 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts @@ -11,6 +11,29 @@ import { firstScopePart, workbenchTimelineRowScopeKey } from "@/utils/workbench- export type WorkbenchTimelineRowType = "TurnGap" | "CommentStrip" | "UserMessage" | "TurnDivider" | "AssistantPart" | "Thinking" | "Retry" | "DiffSummary" | "Error"; +export type WorkbenchTimelineOpenCodeParityStatus = "preserved" | "adapted" | "not-applicable"; + +export interface WorkbenchTimelineOpenCodeParityRow { + openCodeRows: string; + openCodeSource: string; + hwlabRows: WorkbenchTimelineRowType | "MessageTraceDebugPanel" | "none"; + status: WorkbenchTimelineOpenCodeParityStatus; + note: string; +} + +export const WORKBENCH_TIMELINE_OPENCODE_PARITY: readonly WorkbenchTimelineOpenCodeParityRow[] = Object.freeze([ + { openCodeRows: "TurnGap", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "TurnGap", status: "preserved", note: "User turn boundaries are rebuilt from authoritative ChatMessage order." }, + { openCodeRows: "UserMessage", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "UserMessage", status: "preserved", note: "User text remains a first-class row keyed by message identity." }, + { openCodeRows: "AssistantPart", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "AssistantPart", status: "adapted", note: "HWLAB uses read-model ChatMessage projection instead of OpenCode message parts." }, + { openCodeRows: "Thinking", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "Thinking", status: "adapted", note: "Running agent rows with no visible text keep the thinking placeholder and reasoning heading." }, + { openCodeRows: "Retry", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "Retry", status: "preserved", note: "Retry status produces an explicit retry row." }, + { openCodeRows: "Error", openCodeSource: "pages/session/timeline/rows.ts:284-329", hwlabRows: "Error", status: "preserved", note: "Provider/config JSON errors are unwrapped to readable error rows." }, + { openCodeRows: "DiffSummary", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "DiffSummary", status: "adapted", note: "HWLAB projects summary.diffs from read model and deduplicates by file." }, + { openCodeRows: "Tool", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "MessageTraceDebugPanel", status: "adapted", note: "Tool call details live in the trace timeline/debug panel; collapsed command previews are guarded separately." }, + { openCodeRows: "Permission", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "none", status: "not-applicable", note: "HWLAB Workbench does not expose an interactive OpenCode permission prompt row in the current read model." }, + { openCodeRows: "Question", openCodeSource: "pages/session/timeline/rows.ts:107-242", hwlabRows: "none", status: "not-applicable", note: "HWLAB Workbench has no OpenCode question row authority; user prompts remain UserMessage rows." } +]); + interface WorkbenchTimelineRowBase { type: WorkbenchTimelineRowType; key: string;