2026-05-02 19:23:46 +08:00
import type {
ChatCommentAttachment ,
2026-05-13 20:02:19 +08:00
ChatCommentSelectionKind ,
2026-05-02 19:23:46 +08:00
ChatMessage ,
2026-05-25 15:24:21 +08:00
PreviewAnnotationStyle ,
2026-08-05 08:31:54 +08:00
PreviewCommentAnchorState ,
2026-06-03 23:46:22 +08:00
PreviewCommentAttachment ,
2026-05-05 20:09:20 +07:00
PreviewCommentMember ,
2026-05-02 19:23:46 +08:00
PreviewComment ,
2026-05-05 20:09:20 +07:00
PreviewCommentSelectionKind ,
2026-05-02 19:23:46 +08:00
PreviewCommentTarget ,
2026-05-13 20:02:19 +08:00
PreviewVisualMarkKind ,
2026-05-02 19:23:46 +08:00
} from './types' ;
2026-10-19 06:15:28 +00:00
import { overlayBoundsFromSnapshot , resolveCommentAnchor } from './comments/anchor' ;
import type { CommentAnchorResolution , PreviewCommentSnapshot } from './comments/anchor' ;
2026-05-02 19:23:46 +08:00
2026-10-19 06:15:28 +00:00
export { overlayBoundsFromSnapshot , resolveCommentAnchor };
export type { CommentAnchorResolution , PreviewCommentSnapshot };
2026-05-02 19:23:46 +08:00
2026-05-13 20:02:19 +08:00
export interface VisualAnnotationTarget {
filePath : string ;
elementId? : string ;
selector? : string ;
label? : string ;
text? : string ;
position ?: { x : number ; y : number ; width : number ; height : number };
htmlHint? : string ;
2026-05-25 15:24:21 +08:00
style? : PreviewAnnotationStyle ;
2026-05-13 20:02:19 +08:00
}
export interface VisualAnnotationAttachmentInput {
order : number ;
idSeed? : string ;
2026-07-14 15:05:44 +08:00
/** Absent when the preview screenshot could not be captured (#4080); the
* attachment then carries only the structured location (file/position/
* markKind) so the agent can still find the marked region (#4084). */
screenshotPath? : string ;
2026-05-13 20:02:19 +08:00
markKind : PreviewVisualMarkKind ;
note : string ;
bounds : { x : number ; y : number ; width : number ; height : number };
target? : VisualAnnotationTarget | null ;
}
2026-05-26 18:31:19 +08:00
export function isInternalCommentTargetName ( value : string | undefined | null ) : boolean {
const trimmed = String ( value ?? '' ). trim ();
return /^path-\d+(?:-\d+)*$/ . test ( trimmed );
}
export function commentTargetDisplayName (
target : {
elementId? : string | null ;
label? : string | null ;
selectionKind? : ChatCommentSelectionKind | PreviewCommentSelectionKind | null ;
},
fallback = 'Annotation' ,
) : string {
if ( target . selectionKind === 'visual' ) return 'Visual mark' ;
const label = String ( target . label ?? '' ). trim ();
if ( label && ! isInternalCommentTargetName ( label )) return label ;
const elementId = String ( target . elementId ?? '' ). trim ();
if ( elementId && ! isInternalCommentTargetName ( elementId )) return elementId ;
return fallback ;
}
2026-05-02 19:23:46 +08:00
export function targetFromSnapshot ( snapshot : PreviewCommentSnapshot ) : PreviewCommentTarget {
2026-05-05 20:09:20 +07:00
const podMembers = normalizeMembers ( snapshot . podMembers );
2026-05-02 19:23:46 +08:00
return {
filePath : snapshot.filePath ,
elementId : snapshot.elementId ,
selector : snapshot.selector ,
label : snapshot.label ,
text : trimContextText ( snapshot . text ),
position : normalizePosition ( snapshot . position ),
htmlHint : trimHtmlHint ( snapshot . htmlHint ),
2026-05-25 15:24:21 +08:00
style : normalizeStyle ( snapshot . style ),
2026-05-05 20:09:20 +07:00
selectionKind : snapshot.selectionKind === 'pod' ? 'pod' : 'element' ,
memberCount :
snapshot.selectionKind === 'pod'
? ( podMembers . length > 0
? podMembers.length
: Number.isFinite ( snapshot . memberCount )
? Math . round ( snapshot . memberCount as number )
: 0 )
: undefined ,
podMembers : podMembers.length > 0 ? podMembers : undefined ,
2026-06-01 15:00:50 +08:00
...( snapshot . slideIndex === undefined ? {} : { slideIndex : snapshot.slideIndex }),
2026-05-02 19:23:46 +08:00
};
}
2026-06-01 15:00:50 +08:00
export function isValidCommentOverlayPosition (
position : { x : number ; y : number ; width : number ; height : number } | undefined | null ,
) : boolean {
if ( ! position ) return false ;
const normalized = normalizePosition ( position );
return (
Number . isFinite ( normalized . x )
&& Number . isFinite ( normalized . y )
&& Number . isFinite ( normalized . width )
&& Number . isFinite ( normalized . height )
&& normalized . width > 0
&& normalized . height > 0
);
}
export function commentVisibleOnDeckSlide (
comment : Pick < PreviewComment , 'slideIndex' >,
activeSlideIndex : number | null | undefined ,
) : boolean {
if ( activeSlideIndex == null ) return true ;
if ( typeof comment . slideIndex !== 'number' ) return true ;
return comment . slideIndex === activeSlideIndex ;
}
2026-06-04 21:48:03 +08:00
// When a queued chat send starts processing, the deck preview should flip to
// the slide its marked element lives on so the user watches the edit land in
// context instead of staring at slide 1. The mark's `slideIndex` is captured
// at queue time and carried on each comment attachment. Return the first
// attachment that names a deck file and a concrete slide; null means there is
// nothing slide-scoped to navigate to (plain prompt, free pin, missing index).
export function queuedSlideNavTarget (
commentAttachments : readonly ChatCommentAttachment [] | null | undefined ,
) : { filePath : string ; slideIndex : number } | null {
if ( ! commentAttachments ) return null ;
for ( const attachment of commentAttachments ) {
const filePath = attachment . filePath ? . trim ();
const slideIndex = attachment . slideIndex ;
if (
filePath &&
typeof slideIndex === 'number' &&
Number . isFinite ( slideIndex ) &&
slideIndex >= 0
) {
return { filePath , slideIndex : Math.floor ( slideIndex ) };
}
}
return null ;
}
2026-06-01 15:00:50 +08:00
export function commentSnapshotOverlayEqual (
a : PreviewCommentSnapshot ,
b : PreviewCommentSnapshot ,
) : boolean {
const positionA = normalizePosition ( a . position );
const positionB = normalizePosition ( b . position );
return (
a . elementId === b . elementId
&& a . filePath === b . filePath
&& positionA . x === positionB . x
&& positionA . y === positionB . y
&& positionA . width === positionB . width
&& positionA . height === positionB . height
&& ( a . slideIndex ?? - 1 ) === ( b . slideIndex ?? - 1 )
);
}
export function commentSnapshotEqual (
a : PreviewCommentSnapshot ,
b : PreviewCommentSnapshot ,
) : boolean {
if ( ! commentSnapshotOverlayEqual ( a , b )) return false ;
return (
a . selector === b . selector
&& a . label === b . label
&& trimContextText ( a . text ) === trimContextText ( b . text )
&& trimHtmlHint ( a . htmlHint ) === trimHtmlHint ( b . htmlHint )
&& normalizeSelectionKind ( a . selectionKind ) === normalizeSelectionKind ( b . selectionKind )
&& normalizeMemberCount ( a . memberCount ) === normalizeMemberCount ( b . memberCount )
&& JSON . stringify ( normalizeStyle ( a . style ) ?? null ) === JSON . stringify ( normalizeStyle ( b . style ) ?? null )
&& JSON . stringify ( normalizeMembers ( a . podMembers )) === JSON . stringify ( normalizeMembers ( b . podMembers ))
&& normalizeHoverPoint ( a . hoverPoint ). x === normalizeHoverPoint ( b . hoverPoint ). x
&& normalizeHoverPoint ( a . hoverPoint ). y === normalizeHoverPoint ( b . hoverPoint ). y
);
}
export function liveCommentTargetMapsEqual (
current : Map < string , PreviewCommentSnapshot >,
next : Map < string , PreviewCommentSnapshot >,
) : boolean {
if ( current . size !== next . size ) return false ;
for ( const [ elementId , snapshot ] of current ) {
const candidate = next . get ( elementId );
if ( ! candidate || ! commentSnapshotEqual ( snapshot , candidate )) return false ;
}
return true ;
}
2026-05-02 19:23:46 +08:00
export function liveSnapshotForComment (
comment : PreviewComment ,
snapshots : Map < string , PreviewCommentSnapshot >,
) : PreviewCommentSnapshot | null {
const snapshot = snapshots . get ( comment . elementId );
2026-10-19 06:15:28 +00:00
if ( snapshot && snapshot . filePath === comment . filePath && isValidCommentOverlayPosition ( snapshot . position )) return snapshot ;
return comment . elementId . startsWith ( 'pin-' ) && isValidCommentOverlayPosition ( comment . position )
? ghostSnapshotFromComment ( comment )
: null ;
2026-08-05 08:31:54 +08:00
}
2026-10-19 06:15:28 +00:00
function validPosition ( position : PreviewComment [ 'position' ] | undefined ) : PreviewCommentSnapshot [ 'position' ] | undefined {
return position && isValidCommentOverlayPosition ( position ) ? normalizePosition ( position ) : undefined ;
2026-08-05 08:31:54 +08:00
}
2026-10-19 06:15:28 +00:00
function ghostSnapshotFromComment ( comment : PreviewComment , position? : PreviewCommentSnapshot [ 'position' ]) : PreviewCommentSnapshot {
return {
filePath : comment.filePath ,
elementId : comment.elementId ,
selector : comment.selector ,
label : comment.label ,
text : trimContextText ( comment . text ),
position : normalizePosition ( position ?? comment . position ),
htmlHint : trimHtmlHint ( comment . htmlHint ),
style : normalizeStyle ( comment . style ),
selectionKind : comment.selectionKind === 'pod' ? 'pod' : 'element' ,
memberCount : comment.memberCount ,
podMembers : normalizeMembers ( comment . podMembers ),
slideIndex : comment.slideIndex ,
};
2026-08-05 08:31:54 +08:00
}
export interface AnchorWriteBack {
commentId : string ;
anchorState : PreviewCommentAnchorState ;
lastGoodPosition : PreviewCommentSnapshot [ 'position' ];
}
/**
* The only durable fact the drift ladder needs to persist: when a comment first
* becomes `lost`, capture its last-good position so the ghost pin keeps a stable
* spot even after the anchoring content is gone. The anchored /
* reanchored / stale states are derived per-viewer against that viewer's live
* snapshots, so they are intentionally NOT written back — persisting them would
* let one member's view overwrite another's. This is idempotent: a comment that
* already carries a stored lastGoodPosition is skipped, so re-renders of the
* same lost comment never produce a write storm.
*/
export function planLostAnchorWriteBacks (
entries : Array < { comment : PreviewComment ; resolution : CommentAnchorResolution } > ,
) : AnchorWriteBack [] {
const out : AnchorWriteBack [] = [];
for ( const { comment , resolution } of entries ) {
if ( resolution . state !== 'lost' ) continue ;
if ( validPosition ( comment . lastGoodPosition )) continue ; // already durable — idempotent
const lastGoodPosition = resolution . snapshot ? validPosition ( resolution . snapshot . position ) : undefined ;
if ( ! lastGoodPosition ) continue ; // no ghost position to anchor even a lost pin
out . push ({ commentId : comment.id , anchorState : 'lost' , lastGoodPosition });
}
return out ;
}
2026-05-02 19:23:46 +08:00
export function commentToAttachment (
comment : PreviewComment ,
order : number ,
) : ChatCommentAttachment {
2026-05-05 20:09:20 +07:00
const podMembers = normalizeMembers ( comment . podMembers );
2026-06-03 23:46:22 +08:00
const imageAttachments = mergePreviewCommentAttachments ( undefined , comment . attachments );
2026-05-02 19:23:46 +08:00
return {
id : comment.id ,
order ,
filePath : comment.filePath ,
elementId : comment.elementId ,
selector : comment.selector ,
label : comment.label ,
2026-06-03 23:46:22 +08:00
comment : comment.note.trim () || imageOnlyCommentFallback ( imageAttachments . length ),
2026-05-02 19:23:46 +08:00
currentText : trimContextText ( comment . text ),
pagePosition : normalizePosition ( comment . position ),
htmlHint : trimHtmlHint ( comment . htmlHint ),
2026-05-25 15:24:21 +08:00
style : normalizeStyle ( comment . style ),
2026-05-05 20:09:20 +07:00
selectionKind : comment.selectionKind === 'pod' ? 'pod' : 'element' ,
memberCount :
comment.selectionKind === 'pod'
? ( podMembers . length > 0
? podMembers.length
: typeof comment . memberCount === 'number'
? Math . round ( comment . memberCount )
: 0 )
: undefined ,
podMembers : podMembers.length > 0 ? podMembers : undefined ,
2026-06-04 21:48:03 +08:00
...( typeof comment . slideIndex === 'number' ? { slideIndex : comment.slideIndex } : {}),
2026-06-03 23:46:22 +08:00
imageAttachments : imageAttachments.length > 0 ? imageAttachments : undefined ,
2026-05-05 20:09:20 +07:00
source : 'saved-comment' ,
2026-05-02 19:23:46 +08:00
};
}
export function commentsToAttachments ( comments : PreviewComment []) : ChatCommentAttachment [] {
return comments . map (( comment , index ) => commentToAttachment ( comment , index + 1 ));
}
2026-08-07 10:58:58 +08:00
/**
* Provisional canvas pin number for a comment that has not been saved yet.
*
* Invariant: a new pin gets one past the HIGHEST pin number the daemon could
* already have handed out for this file — mirroring its `MAX(pin_seq)+1`
* assignment rule (`upsertPreviewComment` in apps/daemon/src/db.ts), which
* scans every row for the project/file regardless of status — never
* `count + 1`. Pin numbers are permanent: deleting a comment retires its
* number, and a non-open comment (resolved / attached / applying /
* needs_review / failed) keeps its row — and therefore its number — even
* though the canvas renders no marker for it. Callers must therefore pass
* the file's comments across ALL statuses, not just the open ones on screen.
*
* A comment without a server-assigned `pinSeq` yet (legacy row / test
* fixture) contributes its creation rank (`index + 1` — callers pass
* `comments` in creation order), matching the daemon's creation-ordered
* `pin_seq` backfill for pre-`pin_seq` rows.
*/
export function provisionalNextPinNumber ( comments : readonly PreviewComment []) : number {
let highest = 0 ;
comments . forEach (( comment , index ) => {
const rendered = typeof comment . pinSeq === 'number' ? comment.pinSeq : index + 1 ;
if ( rendered > highest ) highest = rendered ;
});
return highest + 1 ;
}
2026-05-05 20:09:20 +07:00
export function buildBoardCommentAttachments ( input : {
target : PreviewCommentTarget ;
notes : string [];
2026-06-03 23:46:22 +08:00
includeImageOnly? : boolean ;
imageAttachmentCount? : number ;
2026-05-05 20:09:20 +07:00
}) : ChatCommentAttachment [] {
const podMembers = normalizeMembers ( input . target . podMembers );
const selectionKind = input . target . selectionKind === 'pod' ? 'pod' : 'element' ;
const memberCount =
selectionKind === 'pod'
? ( podMembers . length > 0
? podMembers.length
: typeof input . target . memberCount === 'number'
? Math . round ( input . target . memberCount )
: 0 )
: undefined ;
2026-06-03 23:46:22 +08:00
const notes = input . notes
2026-05-05 20:09:20 +07:00
. map (( note ) => note . trim ())
2026-06-03 23:46:22 +08:00
. filter ( Boolean );
const comments = notes . length > 0
? notes
: input.includeImageOnly
? [ imageOnlyCommentFallback ( input . imageAttachmentCount ?? 0 )]
: [];
return comments
2026-05-05 20:09:20 +07:00
. filter ( Boolean )
. map (( note , index ) => ({
id : ` ${ input . target . elementId } -board- ${ index + 1 } ` ,
order : index + 1 ,
filePath : input.target.filePath ,
elementId : input.target.elementId ,
selector : input.target.selector ,
label : input.target.label ,
comment : note ,
currentText : trimContextText ( input . target . text ),
pagePosition : normalizePosition ( input . target . position ),
htmlHint : trimHtmlHint ( input . target . htmlHint ),
2026-05-25 15:24:21 +08:00
style : normalizeStyle ( input . target . style ),
2026-05-05 20:09:20 +07:00
selectionKind ,
memberCount ,
podMembers : podMembers.length > 0 ? podMembers : undefined ,
2026-06-04 21:48:03 +08:00
...( typeof input . target . slideIndex === 'number' ? { slideIndex : input.target.slideIndex } : {}),
2026-05-05 20:09:20 +07:00
source : 'board-batch' ,
}));
}
2026-05-13 20:02:19 +08:00
export function buildVisualAnnotationAttachment ( input : VisualAnnotationAttachmentInput ) : ChatCommentAttachment {
const target = input . target ?? null ;
2026-07-14 15:05:44 +08:00
const intent = input . screenshotPath
? visualAnnotationIntent ( input . markKind )
: visualAnnotationIntentWithoutScreenshot ();
2026-05-13 20:02:19 +08:00
const visualId = sanitizeVisualAttachmentId ( input . idSeed || input . screenshotPath || String ( input . order ));
const elementId = target ? . elementId ? . trim () || `visual-mark- ${ visualId } ` ;
2026-07-14 15:05:44 +08:00
const label = target ? . label ? . trim () || ( input . screenshotPath ? 'Marked screenshot region' : 'Marked preview region' );
2026-05-13 20:02:19 +08:00
const comment = input . note . trim () || intent ;
return {
id : ` ${ elementId } -visual- ${ visualId } ` ,
order : input.order ,
2026-07-14 15:05:44 +08:00
filePath : target?.filePath?.trim () || input . screenshotPath || '' ,
2026-05-13 20:02:19 +08:00
elementId ,
selector : target?.selector?.trim () || '' ,
label ,
comment ,
currentText : trimContextText ( target ? . text || '' ),
pagePosition : normalizePosition ( target ? . position ?? input . bounds ),
htmlHint : trimHtmlHint ( target ? . htmlHint || '' ),
2026-05-25 15:24:21 +08:00
style : normalizeStyle ( target ? . style ),
2026-05-13 20:02:19 +08:00
selectionKind : 'visual' ,
screenshotPath : input.screenshotPath ,
markKind : input.markKind ,
intent ,
source : 'board-batch' ,
};
}
function sanitizeVisualAttachmentId ( value : string ) : string {
const id = value . trim (). replace ( /[^a-zA-Z0-9_-]+/g , '-' ). replace ( /^-+|-+$/g , '' );
return id || 'mark' ;
}
2026-05-02 19:23:46 +08:00
export function messageContentWithCommentAttachments (
content : string ,
commentAttachments : ChatCommentAttachment [],
) : string {
if ( commentAttachments . length === 0 ) return content ;
const visibleContent = content . trim () || '(No extra typed instruction.)' ;
return ` ${ visibleContent }${ renderCommentAttachmentContext ( commentAttachments ) } ` ;
}
export function historyWithCommentAttachmentContext (
history : ChatMessage [],
messageId : string ,
) : ChatMessage [] {
return history . map (( message ) => {
const commentAttachments = message . commentAttachments ?? [];
if ( message . id !== messageId || message . role !== 'user' || commentAttachments . length === 0 ) return message ;
return {
... message ,
content : messageContentWithCommentAttachments ( message . content , commentAttachments ),
};
});
}
export function mergeAttachedComments (
current : PreviewComment [],
next : PreviewComment ,
) : PreviewComment [] {
const byId = new Map ( current . map (( comment ) => [ comment . id , comment ]));
byId . set ( next . id , next );
return Array . from ( byId . values ());
}
export function removeAttachedComment (
current : PreviewComment [],
commentId : string ,
) : PreviewComment [] {
return current . filter (( comment ) => comment . id !== commentId );
}
2026-06-03 23:46:22 +08:00
export function mergePreviewCommentAttachments (
existing : PreviewCommentAttachment [] | undefined ,
incoming : PreviewCommentAttachment [] | undefined ,
) : PreviewCommentAttachment [] {
const merged : PreviewCommentAttachment [] = [];
const seen = new Set < string >();
for ( const item of [...( existing ?? []), ...( incoming ?? [])]) {
const path = String ( item . path || '' ). trim ();
if ( ! path || seen . has ( path )) continue ;
seen . add ( path );
const name = String ( item . name || '' ). trim () || path . split ( '/' ). pop () || path ;
merged . push ({ path , name });
}
return merged ;
}
2026-05-02 19:23:46 +08:00
export function simplePositionLabel ( position : PreviewComment [ 'position' ]) : string {
const normalized = normalizePosition ( position );
return `x ${ normalized . x } y ${ normalized . y } ` ;
}
2026-05-05 20:09:20 +07:00
export function selectionKindLabel (
2026-05-13 20:02:19 +08:00
selectionKind : ChatCommentSelectionKind | undefined ,
2026-05-05 20:09:20 +07:00
memberCount? : number ,
) : string {
2026-05-13 20:02:19 +08:00
if ( selectionKind === 'visual' ) return 'Visual mark' ;
2026-05-05 20:09:20 +07:00
if ( selectionKind === 'pod' ) {
return memberCount && memberCount > 0 ? `Pod · ${ memberCount } items` : 'Pod' ;
}
return 'Element' ;
}
2026-05-02 19:23:46 +08:00
export function trimContextText ( value : string ) : string {
const text = String ( value || '' ). replace ( /\s+/g , ' ' ). trim ();
return text . length > 160 ? ` ${ text . slice ( 0 , 157 ) } ...` : text ;
}
export function trimHtmlHint ( value : string ) : string {
const text = String ( value || '' ). replace ( /\s+/g , ' ' ). trim ();
return text . length > 180 ? ` ${ text . slice ( 0 , 177 ) } ...` : text ;
}
function renderCommentAttachmentContext ( commentAttachments : ChatCommentAttachment []) : string {
const lines = [
'' ,
'' ,
'<attached-preview-comments>' ,
2026-07-14 15:05:44 +08:00
"Hard scope: change ONLY the elements identified below by selector / position / pod members. Do NOT modify sibling sub-pages, parent layout, global CSS, design tokens, or unrelated rules even if you notice issues there — surface those as a follow-up note in your reply instead of editing them. If the user's request cannot be satisfied without touching outside this scope, ask the user before proceeding. For visual marks, inspect the screenshot when one is provided (otherwise locate the region from the structured fields) and modify the marked region first." ,
2026-05-02 19:23:46 +08:00
];
commentAttachments . forEach (( item ) => {
const position = normalizePosition ( item . pagePosition );
2026-05-13 20:02:19 +08:00
const selectionKind =
item . selectionKind === 'visual' ? 'visual' : item . selectionKind === 'pod' ? 'pod' : 'element' ;
2026-05-02 19:23:46 +08:00
lines . push (
'' ,
` ${ item . order } . ${ item . elementId } ` ,
2026-05-05 20:09:20 +07:00
`targetKind: ${ selectionKind } ` ,
2026-05-02 19:23:46 +08:00
`file: ${ item . filePath } ` ,
`label: ${ item . label || '(unlabeled)' } ` ,
`position: x ${ position . x } y ${ position . y } ${ position . width } x ${ position . height } ` ,
`currentText: ${ trimContextText ( item . currentText || '' ) || '(empty)' } ` ,
`htmlHint: ${ trimHtmlHint ( item . htmlHint || '' ) || '(none)' } ` ,
2026-05-25 15:24:21 +08:00
`computedStyle: ${ formatAnnotationStyle ( item . style ) || '(none)' } ` ,
2026-05-02 19:23:46 +08:00
);
2026-06-03 23:46:22 +08:00
if ( item . comment && item . commentContext !== 'query' ) {
lines . push ( `comment: ${ item . comment } ` );
}
2026-05-13 20:02:19 +08:00
if ( selectionKind === 'visual' ) {
2026-07-14 15:05:44 +08:00
if ( item . screenshotPath ) {
lines . push (
`screenshot: ${ item . screenshotPath } ` ,
`markKind: ${ item . markKind || 'stroke' } ` ,
`intent: ${ item . intent || visualAnnotationIntent ( item . markKind || 'stroke' ) } ` ,
);
} else {
lines . push (
`markKind: ${ item . markKind || 'stroke' } ` ,
`intent: ${ item . intent || visualAnnotationIntentWithoutScreenshot () } ` ,
);
}
2026-05-13 20:02:19 +08:00
if ( item . selector ) lines . push ( `selector: ${ item . selector } ` );
} else {
lines . splice ( lines . length - 4 , 0 , `selector: ${ item . selector } ` );
}
2026-05-05 20:09:20 +07:00
if ( selectionKind === 'pod' ) {
lines . push ( `memberCount: ${ item . memberCount || item . podMembers ? . length || 0 } ` );
( item . podMembers ?? []). slice ( 0 , 8 ). forEach (( member , memberIndex ) => {
lines . push (
`member. ${ memberIndex + 1 } : ${ member . elementId } | ${ member . label || '(unlabeled)' } | ${ member . selector } ` ,
);
2026-05-25 15:24:21 +08:00
const memberStyle = formatAnnotationStyle ( member . style );
if ( memberStyle ) lines . push ( `member. ${ memberIndex + 1 } .computedStyle: ${ memberStyle } ` );
2026-05-05 20:09:20 +07:00
});
}
2026-06-03 23:46:22 +08:00
const imageAttachments = mergePreviewCommentAttachments ( undefined , item . imageAttachments );
if ( imageAttachments . length > 0 ) {
lines . push ( `imageAttachments: ${ imageAttachments . length } ` );
imageAttachments . forEach (( attachment , attachmentIndex ) => {
lines . push ( `image. ${ attachmentIndex + 1 } : ${ attachment . path } | ${ attachment . name } ` );
});
}
2026-05-02 19:23:46 +08:00
});
lines . push ( '</attached-preview-comments>' );
return lines . join ( '\n' );
}
2026-06-03 23:46:22 +08:00
function imageOnlyCommentFallback ( count : number ) : string {
if ( count <= 0 ) return '' ;
return count > 1
? `Use the ${ count } attached images as the comment reference.`
: 'Use the attached image as the comment reference.' ;
}
2026-05-13 20:02:19 +08:00
function visualAnnotationIntent ( markKind : PreviewVisualMarkKind ) : string {
if ( markKind === 'click' ) {
return 'The screenshot has a blue focus box around the picked element; modify that picked part first.' ;
}
if ( markKind === 'click+stroke' ) {
return 'The screenshot has a blue focus box and red strokes; together they identify the part the user wants changed.' ;
}
return 'The screenshot has red strokes that identify the visual region the user wants changed.' ;
}
2026-07-14 15:05:44 +08:00
// Screenshot-less variant (#4084): the capture failed (#4080), so pointing the
// agent at a screenshot would point at nothing. The structured fields are the
// only anchor.
function visualAnnotationIntentWithoutScreenshot () : string {
return 'The user marked a region of the live preview; no screenshot was captured, so locate the region using file, position, and currentText.' ;
}
2026-05-02 19:23:46 +08:00
function normalizePosition ( input : PreviewComment [ 'position' ]) : PreviewComment [ 'position' ] {
return {
x : finite ( input ? . x ),
y : finite ( input ? . y ),
width : finite ( input ? . width ),
height : finite ( input ? . height ),
};
}
function finite ( value : number | undefined ) : number {
return Number . isFinite ( value ) ? Math . round ( value as number ) : 0 ;
}
2026-05-05 20:09:20 +07:00
2026-06-01 15:00:50 +08:00
function normalizeSelectionKind (
selectionKind : PreviewCommentSnapshot [ 'selectionKind' ],
) : PreviewCommentSelectionKind {
return selectionKind === 'pod' ? 'pod' : 'element' ;
}
function normalizeMemberCount ( value : number | undefined ) : number | undefined {
return Number . isFinite ( value ) ? Math . round ( value as number ) : undefined ;
}
function normalizeHoverPoint (
input : PreviewCommentSnapshot [ 'hoverPoint' ],
) : { x : number | undefined ; y : number | undefined } {
if ( ! input ) return { x : undefined , y : undefined };
return {
x : Number.isFinite ( input . x ) ? Math . round ( input . x ) : undefined ,
y : Number.isFinite ( input . y ) ? Math . round ( input . y ) : undefined ,
};
}
2026-05-05 20:09:20 +07:00
function normalizeMembers ( input : PreviewCommentMember [] | undefined ) : PreviewCommentMember [] {
if ( ! Array . isArray ( input )) return [];
return input
. map (( member ) => ({
elementId : String ( member . elementId || '' ). trim (),
selector : String ( member . selector || '' ). trim (),
label : String ( member . label || '' ). trim (),
text : trimContextText ( String ( member . text || '' )),
position : normalizePosition ( member . position ),
htmlHint : trimHtmlHint ( String ( member . htmlHint || '' )),
2026-05-25 15:24:21 +08:00
style : normalizeStyle ( member . style ),
2026-05-05 20:09:20 +07:00
}))
. filter (( member ) => member . elementId && member . selector );
}
2026-05-25 15:24:21 +08:00
function normalizeStyle ( input : unknown ) : PreviewAnnotationStyle | undefined {
if ( ! input || typeof input !== 'object' ) return undefined ;
const raw = input as Record < string , unknown >;
const style : PreviewAnnotationStyle = {};
for ( const key of ANNOTATION_STYLE_KEYS ) {
const value = raw [ key ];
if ( typeof value !== 'string' ) continue ;
const trimmed = value . replace ( /\s+/g , ' ' ). trim ();
if ( trimmed ) style [ key ] = trimmed . slice ( 0 , 120 );
}
return Object . keys ( style ). length > 0 ? style : undefined ;
}
function formatAnnotationStyle ( style : PreviewAnnotationStyle | undefined ) : string {
if ( ! style ) return '' ;
return ANNOTATION_STYLE_KEYS
. map (( key ) => {
const value = style [ key ];
return value ? ` ${ key } : ${ value } ` : null ;
})
. filter (( item ) : item is string => Boolean ( item ))
. join ( '; ' );
}
const ANNOTATION_STYLE_KEYS = [
'color' ,
'backgroundColor' ,
'fontSize' ,
'fontWeight' ,
'lineHeight' ,
'textAlign' ,
'fontFamily' ,
'paddingTop' ,
'paddingRight' ,
'paddingBottom' ,
'paddingLeft' ,
'borderRadius' ,
] as const ;