diff --git a/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/design.md b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/design.md new file mode 100644 index 0000000..0d64c16 --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/design.md @@ -0,0 +1,108 @@ +## Context + +The user explicitly asked for both timeline phases to be designed +together with a shared data layer (delivered in Change 1) and for a +logarithmic time axis "from the start," without specifying which +direction. The plan for this change flagged the log-scale direction as +the one piece worth sanity-checking against real data before writing +it into a spec, rather than locking in a formula blind. + +## Goals / Non-Goals + +**Goals:** +- Several changes' activity is comparable side by side on one shared + axis, with a log scale chosen for a concrete, verified reason (not + "log scale sounds sophisticated"). +- No new charting library or canvas/SVG rendering — plain CSS + positioning (`left: X%`), per the explicit request to avoid heavy + chart infrastructure. +- Reuses Change 1's data layer and Change 2's `ChangeTimeline` type + completely unchanged. + +**Non-Goals:** +- Not building collision/overlap avoidance for near-simultaneous points + within a lane (e.g. nudging overlapping dots apart). The log-scale + fix already addresses the primary practical case (a dense + squash-merge cluster) by spreading it out — see the empirical check + below. Minor residual overlap for genuinely simultaneous points is + accepted for this first version rather than adding a general + collision-layout algorithm. +- Not adding a manual date-range text input to the VS Code extension's + Command Palette flow — no native date picker exists in VS Code's own + prompt UI, and typed ISO-date entry is worse UX than deriving a + sensible default from the data itself (see Decisions). + +## Decisions + +### Log-scale direction: from the range start, confirmed empirically + +Before writing any component code, real timestamps from this +repository's own archived changes (spanning 2026-08-26, 09:04 through +20:03, including a dense 8-change cluster between 09:04 and 11:03 — +this repository's own squash-merge workflow, exactly the case earlier +discussion in this session flagged as the common outcome) were plotted +under three candidate scales in a throwaway HTML/Playwright sketch: +linear, `log1p(elapsed since range start)`, and `log1p(remaining time +until range end)`. The "from start" direction visibly spread the dense +morning cluster into distinguishable points; "from end" compressed the +same cluster *tighter* than even the linear baseline, defeating the +purpose. `logPosition` (`timeline-scale.ts`) implements the "from +start" direction: + +``` +raw = (log1p(elapsed) / log1p(span)) * 100, clamped to [0, 100] +``` + +### Extension global command derives its date range from the data, not user input + +`computeDefaultRange` (`commands.ts`) takes the earliest/latest +determinable date across every selected change's created/task/archived +dates. This avoids a manual ISO-date-entry prompt (VS Code's +`showInputBox` has no date-picker affordance) while still producing a +sensible, data-driven range. The standalone app's web UI *does* offer +real `` pickers (a browser-native control VS Code's +prompt API doesn't have), so this asymmetry is a genuine capability +difference between the two hosts, not an inconsistency to paper over. + +### `archivedDate` is anchored to end-of-day, not midnight + +Found during this change's own verification (a real-browser smoke test +against real archived-change data): `archivedDate` is a plain calendar +date parsed from the archive folder name, with no time-of-day +information. Anchoring it to midnight (`T00:00:00.000Z`) made it +plot — and sort into the default range — *before* that same day's +actual `createdDate`/task timestamps, even though archiving is +chronologically the *last* thing that happens to a change. Anchored to +end-of-day (`T23:59:59.999Z`) instead, in both +`MultiChangeTimelineView.tsx`'s point-building and `commands.ts`'s +`computeDefaultRange`. + +### Timeline CSS was missing entirely — added as part of this change + +Also found during verification: neither this change's nor Change 2's +timeline components had any matching CSS in `shell-ui.ts`. For Change +2's single-change view this was a cosmetic gap (default HTML element +styling made it readable regardless). For this change's positioned-dots +layout it was a functional one — `left: X%` inline styles do nothing +without `position: relative` on the track and `position: absolute` on +each point, so the axis did not visually exist at all beforehand. Added +`.openspec-timeline-*` and `.openspec-multi-timeline-*` rules to +`shellThemeCss`, matching the existing CSS-variable palette +(`--surface`, `--surface-2`, `--line`, `--ink`, `--muted`, `--primary`, +`--danger`). + +## Risks / Trade-offs + +- **[Risk]** A smoke test against the bundled `dist/timeline.js` in a + bare browser page (not a real VS Code webview) showed the new track/ + point CSS resolving to transparent colors, because `vscodeThemeCss`'s + `:root` rules (which come after `shellThemeCss` in the concatenated + stylesheet and win) reference `--vscode-*` custom properties that + only exist inside a real VS Code webview, not a plain browser page. + → **Mitigation**: confirmed via computed styles that `position`/ + `left`/`transform` (the actual functional positioning) resolved + correctly regardless; the missing color is a known, pre-existing + limitation of this lightweight smoke-test technique (already true for + `AiPanel`'s own styling, not something introduced by this change), + not a defect inside a real VS Code window, where `--vscode-*` + variables are genuinely defined by the host. diff --git a/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/proposal.md b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/proposal.md new file mode 100644 index 0000000..279d8b1 --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/proposal.md @@ -0,0 +1,72 @@ +## Why + +Third and final change building the "change timeline" feature (see +`2026-08-26-add-change-timeline-data-layer` and +`2026-08-26-add-change-timeline-view`). This adds Phase 2: a global +"compare changes" view showing several changes in parallel lanes on a +shared, log-scale time axis with a date-range picker — confirmed +explicitly by the user, including the log-scale requirement, and +verified empirically against this repository's own real archived-change +timestamps before implementation (see design.md, "Log-scale direction"). + +## What Changes + +- Add `packages/webui/src/timeline-scale.ts` (`logPosition`): a + log1p-from-range-start position function, 0-100, clamped. Validated + against real data: this direction spreads a dense cluster of + near-simultaneous changes (this project's own squash-merge workflow + produces exactly this) into something readable; the opposite + direction (log from the range end) made the same cluster *more* + overlapped than a plain linear scale. +- Add `packages/webui/src/components/MultiChangeTimelineView.tsx`: one + lane per selected change, plotting created/task/archived points along + the shared axis. Reuses Change 1's `getChangeTimelines` batch + function and Change 2's `ChangeTimeline` type unchanged — no changes + to the data layer. +- Standalone app: a "Single change" / "Compare changes" mode toggle + within the existing Timeline tab (not a new tab) — a date-range + picker plus a multi-select change list, calling `loadChangeTimelines` + via real REST. +- VS Code extension: a new global command, + `openspec-ui.showAllChangesTimeline` (Command Palette only, no tree + item — matching the `openspec-ui.openspecView` precedent), with a + multi-select `showQuickPick` over active and archived changes. The + date range is derived automatically from the selected changes' own + data (earliest/latest determinable date) rather than asking the user + to type ISO dates, since VS Code's prompt UI has no native date + picker. +- `TimelineWebviewPanel` gains `showMulti(...)`, reusing the same + not-a-singleton, embed-the-data-in-initial-HTML approach as `show()` + (Change 2); `timeline-entry.tsx` renders whichever of + `window.__OPENSPEC_UI_TIMELINE__` / `__OPENSPEC_UI_MULTI_TIMELINE__` + is present. +- Add CSS for both this change's and Change 2's timeline classes to + `packages/webui/src/shell-ui.ts` — a real gap found during this + change's own verification: the positioned-dots-on-an-axis layout is + the actual point of the feature, and it did not render as a + positioned axis at all without `position: relative`/`absolute` rules, + which had never been added. +- Fix: an archived change's `archivedDate` (a plain calendar date, no + time-of-day) was anchored to midnight when computing plot positions + and default ranges, which could sort it *before* that same day's real + created/task timestamps — since archiving is chronologically last, + it is now anchored to end-of-day instead. + +## Capabilities + +### Modified Capabilities + +- `vscode-extension`: adds a Requirement for the global comparison + command. +- `standalone-app`: adds a Requirement for the compare-changes mode. + +## Impact + +- `packages/webui/src/timeline-scale.ts` (new) +- `packages/webui/src/components/MultiChangeTimelineView.tsx` (new) +- `packages/webui/src/standalone-entry.tsx` +- `packages/webui/src/timeline-entry.tsx` +- `packages/webui/src/shell-ui.ts` +- `packages/extension/src/webview/timeline-panel.ts` +- `packages/extension/src/commands.ts`, `package.json` +- `.changeset/*.md` (new changeset file) diff --git a/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/specs/standalone-app/spec.md b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/specs/standalone-app/spec.md new file mode 100644 index 0000000..1f93e7c --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/specs/standalone-app/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: The Timeline tab offers a compare-changes mode with a date-range picker + +The system SHALL offer, within the existing Timeline tab, a mode that +lets the user pick a date range and select multiple active and/or +archived changes, then shows them as parallel lanes on a shared, +log-scaled time axis. + +#### Scenario: User picks a date range and multiple changes + +- **WHEN** the user selects a start date, an end date, and multiple + changes, then loads the comparison +- **THEN** the tab shows one lane per selected change within that range + +#### Scenario: User has not selected a range or any changes + +- **WHEN** the user attempts to load a comparison without a complete + date range or without selecting any change +- **THEN** the system reports what is missing rather than loading an + empty or partial comparison diff --git a/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/specs/vscode-extension/spec.md b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/specs/vscode-extension/spec.md new file mode 100644 index 0000000..af6fea6 --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/specs/vscode-extension/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: A global command compares several changes on a shared timeline + +The system SHALL offer a Command Palette command, not tied to any +single tree item, that lets the user select multiple active and/or +archived changes and shows them as parallel lanes on a shared, +log-scaled time axis derived from the selected changes' own data. The +axis SHALL use a logarithmic scale from the range start, chosen because +it spreads a dense cluster of near-simultaneous changes into readable +detail rather than compressing it further. + +#### Scenario: User selects changes across active and archived + +- **WHEN** the user invokes "Show Change Comparison Timeline" and + selects both active and archived changes +- **THEN** a webview opens showing one lane per selected change, points + positioned by their best-effort dates + +#### Scenario: User selects no changes + +- **WHEN** the user cancels the selection without picking any change +- **THEN** no webview opens + +#### Scenario: The comparison computation fails + +- **WHEN** fetching the selected changes' timelines throws +- **THEN** the extension shows an error message and does not open a + webview diff --git a/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/tasks.md b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/tasks.md new file mode 100644 index 0000000..de6fd10 --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-multi-change-timeline-view/tasks.md @@ -0,0 +1,76 @@ +## 1. Log-scale util + +- [x] 1.1 Before writing any component, validate the log-scale + direction against real data: a throwaway HTML/Playwright sketch using + this repository's own real archived-change timestamps (a dense + 8-change cluster plus sparser later changes), comparing linear, + "log from range start," and "log from range end." Confirmed "from + start" spreads the dense cluster into readable points; "from end" + compressed it tighter than linear. +- [x] 1.2 Add `packages/webui/src/timeline-scale.ts` (`logPosition`) + implementing the confirmed direction, clamped to `[0, 100]`. +- [x] 1.3 Add `timeline-scale.test.ts`. + +## 2. Webui: multi-change component + +- [x] 2.1 Add `packages/webui/src/components/MultiChangeTimelineView.tsx`: + props `{ timelines, rangeStart, rangeEnd }`, one lane per timeline, + created/task/archived points plotted via `logPosition`. Archived + points anchored to end-of-day (`T23:59:59.999Z`), not midnight (see + design.md). +- [x] 2.2 Add `MultiChangeTimelineView.test.tsx`. +- [x] 2.3 Add `.openspec-timeline-*`/`.openspec-multi-timeline-*` CSS to + `packages/webui/src/shell-ui.ts` — found missing entirely during + verification; required for the positioned-axis layout to render at + all, not just cosmetic (see design.md). + +## 3. Standalone app: compare-changes mode + +- [x] 3.1 Add a "Single change" / "Compare changes" mode toggle within + the existing Timeline tab in `standalone-entry.tsx`: date-range + `` pair, a multi-select change list, and + `loadChangeTimelines` via real REST. + +## 4. Extension: global comparison command + +- [x] 4.1 Add `pickChangesForTimeline` (multi-select `showQuickPick` + over active and archived changes) and `computeDefaultRange` + (data-derived date range — no manual date entry, see design.md) to + `commands.ts`. +- [x] 4.2 Register `openspec-ui.showAllChangesTimeline` (Command + Palette only, no tree item, matching `openspec-ui.openspecView`'s + precedent), calling `getChangeTimelines` directly and + `timelinePanel.showMulti(...)`. +- [x] 4.3 Add `showMulti(...)` to `TimelineWebviewPanel`, reusing the + same not-a-singleton, embed-in-initial-HTML approach as `show()`. +- [x] 4.4 Extend `timeline-entry.tsx` to render whichever of + `window.__OPENSPEC_UI_TIMELINE__` / `__OPENSPEC_UI_MULTI_TIMELINE__` + is present. +- [x] 4.5 Add `contributes.commands` entry in `package.json`. +- [x] 4.6 Add tests to `commands.test.ts`: picks across active and + archived, computes the expected default range, calls `showMulti`; + does nothing when no changes are picked. + +## 5. Verification + +- [x] 5.1 `npm run typecheck` and `npm run lint` (including + `lint:english`) pass workspace-wide. +- [x] 5.2 `npm run test` passes workspace-wide, including all new test + files. +- [x] 5.3 Rebuild the VSIX (`npm run package --workspace + openspec-ui-vscode`) and confirm it packages without error. +- [x] 5.4 Live smoke test: same pre-existing, environment-specific + `@vscode/test-electron` failure reproduces on this machine + (documented since `2026-08-26-signal-run-completion`, unrelated to + this change). As a substitute, verified the built `dist/timeline.js` + bundle in a real Chromium browser (Playwright) loading real + `getChangeTimelines` data for 7 of this repository's own archived + changes: correct lane count, correct point count, zero console + errors, and — after finding and fixing the missing CSS — confirmed + via computed styles that `position`/`left`/`transform` resolve + correctly on each plotted point. +- [x] 5.5 Propose a changeset (`npx changeset`) for `openspec-ui-vscode` + and `@openspec-ui/webui` (both minor: new capability, no breaking + change) instead of hand-editing `version`/`CHANGELOG.md`; apply it + via `npx changeset version`. +- [x] 5.6 Run `openspec change validate --strict add-multi-change-timeline-view`. diff --git a/openspec/specs/standalone-app/spec.md b/openspec/specs/standalone-app/spec.md index 2b3d415..d0c43c6 100644 --- a/openspec/specs/standalone-app/spec.md +++ b/openspec/specs/standalone-app/spec.md @@ -223,3 +223,23 @@ with pending or undated tasks shown distinctly rather than omitted. - **THEN** it is shown without a date rather than omitted or given a misleading date +### Requirement: The Timeline tab offers a compare-changes mode with a date-range picker + +The system SHALL offer, within the existing Timeline tab, a mode that +lets the user pick a date range and select multiple active and/or +archived changes, then shows them as parallel lanes on a shared, +log-scaled time axis. + +#### Scenario: User picks a date range and multiple changes + +- **WHEN** the user selects a start date, an end date, and multiple + changes, then loads the comparison +- **THEN** the tab shows one lane per selected change within that range + +#### Scenario: User has not selected a range or any changes + +- **WHEN** the user attempts to load a comparison without a complete + date range or without selecting any change +- **THEN** the system reports what is missing rather than loading an + empty or partial comparison + diff --git a/openspec/specs/vscode-extension/spec.md b/openspec/specs/vscode-extension/spec.md index 12b2ad1..59afaa4 100644 --- a/openspec/specs/vscode-extension/spec.md +++ b/openspec/specs/vscode-extension/spec.md @@ -341,3 +341,31 @@ SHALL each open in their own tab, not replace one another. - **THEN** the extension shows an error message and does not open a webview +### Requirement: A global command compares several changes on a shared timeline + +The system SHALL offer a Command Palette command, not tied to any +single tree item, that lets the user select multiple active and/or +archived changes and shows them as parallel lanes on a shared, +log-scaled time axis derived from the selected changes' own data. The +axis SHALL use a logarithmic scale from the range start, chosen because +it spreads a dense cluster of near-simultaneous changes into readable +detail rather than compressing it further. + +#### Scenario: User selects changes across active and archived + +- **WHEN** the user invokes "Show Change Comparison Timeline" and + selects both active and archived changes +- **THEN** a webview opens showing one lane per selected change, points + positioned by their best-effort dates + +#### Scenario: User selects no changes + +- **WHEN** the user cancels the selection without picking any change +- **THEN** no webview opens + +#### Scenario: The comparison computation fails + +- **WHEN** fetching the selected changes' timelines throws +- **THEN** the extension shows an error message and does not open a + webview + diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 0473ea3..b36141c 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.24.0 + +### Minor Changes + +- Add a "compare changes" timeline: a new global command + (`openspec-ui.showAllChangesTimeline`) and a standalone Timeline-tab + mode that show several changes as parallel lanes on a shared, + log-scaled time axis (verified against real archived-change data + before choosing the log-scale direction). Also adds the CSS the + single-change timeline view needed but was missing, and fixes archived + dates plotting before same-day created/task timestamps. + +### Patch Changes + +- Updated dependencies + - @openspec-ui/webui@1.13.0 + ## 0.23.0 ### Minor Changes diff --git a/packages/extension/package.json b/packages/extension/package.json index 15f0ae8..87156a2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,7 +4,7 @@ "displayName": "OpenSpec Workbench", "description": "A dashboard + VS Code extension for OpenSpec, with Claude, Copilot, Codex, and Gemini agents built in.", "publisher": "openspec-ui", - "version": "0.23.0", + "version": "0.24.0", "icon": "media/icon.png", "license": "MIT", "repository": { @@ -107,6 +107,10 @@ "command": "openspec-ui.openspecView", "title": "OpenSpec UI: OpenSpec View (CLI)" }, + { + "command": "openspec-ui.showAllChangesTimeline", + "title": "OpenSpec UI: Show Change Comparison Timeline" + }, { "command": "openspec-ui.showChangeDetails", "title": "OpenSpec UI: Show Change Details" diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index 1565255..61cb0ce 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -12,6 +12,8 @@ const validateChangeMock = vi.fn(); const archiveChangeMock = vi.fn(); const checkChangesetReminderMock = vi.fn(); const getChangeTimelineMock = vi.fn(); +const getChangeTimelinesMock = vi.fn(); +const discoverOpenSpecWorkspaceMock = vi.fn(); const createChangeMock = vi.fn(); const deleteChangeMock = vi.fn(); const unarchiveChangeMock = vi.fn(); @@ -36,7 +38,9 @@ vi.mock("@openspec-ui/core", () => ({ deleteChange: (...args: unknown[]) => deleteChangeMock(...args), deleteProjectTemplate: (...args: unknown[]) => deleteProjectTemplateMock(...args), deleteTaskLine: (...args: unknown[]) => deleteTaskLineMock(...args), + discoverOpenSpecWorkspace: (...args: unknown[]) => discoverOpenSpecWorkspaceMock(...args), getChangeTimeline: (...args: unknown[]) => getChangeTimelineMock(...args), + getChangeTimelines: (...args: unknown[]) => getChangeTimelinesMock(...args), initOpenSpec: (...args: unknown[]) => initOpenSpecMock(...args), listBootstrapProjectTypes: () => [ { id: "node", label: "Node.js / TypeScript" }, @@ -62,8 +66,12 @@ const openDiffAgainstHeadMock = vi.fn(); vi.mock("./native/diff.js", () => ({ openDiffAgainstHead: (...args: unknown[]) => openDiffAgainstHeadMock(...args) })); const timelinePanelShowMock = vi.fn(); +const timelinePanelShowMultiMock = vi.fn(); vi.mock("./webview/timeline-panel.js", () => ({ - TimelineWebviewPanel: vi.fn().mockImplementation(() => ({ show: timelinePanelShowMock })), + TimelineWebviewPanel: vi.fn().mockImplementation(() => ({ + show: timelinePanelShowMock, + showMulti: timelinePanelShowMultiMock, + })), })); const { registerCommands } = await import("./commands.js"); @@ -138,6 +146,7 @@ describe("registerCommands", () => { "openspec-ui.createChange", "openspec-ui.validateSelectedChange", "openspec-ui.showChangeTimeline", + "openspec-ui.showAllChangesTimeline", "openspec-ui.archiveChange", "openspec-ui.unarchiveChange", "openspec-ui.deleteChange", @@ -210,6 +219,65 @@ describe("registerCommands", () => { ); }); + it("picks changes across active and archived, fetches, and shows a comparison", async () => { + discoverOpenSpecWorkspaceMock.mockResolvedValue({ + changes: [{ name: "active-change" }], + archivedChanges: [{ name: "2026-01-01-old-change" }], + }); + vscodeMock.window.showQuickPick.mockResolvedValue([ + { label: "active-change", description: "active", archived: false }, + { label: "2026-01-01-old-change", description: "archived", archived: true }, + ]); + const timelines = [ + { + changeName: "active-change", + archived: false, + createdDate: "2026-01-02T00:00:00.000Z", + archivedDate: null, + tasks: [], + }, + { + changeName: "2026-01-01-old-change", + archived: true, + createdDate: null, + archivedDate: "2026-01-01", + tasks: [], + }, + ]; + getChangeTimelinesMock.mockResolvedValue(timelines); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.showAllChangesTimeline")?.(); + + expect(getChangeTimelinesMock).toHaveBeenCalledWith("/workspace/repo", [ + { changeName: "active-change", archived: false }, + { changeName: "2026-01-01-old-change", archived: true }, + ]); + expect(timelinePanelShowMultiMock).toHaveBeenCalledWith( + expect.objectContaining({ + timelines, + rangeStart: "2026-01-01T23:59:59.999Z", + rangeEnd: "2026-01-02T00:00:00.000Z", + }), + ); + }); + + it("does nothing when no changes are picked for comparison", async () => { + discoverOpenSpecWorkspaceMock.mockResolvedValue({ + changes: [{ name: "active-change" }], + archivedChanges: [], + }); + vscodeMock.window.showQuickPick.mockResolvedValue(undefined); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.showAllChangesTimeline")?.(); + + expect(getChangeTimelinesMock).not.toHaveBeenCalled(); + expect(timelinePanelShowMultiMock).not.toHaveBeenCalled(); + }); + it("archives a confirmed active change and refreshes", async () => { vscodeMock.window.showWarningMessage.mockResolvedValue("Archive"); archiveChangeMock.mockResolvedValue({ ok: true }); diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index 973436c..5f41d41 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -16,7 +16,9 @@ import { deleteChange, deleteProjectTemplate, deleteTaskLine, + discoverOpenSpecWorkspace, getChangeTimeline, + getChangeTimelines, initOpenSpec, listBootstrapProjectTypes, listChanges, @@ -31,6 +33,7 @@ import { writeSubtypeInstructions, type StartProcessOptions, type WorkbenchProcessScheduler, + type ChangeTimeline, type Command, type OpenSpecShowResult, type OpenSpecValidateResult, @@ -243,6 +246,55 @@ async function pickChange(workspaceRoot: string): Promise<{ name: string; change return { name: pick.label, changeDir: path.join(workspaceRoot, "openspec", "changes", pick.label) }; } +async function pickChangesForTimeline( + workspaceRoot: string, +): Promise | undefined> { + const workspace = await discoverOpenSpecWorkspace(workspaceRoot); + const items = [ + ...workspace.changes.map((c) => ({ label: c.name, description: "active", archived: false })), + ...workspace.archivedChanges.map((c) => ({ label: c.name, description: "archived", archived: true })), + ]; + if (items.length === 0) { + void vscode.window.showWarningMessage("OpenSpec UI: no changes found in openspec/changes/."); + return undefined; + } + const picks = await vscode.window.showQuickPick(items, { + placeHolder: "Select changes to compare", + canPickMany: true, + }); + if (!picks || picks.length === 0) return undefined; + return picks.map((pick) => ({ changeName: pick.label, archived: pick.archived })); +} + +/** The date-range axis for the multi-change view is derived from the + * selected changes' own data (earliest/latest of every created/task/ + * archived date) rather than asking the user to type ISO dates — no + * native date picker exists in VS Code's own prompt UI, and the data's + * own extent is a reasonable default range. Falls back to a 1-day + * window around now if no change carries any determinable date. */ +function computeDefaultRange(timelines: ChangeTimeline[]): { rangeStart: string; rangeEnd: string } { + const dates: string[] = []; + for (const timeline of timelines) { + if (timeline.createdDate) dates.push(timeline.createdDate); + // Archiving is chronologically last, but archivedDate has no + // time-of-day (parsed from the folder name) — end-of-day avoids it + // sorting before that same day's actual created/task timestamps. + if (timeline.archived && timeline.archivedDate) dates.push(`${timeline.archivedDate}T23:59:59.999Z`); + for (const task of timeline.tasks) { + if (task.date) dates.push(task.date); + } + } + if (dates.length === 0) { + const now = Date.now(); + return { + rangeStart: new Date(now - 12 * 60 * 60 * 1000).toISOString(), + rangeEnd: new Date(now + 12 * 60 * 60 * 1000).toISOString(), + }; + } + const sorted = [...dates].sort(); + return { rangeStart: sorted[0] as string, rangeEnd: sorted[sorted.length - 1] as string }; +} + export function registerCommands(context: vscode.ExtensionContext, deps: CommandsDeps): void { const timelinePanel = new TimelineWebviewPanel({ extensionUri: context.extensionUri }); context.subscriptions.push( @@ -797,6 +849,25 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command }), ); + context.subscriptions.push( + vscode.commands.registerCommand("openspec-ui.showAllChangesTimeline", async () => { + const workspaceRoot = deps.getWorkspaceRoot(); + if (!workspaceRoot) { + void vscode.window.showErrorMessage("OpenSpec UI: open a folder or workspace first."); + return; + } + const entries = await pickChangesForTimeline(workspaceRoot); + if (!entries) return; + try { + const timelines = await getChangeTimelines(workspaceRoot, entries); + const { rangeStart, rangeEnd } = computeDefaultRange(timelines); + timelinePanel.showMulti({ timelines, rangeStart, rangeEnd }); + } catch (error) { + await showCommandError("show change comparison", error); + } + }), + ); + context.subscriptions.push( vscode.commands.registerCommand("openspec-ui.showChangeDetails", async () => { const workspaceRoot = deps.getWorkspaceRoot(); diff --git a/packages/extension/src/webview/timeline-panel.ts b/packages/extension/src/webview/timeline-panel.ts index 70acfb0..0c581da 100644 --- a/packages/extension/src/webview/timeline-panel.ts +++ b/packages/extension/src/webview/timeline-panel.ts @@ -7,6 +7,12 @@ import * as vscode from "vscode"; import type { ChangeTimeline } from "@openspec-ui/core"; +interface MultiChangeTimelinePayload { + timelines: ChangeTimeline[]; + rangeStart: string; + rangeEnd: string; +} + export class TimelineWebviewPanel { constructor(private readonly deps: { extensionUri: vscode.Uri }) { } @@ -14,24 +20,36 @@ export class TimelineWebviewPanel { * singleton (unlike `AiPanel`): opening timelines for different * changes yields separate tabs a user can compare side by side. */ show(changeName: string, timeline: ChangeTimeline): void { - const panel = vscode.window.createWebviewPanel( + const panel = this.createPanel(`OpenSpec UI: ${changeName} timeline`); + panel.webview.html = this.getHtml(panel.webview, "__OPENSPEC_UI_TIMELINE__", timeline); + } + + /** Same not-a-singleton shape as `show()`, for the multi-change + * comparison view (see openspec/changes/ + * add-multi-change-timeline-view/design.md). */ + showMulti(payload: MultiChangeTimelinePayload): void { + const panel = this.createPanel("OpenSpec UI: change comparison"); + panel.webview.html = this.getHtml(panel.webview, "__OPENSPEC_UI_MULTI_TIMELINE__", payload); + } + + private createPanel(title: string): vscode.WebviewPanel { + return vscode.window.createWebviewPanel( "openspecUiChangeTimeline", - `OpenSpec UI: ${changeName} timeline`, + title, vscode.ViewColumn.Beside, { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(this.deps.extensionUri, "dist")], }, ); - panel.webview.html = this.getHtml(panel.webview, timeline); } - private getHtml(webview: vscode.Webview, timeline: ChangeTimeline): string { + private getHtml(webview: vscode.Webview, globalName: string, payload: unknown): string { const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.deps.extensionUri, "dist", "timeline.js")); const csp = `default-src 'none'; script-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline';`; // `<` -> `<` prevents an embedded `` sequence (e.g. inside // markdown content) from closing the script tag early. - const timelineJson = JSON.stringify(timeline).replaceAll("<", "\\u003c"); + const payloadJson = JSON.stringify(payload).replaceAll("<", "\\u003c"); return ` @@ -41,7 +59,7 @@ export class TimelineWebviewPanel {
- + `; diff --git a/packages/webui/CHANGELOG.md b/packages/webui/CHANGELOG.md index 5539db9..58f25c0 100644 --- a/packages/webui/CHANGELOG.md +++ b/packages/webui/CHANGELOG.md @@ -1,5 +1,17 @@ # @openspec-ui/webui +## 1.13.0 + +### Minor Changes + +- Add a "compare changes" timeline: a new global command + (`openspec-ui.showAllChangesTimeline`) and a standalone Timeline-tab + mode that show several changes as parallel lanes on a shared, + log-scaled time axis (verified against real archived-change data + before choosing the log-scale direction). Also adds the CSS the + single-change timeline view needed but was missing, and fixes archived + dates plotting before same-day created/task timestamps. + ## 1.12.0 ### Minor Changes diff --git a/packages/webui/package.json b/packages/webui/package.json index af5a3b6..30ce14d 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/webui", "private": true, - "version": "1.12.0", + "version": "1.13.0", "type": "module", "main": "src/index.ts", "dependencies": { diff --git a/packages/webui/src/components/MultiChangeTimelineView.test.tsx b/packages/webui/src/components/MultiChangeTimelineView.test.tsx new file mode 100644 index 0000000..d458b02 --- /dev/null +++ b/packages/webui/src/components/MultiChangeTimelineView.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MultiChangeTimelineView } from "./MultiChangeTimelineView.js"; +import type { ChangeTimeline } from "../change-timeline-client.js"; + +const rangeStart = "2026-01-01T00:00:00.000Z"; +const rangeEnd = "2026-01-11T00:00:00.000Z"; + +const timelineA: ChangeTimeline = { + changeName: "change-a", + archived: true, + createdDate: "2026-01-02T00:00:00.000Z", + archivedDate: "2026-01-03", + proposal: "", + design: "", + specs: [], + tasks: [{ lineNumber: 0, text: "done task", done: true, date: "2026-01-02T12:00:00.000Z" }], +}; + +const timelineB: ChangeTimeline = { + changeName: "change-b", + archived: false, + createdDate: "2026-01-05T00:00:00.000Z", + archivedDate: null, + proposal: "", + design: "", + specs: [], + tasks: [{ lineNumber: 0, text: "pending task", done: false, date: null }], +}; + +describe("MultiChangeTimelineView", () => { + it("renders one lane per timeline", () => { + render(); + + expect(screen.getByTestId("multi-timeline-lane-change-a")).toBeInTheDocument(); + expect(screen.getByTestId("multi-timeline-lane-change-b")).toBeInTheDocument(); + }); + + it("plots created, task, and archived points for an archived change", () => { + render(); + + const lane = screen.getByTestId("multi-timeline-lane-change-a"); + expect(lane.querySelectorAll(".openspec-multi-timeline-point-created")).toHaveLength(1); + expect(lane.querySelectorAll(".openspec-multi-timeline-point-task")).toHaveLength(1); + expect(lane.querySelectorAll(".openspec-multi-timeline-point-archived")).toHaveLength(1); + }); + + it("plots only a created point for an active change with no completed tasks", () => { + render(); + + const lane = screen.getByTestId("multi-timeline-lane-change-b"); + expect(lane.querySelectorAll(".openspec-multi-timeline-point-created")).toHaveLength(1); + expect(lane.querySelectorAll(".openspec-multi-timeline-point-task")).toHaveLength(0); + expect(lane.querySelectorAll(".openspec-multi-timeline-point-archived")).toHaveLength(0); + }); + + it("positions the range start and end labels", () => { + render(); + expect(screen.getByText(new Date(rangeStart).toLocaleDateString())).toBeInTheDocument(); + expect(screen.getByText(new Date(rangeEnd).toLocaleDateString())).toBeInTheDocument(); + }); + + it("shows a message when no changes are selected", () => { + render(); + expect(screen.getByText("No changes selected.")).toBeInTheDocument(); + }); +}); diff --git a/packages/webui/src/components/MultiChangeTimelineView.tsx b/packages/webui/src/components/MultiChangeTimelineView.tsx new file mode 100644 index 0000000..ac03e59 --- /dev/null +++ b/packages/webui/src/components/MultiChangeTimelineView.tsx @@ -0,0 +1,82 @@ +// 2.1 Presentational, transport-agnostic (props only) — the host fetches +// timelines for a date range and passes them in. See +// openspec/changes/add-multi-change-timeline-view/design.md. + +import { logPosition } from "../timeline-scale.js"; +import type { ChangeTimeline } from "../change-timeline-client.js"; + +export interface MultiChangeTimelineViewProps { + timelines: ChangeTimeline[]; + rangeStart: string; + rangeEnd: string; +} + +interface TimelinePoint { + kind: "created" | "task" | "archived"; + label: string; + date: string; +} + +function pointsFor(timeline: ChangeTimeline): TimelinePoint[] { + const points: TimelinePoint[] = []; + if (timeline.createdDate) { + points.push({ kind: "created", label: "Created", date: timeline.createdDate }); + } + for (const task of timeline.tasks) { + if (task.date) points.push({ kind: "task", label: task.text, date: task.date }); + } + if (timeline.archived && timeline.archivedDate) { + // Archiving is chronologically the last thing that happens to a + // change, but `archivedDate` is a plain calendar date (parsed from + // the archive folder name, no time-of-day available) — anchoring it + // to end-of-day rather than midnight avoids it plotting *before* + // that same day's actual created/task timestamps. + points.push({ kind: "archived", label: "Archived", date: `${timeline.archivedDate}T23:59:59.999Z` }); + } + return points; +} + +const KIND_MARKER: Record = { + created: "▶", + task: "●", + archived: "■", +}; + +export function MultiChangeTimelineView({ timelines, rangeStart, rangeEnd }: MultiChangeTimelineViewProps) { + const rangeStartMs = new Date(rangeStart).getTime(); + const rangeEndMs = new Date(rangeEnd).getTime(); + + return ( +
+
+ {new Date(rangeStart).toLocaleDateString()} + {new Date(rangeEnd).toLocaleDateString()} +
+ {timelines.length === 0 ? ( +

No changes selected.

+ ) : ( + timelines.map((timeline) => ( +
+ {timeline.changeName} +
+ {pointsFor(timeline).map((point, index) => ( + + {KIND_MARKER[point.kind]} + + ))} +
+
+ )) + )} +
+ ); +} diff --git a/packages/webui/src/shell-ui.ts b/packages/webui/src/shell-ui.ts index fa709a9..da3ca74 100644 --- a/packages/webui/src/shell-ui.ts +++ b/packages/webui/src/shell-ui.ts @@ -680,6 +680,130 @@ export const shellThemeCss = ` background: var(--surface-2); } + .openspec-timeline-header dl { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin: 4px 0 0; + } + + .openspec-timeline-header dt { + color: var(--muted); + font-size: 12px; + } + + .openspec-timeline-header dd { + margin: 0 16px 0 4px; + font-size: 12px; + } + + .openspec-timeline-artifact { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 12px 16px; + } + + .openspec-timeline-tasks ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 4px; + } + + .openspec-timeline-task-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-2); + color: var(--ink); + padding: 6px 10px; + cursor: pointer; + } + + .openspec-timeline-task-marker { + color: var(--primary); + flex-shrink: 0; + } + + .openspec-timeline-task-date { + color: var(--muted); + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + } + + .openspec-timeline-task-pending { + font-style: italic; + } + + .openspec-timeline-task-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .openspec-timeline-task-detail { + margin: 4px 0 0 10px; + padding: 8px 10px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + font-size: 13px; + } + + .openspec-multi-timeline-axis { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 12px; + margin-bottom: 8px; + } + + .openspec-multi-timeline-lane { + margin-bottom: 18px; + } + + .openspec-multi-timeline-lane-label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; + } + + .openspec-multi-timeline-track { + position: relative; + height: 28px; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 8px; + } + + .openspec-multi-timeline-point { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + font-size: 12px; + line-height: 1; + } + + .openspec-multi-timeline-point-created { + color: var(--muted); + } + + .openspec-multi-timeline-point-task { + color: var(--primary); + } + + .openspec-multi-timeline-point-archived { + color: var(--danger); + } + @media (max-width: 760px) { .openspec-shell-grid { grid-template-columns: 1fr; } .openspec-editor-grid { grid-template-columns: 1fr; } diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx index 8283267..7ea5cf7 100644 --- a/packages/webui/src/standalone-entry.tsx +++ b/packages/webui/src/standalone-entry.tsx @@ -24,7 +24,8 @@ import { saveChangeEditorDocument, type ChangeEditorFiles, } from "./change-editor-client.js"; -import { loadChangeTimeline, type ChangeTimeline } from "./change-timeline-client.js"; +import { loadChangeTimeline, loadChangeTimelines, type ChangeTimeline, type ChangeTimelineEntry } from "./change-timeline-client.js"; +import { MultiChangeTimelineView } from "./components/MultiChangeTimelineView.js"; import { customizeTemplate as customizeTemplateApi, deleteProjectTemplate as deleteProjectTemplateApi, @@ -208,6 +209,13 @@ function StandaloneApp() { const [timeline, setTimeline] = useState(null); const [timelineLoading, setTimelineLoading] = useState(false); const [timelineMessage, setTimelineMessage] = useState(null); + const [timelineMode, setTimelineMode] = useState<"single" | "multi">("single"); + const [multiRangeStart, setMultiRangeStart] = useState(""); + const [multiRangeEnd, setMultiRangeEnd] = useState(""); + const [multiSelection, setMultiSelection] = useState([]); + const [multiTimelines, setMultiTimelines] = useState([]); + const [multiLoading, setMultiLoading] = useState(false); + const [multiMessage, setMultiMessage] = useState(null); const [initTools, setInitTools] = useState(["github-copilot"]); const [initLoading, setInitLoading] = useState(false); const [initMessage, setInitMessage] = useState(null); @@ -376,6 +384,41 @@ function StandaloneApp() { } } + function decodeSelection(selection: string): ChangeTimelineEntry | undefined { + const [prefix, ...rest] = selection.split(":"); + const changeName = rest.join(":"); + if (!changeName || (prefix !== "active" && prefix !== "archived")) return undefined; + return { changeName, archived: prefix === "archived" }; + } + + async function loadMultiTimelines() { + if (cwd.trim().length === 0) { + setMultiMessage("Enter workspace root first."); + return; + } + if (multiRangeStart.trim().length === 0 || multiRangeEnd.trim().length === 0) { + setMultiMessage("Select a date range first."); + return; + } + const entries = multiSelection.map(decodeSelection).filter((entry): entry is ChangeTimelineEntry => Boolean(entry)); + if (entries.length === 0) { + setMultiMessage("Select at least one change first."); + return; + } + setMultiLoading(true); + setMultiMessage(null); + try { + const loaded = await loadChangeTimelines(apiFetch, cwd, entries); + setMultiTimelines(loaded); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setMultiMessage(`Load failed: ${message}`); + setMultiTimelines([]); + } finally { + setMultiLoading(false); + } + } + async function handleInsertTasksTemplate() { if (cwd.trim().length === 0 || editorChangeName.trim().length === 0) { setArchivedTemplateMessage("Load a non-archived change first."); @@ -1126,32 +1169,111 @@ function StandaloneApp() { when git shows each was completed.

-
- + Single change +
- {timelineMessage ?

{timelineMessage}

: null} - {timeline ? : null} + {timelineMode === "single" ? ( + +
+ + +
+ + {timelineMessage ?

{timelineMessage}

: null} + {timeline ? : null} +
+ ) : ( + +
+ + +
+ +
+ +
+ + {multiMessage ?

{multiMessage}

: null} + {multiTimelines.length > 0 && multiRangeStart && multiRangeEnd ? ( + + ) : null} +
+ )} )} diff --git a/packages/webui/src/timeline-entry.tsx b/packages/webui/src/timeline-entry.tsx index fc15451..f724278 100644 --- a/packages/webui/src/timeline-entry.tsx +++ b/packages/webui/src/timeline-entry.tsx @@ -9,20 +9,40 @@ import { createRoot } from "react-dom/client"; import { ChangeTimelineView } from "./components/ChangeTimelineView.js"; +import { MultiChangeTimelineView } from "./components/MultiChangeTimelineView.js"; import { shellThemeCss, vscodeThemeCss } from "./shell-ui.js"; import type { ChangeTimeline } from "./change-timeline-client.js"; +interface MultiChangeTimelinePayload { + timelines: ChangeTimeline[]; + rangeStart: string; + rangeEnd: string; +} + declare global { interface Window { __OPENSPEC_UI_TIMELINE__?: ChangeTimeline; + __OPENSPEC_UI_MULTI_TIMELINE__?: MultiChangeTimelinePayload; } } -function TimelineApp({ timeline }: { timeline: ChangeTimeline | undefined }) { +function TimelineApp({ + timeline, + multi, +}: { + timeline: ChangeTimeline | undefined; + multi: MultiChangeTimelinePayload | undefined; +}) { return (
- {timeline ? :

No timeline data.

} + {timeline ? ( + + ) : multi ? ( + + ) : ( +

No timeline data.

+ )}
); } @@ -31,4 +51,6 @@ const container = document.getElementById("root"); if (!container) { throw new Error("timeline-entry: #root element not found"); } -createRoot(container).render(); +createRoot(container).render( + , +); diff --git a/packages/webui/src/timeline-scale.test.ts b/packages/webui/src/timeline-scale.test.ts new file mode 100644 index 0000000..7ef63a4 --- /dev/null +++ b/packages/webui/src/timeline-scale.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { logPosition } from "./timeline-scale.js"; + +const rangeStart = new Date("2026-01-01T00:00:00.000Z").getTime(); +const rangeEnd = new Date("2026-01-11T00:00:00.000Z").getTime(); + +describe("logPosition", () => { + it("positions the range start at 0", () => { + expect(logPosition(rangeStart, rangeStart, rangeEnd)).toBe(0); + }); + + it("positions the range end at 100", () => { + expect(logPosition(rangeEnd, rangeStart, rangeEnd)).toBe(100); + }); + + it("clamps a timestamp before the range start to 0", () => { + const before = rangeStart - 1000 * 60 * 60 * 24; + expect(logPosition(before, rangeStart, rangeEnd)).toBe(0); + }); + + it("clamps a timestamp after the range end to 100", () => { + const after = rangeEnd + 1000 * 60 * 60 * 24; + expect(logPosition(after, rangeStart, rangeEnd)).toBe(100); + }); + + it("spreads a dense early cluster further apart than a linear scale would", () => { + const dayMs = 1000 * 60 * 60 * 24; + const first = rangeStart + dayMs * 0.1; + const second = rangeStart + dayMs * 0.2; + const linearGap = ((second - first) / (rangeEnd - rangeStart)) * 100; + const logGap = logPosition(second, rangeStart, rangeEnd) - logPosition(first, rangeStart, rangeEnd); + expect(logGap).toBeGreaterThan(linearGap); + }); + + it("compresses a late gap tighter than a linear scale would", () => { + const dayMs = 1000 * 60 * 60 * 24; + const first = rangeStart + dayMs * 8; + const second = rangeStart + dayMs * 9; + const linearGap = ((second - first) / (rangeEnd - rangeStart)) * 100; + const logGap = logPosition(second, rangeStart, rangeEnd) - logPosition(first, rangeStart, rangeEnd); + expect(logGap).toBeLessThan(linearGap); + }); + + it("treats a zero-length range as fully compressed at the start", () => { + expect(logPosition(rangeStart, rangeStart, rangeStart)).toBe(0); + }); +}); diff --git a/packages/webui/src/timeline-scale.ts b/packages/webui/src/timeline-scale.ts new file mode 100644 index 0000000..cfc0c33 --- /dev/null +++ b/packages/webui/src/timeline-scale.ts @@ -0,0 +1,21 @@ +// Log-scale position for the multi-change timeline (see +// openspec/changes/add-multi-change-timeline-view/design.md, "Log-scale +// direction"). Validated against this repository's own real archived- +// change timestamps from 2026-08-26 before picking a direction: a +// log1p of elapsed time *since the range start* spreads out a dense +// cluster of near-simultaneous changes (the common case this project's +// own squash-merge workflow produces) into something readable, while +// compressing sparser later activity — the opposite direction (log of +// remaining time until the range end) made the dense cluster *more* +// overlapped than a plain linear scale, defeating the point. + +/** Returns a 0-100 position for `timestampMs` along `[rangeStartMs, + * rangeEndMs]`, log-scaled from the range start. Clamped to [0, 100] — + * a timestamp outside the picked range still renders at an edge rather + * than off-screen. */ +export function logPosition(timestampMs: number, rangeStartMs: number, rangeEndMs: number): number { + const elapsed = Math.max(0, timestampMs - rangeStartMs); + const span = Math.max(1, rangeEndMs - rangeStartMs); + const raw = (Math.log1p(elapsed) / Math.log1p(span)) * 100; + return Math.min(100, Math.max(0, raw)); +}