@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.
- package/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- 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
|
+
});
|