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.
This commit is contained in:
Eli 2026-05-12 15:05:08 +08:00 committed by GitHub
parent 928079daf5
commit 77f69257a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 526 additions and 144 deletions

View file

@ -69,8 +69,20 @@ export function liveSnapshotForComment(
snapshots: Map<string, PreviewCommentSnapshot>,
): PreviewCommentSnapshot | null {
const snapshot = snapshots.get(comment.elementId);
if (!snapshot || snapshot.filePath !== comment.filePath) return null;
return snapshot;
if (snapshot && snapshot.filePath === comment.filePath) return snapshot;
if (!comment.elementId.startsWith('pin-')) return null;
return {
filePath: comment.filePath,
elementId: comment.elementId,
selector: comment.selector,
label: comment.label,
text: trimContextText(comment.text),
position: normalizePosition(comment.position),
htmlHint: trimHtmlHint(comment.htmlHint),
selectionKind: comment.selectionKind === 'pod' ? 'pod' : 'element',
memberCount: comment.memberCount,
podMembers: normalizeMembers(comment.podMembers),
};
}
export function commentToAttachment(

View file

@ -518,26 +518,6 @@ export function ChatPane({
return (
<div className="pane">
<div className="chat-header">
<div className="chat-header-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === 'chat'}
className={`chat-header-tab${tab === 'chat' ? ' active' : ''}`}
onClick={() => setTab('chat')}
>
{t('chat.tabChat')}
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'comments'}
className={`chat-header-tab${tab === 'comments' ? ' active' : ''}`}
onClick={() => setTab('comments')}
>
{t('chat.tabComments')}
</button>
</div>
<div className="chat-header-actions">
<div
className={`chat-history-wrap${showConvList ? ' open' : ''}`}
@ -558,9 +538,6 @@ export function ChatPane({
onClick={() => setShowConvList((v) => !v)}
>
<Icon name="history" size={15} />
{conversations.length > 1 ? (
<span className="chat-history-badge">{conversations.length}</span>
) : null}
</button>
{showConvList ? (
<div className="chat-history-menu" role="menu" data-testid="conversation-history-menu">
@ -761,16 +738,6 @@ export function ChatPane({
/>
</>
) : null}
{tab === 'comments' ? (
<CommentsPanel
comments={previewComments}
attachedComments={attachedComments}
onAttach={onAttachComment}
onDetach={onDetachComment}
onDelete={onDeleteComment}
t={t}
/>
) : null}
</div>
);
}

View file

@ -57,10 +57,12 @@ import type {
ProjectFile,
} from '../types';
import { Icon } from './Icon';
import { Toast } from './Toast';
import { PaletteTweaks, type PaletteId } from './PaletteTweaks';
import { PreviewDrawOverlay } from './PreviewDrawOverlay';
import {
buildBoardCommentAttachments,
commentsToAttachments,
liveSnapshotForComment,
overlayBoundsFromSnapshot,
selectionKindLabel,
@ -1673,6 +1675,7 @@ function BoardComposerPopover({
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
const podMembers = target.podMembers ?? [];
const titleId = useId();
const isFreePin = target.elementId.startsWith('pin-');
return (
<div
className="comment-popover"
@ -1689,12 +1692,26 @@ function BoardComposerPopover({
>
<div className="comment-popover-head">
<div title={target.elementId}>
<strong id={titleId}>{target.elementId}</strong>
<span>{target.label}</span>
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
{isFreePin ? (
<>
<strong id={titleId}>Pin</strong>
<span>at {target.position.x + 12}, {target.position.y + 12}</span>
</>
) : (
<>
<strong id={titleId}>{target.label || target.elementId}</strong>
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
</>
)}
</div>
<button type="button" className="ghost" onClick={onClose} title={t('common.close')}>
{t('common.close')}
<button
type="button"
className="comment-popover-close"
onClick={onClose}
title={t('common.close')}
aria-label={t('common.close')}
>
<Icon name="close" size={12} />
</button>
</div>
{podMembers.length > 0 ? (
@ -1731,42 +1748,165 @@ function BoardComposerPopover({
/>
<div className="comment-popover-actions">
{existing ? (
<button type="button" className="comment-popover-remove" onClick={() => onRemove(existing.id)}>
{t('chat.comments.remove')}
</button>
) : <span />}
<button
type="button"
className="ghost"
disabled={!draft.trim()}
onClick={onAddDraft}
>
Add note
</button>
{target.selectionKind === 'pod' ? null : (
<button
type="button"
className="ghost"
disabled={!draft.trim()}
onClick={() => void onSaveComment()}
className="comment-popover-remove"
onClick={() => onRemove(existing.id)}
title={t('chat.comments.remove')}
>
Save comment
{t('chat.comments.remove')}
</button>
)}
<button
type="button"
className="primary"
data-testid="comment-add-send"
disabled={pendingCount === 0 || sending}
onClick={() => void onSendBatch()}
>
{sending ? 'Sending...' : 'Send to chat'}
</button>
) : null}
<div className="comment-popover-actions-end">
{target.selectionKind === 'pod' ? (
<button
type="button"
className="ghost"
data-testid="comment-popover-add-note"
disabled={!draft.trim()}
onClick={onAddDraft}
>
Add note
</button>
) : (
<button
type="button"
className="ghost"
data-testid="comment-popover-save"
disabled={!draft.trim()}
onClick={() => void onSaveComment()}
>
Comment
</button>
)}
<button
type="button"
className="primary"
data-testid="comment-add-send"
disabled={pendingCount === 0 || sending}
onClick={() => void onSendBatch()}
>
{sending ? 'Sending…' : 'Send to Claude'}
</button>
</div>
</div>
</div>
);
}
function formatCommentTime(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60_000) return 'just now';
const mins = Math.floor(diff / 60_000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w ago`;
return new Date(ts).toLocaleDateString();
}
function commentDisplayLabel(comment: PreviewComment): string {
if (comment.elementId.startsWith('pin-')) return 'Pin';
return comment.label || comment.elementId;
}
function commentAvatarInitial(comment: PreviewComment): string {
const seed = comment.label || comment.elementId || '?';
return seed.charAt(0).toUpperCase();
}
function CommentSidePanel({
comments,
selectedIds,
onToggleSelect,
onClearSelection,
onReply,
onSendSelected,
sending,
t,
}: {
comments: PreviewComment[];
selectedIds: Set<string>;
onToggleSelect: (commentId: string) => void;
onClearSelection: () => void;
onReply: (comment: PreviewComment) => void;
onSendSelected: () => void | Promise<void>;
sending: boolean;
t: TranslateFn;
}) {
const sorted = [...comments].sort((a, b) => b.createdAt - a.createdAt);
const visibleSelectedIds = new Set(comments.filter((comment) => selectedIds.has(comment.id)).map((comment) => comment.id));
const selectedCount = visibleSelectedIds.size;
return (
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={t('chat.tabComments')}>
<div className="comment-side-list">
{sorted.length === 0 ? (
<div className="comment-side-empty">
{t('chat.comments.emptySaved')}
</div>
) : sorted.map((comment) => {
const selected = visibleSelectedIds.has(comment.id);
return (
<div
key={comment.id}
className={`comment-side-item${selected ? ' selected' : ''}`}
data-testid="comment-side-item"
>
<div className="comment-side-item-head">
<span className="comment-side-author">
<span className="comment-side-avatar" aria-hidden>
{commentAvatarInitial(comment)}
</span>
<strong>{commentDisplayLabel(comment)}</strong>
</span>
<span className="comment-side-time">{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
className={`comment-side-check${selected ? ' checked' : ''}`}
aria-label={selected ? 'Deselect' : 'Select'}
aria-pressed={selected}
onClick={() => onToggleSelect(comment.id)}
>
{selected ? <Icon name="check" size={11} /> : null}
</button>
</div>
<div className="comment-side-body">{comment.note}</div>
<button
type="button"
className="comment-side-reply"
data-testid="comment-side-edit"
onClick={() => onReply(comment)}
>
Edit
</button>
</div>
);
})}
</div>
{selectedCount > 0 ? (
<div className="comment-side-selectbar" data-testid="comment-side-selectbar">
<span className="comment-side-selectcount">{selectedCount} selected</span>
<button type="button" className="ghost" onClick={onClearSelection}>
Clear
</button>
<button
type="button"
className="primary"
data-testid="comment-side-send-claude"
disabled={sending}
onClick={() => void onSendSelected()}
>
{sending ? 'Sending…' : 'Send to Claude'}
</button>
</div>
) : null}
</aside>
);
}
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
@ -3263,6 +3403,8 @@ function HtmlViewer({
const [inspectError, setInspectError] = useState<string | null>(null);
const [queuedBoardNotes, setQueuedBoardNotes] = useState<string[]>([]);
const [sendingBoardBatch, setSendingBoardBatch] = useState(false);
const [commentSavedToast, setCommentSavedToast] = useState<string | null>(null);
const [selectedSideCommentIds, setSelectedSideCommentIds] = useState<Set<string>>(() => new Set());
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
@ -4415,13 +4557,15 @@ function HtmlViewer({
async function savePersistentComment() {
if (!activeCommentTarget || !commentDraft.trim() || !onSavePreviewComment) return;
const isFreePin = activeCommentTarget.elementId.startsWith('pin-');
const saved = await onSavePreviewComment(
targetFromSnapshot(activeCommentTarget),
commentDraft.trim(),
false,
);
if (saved) {
setCommentDraft('');
clearBoardComposer();
setCommentSavedToast(isFreePin ? 'Pin saved' : 'Comment saved');
}
}
@ -4430,6 +4574,9 @@ function HtmlViewer({
const exportTitle = file.name.replace(/\.html?$/i, '') || file.name;
const canPptx = canShare && Boolean(onExportAsPptx) && !streaming;
const boardAvailable = source !== null;
const visibleSideComments = previewComments.filter(
(comment) => comment.filePath === file.name && comment.status === 'open',
);
const activeDeployment = deployResult || deployment;
const activeDeployedUrl = activeDeployment?.url?.trim() || '';
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
@ -4625,9 +4772,9 @@ function HtmlViewer({
</div>
<button
type="button"
className={`viewer-toggle${boardMode ? ' active' : ''}`}
className={`viewer-action viewer-comment-toggle${boardMode ? ' active' : ''}`}
data-testid="board-mode-toggle"
title={t('fileViewer.tweaks')}
title="Comment"
aria-pressed={boardMode}
disabled={!boardAvailable}
onClick={() => {
@ -4640,9 +4787,8 @@ function HtmlViewer({
activateBoard(boardTool);
}}
>
<Icon name="tweaks" size={13} />
<span>{t('fileViewer.tweaks')}</span>
<span className="switch" aria-hidden />
<Icon name="comment" size={13} />
<span>Comment</span>
</button>
{boardMode ? (
<>
@ -5057,6 +5203,15 @@ function HtmlViewer({
}}
/>
) : null}
{commentSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={commentSavedToast}
ttlMs={2200}
onDismiss={() => setCommentSavedToast(null)}
/>
</div>
) : null}
{boardMode && activeCommentTarget ? (
<BoardComposerPopover
target={activeCommentTarget}
@ -5080,6 +5235,60 @@ function HtmlViewer({
t={t}
/>
) : null}
{boardMode ? (
<CommentSidePanel
comments={visibleSideComments}
selectedIds={selectedSideCommentIds}
onToggleSelect={(commentId) => {
setSelectedSideCommentIds((current) => {
const next = new Set(current);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
}}
onClearSelection={() => setSelectedSideCommentIds(new Set())}
onReply={(comment) => {
// Reply == edit on a flat-thread model: prefill the
// popover with the existing note so the user sees and
// mutates the current text. Save runs through the
// same upsert path; matching project/conv/file/element
// updates note in place rather than creating a new row.
const snapshot = liveSnapshotForComment(comment, liveCommentTargets) ?? {
filePath: comment.filePath,
elementId: comment.elementId,
selector: comment.selector,
label: comment.label,
text: comment.text,
position: comment.position,
htmlHint: comment.htmlHint,
selectionKind: comment.selectionKind ?? 'element',
memberCount: comment.memberCount,
podMembers: comment.podMembers,
};
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setCommentDraft(comment.note);
setQueuedBoardNotes([]);
}}
onSendSelected={async () => {
if (!onSendBoardCommentAttachments) return;
const selected = visibleSideComments.filter(
(comment) => selectedSideCommentIds.has(comment.id),
);
if (selected.length === 0) return;
setSendingBoardBatch(true);
try {
await onSendBoardCommentAttachments(commentsToAttachments(selected));
setSelectedSideCommentIds(new Set());
} finally {
setSendingBoardBatch(false);
}
}}
sending={sendingBoardBatch || streaming}
t={t}
/>
) : null}
{inspectMode && activeInspectTarget ? (
<InspectPanel
target={activeInspectTarget}

View file

@ -947,7 +947,7 @@ code {
color: var(--text);
border-bottom-color: var(--text);
}
.chat-header-actions { display: inline-flex; gap: 4px; align-items: center; }
.chat-header-actions { display: inline-flex; gap: 4px; align-items: center; margin-left: auto; }
.chat-header-actions .icon-only {
width: 28px; height: 28px;
padding: 0;
@ -8947,7 +8947,7 @@ button.connector-action.is-loading {
position: absolute;
left: 14px;
top: 14px;
z-index: 4;
z-index: 40;
width: min(320px, calc(100% - 28px));
padding: 10px;
border: 1px solid var(--border);
@ -9068,6 +9068,186 @@ button.connector-action.is-loading {
border-color: var(--red-border);
}
/* Right-side comment thread panel. Shown while board (comment) mode
is on; takes the place of the chat sidebar's removed Comments tab.
Floats over the artifact preview at the right edge. */
.comment-side-panel {
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
width: 320px;
max-width: calc(100% - 16px);
background: var(--bg-panel, #fff);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
z-index: 30;
overflow: hidden;
}
.comment-side-list {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.comment-side-empty {
padding: 24px 8px;
text-align: center;
color: var(--text-muted);
font-size: 12.5px;
}
.comment-side-item {
border: 1px solid transparent;
border-radius: 8px;
padding: 10px 12px;
background: transparent;
display: flex;
flex-direction: column;
gap: 4px;
}
.comment-side-item.selected {
background: #fff1ec;
border-color: #ff8c75;
}
.comment-side-item-head {
display: flex;
align-items: center;
gap: 8px;
}
.comment-side-author {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.comment-side-author strong {
font-size: 13px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-side-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: #0a0a0a;
color: #fff;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.comment-side-time {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.comment-side-check {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1.5px solid var(--border);
background: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: #fff;
flex-shrink: 0;
}
.comment-side-check.checked {
background: #ff5a3c;
border-color: #ff5a3c;
}
.comment-side-body {
font-size: 13px;
color: var(--text);
line-height: 1.45;
word-break: break-word;
white-space: pre-wrap;
}
.comment-side-reply {
align-self: flex-start;
background: transparent;
border: none;
padding: 0;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
}
.comment-side-reply:hover {
color: var(--text);
text-decoration: underline;
}
.comment-side-selectbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid var(--border);
background: #fff1ec;
}
.comment-side-selectcount {
flex: 1;
font-size: 12px;
color: #cc3a20;
}
.comment-side-selectbar .ghost {
background: transparent;
border: none;
font-size: 12px;
color: var(--text);
cursor: pointer;
padding: 4px 6px;
}
.comment-side-selectbar .ghost:hover {
color: var(--text);
text-decoration: underline;
}
.comment-side-selectbar .primary {
display: inline-flex;
align-items: center;
gap: 4px;
background: #ff5a3c;
color: #fff;
border: 1px solid #ff5a3c;
border-radius: 6px;
padding: 5px 10px;
font-size: 12px;
cursor: pointer;
}
.comment-side-selectbar .primary:disabled {
background: #ffb9aa;
border-color: #ffb9aa;
cursor: not-allowed;
}
.comment-side-selectbar .primary:hover:not(:disabled) {
background: #e94a2d;
border-color: #e94a2d;
}
/* Anchor for the transient comment-save toast. Centered at the top
of the artifact preview so the confirmation is visible without
covering the popover's former position. */
.comment-toast-anchor {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
pointer-events: auto;
}
/* Inspect panel sibling of the comment popover. Anchored to the
right side of the preview surface. Width is fixed so layout doesn't
reflow as the user scrubs slider values; controls reserve space for

View file

@ -832,11 +832,48 @@ function injectSelectionBridge(
document.addEventListener('click', function(ev){
if (!pickerActive()) return;
var el = closestTarget(ev);
if (!el) return;
if (el) {
ev.preventDefault();
ev.stopPropagation();
var payload = targetFrom(el, commentEnabled && mode === 'picker' && !inspectEnabled);
if (payload) window.parent.postMessage(payload, '*');
return;
}
// Free-pin fallback (comment mode only). Lets users drop a comment
// at a click location even when the artifact has no data-od-id
// annotations. Skipped for pod mode (drawing) and inspect mode
// (needs a real selector for live overrides).
if (!canUseDomFallback() || mode === 'pod') return;
// Skip clicks on interactive elements so links / buttons / inputs
// keep their native behavior; pin only on inert surfaces.
var t = ev.target;
var walk = t && t.nodeType === 1 ? t : null;
while (walk && walk !== document.documentElement) {
var tag = walk.tagName;
if (tag === 'A' || tag === 'BUTTON' || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'LABEL') return;
if (walk.isContentEditable) return;
walk = walk.parentElement;
}
ev.preventDefault();
ev.stopPropagation();
var payload = targetFrom(el, commentEnabled && mode === 'picker' && !inspectEnabled);
if (payload) window.parent.postMessage(payload, '*');
// Store viewport coordinates to match regular getBoundingClientRect()
// element targets; the host overlay renders this position directly.
var pinX = Math.round(ev.clientX);
var pinY = Math.round(ev.clientY);
var pinId = 'pin-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36);
window.parent.postMessage({
type: 'od:comment-target',
elementId: pinId,
// Synthetic selector / label so daemon upsert validation (which
// requires both to be non-empty) accepts the saved free-pin.
selector: '[data-od-pin="' + pinId + '"]',
label: 'pin',
text: '',
position: { x: pinX - 12, y: pinY - 12, width: 24, height: 24 },
htmlHint: '',
style: null,
freePin: true
}, '*');
}, true);
// Pod drawing — only active in comment mode with the 'pod' tool.
document.addEventListener('pointerdown', function(ev){

View file

@ -184,6 +184,25 @@ describe('preview comment attachment helpers', () => {
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' }),

View file

@ -144,64 +144,12 @@ async function flushFrame() {
});
}
async function switchTab(name: 'Chat' | 'Comments') {
const tab = screen.getByRole('tab', { name });
await act(async () => {
fireEvent.click(tab);
});
}
describe('chat scroll preservation across tab switches', () => {
it('restores absolute scrollTop when user was scrolled up', async () => {
describe('chat scroll behavior', () => {
it('does not render the removed comments tab beside chat', () => {
renderChatPane(sampleMessages);
setGeom({ scrollHeight: 1000, clientHeight: 400, scrollTop: 0 });
// User scrolls up to 200 (well above bottom: distance=400).
setUserScroll(200);
await switchTab('Comments');
await switchTab('Chat');
await flushFrame();
expect(geom.scrollTop).toBe(200);
});
it('snaps to new scrollHeight when user was pinned to bottom and new content arrived off-tab', async () => {
renderChatPane(sampleMessages);
setGeom({ scrollHeight: 1000, clientHeight: 400, scrollTop: 0 });
// User is pinned at bottom (distance = 1000 - 600 - 400 = 0 < 50).
setUserScroll(600);
await switchTab('Comments');
// While off-tab, new messages would normally grow scrollHeight.
setGeom({ scrollHeight: 1500 });
await switchTab('Chat');
await flushFrame();
// Bottom-pinned user lands at scrollHeight, not at the old offset.
expect(geom.scrollTop).toBe(1500);
});
it('reveals the jump-to-latest button when restored position is no longer near bottom', async () => {
renderChatPane(sampleMessages);
setGeom({ scrollHeight: 1000, clientHeight: 400, scrollTop: 0 });
// User leaves Chat ~60px from the bottom (distance = 1000 - 540 - 400 = 60).
setUserScroll(540);
await switchTab('Comments');
// While off-tab, new messages stack underneath. scrollHeight grows
// dramatically; the saved absolute scrollTop is now hundreds of
// pixels above the latest turn.
setGeom({ scrollHeight: 2000 });
await switchTab('Chat');
await flushFrame();
// Restored to old offset (540), but distance = 2000 - 540 - 400 = 1060
// is well past the 120px threshold, so the jump-to-latest button
// must be visible immediately, not hidden until the user scrolls.
expect(geom.scrollTop).toBe(540);
expect(screen.getByRole('button', { name: /jump to latest/i })).toBeTruthy();
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 () => {
@ -234,25 +182,22 @@ describe('chat scroll preservation across tab switches', () => {
expect(geom.scrollTop).toBe(510);
});
it('lands new conversation at its own bottom when switching conversations off-tab', async () => {
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 and switches to Comments.
// User scrolls up in conversation A.
setUserScroll(150);
await switchTab('Comments');
// While off-tab the active conversation changes to B. Returning to
// Chat must land at conversation B's own initial bottom, not at
// scrollTop: 0 and not at conversation A's saved offset.
// 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 switchTab('Chat');
await flushFrame();
// Saved state was cleared by the activeConversationId-reset effect,
// and the initial-bottom-scroll effect re-runs with `tab` in its
// deps, so the new conversation lands at its own scrollHeight rather
// than the browser default 0.
// so the new conversation lands at its own scrollHeight rather than
// the browser default 0.
expect(geom.scrollTop).toBe(1000);
});
});

View file

@ -66,6 +66,19 @@ describe('buildSrcdoc', () => {
expect(srcdoc).toContain('data-od-selection-bridge-style');
});
it('emits free-pin fallback coordinates in viewport space', () => {
const srcdoc = buildSrcdoc('<main>Hero</main>', { commentBridge: true });
const freePinBlock = srcdoc.slice(srcdoc.indexOf('var pinX = Math.round(ev.clientX);'));
expect(freePinBlock).toContain('var pinX = Math.round(ev.clientX);');
expect(freePinBlock).toContain('var pinY = Math.round(ev.clientY);');
expect(freePinBlock).toContain('position: { x: pinX - 12, y: pinY - 12, width: 24, height: 24 }');
expect(freePinBlock).not.toContain('scrollX');
expect(freePinBlock).not.toContain('scrollY');
expect(freePinBlock).not.toContain('pageXOffset');
expect(freePinBlock).not.toContain('pageYOffset');
});
it('injects the selection bridge for inspect mode and exposes override hooks', () => {
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
inspectBridge: true,