open-design/apps/web/tests/comments.test.ts
Eli 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

275 lines
9.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildBoardCommentAttachments,
commentsToAttachments,
historyWithCommentAttachmentContext,
liveSnapshotForComment,
mergeAttachedComments,
messageContentWithCommentAttachments,
overlayBoundsFromSnapshot,
removeAttachedComment,
targetFromSnapshot,
} from '../src/comments';
import type { ChatMessage, PreviewComment } from '../src/types';
describe('preview comment attachment helpers', () => {
it('builds compact target context from an iframe snapshot', () => {
const target = targetFromSnapshot({
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: ` ${'Title '.repeat(80)} `,
htmlHint: `<h1 class="hero-title" data-od-id="hero-title">${'x'.repeat(240)}</h1>`,
position: { x: 10.4, y: 20.5, width: 300.2, height: 88.8 },
});
expect(target.text.length).toBeLessThanOrEqual(160);
expect(target.htmlHint.length).toBeLessThanOrEqual(180);
expect(target.position).toEqual({ x: 10, y: 21, width: 300, height: 89 });
});
it('creates ordered compact send payloads from attached comments', () => {
const attachments = commentsToAttachments([
comment({ id: 'c1', elementId: 'hero-title', note: 'Shorten this title' }),
comment({ id: 'c2', elementId: 'chart', note: 'Make it feel real' }),
]);
expect(attachments).toMatchObject([
{ id: 'c1', order: 1, elementId: 'hero-title', comment: 'Shorten this title' },
{ id: 'c2', order: 2, elementId: 'chart', comment: 'Make it feel real' },
]);
});
it('builds grouped board payloads for pod selections', () => {
const attachments = buildBoardCommentAttachments({
target: {
filePath: 'atlas.html',
elementId: 'pod-1',
selector: '[data-od-id="hero"], [data-od-id="chart"]',
label: 'Hero and chart',
text: 'Hero title Chart value',
position: { x: 10, y: 20, width: 300, height: 200 },
htmlHint: '<section data-od-id="hero">',
selectionKind: 'pod',
memberCount: 2,
podMembers: [
{
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'section.hero',
text: 'Hero title',
position: { x: 10, y: 20, width: 200, height: 100 },
htmlHint: '<section data-od-id="hero">',
},
{
elementId: 'chart',
selector: '[data-od-id="chart"]',
label: 'section.chart',
text: 'Chart value',
position: { x: 120, y: 80, width: 190, height: 120 },
htmlHint: '<section data-od-id="chart">',
},
],
},
notes: ['Tighten the hierarchy', 'Make the chart feel premium'],
});
expect(attachments).toHaveLength(2);
expect(attachments[0]).toMatchObject({
selectionKind: 'pod',
memberCount: 2,
source: 'board-batch',
comment: 'Tighten the hierarchy',
});
expect(messageContentWithCommentAttachments('', attachments)).toContain('memberCount: 2');
});
it('keeps large queued board-note batches ordered in one send payload', () => {
const notes = Array.from({ length: 8 }, (_, index) => `Note ${index + 1}`);
const attachments = buildBoardCommentAttachments({
target: {
filePath: 'atlas.html',
elementId: 'pod-2',
selector: '[data-od-id="card"]',
label: 'Card pod',
text: 'Heading Body CTA',
position: { x: 20, y: 30, width: 240, height: 160 },
htmlHint: '<section data-od-id="card">',
selectionKind: 'pod',
memberCount: 3,
podMembers: [
{
elementId: 'card-heading',
selector: '[data-od-id="card-heading"]',
label: 'h2.card-heading',
text: 'Heading',
position: { x: 24, y: 34, width: 100, height: 32 },
htmlHint: '<h2 data-od-id="card-heading">',
},
{
elementId: 'card-body',
selector: '[data-od-id="card-body"]',
label: 'p.card-body',
text: 'Body',
position: { x: 24, y: 72, width: 180, height: 48 },
htmlHint: '<p data-od-id="card-body">',
},
{
elementId: 'card-cta',
selector: '[data-od-id="card-cta"]',
label: 'button.card-cta',
text: 'CTA',
position: { x: 24, y: 128, width: 96, height: 32 },
htmlHint: '<button data-od-id="card-cta">',
},
],
},
notes,
});
expect(attachments).toHaveLength(8);
expect(attachments.map((attachment) => attachment.order)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(attachments.map((attachment) => attachment.comment)).toEqual(notes);
expect(messageContentWithCommentAttachments('', attachments)).toContain('8. pod-2');
});
it('updates and removes attached comments by saved comment id', () => {
const first = comment({ id: 'c1', elementId: 'hero-title', note: 'Original' });
const updated = comment({ id: 'c1', elementId: 'hero-title', note: 'Updated' });
const chart = comment({ id: 'c2', elementId: 'chart', note: 'Fix chart' });
const merged = mergeAttachedComments([first, chart], updated);
expect(merged).toHaveLength(2);
expect(merged[0]?.note).toBe('Updated');
const remaining = removeAttachedComment(merged, 'c1');
expect(commentsToAttachments(remaining)).toEqual([
expect.objectContaining({ id: 'c2', elementId: 'chart' }),
]);
});
it('converts iframe snapshot bounds into scaled overlay bounds', () => {
expect(overlayBoundsFromSnapshot({
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: '',
htmlHint: '',
position: { x: 10, y: 20, width: 120, height: 40 },
}, 1.25)).toEqual({
left: 12.5,
top: 25,
width: 150,
height: 50,
});
});
it('only resolves saved markers from live snapshots for the same file', () => {
const saved = comment({ filePath: 'index.html', elementId: 'hero-title' });
const snapshots = new Map([
['hero-title', {
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: '',
htmlHint: '',
position: { x: 1, y: 2, width: 3, height: 4 },
}],
]);
expect(liveSnapshotForComment(saved, snapshots)?.elementId).toBe('hero-title');
expect(liveSnapshotForComment(comment({ filePath: 'other.html' }), snapshots)).toBeNull();
});
it('rehydrates saved free-pin markers from persisted comment position after iframe reload', () => {
const saved = comment({
elementId: 'pin-abc123',
selector: '[data-od-pin="pin-abc123"]',
label: 'pin',
text: '',
htmlHint: '',
position: { x: 88, y: 144, width: 24, height: 24 },
});
expect(liveSnapshotForComment(saved, new Map())).toMatchObject({
filePath: 'index.html',
elementId: 'pin-abc123',
selector: '[data-od-pin="pin-abc123"]',
label: 'pin',
position: { x: 88, y: 144, width: 24, height: 24 },
});
});
it('serializes selected comments into API-mode prompt context without visible input', () => {
const attachments = commentsToAttachments([
comment({ id: 'c1', elementId: 'hero-title', note: 'Only shorten this title' }),
]);
const content = messageContentWithCommentAttachments('', attachments);
expect(content).toContain('(No extra typed instruction.)');
expect(content).toContain('<attached-preview-comments>');
expect(content).toContain('selector: [data-od-id="hero-title"]');
expect(content).toContain('comment: Only shorten this title');
});
it('adds hidden comment context only to the current user message sent to API providers', () => {
const attachments = commentsToAttachments([
comment({ id: 'c1', elementId: 'hero-title', note: 'Make it bolder' }),
]);
const history: ChatMessage[] = [
{
id: 'old',
role: 'user',
content: 'Previous request',
createdAt: 0,
commentAttachments: attachments,
},
{
id: 'u1',
role: 'user',
content: '',
createdAt: 1,
commentAttachments: attachments,
},
{
id: 'a1',
role: 'assistant',
content: 'Ready',
createdAt: 2,
commentAttachments: attachments,
},
];
const next = historyWithCommentAttachmentContext(history, 'u1');
expect(next[0]?.content).toBe('Previous request');
expect(next[1]?.content).toContain('<attached-preview-comments>');
expect(next[1]?.content).toContain('comment: Make it bolder');
expect(next[2]?.content).toBe('Ready');
expect(history[1]?.content).toBe('');
});
});
function comment(patch: Partial<PreviewComment>): PreviewComment {
return {
id: 'c1',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: 'Current title',
position: { x: 1, y: 2, width: 3, height: 4 },
htmlHint: '<h1 data-od-id="hero-title">',
note: 'Comment',
status: 'open',
createdAt: 1,
updatedAt: 1,
...patch,
};
}