Files
open-design/apps/web/tests/components/chat-scroll-preservation.test.tsx
EliandGitHub 77f69257a7 feat(web): in-context comment thread for the artifact preview (#1276)
* feat(web): free-pin fallback in comment mode for unannotated artifacts

When the artifact has no data-od-id annotations, clicking in Comment
mode now posts a synthetic position-based target so the host opens a
popover at the click location. Daemon upsert validation requires a
non-empty selector/label, so the pin uses [data-od-pin=ID] and label
'pin'. Coordinates are document-space (viewport + scrollY) so pins
stay anchored after scroll/reload. Clicks on interactive elements
(a/button/input/textarea/select/label/contenteditable) keep their
native behavior and are not pinned.

* feat(web): tighten comment popover layout for free-pin and element targets

The popover header used to dump the raw elementId verbatim — fine for
data-od-id targets like 'hero-cta' but jarring for free-pins where
elementId is a synthetic 'pin-...' string. Branch on the prefix and
show 'Pin · at X, Y' for free-pins; keep the label + selection kind
for real element / pod targets. Replace the text 'Close' button with
an icon-only close affordance to match the popover-as-card visual.

Action row is now two right-aligned buttons (Comment + Send to
Claude) for element targets and (Add note + Send to Claude) for pod
targets, eliminating the three-button row that wrapped onto two
lines at narrow widths. The 'Remove' affordance for existing
comments stays left-aligned.

* feat(web): drop comments tab from chat sidebar

The chat sidebar's 'Comments' tab listed saved/attached preview
comments but duplicates the per-element popover already shown in the
artifact viewer. Hide the tab and its content while the right-side
comment thread panel takes over the same surface in-context. The
CommentsPanel / CommentSection components stay defined as dead code
for the moment so callers and translation keys remain valid; a later
pass can delete them.

* feat(web): right-side comment thread panel in board mode

Render a 320px CommentSidePanel anchored to the right of the
artifact preview whenever board (comment) mode is on. The panel
lists every saved preview comment for the current file with an
avatar initial, the element label (or 'Pin' for free-pin synthetic
ids), an Xd/Xh/Xm-ago timestamp, the note body, a Reply link, and
a checkbox.

Reply focuses the comment's element via liveSnapshotForComment so
the popover opens at the right anchor. Selecting one or more
comments via the checkboxes surfaces a 'N selected · Clear · Send
to Claude' action bar above the list; Send to Claude reuses the
existing onSendBoardCommentAttachments pipeline via
commentsToAttachments. The panel takes the place of the chat
sidebar's removed Comments tab so the thread lives next to the
artifact instead of behind a tab switch.

* feat(web): styles for right-side comment thread panel

Floating 320px panel anchored to the right edge of the artifact
preview with a scrollable comment list and a coral selection bar
that appears when one or more comments are checked. Selected items
get a coral tint; the reply / check / send-to-claude controls
match the popover's coral primary tone.

* feat(web): toast confirmation on comment save, close popover

After savePersistentComment succeeds, close the popover via
clearBoardComposer and surface a transient 'Comment saved' (or
'Pin saved' for free-pin targets) toast for 2.2s. Replaces the
previous behavior where the popover stayed open with an empty
draft after save, which left users uncertain whether the save
landed and forced an extra click to dismiss.

* feat(web): position the comment-save toast at the top of the preview

* feat(web): allow editing saved comment notes via the side panel

Rename the per-item 'Reply' affordance to 'Edit' (no thread model
exists yet, so reply was misleading) and pre-fill the popover with
the existing note when clicked. The save path goes through
onSavePreviewComment which the daemon implements as an upsert keyed
on (project, conversation, filePath, elementId), so the edit
overwrites the existing row's note without spawning a duplicate.

Also fall back to a snapshot synthesized from the saved comment's
own fields when the corresponding live target is no longer in the
iframe DOM (e.g. free-pin parents that were re-rendered), so the
edit path still works after artifact reloads.

* feat(web): hide already-sent comments from the side panel

After Send to Claude, the daemon flips the comment status from
'open' to 'applying' (and then 'needs_review' / 'resolved' /
'failed' depending on the run). Filter the side panel to status
=== 'open' so sent comments visibly leave the list — the user
gets clear feedback that the send landed and the panel stays
focused on actionable, un-sent items.

* feat(web): drop single-tab bar and conversation count badge

After the Comments tab was removed the chat header still rendered
a one-tab 'tablist' just for the Chat tab, which read as visual
noise without a sibling to switch between. Drop the tabs wrapper
entirely; the chat content stays mounted and the header now hosts
only the conversation-history affordance.

Also drop the numeric badge that overlaid the conversation history
button: counting open conversations next to a generic history icon
was easy to mistake for an unread / notification count. The dropdown
itself remains the canonical place to see and switch between past
conversations.

* feat(web): right-align chat header actions after tab bar removal

With the tabs wrapper gone, chat-header-actions sat flush left
because nothing was pushing it across the header. Add margin-left:
auto so the history / new-conversation / collapse buttons land at
the right edge, matching the design files / index.html tab row's
own right-aligned controls.

* feat(web): rename board-mode toggle to Comment with comment icon

The artifact preview toolbar's board-mode entry was labeled 'Tweaks'
with the tweaks icon, which collided with the palette Tweaks button
next to it and hid the comment capability behind a generic label.
Rename to 'Comment' with the comment icon and switch to the
viewer-action class so the button matches the surrounding toolbar
items (Edit/Draw) and the coral active state lands on the right
surface.

* fix(web): pass designTemplates to ProjectView in api-empty-response test

The test props for ProjectView were missing the designTemplates
prop that was added to Props in #955 (generic skills split). CI's
strict typecheck (tsc -b --noEmit) caught it; local runs that hit
project references differently did not. Pass an empty SkillSummary
array — matches the empty skills fixture for the same reason.
2026-05-12 15:05:08 +08:00

204 lines
7.5 KiB
TypeScript

// @vitest-environment jsdom
// Polyfill scrollTo for jsdom (not available in jsdom's HTMLElement)
if (typeof HTMLElement.prototype.scrollTo !== 'function') {
HTMLElement.prototype.scrollTo = function (
options?: ScrollToOptions | number,
_y?: number,
) {
if (typeof options === 'object' && options !== null) {
if (options.top !== undefined) this.scrollTop = options.top;
if (options.left !== undefined) this.scrollLeft = options.left;
}
};
}
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import type { ChatMessage } from '../../src/types';
// jsdom does not run a layout engine, so scrollHeight/clientHeight/scrollTop
// are all 0. The scroll-preservation effect derives "near bottom" and the
// restore target from those, so we install prototype-level getters/setters
// that route every chat-log read/write through a per-test `geom` object.
//
// The earlier shape installed instance-level Object.defineProperty mocks on
// the *remounted* chat-log only AFTER `await switchTab('Chat')`. Inside that
// act() the component schedules a rAF that writes scrollTop on the new
// element; depending on whether jsdom's rAF polyfill flushed before the await
// resolved, the write either landed on the still-default prototype setter
// (lost) or the not-yet-installed instance setter (also lost). The instance
// mock's closure-captured `remountedTop` then served its initial 0 forever
// and the assertion failed nondeterministically across CI runs without any
// product-code change. Patching at the prototype level eliminates the race
// because any chat-log instance React mounts later automatically reads/writes
// through this single test-controlled geometry.
type Geom = { scrollHeight: number; clientHeight: number; scrollTop: number };
let geom: Geom;
let rafCallbacks: FrameRequestCallback[] = [];
let savedDescriptors: Record<
'scrollTop' | 'scrollHeight' | 'clientHeight',
PropertyDescriptor | undefined
>;
function isChatLog(el: HTMLElement): boolean {
return typeof el?.classList?.contains === 'function' && el.classList.contains('chat-log');
}
beforeEach(() => {
geom = { scrollHeight: 0, clientHeight: 0, scrollTop: 0 };
rafCallbacks = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});
savedDescriptors = {
scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
scrollHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'),
clientHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight'),
};
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? geom.scrollTop : 0;
},
set(this: HTMLElement, v: number) {
if (isChatLog(this)) geom.scrollTop = v;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? geom.scrollHeight : 0;
},
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? geom.clientHeight : 0;
},
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
rafCallbacks = [];
for (const key of ['scrollTop', 'scrollHeight', 'clientHeight'] as const) {
const original = savedDescriptors[key];
if (original) {
Object.defineProperty(HTMLElement.prototype, key, original);
} else {
delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
}
}
});
function setGeom(partial: Partial<Geom>) {
geom = { ...geom, ...partial };
}
function setUserScroll(top: number) {
geom.scrollTop = top;
const el = document.querySelector('.chat-log');
if (el) fireEvent.scroll(el);
}
function chatPaneEl(messages: ChatMessage[], activeConversationId: string | null) {
return (
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={() => {}}
onStop={() => {}}
conversations={[]}
activeConversationId={activeConversationId}
onSelectConversation={() => {}}
onDeleteConversation={() => {}}
/>
);
}
function renderChatPane(messages: ChatMessage[], activeConversationId: string | null = null) {
return render(chatPaneEl(messages, activeConversationId));
}
const sampleMessages: ChatMessage[] = [
{ id: 'u1', role: 'user', content: 'first request', createdAt: Date.now() },
{ id: 'a1', role: 'assistant', content: 'first reply', createdAt: Date.now() },
{ id: 'u2', role: 'user', content: 'second request', createdAt: Date.now() },
{ id: 'a2', role: 'assistant', content: 'second reply', createdAt: Date.now() },
];
async function flushFrame() {
await act(async () => {
const callbacks = rafCallbacks;
rafCallbacks = [];
callbacks.forEach((callback) => callback(performance.now()));
await Promise.resolve();
});
}
describe('chat scroll behavior', () => {
it('does not render the removed comments tab beside chat', () => {
renderChatPane(sampleMessages);
expect(screen.queryByRole('tab', { name: 'Comments' })).toBeNull();
expect(screen.queryByRole('tab', { name: 'Chat' })).toBeNull();
});
it('does not auto-scroll a short scrollback (~90px above bottom) when new content streams in', async () => {
setGeom({ scrollHeight: 1000, clientHeight: 400, scrollTop: 0 });
const { rerender } = render(chatPaneEl(sampleMessages, null));
// Drain the initial-bottom-scroll rAF queued during the first render,
// otherwise it fires after our setUserScroll calls and re-pins the
// ref to true behind the test's back.
await flushFrame();
// User intentionally scrolls 90px up: distance = 1000 - 510 - 400 = 90.
// That's between the 80px auto-follow cutoff and the 120px jump-button
// threshold, so the user is deliberately reading slightly earlier
// output and should not be yanked to the latest message.
setUserScroll(510);
// A new assistant message streams in; scrollHeight grows.
const streamed: ChatMessage[] = [
...sampleMessages,
{ id: 'a3', role: 'assistant', content: 'streaming chunk', createdAt: Date.now() },
];
setGeom({ scrollHeight: 1100 });
await act(async () => {
rerender(chatPaneEl(streamed, null));
});
await flushFrame();
// Auto-scroll must respect the user's pause: scrollTop stays where
// they put it instead of snapping to the new scrollHeight.
expect(geom.scrollTop).toBe(510);
});
it('lands new conversation at its own bottom when switching conversations', async () => {
const { rerender } = render(chatPaneEl(sampleMessages, 'conv-A'));
setGeom({ scrollHeight: 1000, clientHeight: 400, scrollTop: 0 });
// User scrolls up in conversation A.
setUserScroll(150);
// The active conversation changes to B. It must land at conversation
// B's own initial bottom, not at scrollTop: 0 and not at conversation
// A's saved offset.
rerender(chatPaneEl(sampleMessages, 'conv-B'));
await flushFrame();
// Saved state was cleared by the activeConversationId-reset effect,
// so the new conversation lands at its own scrollHeight rather than
// the browser default 0.
expect(geom.scrollTop).toBe(1000);
});
});