| 1 | import { |
| 2 | COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS, |
| 3 | COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS, |
| 4 | } from "@t3tools/contracts"; |
| 5 | import type { |
| 6 | ComposerContextId, |
| 7 | ComposerContextRecord, |
| 8 | EnvironmentId, |
| 9 | FileContextRecord, |
| 10 | ImageContextRecord, |
| 11 | KnownComposerContextRecord, |
| 12 | MessageId, |
| 13 | OrchestrationMessageContext, |
| 14 | PreviewAnnotationContextRecord, |
| 15 | PreviewAnnotationPayload, |
| 16 | ReviewCommentContextRecord, |
| 17 | ScopedThreadRef, |
| 18 | TerminalContextRecord, |
| 19 | ThreadContextRecord, |
| 20 | ThreadId, |
| 21 | } from "@t3tools/contracts"; |
| 22 | import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; |
| 23 | import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; |
| 24 | import { |
| 25 | collectComposerContextReferences, |
| 26 | sanitizeComposerContextLabel, |
| 27 | } from "@t3tools/shared/composerContextReferences"; |
| 28 | |
| 29 | import { |
| 30 | type ComposerContextReference, |
| 31 | producerIdFromComposerContextId, |
| 32 | toKindScopedComposerContextId, |
| 33 | } from "./composerContextReferences"; |
| 34 | import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore"; |
| 35 | import type { AttachmentUploadState } from "./attachmentUploadState"; |
| 36 | import { normalizeElementContextSelection } from "./elementContext"; |
| 37 | import { |
| 38 | formatTerminalContextLabel, |
| 39 | normalizeTerminalContextText, |
| 40 | type TerminalContextDraft, |
| 41 | } from "./terminalContext"; |
| 42 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 43 | |
| 44 | /** |
| 45 | * Builds the wire records behind a draft's inline references, and the reverse for reading a |
| 46 | * message. Draft shapes stay what the producing panels emit; only the send boundary converts. |
| 47 | */ |
| 48 | |
| 49 | const PREVIEW_LABEL_MAX_CHARS = 48; |
| 50 | |
| 51 | /** |
| 52 | * A review selection can be arbitrarily large, but the wire schema bounds `text` and `diff`. |
| 53 | * Clamp at the same boundary so an oversized selection still sends, marked where it was cut, |
| 54 | * rather than failing to encode at send time. |
| 55 | */ |
| 56 | const TRUNCATION_MARKER = "\n… truncated …"; |
| 57 | |
| 58 | function clampContextText(value: string, max: number): string { |
| 59 | if (value.length <= max) return value; |
| 60 | return `${value.slice(0, Math.max(0, max - TRUNCATION_MARKER.length))}${TRUNCATION_MARKER}`; |
| 61 | } |
| 62 | |
| 63 | type ReviewCommentPresentation = ReviewCommentContext | ReviewCommentContextRecord; |
| 64 | |
| 65 | function basename(filePath: string): string { |
| 66 | return filePath.split(/[\\/]/).at(-1) ?? filePath; |
| 67 | } |
| 68 | |
| 69 | export function reviewCommentContextLabel(comment: ReviewCommentPresentation): string { |
| 70 | const pullRequestNumber = pullRequestContextNumber(comment); |
| 71 | if (isPullRequestSummaryContext(comment) && pullRequestNumber !== null) { |
| 72 | return `#${pullRequestNumber}`; |
| 73 | } |
| 74 | const diffRange = /^([+-])(\d+)(?: to \1(\d+))?$/u.exec(comment.rangeLabel); |
| 75 | const rangeLabel = diffRange |
| 76 | ? `L${diffRange[2]}${diffRange[3] ? ` to L${diffRange[3]}` : ""}${diffRange[1] === "-" ? " (before)" : ""}` |
| 77 | : comment.rangeLabel; |
| 78 | return `${basename(comment.filePath)} ${rangeLabel}`; |
| 79 | } |
| 80 | |
| 81 | export function isPullRequestSummaryContext(comment: ReviewCommentPresentation): boolean { |
| 82 | if (comment.pullRequest !== undefined) return true; |
| 83 | return ( |
| 84 | comment.sectionId.startsWith("pull-request:") && |
| 85 | comment.diff.trim().length === 0 && |
| 86 | /^PR #\d+$/u.test(comment.filePath) |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | function pullRequestContextNumber(comment: ReviewCommentPresentation): number | null { |
| 91 | if (comment.pullRequest !== undefined) return comment.pullRequest.number; |
| 92 | const legacyNumber = /^PR #(\d+)$/u.exec(comment.filePath)?.[1]; |
| 93 | return legacyNumber === undefined ? null : Number(legacyNumber); |
| 94 | } |
| 95 | |
| 96 | export type PullRequestContextDisplayState = "open" | "draft" | "merged" | "closed"; |
| 97 | |
| 98 | export function pullRequestContextDisplayState( |
| 99 | comment: ReviewCommentPresentation, |
| 100 | ): PullRequestContextDisplayState | null { |
| 101 | const pullRequest = comment.pullRequest; |
| 102 | if (pullRequest === undefined) return null; |
| 103 | return pullRequest.state === "open" && pullRequest.isDraft ? "draft" : pullRequest.state; |
| 104 | } |
| 105 | |
| 106 | export function pullRequestContextKindLabel(comment: ReviewCommentPresentation): string { |
| 107 | const state = pullRequestContextDisplayState(comment); |
| 108 | if (state === null) return "Pull request"; |
| 109 | return `${state[0]!.toUpperCase()}${state.slice(1)} pull request`; |
| 110 | } |
| 111 | |
| 112 | export function previewAnnotationContextLabel(annotation: PreviewAnnotationPayload): string { |
| 113 | const comment = annotation.comment.trim().replace(/\s+/g, " "); |
| 114 | if (comment) { |
| 115 | return comment.length > PREVIEW_LABEL_MAX_CHARS |
| 116 | ? `${comment.slice(0, PREVIEW_LABEL_MAX_CHARS - 1)}…` |
| 117 | : comment; |
| 118 | } |
| 119 | return annotation.pageTitle?.trim() || "Preview annotation"; |
| 120 | } |
| 121 | |
| 122 | export function terminalContextReference(context: TerminalContextDraft): ComposerContextReference { |
| 123 | return { |
| 124 | kind: "terminal", |
| 125 | contextId: toKindScopedComposerContextId("terminal", context.id), |
| 126 | label: formatTerminalContextLabel(context), |
| 127 | }; |
| 128 | } |
| 129 | |
| 130 | /** Review producers mint ids in their own grammars; the context id is a folded form of them. */ |
| 131 | export function reviewCommentContextId(commentId: string): ComposerContextId { |
| 132 | return toKindScopedComposerContextId("review-comment", commentId); |
| 133 | } |
| 134 | |
| 135 | /** Distinct from the screenshot image, which reuses the annotation id as its attachment id. */ |
| 136 | export function previewAnnotationContextId(annotationId: string): ComposerContextId { |
| 137 | return toKindScopedComposerContextId("preview-annotation", annotationId); |
| 138 | } |
| 139 | |
| 140 | export function reviewCommentContextReference( |
| 141 | comment: ReviewCommentContext, |
| 142 | ): ComposerContextReference { |
| 143 | return { |
| 144 | kind: "review-comment", |
| 145 | contextId: reviewCommentContextId(comment.id), |
| 146 | label: reviewCommentContextLabel(comment), |
| 147 | }; |
| 148 | } |
| 149 | |
| 150 | export function previewAnnotationContextReference( |
| 151 | annotation: PreviewAnnotationPayload, |
| 152 | ): ComposerContextReference { |
| 153 | return { |
| 154 | kind: "preview-annotation", |
| 155 | contextId: previewAnnotationContextId(annotation.id), |
| 156 | label: previewAnnotationContextLabel(annotation), |
| 157 | }; |
| 158 | } |
| 159 | |
| 160 | /** One record per thread: attaching the same thread twice reuses the chip. */ |
| 161 | function threadContextId(threadId: ThreadId): ComposerContextId { |
| 162 | return toKindScopedComposerContextId("thread", threadId); |
| 163 | } |
| 164 | |
| 165 | export function threadContextReference(record: ThreadContextRecord): ComposerContextReference { |
| 166 | return { kind: "thread", contextId: record.contextId, label: record.label }; |
| 167 | } |
| 168 | |
| 169 | export function threadContextRecord(ref: ScopedThreadRef, title: string): ThreadContextRecord { |
| 170 | const label = sanitizeComposerContextLabel(title, "thread"); |
| 171 | return { |
| 172 | version: 1, |
| 173 | kind: "thread", |
| 174 | contextId: threadContextId(ref.threadId), |
| 175 | label, |
| 176 | environmentId: ref.environmentId, |
| 177 | threadId: ref.threadId, |
| 178 | title: label, |
| 179 | }; |
| 180 | } |
| 181 | |
| 182 | export function terminalContextRecord(context: TerminalContextDraft): TerminalContextRecord { |
| 183 | return { |
| 184 | version: 1, |
| 185 | contextId: toKindScopedComposerContextId("terminal", context.id), |
| 186 | kind: "terminal", |
| 187 | label: sanitizeComposerContextLabel(formatTerminalContextLabel(context), "terminal"), |
| 188 | terminalId: context.terminalId, |
| 189 | terminalLabel: context.terminalLabel, |
| 190 | lineStart: context.lineStart, |
| 191 | lineEnd: context.lineEnd, |
| 192 | text: normalizeTerminalContextText(context.text), |
| 193 | }; |
| 194 | } |
| 195 | |
| 196 | export function reviewCommentContextRecord( |
| 197 | comment: ReviewCommentContext, |
| 198 | ): ReviewCommentContextRecord { |
| 199 | return { |
| 200 | version: 1, |
| 201 | contextId: reviewCommentContextId(comment.id), |
| 202 | kind: "review-comment", |
| 203 | label: sanitizeComposerContextLabel(reviewCommentContextLabel(comment), "review-comment"), |
| 204 | sectionId: comment.sectionId, |
| 205 | sectionTitle: comment.sectionTitle, |
| 206 | filePath: comment.filePath, |
| 207 | startIndex: comment.startIndex, |
| 208 | endIndex: comment.endIndex, |
| 209 | rangeLabel: comment.rangeLabel, |
| 210 | text: clampContextText(comment.text, COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS), |
| 211 | diff: clampContextText(comment.diff, COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS), |
| 212 | ...(comment.fenceLanguage !== undefined ? { fenceLanguage: comment.fenceLanguage } : {}), |
| 213 | ...(comment.pullRequest !== undefined ? { pullRequest: comment.pullRequest } : {}), |
| 214 | }; |
| 215 | } |
| 216 | |
| 217 | function previewAnnotationTargetSummary(annotation: PreviewAnnotationPayload): string { |
| 218 | const parts: string[] = []; |
| 219 | const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; |
| 220 | if (annotation.elements.length > 0) |
| 221 | parts.push(plural(annotation.elements.length, "selected element")); |
| 222 | if (annotation.regions.length > 0) parts.push(plural(annotation.regions.length, "marked region")); |
| 223 | if (annotation.strokes.length > 0) parts.push(plural(annotation.strokes.length, "drawing")); |
| 224 | return parts.join(", "); |
| 225 | } |
| 226 | |
| 227 | export function previewAnnotationContextRecord( |
| 228 | annotation: PreviewAnnotationPayload, |
| 229 | options?: { screenshotContextId?: string | undefined }, |
| 230 | ): PreviewAnnotationContextRecord { |
| 231 | const targets = annotation.elements.flatMap((target) => { |
| 232 | const element = normalizeElementContextSelection(target.element); |
| 233 | return element ? [{ id: target.id, element }] : []; |
| 234 | }); |
| 235 | return { |
| 236 | version: 1, |
| 237 | contextId: previewAnnotationContextId(annotation.id), |
| 238 | kind: "preview-annotation", |
| 239 | label: sanitizeComposerContextLabel( |
| 240 | previewAnnotationContextLabel(annotation), |
| 241 | "preview-annotation", |
| 242 | ), |
| 243 | annotationId: annotation.id, |
| 244 | pageUrl: annotation.pageUrl, |
| 245 | pageTitle: annotation.pageTitle, |
| 246 | comment: annotation.comment.trim(), |
| 247 | targetSummary: previewAnnotationTargetSummary(annotation), |
| 248 | styleChanges: annotation.styleChanges.map( |
| 249 | (change) => `${change.property}: ${change.previousValue || "(unset)"} → ${change.value}`, |
| 250 | ), |
| 251 | ...(targets.length > 0 |
| 252 | ? { |
| 253 | elements: targets.map((target) => target.element), |
| 254 | elementIds: targets.map((target) => target.id), |
| 255 | } |
| 256 | : {}), |
| 257 | styleChangeDetails: annotation.styleChanges.map((change) => ({ ...change })), |
| 258 | ...(annotation.regions.length > 0 ? { regionCount: annotation.regions.length } : {}), |
| 259 | ...(annotation.strokes.length > 0 ? { strokeCount: annotation.strokes.length } : {}), |
| 260 | ...(options?.screenshotContextId !== undefined |
| 261 | ? { screenshotContextId: toKindScopedComposerContextId("image", options.screenshotContextId) } |
| 262 | : {}), |
| 263 | }; |
| 264 | } |
| 265 | |
| 266 | export function imageContextReference(image: ComposerImageAttachment): ComposerContextReference { |
| 267 | return { |
| 268 | kind: "image", |
| 269 | contextId: toKindScopedComposerContextId("image", image.id), |
| 270 | label: image.name, |
| 271 | }; |
| 272 | } |
| 273 | |
| 274 | export function fileContextReference(file: ComposerFileAttachment): ComposerContextReference { |
| 275 | return { |
| 276 | kind: "file", |
| 277 | contextId: toKindScopedComposerContextId("file", file.id), |
| 278 | label: file.name, |
| 279 | }; |
| 280 | } |
| 281 | |
| 282 | /** Binds a draft attachment to the id the receiving side will know it by. */ |
| 283 | export interface BoundComposerAttachment { |
| 284 | attachment: ComposerImageAttachment | ComposerFileAttachment; |
| 285 | attachmentId: string; |
| 286 | } |
| 287 | |
| 288 | /** Clipboard payloads may only point at attachments that already exist on the server. */ |
| 289 | export function uploadedAttachmentContextRecord( |
| 290 | attachment: ComposerImageAttachment | ComposerFileAttachment, |
| 291 | upload: AttachmentUploadState | undefined, |
| 292 | ): ImageContextRecord | FileContextRecord | null { |
| 293 | const attachmentId = |
| 294 | attachment.type === "file" && attachment.uploadedAttachmentId !== undefined |
| 295 | ? attachment.uploadedAttachmentId |
| 296 | : upload?.status === "ready" |
| 297 | ? upload.attachmentId |
| 298 | : undefined; |
| 299 | return attachmentId === undefined ? null : attachmentContextRecord({ attachment, attachmentId }); |
| 300 | } |
| 301 | |
| 302 | export function attachmentContextRecord( |
| 303 | bound: BoundComposerAttachment, |
| 304 | ): ImageContextRecord | FileContextRecord { |
| 305 | const { attachment, attachmentId } = bound; |
| 306 | const base = { |
| 307 | version: 1 as const, |
| 308 | contextId: toKindScopedComposerContextId(attachment.type, attachment.id), |
| 309 | label: sanitizeComposerContextLabel(attachment.name, attachment.type), |
| 310 | attachmentId, |
| 311 | name: attachment.name, |
| 312 | mimeType: attachment.mimeType, |
| 313 | sizeBytes: attachment.sizeBytes, |
| 314 | }; |
| 315 | return attachment.type === "image" ? { ...base, kind: "image" } : { ...base, kind: "file" }; |
| 316 | } |
| 317 | |
| 318 | export function buildMessageContext(input: { |
| 319 | terminalContexts: ReadonlyArray<TerminalContextDraft>; |
| 320 | reviewComments: ReadonlyArray<ReviewCommentContext>; |
| 321 | previewAnnotations: ReadonlyArray<PreviewAnnotationPayload>; |
| 322 | threadContexts?: ReadonlyArray<ThreadContextRecord>; |
| 323 | attachments?: ReadonlyArray<BoundComposerAttachment>; |
| 324 | }): OrchestrationMessageContext | undefined { |
| 325 | // An annotation's screenshot travels as the image attachment that reuses its id. |
| 326 | const screenshotAttachmentIds = new Set( |
| 327 | (input.attachments ?? []).flatMap(({ attachment }) => |
| 328 | attachment.type === "image" ? [attachment.id] : [], |
| 329 | ), |
| 330 | ); |
| 331 | const records: ComposerContextRecord[] = [ |
| 332 | ...input.terminalContexts.map(terminalContextRecord), |
| 333 | ...input.reviewComments.map(reviewCommentContextRecord), |
| 334 | ...(input.threadContexts ?? []), |
| 335 | ...input.previewAnnotations.map((annotation) => |
| 336 | previewAnnotationContextRecord(annotation, { |
| 337 | screenshotContextId: screenshotAttachmentIds.has(annotation.id) ? annotation.id : undefined, |
| 338 | }), |
| 339 | ), |
| 340 | ...(input.attachments ?? []).map(attachmentContextRecord), |
| 341 | ]; |
| 342 | return records.length === 0 ? undefined : { version: 1, records }; |
| 343 | } |
| 344 | |
| 345 | /** |
| 346 | * Narrows away the unknown-kind member. Its `kind` is an open string, so a plain |
| 347 | * `record.kind === "terminal"` check cannot discriminate the union on its own. |
| 348 | */ |
| 349 | export function asKnownContextRecord( |
| 350 | record: ComposerContextRecord | undefined, |
| 351 | ): KnownComposerContextRecord | undefined { |
| 352 | if (!record || "payload" in record) return undefined; |
| 353 | return record as KnownComposerContextRecord; |
| 354 | } |
| 355 | |
| 356 | /** Candidate keys for finding the draft record an imported wire record would reconstruct. */ |
| 357 | export function composerContextImportLookupIds( |
| 358 | record: KnownComposerContextRecord, |
| 359 | ): ReadonlyArray<ComposerContextId> { |
| 360 | const destinationId = toKindScopedComposerContextId( |
| 361 | record.kind, |
| 362 | producerIdFromComposerContextId(record.kind, record.contextId), |
| 363 | ); |
| 364 | return destinationId === record.contextId |
| 365 | ? [record.contextId] |
| 366 | : [destinationId, record.contextId]; |
| 367 | } |
| 368 | |
| 369 | export interface ResolvedUserMessageContext { |
| 370 | text: string; |
| 371 | records: ReadonlyArray<ComposerContextRecord>; |
| 372 | recordsById: ReadonlyMap<string, ComposerContextRecord>; |
| 373 | } |
| 374 | |
| 375 | /** |
| 376 | * The clipboard fragment behind a timeline message selection: only records for |
| 377 | * chips actually inside the selection travel, so copying prose next to an |
| 378 | * image never starts importing that image somewhere else. Returns null when no |
| 379 | * selected chip has backing records. |
| 380 | */ |
| 381 | export function selectedMessageContextFragment(input: { |
| 382 | readonly markdown: string; |
| 383 | readonly records: ReadonlyArray<ComposerContextRecord>; |
| 384 | readonly environmentId: EnvironmentId; |
| 385 | readonly threadId?: ThreadId; |
| 386 | readonly messageId: MessageId; |
| 387 | }): string | null { |
| 388 | const selectedIds = new Set( |
| 389 | collectComposerContextReferences(input.markdown).map((occurrence) => occurrence.contextId), |
| 390 | ); |
| 391 | const records = input.records.filter((record) => selectedIds.has(record.contextId)); |
| 392 | if (records.length === 0) return null; |
| 393 | return encodeComposerContextFragment({ |
| 394 | version: 1, |
| 395 | source: { |
| 396 | environmentId: input.environmentId, |
| 397 | ...(input.threadId ? { threadId: input.threadId } : {}), |
| 398 | messageId: input.messageId, |
| 399 | }, |
| 400 | records, |
| 401 | }); |
| 402 | } |
| 403 | |
| 404 | /** A message's canonical text plus records; old messages are upgraded in memory on read. */ |
| 405 | export function resolveUserMessageContext(message: { |
| 406 | text: string; |
| 407 | context?: OrchestrationMessageContext | undefined; |
| 408 | }): ResolvedUserMessageContext { |
| 409 | const resolved = message.context |
| 410 | ? { text: message.text, records: message.context.records } |
| 411 | : upgradeLegacyContextMessage(message.text); |
| 412 | return { |
| 413 | text: resolved.text, |
| 414 | records: resolved.records, |
| 415 | recordsById: new Map(resolved.records.map((record) => [record.contextId, record])), |
| 416 | }; |
| 417 | } |
| 418 | |
| 419 | // --------------------------------------------------------------------------- |
| 420 | // Records back into draft shapes (paste) |
| 421 | // --------------------------------------------------------------------------- |
| 422 | |
| 423 | export function terminalContextDraftFromRecord( |
| 424 | record: TerminalContextRecord, |
| 425 | threadId: ThreadId, |
| 426 | ): TerminalContextDraft { |
| 427 | return { |
| 428 | id: producerIdFromComposerContextId("terminal", record.contextId), |
| 429 | threadId, |
| 430 | createdAt: new Date().toISOString(), |
| 431 | terminalId: record.terminalId, |
| 432 | terminalLabel: record.terminalLabel, |
| 433 | lineStart: record.lineStart, |
| 434 | lineEnd: record.lineEnd, |
| 435 | text: record.text, |
| 436 | }; |
| 437 | } |
| 438 | |
| 439 | export function reviewCommentFromRecord(record: ReviewCommentContextRecord): ReviewCommentContext { |
| 440 | return { |
| 441 | id: producerIdFromComposerContextId("review-comment", record.contextId), |
| 442 | sectionId: record.sectionId, |
| 443 | sectionTitle: record.sectionTitle, |
| 444 | filePath: record.filePath, |
| 445 | startIndex: record.startIndex, |
| 446 | endIndex: record.endIndex, |
| 447 | rangeLabel: record.rangeLabel, |
| 448 | text: record.text, |
| 449 | diff: record.diff, |
| 450 | ...(record.fenceLanguage !== undefined ? { fenceLanguage: record.fenceLanguage } : {}), |
| 451 | ...(record.pullRequest !== undefined ? { pullRequest: record.pullRequest } : {}), |
| 452 | }; |
| 453 | } |
| 454 | |
| 455 | /** Lossy on purpose: geometry and screenshot do not travel; the agent-facing detail does. */ |
| 456 | export function previewAnnotationFromRecord( |
| 457 | record: PreviewAnnotationContextRecord, |
| 458 | ): PreviewAnnotationPayload { |
| 459 | return { |
| 460 | id: |
| 461 | record.annotationId || |
| 462 | producerIdFromComposerContextId("preview-annotation", record.contextId), |
| 463 | pageUrl: record.pageUrl, |
| 464 | pageTitle: record.pageTitle, |
| 465 | comment: record.comment, |
| 466 | elements: (record.elements ?? []).map((element, index) => ({ |
| 467 | id: record.elementIds?.[index] ?? `${record.contextId}-element-${index + 1}`, |
| 468 | rect: { x: 0, y: 0, width: 0, height: 0 }, |
| 469 | element: { ...element, stack: [], pickedAt: new Date().toISOString() }, |
| 470 | })), |
| 471 | // Geometry does not travel, but the counts do, so the rebuilt summary still reports |
| 472 | // what the annotation marked. |
| 473 | regions: Array.from({ length: record.regionCount ?? 0 }, (_unused, index) => ({ |
| 474 | id: `${record.contextId}-region-${index + 1}`, |
| 475 | rect: { x: 0, y: 0, width: 0, height: 0 }, |
| 476 | })), |
| 477 | strokes: Array.from({ length: record.strokeCount ?? 0 }, (_unused, index) => ({ |
| 478 | id: `${record.contextId}-stroke-${index + 1}`, |
| 479 | color: "", |
| 480 | width: 0, |
| 481 | points: [], |
| 482 | bounds: { x: 0, y: 0, width: 0, height: 0 }, |
| 483 | })), |
| 484 | styleChanges: |
| 485 | record.styleChangeDetails ?? |
| 486 | record.styleChanges.flatMap((change) => { |
| 487 | const match = /^(.+?): ([\s\S]*?) → ([\s\S]*)$/.exec(change); |
| 488 | if (!match) return []; |
| 489 | return [ |
| 490 | { |
| 491 | targetId: record.elementIds?.[0] ?? `${record.contextId}-element-1`, |
| 492 | selector: null, |
| 493 | property: match[1]!, |
| 494 | previousValue: match[2] === "(unset)" ? "" : match[2]!, |
| 495 | value: match[3]!, |
| 496 | }, |
| 497 | ]; |
| 498 | }), |
| 499 | screenshot: null, |
| 500 | createdAt: new Date().toISOString(), |
| 501 | }; |
| 502 | } |
| 503 | |
| 504 | /** |
| 505 | * Whether two records carry the same payload, ignoring the label (display text, never |
| 506 | * identity). Context ids are a folded form of producer ids, so a collision alone does not mean |
| 507 | * the records are the same excerpt; the paste path compares payloads before de-duplicating. |
| 508 | */ |
| 509 | export function isSameComposerContextPayload( |
| 510 | left: ComposerContextRecord, |
| 511 | right: ComposerContextRecord, |
| 512 | ): boolean { |
| 513 | if (left.kind !== right.kind) return false; |
| 514 | const stableKey = (record: ComposerContextRecord) => { |
| 515 | // Labels are display text and context ids are folded producer ids: neither |
| 516 | // distinguishes excerpts, so an imported legacy id must still match the |
| 517 | // canonical id reconstructed for the same payload instead of duplicating it. |
| 518 | const { label: _label, contextId: _contextId, ...rest } = record; |
| 519 | const sortDeep = (value: unknown): unknown => { |
| 520 | if (Array.isArray(value)) return value.map(sortDeep); |
| 521 | if (value === null || typeof value !== "object") return value; |
| 522 | return Object.fromEntries( |
| 523 | Object.entries(value) |
| 524 | .toSorted(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) |
| 525 | .map(([key, nested]) => [key, sortDeep(nested)]), |
| 526 | ); |
| 527 | }; |
| 528 | return JSON.stringify(sortDeep(rest)); |
| 529 | }; |
| 530 | return stableKey(left) === stableKey(right); |
| 531 | } |