1import {
2 COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS,
3 COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS,
4} from "@t3tools/contracts";
5import 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";
22import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy";
23import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
24import {
25 collectComposerContextReferences,
26 sanitizeComposerContextLabel,
27} from "@t3tools/shared/composerContextReferences";
28
29import {
30 type ComposerContextReference,
31 producerIdFromComposerContextId,
32 toKindScopedComposerContextId,
33} from "./composerContextReferences";
34import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";
35import type { AttachmentUploadState } from "./attachmentUploadState";
36import { normalizeElementContextSelection } from "./elementContext";
37import {
38 formatTerminalContextLabel,
39 normalizeTerminalContextText,
40 type TerminalContextDraft,
41} from "./terminalContext";
42import 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
49const 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 */
56const TRUNCATION_MARKER = "\n… truncated …";
57
58function 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
63type ReviewCommentPresentation = ReviewCommentContext | ReviewCommentContextRecord;
64
65function basename(filePath: string): string {
66 return filePath.split(/[\\/]/).at(-1) ?? filePath;
67}
68
69export 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
81export 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
90function 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
96export type PullRequestContextDisplayState = "open" | "draft" | "merged" | "closed";
97
98export 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
106export 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
112export 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
122export 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. */
131export 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. */
136export function previewAnnotationContextId(annotationId: string): ComposerContextId {
137 return toKindScopedComposerContextId("preview-annotation", annotationId);
138}
139
140export 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
150export 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. */
161function threadContextId(threadId: ThreadId): ComposerContextId {
162 return toKindScopedComposerContextId("thread", threadId);
163}
164
165export function threadContextReference(record: ThreadContextRecord): ComposerContextReference {
166 return { kind: "thread", contextId: record.contextId, label: record.label };
167}
168
169export 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
182export 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
196export 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
217function 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
227export 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
266export function imageContextReference(image: ComposerImageAttachment): ComposerContextReference {
267 return {
268 kind: "image",
269 contextId: toKindScopedComposerContextId("image", image.id),
270 label: image.name,
271 };
272}
273
274export 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. */
283export 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. */
289export 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
302export 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
318export 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 */
349export 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. */
357export 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
369export 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 */
381export 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. */
405export 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
423export 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
439export 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. */
456export 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 */
509export 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}