@elabs-ai/components-ai 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +61 -11
  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 +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -0,0 +1,175 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { MessageCompare, MessageCompareColumn } from "./message-compare";
4
+ import { MessageFeedback } from "./message-feedback";
5
+ import { MessageResponse } from "./message";
6
+ import { Switch } from "@elabs-ai/components-ui";
7
+
8
+ const meta = {
9
+ title: "AI/MessageCompare",
10
+ component: MessageCompare,
11
+ tags: ["autodocs"],
12
+ parameters: {
13
+ layout: "padded",
14
+ docs: {
15
+ description: {
16
+ component:
17
+ "Two to four model ANSWERS side by side — not a diff. Nothing here is line-level; for that, start from `AI/DiffView` and the rest of the diff family in [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). The one-at-a-time sibling is `MessageBranch`, which switches between responses instead of showing them at once; each pane here keeps its own status, its own scroll position and its own feedback.",
18
+ },
19
+ },
20
+ },
21
+ } satisfies Meta<typeof MessageCompare>;
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ const GPT_ANSWER = `Postgres row-level security is the safer default for a multi-tenant
26
+ schema: every query is filtered by the database itself, so a missing \`WHERE tenant_id = …\`
27
+ in application code can't leak another tenant's rows.`;
28
+
29
+ const CLAUDE_ANSWER = `I'd reach for a separate schema per tenant only once you're past a few
30
+ hundred tenants — below that, a shared schema with row-level security is simpler to migrate
31
+ and easier to reason about, and it keeps a single connection pool.`;
32
+
33
+ const GEMINI_ANSWER = `Both approaches work. Schema-per-tenant gives the strongest isolation
34
+ (useful for compliance-heavy customers) at the cost of migration fan-out; row-level security
35
+ keeps one schema and pushes isolation into policies, which is cheaper to operate at scale.`;
36
+
37
+ /** Two columns: one still streaming, one settled — independent status per column. */
38
+ export const Default: Story = {
39
+ render: () => (
40
+ <div className="h-[520px]">
41
+ <MessageCompare columns={2}>
42
+ <MessageCompareColumn model={{ name: "GPT-5" }} status="streaming">
43
+ <MessageResponse>{GPT_ANSWER}</MessageResponse>
44
+ </MessageCompareColumn>
45
+ <MessageCompareColumn model={{ name: "Claude" }} status="ready">
46
+ <MessageResponse>{CLAUDE_ANSWER}</MessageResponse>
47
+ <MessageFeedback className="mt-3" />
48
+ </MessageCompareColumn>
49
+ </MessageCompare>
50
+ </div>
51
+ ),
52
+ };
53
+
54
+ /** Both columns still generating — the `Shimmer` status indicator per header. */
55
+ export const Streaming: Story = {
56
+ render: () => (
57
+ <div className="h-[520px]">
58
+ <MessageCompare columns={2}>
59
+ <MessageCompareColumn model={{ name: "GPT-5" }} status="streaming">
60
+ <MessageResponse>{GPT_ANSWER.slice(0, 60)}</MessageResponse>
61
+ </MessageCompareColumn>
62
+ <MessageCompareColumn model={{ name: "Claude" }} status="submitted" />
63
+ </MessageCompare>
64
+ </div>
65
+ ),
66
+ };
67
+
68
+ /** One column failed — `role="alert"` + icon + `text-destructive-text`, never colour alone. */
69
+ export const WithError: Story = {
70
+ render: () => (
71
+ <div className="h-[520px]">
72
+ <MessageCompare columns={2}>
73
+ <MessageCompareColumn model={{ name: "GPT-5" }} status="ready">
74
+ <MessageResponse>{GPT_ANSWER}</MessageResponse>
75
+ </MessageCompareColumn>
76
+ <MessageCompareColumn model={{ name: "Claude" }} status="error">
77
+ <p className="text-body text-muted-foreground">No response — the request failed.</p>
78
+ </MessageCompareColumn>
79
+ </MessageCompare>
80
+ </div>
81
+ ),
82
+ };
83
+
84
+ /** Three responses side by side (`columns` supports 2-4). */
85
+ export const ThreeColumns: Story = {
86
+ render: () => (
87
+ <div className="h-[520px]">
88
+ <MessageCompare columns={3}>
89
+ <MessageCompareColumn model={{ name: "GPT-5" }} status="ready">
90
+ <MessageResponse>{GPT_ANSWER}</MessageResponse>
91
+ </MessageCompareColumn>
92
+ <MessageCompareColumn model={{ name: "Claude" }} status="ready">
93
+ <MessageResponse>{CLAUDE_ANSWER}</MessageResponse>
94
+ </MessageCompareColumn>
95
+ <MessageCompareColumn model={{ name: "Gemini" }} status="ready">
96
+ <MessageResponse>{GEMINI_ANSWER}</MessageResponse>
97
+ </MessageCompareColumn>
98
+ </MessageCompare>
99
+ </div>
100
+ ),
101
+ };
102
+
103
+ /** Four responses — the upper bound of `columns`. */
104
+ export const FourColumns: Story = {
105
+ render: () => (
106
+ <div className="h-[520px]">
107
+ <MessageCompare columns={4}>
108
+ <MessageCompareColumn model={{ name: "GPT-5" }} status="ready">
109
+ <MessageResponse>{GPT_ANSWER}</MessageResponse>
110
+ </MessageCompareColumn>
111
+ <MessageCompareColumn model={{ name: "Claude" }} status="ready">
112
+ <MessageResponse>{CLAUDE_ANSWER}</MessageResponse>
113
+ </MessageCompareColumn>
114
+ <MessageCompareColumn model={{ name: "Gemini" }} status="ready">
115
+ <MessageResponse>{GEMINI_ANSWER}</MessageResponse>
116
+ </MessageCompareColumn>
117
+ <MessageCompareColumn model={{ name: "Llama" }} status="streaming">
118
+ <MessageResponse>{GEMINI_ANSWER.slice(0, 40)}</MessageResponse>
119
+ </MessageCompareColumn>
120
+ </MessageCompare>
121
+ </div>
122
+ ),
123
+ };
124
+
125
+ function SyncedScrollDemo() {
126
+ const [syncScroll, setSyncScroll] = useState(true);
127
+ const long = (label: string) =>
128
+ Array.from({ length: 24 }, (_, i) => `${label} — paragraph ${i + 1}. ${GEMINI_ANSWER}`).join(
129
+ "\n\n",
130
+ );
131
+
132
+ return (
133
+ <div className="flex h-[520px] flex-col gap-3">
134
+ <label className="flex w-fit items-center gap-2 text-body">
135
+ <Switch checked={syncScroll} onCheckedChange={setSyncScroll} />
136
+ Sync scroll across columns
137
+ </label>
138
+ <div className="min-h-0 flex-1">
139
+ <MessageCompare columns={2} syncScroll={syncScroll}>
140
+ <MessageCompareColumn model={{ name: "GPT-5" }} status="ready">
141
+ <MessageResponse>{long("GPT-5")}</MessageResponse>
142
+ </MessageCompareColumn>
143
+ <MessageCompareColumn model={{ name: "Claude" }} status="ready">
144
+ <MessageResponse>{long("Claude")}</MessageResponse>
145
+ </MessageCompareColumn>
146
+ </MessageCompare>
147
+ </div>
148
+ </div>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Long content in both columns with a `syncScroll` toggle — scrolling one
154
+ * column proportionally moves its sibling while the toggle is on (issue #23).
155
+ */
156
+ export const SyncedScroll: Story = {
157
+ render: () => <SyncedScrollDemo />,
158
+ };
159
+
160
+ /** Below the `md` breakpoint the grid collapses to a `MessageBranch`-style tab strip. */
161
+ export const Mobile: Story = {
162
+ globals: { viewport: { value: "mobile1", isRotated: false } },
163
+ render: () => (
164
+ <div className="h-[520px]">
165
+ <MessageCompare columns={2}>
166
+ <MessageCompareColumn model={{ name: "GPT-5" }} status="ready">
167
+ <MessageResponse>{GPT_ANSWER}</MessageResponse>
168
+ </MessageCompareColumn>
169
+ <MessageCompareColumn model={{ name: "Claude" }} status="ready">
170
+ <MessageResponse>{CLAUDE_ANSWER}</MessageResponse>
171
+ </MessageCompareColumn>
172
+ </MessageCompare>
173
+ </div>
174
+ ),
175
+ };
@@ -0,0 +1,207 @@
1
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { MessageCompare, MessageCompareColumn } from "./message-compare";
5
+ import { MessageFeedback } from "./message-feedback";
6
+
7
+ afterEach(cleanup);
8
+
9
+ function columnBodies(container: HTMLElement): HTMLElement[] {
10
+ return Array.from(
11
+ container.querySelectorAll<HTMLElement>('[data-slot="message-compare-column-body"]'),
12
+ );
13
+ }
14
+
15
+ /**
16
+ * jsdom performs no layout, so `scrollHeight`/`clientHeight` are always 0.
17
+ * Every test that reads a proportional scroll offset needs a real, non-zero
18
+ * content/viewport size to divide by.
19
+ */
20
+ function mockScrollMetrics(
21
+ el: HTMLElement,
22
+ { scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number },
23
+ ) {
24
+ Object.defineProperty(el, "scrollHeight", { value: scrollHeight, configurable: true });
25
+ Object.defineProperty(el, "clientHeight", { value: clientHeight, configurable: true });
26
+ }
27
+
28
+ describe("MessageCompare", () => {
29
+ it("does not move a settled column's scroll position when a sibling's content grows", () => {
30
+ const { container, rerender } = render(
31
+ <MessageCompare columns={2}>
32
+ <MessageCompareColumn model={{ name: "Model A" }} status="streaming">
33
+ Short
34
+ </MessageCompareColumn>
35
+ <MessageCompareColumn model={{ name: "Model B" }} status="ready">
36
+ Done
37
+ </MessageCompareColumn>
38
+ </MessageCompare>,
39
+ );
40
+
41
+ const complete = columnBodies(container)[1]!;
42
+ mockScrollMetrics(complete, { scrollHeight: 400, clientHeight: 200 });
43
+ complete.scrollTop = 120;
44
+ expect(complete.scrollTop).toBe(120);
45
+
46
+ // Simulate the streaming column's content growing.
47
+ rerender(
48
+ <MessageCompare columns={2}>
49
+ <MessageCompareColumn model={{ name: "Model A" }} status="streaming">
50
+ {"Much longer streamed content. ".repeat(40)}
51
+ </MessageCompareColumn>
52
+ <MessageCompareColumn model={{ name: "Model B" }} status="ready">
53
+ Done
54
+ </MessageCompareColumn>
55
+ </MessageCompare>,
56
+ );
57
+
58
+ const completeAfter = columnBodies(container)[1]!;
59
+ expect(completeAfter.scrollTop).toBe(120);
60
+ });
61
+
62
+ it("does not propagate scroll to siblings by default (syncScroll unset)", () => {
63
+ const { container } = render(
64
+ <MessageCompare columns={2}>
65
+ <MessageCompareColumn model={{ name: "Model A" }}>A</MessageCompareColumn>
66
+ <MessageCompareColumn model={{ name: "Model B" }}>B</MessageCompareColumn>
67
+ </MessageCompare>,
68
+ );
69
+
70
+ const [a, b] = columnBodies(container);
71
+ mockScrollMetrics(a!, { scrollHeight: 1000, clientHeight: 200 });
72
+ mockScrollMetrics(b!, { scrollHeight: 500, clientHeight: 100 });
73
+
74
+ a!.scrollTop = 400; // 50% of A's scrollable range
75
+ fireEvent.scroll(a!);
76
+
77
+ expect(b!.scrollTop).toBe(0);
78
+ });
79
+
80
+ it("propagates scroll to siblings proportionally when syncScroll is on", () => {
81
+ const { container } = render(
82
+ <MessageCompare columns={2} syncScroll>
83
+ <MessageCompareColumn model={{ name: "Model A" }}>A</MessageCompareColumn>
84
+ <MessageCompareColumn model={{ name: "Model B" }}>B</MessageCompareColumn>
85
+ </MessageCompare>,
86
+ );
87
+
88
+ const [a, b] = columnBodies(container);
89
+ mockScrollMetrics(a!, { scrollHeight: 1000, clientHeight: 200 }); // range 800
90
+ mockScrollMetrics(b!, { scrollHeight: 500, clientHeight: 100 }); // range 400
91
+
92
+ a!.scrollTop = 400; // 50% of A's range
93
+ fireEvent.scroll(a!);
94
+
95
+ expect(b!.scrollTop).toBe(200); // 50% of B's range
96
+ });
97
+
98
+ // #12/#53 review (P2): when the destination column's scroll position ALREADY
99
+ // sits at the computed proportional offset, writing the same value produces
100
+ // no real change — real browsers emit no `scroll` event for it, so a naive
101
+ // "always arm suppression" left the flag permanently set, and the
102
+ // destination's next genuine user scroll (a keyboard PageDown/Home in
103
+ // particular) got silently swallowed instead of propagating to its siblings.
104
+ it("does not swallow a column's own next real scroll when a sync write left its offset unchanged", () => {
105
+ const { container } = render(
106
+ <MessageCompare columns={2} syncScroll>
107
+ <MessageCompareColumn model={{ name: "Model A" }}>A</MessageCompareColumn>
108
+ <MessageCompareColumn model={{ name: "Model B" }}>B</MessageCompareColumn>
109
+ </MessageCompare>,
110
+ );
111
+
112
+ const [a, b] = columnBodies(container);
113
+ mockScrollMetrics(a!, { scrollHeight: 1000, clientHeight: 200 }); // range 800
114
+ mockScrollMetrics(b!, { scrollHeight: 500, clientHeight: 100 }); // range 400
115
+
116
+ // B is already sitting at the exact offset A's forthcoming scroll will
117
+ // compute for it (50% of 400 = 200) — set directly, no scroll event, so
118
+ // no suppression flag is armed yet.
119
+ b!.scrollTop = 200;
120
+
121
+ // A scrolls to 50% of its own range. The sync write to B assigns 200 —
122
+ // identical to B's current value, i.e. a genuine no-op write.
123
+ a!.scrollTop = 400;
124
+ fireEvent.scroll(a!);
125
+ expect(b!.scrollTop).toBe(200); // unchanged, as expected
126
+
127
+ // B's OWN next real scroll (e.g. keyboard PageDown) must still propagate
128
+ // to A — it must NOT be swallowed by a suppression flag left armed from
129
+ // the no-op write above.
130
+ b!.scrollTop = 300; // 75% of B's range
131
+ fireEvent.scroll(b!);
132
+
133
+ expect(a!.scrollTop).toBe(600); // 75% of A's range — propagated, not swallowed
134
+ });
135
+
136
+ it("labels each column as a region with a distinct accessible name from its model", () => {
137
+ render(
138
+ <MessageCompare columns={3}>
139
+ <MessageCompareColumn model={{ name: "GPT-5" }}>x</MessageCompareColumn>
140
+ <MessageCompareColumn model={{ name: "Claude" }}>y</MessageCompareColumn>
141
+ <MessageCompareColumn model={{ name: "Gemini" }}>z</MessageCompareColumn>
142
+ </MessageCompare>,
143
+ );
144
+
145
+ const regions = screen.getAllByRole("region");
146
+ expect(regions).toHaveLength(3);
147
+ expect(regions.map((region) => region.getAttribute("aria-label"))).toEqual([
148
+ "GPT-5",
149
+ "Claude",
150
+ "Gemini",
151
+ ]);
152
+ });
153
+
154
+ it("collapses to a tab strip under the responsive breakpoint instead of side-by-side columns", () => {
155
+ const originalWidth = window.innerWidth;
156
+ Object.defineProperty(window, "innerWidth", { value: 500, configurable: true });
157
+
158
+ try {
159
+ render(
160
+ <MessageCompare columns={2}>
161
+ <MessageCompareColumn model={{ name: "Model A" }}>A</MessageCompareColumn>
162
+ <MessageCompareColumn model={{ name: "Model B" }}>B</MessageCompareColumn>
163
+ </MessageCompare>,
164
+ );
165
+
166
+ expect(screen.getByRole("tablist")).toBeInTheDocument();
167
+ expect(screen.getAllByRole("tab")).toHaveLength(2);
168
+ } finally {
169
+ Object.defineProperty(window, "innerWidth", { value: originalWidth, configurable: true });
170
+ }
171
+ });
172
+
173
+ it("does not render a tab strip at a normal (non-mobile) viewport", () => {
174
+ render(
175
+ <MessageCompare columns={2}>
176
+ <MessageCompareColumn model={{ name: "Model A" }}>A</MessageCompareColumn>
177
+ <MessageCompareColumn model={{ name: "Model B" }}>B</MessageCompareColumn>
178
+ </MessageCompare>,
179
+ );
180
+
181
+ expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
182
+ });
183
+
184
+ it("keeps per-column feedback state independent between siblings", async () => {
185
+ const user = userEvent.setup();
186
+ const onSubmitA = vi.fn();
187
+ const onSubmitB = vi.fn();
188
+
189
+ render(
190
+ <MessageCompare columns={2}>
191
+ <MessageCompareColumn model={{ name: "Model A" }}>
192
+ <MessageFeedback onSubmit={onSubmitA} />
193
+ </MessageCompareColumn>
194
+ <MessageCompareColumn model={{ name: "Model B" }}>
195
+ <MessageFeedback onSubmit={onSubmitB} />
196
+ </MessageCompareColumn>
197
+ </MessageCompare>,
198
+ );
199
+
200
+ const [goodA] = screen.getAllByRole("button", { name: "Good response" });
201
+ await user.click(goodA!);
202
+
203
+ expect(onSubmitA).toHaveBeenCalledTimes(1);
204
+ expect(onSubmitA).toHaveBeenCalledWith({ type: "positive" });
205
+ expect(onSubmitB).not.toHaveBeenCalled();
206
+ });
207
+ });