@elabs-ai/components-ai 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -0,0 +1,196 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, within } from "storybook/test";
3
+ import { ChangeReview, type ChangeHunk, type DiffLine } from "@elabs-ai/components-ui";
4
+ import { DiffView } from "./diff-view";
5
+
6
+ const meta = {
7
+ title: "AI/DiffView",
8
+ component: DiffView,
9
+ parameters: {
10
+ layout: "padded",
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "The READ-ONLY patch renderer. Pick a diff surface by what the human is allowed " +
15
+ "to do with the change: reading lines of a patch → `AI/DiffView` " +
16
+ "(`@elabs-ai/components-ai`); accepting or rejecting per hunk → " +
17
+ "`AI/ChangeReview` (`@elabs-ai/components-ui`); editing both sides, side by " +
18
+ "side → `Editor/DiffEditor` (`@elabs-ai/components-editor`); reading a " +
19
+ "patch in a console transcript → `Terminal/TerminalDiffHunk` " +
20
+ "(`@elabs-ai/components-terminal`). See " +
21
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
22
+ "This view renders a `DiffLine[]` someone else computed — it never diffs, " +
23
+ "fetches or parses a patch itself. To put those lines behind an approve/reject " +
24
+ "gate, the app that depends on both packages injects one into the other: pass a " +
25
+ "`<DiffView>` through `ChangeReview`’s `renderHunk` render-prop or a " +
26
+ "hunk’s `after` slot, as the *Composed into ChangeReview (ui)* story below " +
27
+ "does. That seam is ratified, not unfinished — `ChangeHunk` gains no " +
28
+ "`lines` field and `DiffLine` never moves, because `@elabs-ai/components-ui` " +
29
+ "may not import `@elabs-ai/components-ai` and this view needs Shiki.",
30
+ },
31
+ },
32
+ },
33
+ tags: ["autodocs"],
34
+ } satisfies Meta<typeof DiffView>;
35
+ export default meta;
36
+ type Story = StoryObj<typeof meta>;
37
+
38
+ // A representative mix of every DiffLineType — meta headers, a hunk range, a
39
+ // deletion, two additions and trailing context (#102 acceptance criteria).
40
+ const SAMPLE_LINES: DiffLine[] = [
41
+ { type: "meta", text: "diff --git a/src/math.ts b/src/math.ts" },
42
+ { type: "meta", text: "index a1b2c3d..e4f5g6h 100644" },
43
+ { type: "meta", text: "--- a/src/math.ts" },
44
+ { type: "meta", text: "+++ b/src/math.ts" },
45
+ { type: "hunk", text: "@@ -1,5 +1,6 @@" },
46
+ {
47
+ type: "context",
48
+ oldNumber: 1,
49
+ newNumber: 1,
50
+ text: "export function add(a: number, b: number) {",
51
+ },
52
+ { type: "del", oldNumber: 2, text: " return a - b; // bug: subtracts instead of adds" },
53
+ { type: "add", newNumber: 2, text: " return a + b;" },
54
+ { type: "add", newNumber: 3, text: " // fixed the sign error" },
55
+ { type: "context", oldNumber: 3, newNumber: 4, text: "}" },
56
+ { type: "context", oldNumber: 4, newNumber: 5, text: "" },
57
+ { type: "context", oldNumber: 5, newNumber: 6, text: "export const VERSION = 2;" },
58
+ ];
59
+
60
+ export const Default: Story = {
61
+ render: () => (
62
+ <div className="max-w-2xl">
63
+ <DiffView
64
+ lines={SAMPLE_LINES}
65
+ file="src/math.ts"
66
+ stats={{ additions: 2, deletions: 1 }}
67
+ language="typescript"
68
+ />
69
+ </div>
70
+ ),
71
+ };
72
+
73
+ // Dark theme — the row tints (`bg-success/10` / `bg-destructive/10`), the
74
+ // marker/gutter tokens and the Shiki-derived syntax colour must all resolve
75
+ // from the SCOPED dark region, not the document root (same pattern as
76
+ // `code-block.stories.tsx`'s `DarkTheme`).
77
+ export const DarkTheme: Story = {
78
+ render: () => (
79
+ <div className="max-w-2xl" data-theme="dark">
80
+ <div className="rounded-lg bg-background p-6 text-foreground">
81
+ <DiffView lines={SAMPLE_LINES} file="src/math.ts" stats={{ additions: 2, deletions: 1 }} />
82
+ </div>
83
+ </div>
84
+ ),
85
+ };
86
+
87
+ // variant="split" — two aligned columns (old-file view | new-file view).
88
+ export const SplitVariant: Story = {
89
+ name: 'variant="split"',
90
+ render: () => (
91
+ <div className="max-w-3xl">
92
+ <DiffView lines={SAMPLE_LINES} file="src/math.ts" variant="split" />
93
+ </div>
94
+ ),
95
+ };
96
+
97
+ // pager — absorbs the upstream CodexDiff shape: scroll-position indicator, a
98
+ // key legend, and a named `role="region"`.
99
+ const LONG_LINES: DiffLine[] = Array.from({ length: 60 }, (_, i) => ({
100
+ type: i % 11 === 0 ? "del" : i % 13 === 0 ? "add" : "context",
101
+ oldNumber: i % 13 === 0 ? undefined : i + 1,
102
+ newNumber: i % 11 === 0 ? undefined : i + 1,
103
+ text: `line ${i + 1} of a long file`,
104
+ })) as DiffLine[];
105
+
106
+ export const Pager: Story = {
107
+ name: "pager",
108
+ render: () => (
109
+ <div className="max-w-2xl">
110
+ <DiffView lines={LONG_LINES} file="src/very-long-file.ts" pager />
111
+ </div>
112
+ ),
113
+ };
114
+
115
+ // contextLines — a long run of unchanged lines collapses behind a "show more"
116
+ // control that restores it on click.
117
+ const CONTEXT_RUN_LINES: DiffLine[] = [
118
+ { type: "hunk", text: "@@ -1,12 +1,13 @@" },
119
+ ...Array.from({ length: 10 }, (_, i) => ({
120
+ type: "context" as const,
121
+ oldNumber: i + 1,
122
+ newNumber: i + 1,
123
+ text: `unchanged line ${i + 1}`,
124
+ })),
125
+ { type: "add", newNumber: 11, text: "// a single new line at the end" },
126
+ ];
127
+
128
+ export const CollapsedContext: Story = {
129
+ name: "contextLines",
130
+ render: () => (
131
+ <div className="max-w-2xl">
132
+ <DiffView lines={CONTEXT_RUN_LINES} contextLines={4} />
133
+ </div>
134
+ ),
135
+ play: async ({ canvasElement }) => {
136
+ const canvas = within(canvasElement);
137
+ const showMore = await canvas.findByRole("button", { name: /more line/ });
138
+ await expect(canvas.queryByText("unchanged line 5")).not.toBeInTheDocument();
139
+ await userEvent.click(showMore);
140
+ await expect(canvas.getByText("unchanged line 5")).toBeInTheDocument();
141
+ },
142
+ };
143
+
144
+ // LOADING — no renderable diff yet (loading-states.md). Layout-shaped
145
+ // skeleton rows at the real row height; no layout shift once `lines` settles.
146
+ export const Loading: Story = {
147
+ render: () => (
148
+ <div className="max-w-2xl">
149
+ <DiffView lines={[]} loading file="src/math.ts" />
150
+ </div>
151
+ ),
152
+ };
153
+
154
+ // STREAMING — lines are still arriving (loading-states.md `isStreaming`). What
155
+ // exists renders as-is, including a syntactically incomplete final line, and
156
+ // no error surface appears.
157
+ export const Streaming: Story = {
158
+ name: "isStreaming",
159
+ render: () => (
160
+ <div className="max-w-2xl">
161
+ <DiffView
162
+ lines={[
163
+ { type: "context", oldNumber: 1, newNumber: 1, text: "export function add(a, b) {" },
164
+ { type: "add", newNumber: 2, text: " return a + b" },
165
+ ]}
166
+ isStreaming
167
+ />
168
+ </div>
169
+ ),
170
+ };
171
+
172
+ // ─── ChangeReview composition (binding decision — architecture § 3) ────────
173
+ //
174
+ // DiffView (ai) and ChangeReview (ui) never import each other. The app, which
175
+ // depends on both, wires them together through ChangeReview's existing
176
+ // injection seams — here, the `renderHunk` render-prop seam. `ChangeHunk`
177
+ // gains no `lines` field; `DiffLine` never moves into `ui`.
178
+ const REVIEW_HUNK_LINES: Record<string, DiffLine[]> = {
179
+ "hunk-1": SAMPLE_LINES.filter((l) => l.type !== "meta"),
180
+ };
181
+
182
+ const REVIEW_HUNKS: ChangeHunk[] = [{ id: "hunk-1", title: "src/math.ts", status: "modified" }];
183
+
184
+ export const ChangeReviewComposition: Story = {
185
+ name: "Composed into ChangeReview (ui)",
186
+ render: () => (
187
+ <div className="max-w-2xl">
188
+ <ChangeReview
189
+ hunks={REVIEW_HUNKS}
190
+ renderHunk={(hunk) => (
191
+ <DiffView lines={REVIEW_HUNK_LINES[hunk.id] ?? []} variant="inline" />
192
+ )}
193
+ />
194
+ </div>
195
+ ),
196
+ };
@@ -0,0 +1,238 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { render, screen, waitFor, within } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { type DiffLine } from "@elabs-ai/components-ui";
5
+ import { DiffView } from "./diff-view";
6
+
7
+ /**
8
+ * No `LocaleProvider` wrapper: `useLocale()` falls back to the shipped English
9
+ * defaults, so these assertions prove the real `ai.diffView.*` catalogue in
10
+ * `packages/ui/src/components/locale-provider/messages.ts` — not a local stub
11
+ * that would keep passing if the central entry were missing or misspelled.
12
+ */
13
+ function renderDiffView(ui: React.ReactElement) {
14
+ return render(ui);
15
+ }
16
+
17
+ const MIXED_LINES: DiffLine[] = [
18
+ { type: "hunk", text: "@@ -1,3 +1,4 @@" },
19
+ { type: "context", oldNumber: 1, newNumber: 1, text: "function add(a, b) {" },
20
+ { type: "del", oldNumber: 2, text: " return a - b; // bug" },
21
+ { type: "add", newNumber: 2, text: " return a + b;" },
22
+ { type: "add", newNumber: 3, text: " // fixed" },
23
+ { type: "context", oldNumber: 3, newNumber: 4, text: "}" },
24
+ ];
25
+
26
+ describe("DiffView — gutter correctness (the off-by-one this model exists to prevent)", () => {
27
+ it("shows an add row's new number only, and a del row's old number only", () => {
28
+ const { container } = renderDiffView(<DiffView lines={MIXED_LINES} />);
29
+ const rows = container.querySelectorAll('[data-slot="diff-view-row"][data-diff-type]');
30
+
31
+ const del = Array.from(rows).find((r) => r.getAttribute("data-diff-type") === "del")!;
32
+ expect(within(del as HTMLElement).getByText("2")).toBeInTheDocument();
33
+ expect(del.querySelector('[data-slot="diff-view-gutter-old"]')?.textContent).toBe("2");
34
+ expect(del.querySelector('[data-slot="diff-view-gutter-new"]')?.textContent).toBe("");
35
+
36
+ const add = Array.from(rows).find(
37
+ (r) => r.getAttribute("data-diff-type") === "add" && r.textContent?.includes("fixed"),
38
+ )!;
39
+ expect(add.querySelector('[data-slot="diff-view-gutter-new"]')?.textContent).toBe("3");
40
+ expect(add.querySelector('[data-slot="diff-view-gutter-old"]')?.textContent).toBe("");
41
+
42
+ const context = Array.from(rows).find((r) => r.getAttribute("data-diff-type") === "context")!;
43
+ expect(context.querySelector('[data-slot="diff-view-gutter-old"]')?.textContent).toBe("1");
44
+ expect(context.querySelector('[data-slot="diff-view-gutter-new"]')?.textContent).toBe("1");
45
+ });
46
+ });
47
+
48
+ describe("DiffView — colour is never the only channel (accessibility.md)", () => {
49
+ it("gives add and del rows DIFFERENT accessible text and a different marker glyph — not merely different classes", () => {
50
+ const { container } = renderDiffView(
51
+ <DiffView
52
+ lines={[
53
+ { type: "del", oldNumber: 1, text: "old line" },
54
+ { type: "add", newNumber: 1, text: "new line" },
55
+ ]}
56
+ />,
57
+ );
58
+
59
+ // Accessible text: the sr-only polarity word actually differs.
60
+ expect(screen.getByText("Added:", { exact: false })).toBeInTheDocument();
61
+ expect(screen.getByText("Removed:", { exact: false })).toBeInTheDocument();
62
+
63
+ // Marker glyph: a real, different rendered character per row — the shape
64
+ // channel a colour-blind or greyscale reader relies on.
65
+ const delMarker = container.querySelector(
66
+ '[data-diff-type="del"] [data-slot="diff-view-marker"]',
67
+ );
68
+ const addMarker = container.querySelector(
69
+ '[data-diff-type="add"] [data-slot="diff-view-marker"]',
70
+ );
71
+ expect(delMarker?.textContent).toBe("−");
72
+ expect(addMarker?.textContent).toBe("+");
73
+ expect(delMarker?.textContent).not.toBe(addMarker?.textContent);
74
+ });
75
+
76
+ it("gives the del row's marker a distinct tone class from the add row's — but that alone is insufficient (locked above by text+glyph)", () => {
77
+ const { container } = renderDiffView(
78
+ <DiffView
79
+ lines={[
80
+ { type: "del", oldNumber: 1, text: "old line" },
81
+ { type: "add", newNumber: 1, text: "new line" },
82
+ ]}
83
+ />,
84
+ );
85
+ const delMarker = container.querySelector(
86
+ '[data-diff-type="del"] [data-slot="diff-view-marker"]',
87
+ );
88
+ const addMarker = container.querySelector(
89
+ '[data-diff-type="add"] [data-slot="diff-view-marker"]',
90
+ );
91
+ expect(delMarker?.className).not.toBe(addMarker?.className);
92
+ });
93
+ });
94
+
95
+ describe("DiffView — loading vocabulary (loading-states.md)", () => {
96
+ it("loading renders layout-shaped skeleton rows sharing the real row's height class (no shift on settle)", () => {
97
+ const { container: loadingContainer } = renderDiffView(<DiffView lines={[]} loading />);
98
+ const { container: settledContainer } = renderDiffView(<DiffView lines={MIXED_LINES} />);
99
+
100
+ const skeletonRow = loadingContainer.querySelector('[data-slot="diff-view-row"]');
101
+ const realRow = settledContainer.querySelector(
102
+ '[data-slot="diff-view-row"][data-diff-type="add"]',
103
+ );
104
+ expect(skeletonRow).toBeTruthy();
105
+ expect(realRow).toBeTruthy();
106
+ // Same grid template + padding class => same row height; only the tint axis differs.
107
+ expect(skeletonRow?.className).toContain("grid-cols-[2.75rem_2.75rem_1.25rem_1fr]");
108
+ expect(realRow?.className).toContain("grid-cols-[2.75rem_2.75rem_1.25rem_1fr]");
109
+
110
+ expect(screen.getByText("Loading diff…")).toBeInTheDocument();
111
+ });
112
+
113
+ it("isStreaming with a truncated final line renders no error surface", () => {
114
+ const truncated: DiffLine[] = [
115
+ { type: "context", oldNumber: 1, newNumber: 1, text: "const partial = {" },
116
+ { type: "add", newNumber: 2, text: ' key: "unterminated' },
117
+ ];
118
+ renderDiffView(<DiffView lines={truncated} isStreaming />);
119
+
120
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
121
+ expect(screen.getByText("Generating…")).toBeInTheDocument();
122
+ });
123
+ });
124
+
125
+ describe("DiffView — variants", () => {
126
+ it('renders one row per line for variant="inline" (default)', () => {
127
+ const { container } = renderDiffView(<DiffView lines={MIXED_LINES} />);
128
+ expect(container.querySelectorAll('[data-slot="diff-view-row"]')).toHaveLength(
129
+ MIXED_LINES.length,
130
+ );
131
+ });
132
+
133
+ it('renders two aligned columns for variant="split"', () => {
134
+ const { container } = renderDiffView(
135
+ <DiffView
136
+ lines={[
137
+ { type: "del", oldNumber: 1, text: "old" },
138
+ { type: "add", newNumber: 1, text: "new" },
139
+ ]}
140
+ variant="split"
141
+ />,
142
+ );
143
+ const rows = container.querySelectorAll('[data-slot="diff-view-row"][data-diff-type]');
144
+ expect(rows).toHaveLength(2);
145
+ for (const row of rows) {
146
+ // Each row is split into exactly two side-by-side panes.
147
+ expect(row.children).toHaveLength(2);
148
+ }
149
+ });
150
+ });
151
+
152
+ describe("DiffView — pager (absorbs CodexDiff)", () => {
153
+ it("renders a scroll-position indicator, a key legend, and a named region", () => {
154
+ renderDiffView(<DiffView lines={MIXED_LINES} pager file="src/add.ts" />);
155
+
156
+ const region = screen.getByRole("region", { name: "Code diff: src/add.ts" });
157
+ expect(region).toBeInTheDocument();
158
+ expect(
159
+ within(region).getByText("Arrow keys scroll, Page Up/Down page, Home/End jump"),
160
+ ).toBeInTheDocument();
161
+ expect(within(region).getByText("0%")).toBeInTheDocument();
162
+ });
163
+ });
164
+
165
+ describe("DiffView — old/new sides tokenize SEPARATELY (perf review §2 — a deleted comment opener must not swallow the added side)", () => {
166
+ afterEach(() => {
167
+ document.documentElement.removeAttribute("data-theme");
168
+ for (const token of [
169
+ "--code-foreground",
170
+ "--code-comment",
171
+ "--code-keyword",
172
+ "--code-number",
173
+ ]) {
174
+ document.documentElement.style.removeProperty(token);
175
+ }
176
+ });
177
+
178
+ it("does not let an unterminated block comment on the del side swallow the add side's tokens", async () => {
179
+ // Distinct resolved colors per scope (comment vs. keyword vs. number) so
180
+ // a merged/degenerate single-token line is distinguishable from a real,
181
+ // multi-token breakdown — see the module doc comment on `useDiffTokens`.
182
+ document.documentElement.setAttribute("data-theme", "light");
183
+ document.documentElement.style.setProperty("--code-foreground", "oklch(0.1 0 0)");
184
+ document.documentElement.style.setProperty("--code-comment", "oklch(0.5 0 0)");
185
+ document.documentElement.style.setProperty("--code-keyword", "oklch(0.3 0.2 260)");
186
+ document.documentElement.style.setProperty("--code-number", "oklch(0.4 0.2 140)");
187
+
188
+ const lines: DiffLine[] = [
189
+ { type: "del", oldNumber: 1, text: "/* unterminated" },
190
+ { type: "add", newNumber: 1, text: "const value = 1;" },
191
+ ];
192
+
193
+ const { container } = renderDiffView(<DiffView language="tsx" lines={lines} />);
194
+
195
+ // Combining old+new into ONE document (the bug) makes Shiki carry the
196
+ // del line's still-open `/*` across the line boundary, so the ENTIRE add
197
+ // line becomes one comment-colored token. Tokenized as its own document,
198
+ // `const value = 1;` is real code and splits into several distinctly
199
+ // colored spans.
200
+ await waitFor(() => {
201
+ const addLineText = container.querySelector(
202
+ '[data-diff-type="add"] [data-slot="diff-view-line-text"]',
203
+ );
204
+ const spans = addLineText?.querySelectorAll("span[style]") ?? [];
205
+ expect(spans.length).toBeGreaterThan(1);
206
+ });
207
+
208
+ const addLineText = container.querySelector(
209
+ '[data-diff-type="add"] [data-slot="diff-view-line-text"]',
210
+ );
211
+ expect(addLineText?.textContent).toContain("const value = 1;");
212
+ });
213
+ });
214
+
215
+ describe("DiffView — contextLines collapsing", () => {
216
+ const longContext: DiffLine[] = Array.from({ length: 10 }, (_, i) => ({
217
+ type: "context" as const,
218
+ oldNumber: i + 1,
219
+ newNumber: i + 1,
220
+ text: `line ${i + 1}`,
221
+ }));
222
+
223
+ it("collapses a long context run behind a show-more control that restores it", async () => {
224
+ const user = userEvent.setup();
225
+ const { container } = renderDiffView(<DiffView lines={longContext} contextLines={4} />);
226
+
227
+ expect(screen.queryByText("line 5")).not.toBeInTheDocument();
228
+ const control = screen.getByRole("button", { name: /more line/ });
229
+ expect(control).toBeInTheDocument();
230
+
231
+ await user.click(control);
232
+
233
+ expect(screen.getByText("line 5")).toBeInTheDocument();
234
+ expect(container.querySelectorAll('[data-slot="diff-view-row"]')).toHaveLength(
235
+ longContext.length,
236
+ );
237
+ });
238
+ });