Skip to content
Source
Sign inSign up
Open History to see the last commit

composerContextRecords.ts

557 lines20.1 KB

1import {
2 capitalizeTerm,
3 sourceControlClients,
4} from "@t3tools/client-runtime/source-control-clients";
5import {
6 COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS,
7 COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS,
8 COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS,
9} from "@t3tools/contracts";
10import type {
11 ComposerContextId,
12 ComposerContextRecord,
13 EnvironmentContextRecord,
14 EnvironmentId,
15 FileContextRecord,
16 ImageContextRecord,
17 KnownComposerContextRecord,
18 MessageId,
19 OrchestrationMessageContext,
20 PreviewAnnotationContextRecord,
21 PreviewAnnotationPayload,
22 ResponseContextRecord,
23 ReviewCommentContextRecord,
24 TerminalContextRecord,
25 ThreadContextRecord,
26 ThreadId,
27} from "@t3tools/contracts";
28import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy";
29import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
30import {
31 collectComposerContextReferences,
32 sanitizeComposerContextLabel,
33 toKindScopedComposerContextId,
34} from "@t3tools/shared/composerContextReferences";
35​
36import {
37 type ComposerContextReference,
38 producerIdFromComposerContextId,
39} from "./composerContextReferences";
40import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";
41import type { AttachmentUploadState } from "./attachmentUploadState";
42import { normalizeElementContextSelection } from "./elementContext";
43import {
44 formatTerminalContextLabel,
45 normalizeTerminalContextText,
46 type TerminalContextDraft,
47} from "./terminalContext";
48import type { ReviewCommentContext } from "~/reviewCommentContext";
49​
50const PREVIEW_LABEL_MAX_CHARS = 48;
51​
52// Truncate selections to wire bounds before persistence to avoid send-time encoding failures.
53const TRUNCATION_MARKER = "\n… truncated …";
54​
55function clampContextText(value: string, max: number): string {
56 if (value.length <= max) return value;
57 let end = Math.max(0, max - TRUNCATION_MARKER.length);
58 // Never end on a lone high surrogate.
59 const last = value.charCodeAt(end - 1);
60 if (last >= 0xd800 && last <= 0xdbff) end -= 1;
61 return `${value.slice(0, end)}${TRUNCATION_MARKER}`;
63​
64type ReviewCommentPresentation = ReviewCommentContext | ReviewCommentContextRecord;
65​
66function basename(filePath: string): string {
67 return filePath.split(/[\\/]/).at(-1) ?? filePath;
69​
70export function reviewCommentContextLabel(comment: ReviewCommentPresentation): string {
71 const pullRequestNumber = pullRequestContextNumber(comment);
72 if (isPullRequestSummaryContext(comment) && pullRequestNumber !== null) {
73 return `#${pullRequestNumber}`;
74 }
75 const diffRange = /^([+-])(\d+)(?: to \1(\d+))?$/u.exec(comment.rangeLabel);
76 const rangeLabel = diffRange
77 ? `L${diffRange[2]}${diffRange[3] ? ` to L${diffRange[3]}` : ""}${diffRange[1] === "-" ? " (before)" : ""}`
78 : comment.rangeLabel;
79 const sectionPullRequest = /^pull-request:(\d+)$/u.exec(comment.sectionId)?.[1];
80 const prefix = sectionPullRequest === undefined ? "" : `#${sectionPullRequest} `;
81 return `${prefix}${basename(comment.filePath)} ${rangeLabel}`;
83​
84export function isPullRequestSummaryContext(comment: ReviewCommentPresentation): boolean {
85 if (comment.pullRequest !== undefined) return true;
86 return (
87 comment.sectionId.startsWith("pull-request:") &&
88 comment.diff.trim().length === 0 &&
89 /^PR #\d+$/u.test(comment.filePath)
90 );
92​
93function pullRequestContextNumber(comment: ReviewCommentPresentation): number | null {
94 if (comment.pullRequest !== undefined) return comment.pullRequest.number;
95 const legacyNumber = /^PR #(\d+)$/u.exec(comment.filePath)?.[1];
96 return legacyNumber === undefined ? null : Number(legacyNumber);
98​
99export type PullRequestContextDisplayState = "open" | "draft" | "merged" | "closed";
100​
101export function pullRequestContextDisplayState(
102 comment: ReviewCommentPresentation,
103): PullRequestContextDisplayState | null {
104 const pullRequest = comment.pullRequest;
105 if (pullRequest === undefined) return null;
106 return pullRequest.state === "open" && pullRequest.isDraft ? "draft" : pullRequest.state;
108​
109export function pullRequestContextKindLabel(comment: ReviewCommentPresentation): string {
110 const url = comment.pullRequest?.url;
111 const host = url === undefined ? null : sourceControlClients.findByChangeRequestUrl(url);
112 const term = (host ?? sourceControlClients.get(undefined)).changeRequest.singular;
113 const state = pullRequestContextDisplayState(comment);
114 if (state === null) return capitalizeTerm(term);
115 return `${capitalizeTerm(state)} ${term}`;
117​
118export function previewAnnotationContextLabel(annotation: PreviewAnnotationPayload): string {
119 const comment = annotation.comment.trim().replace(/\s+/g, " ");
120 if (comment) {
121 if (comment.length <= PREVIEW_LABEL_MAX_CHARS) return comment;
122 let end = PREVIEW_LABEL_MAX_CHARS - 1;
123 const previous = comment.charCodeAt(end - 1);
124 const next = comment.charCodeAt(end);
125 if (previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end -= 1;
126 return `${comment.slice(0, end)}…`;
127 }
128 return annotation.pageTitle?.trim() || "Preview annotation";
130​
131export function terminalContextReference(context: TerminalContextDraft): ComposerContextReference {
132 return {
133 kind: "terminal",
134 contextId: toKindScopedComposerContextId("terminal", context.id),
135 label: formatTerminalContextLabel(context),
136 };
138​
139export function reviewCommentContextId(commentId: string): ComposerContextId {
140 return toKindScopedComposerContextId("review-comment", commentId);
142​
143export function previewAnnotationContextId(annotationId: string): ComposerContextId {
144 return toKindScopedComposerContextId("preview-annotation", annotationId);
146​
147export function reviewCommentContextReference(
148 comment: ReviewCommentContext,
149): ComposerContextReference {
150 return {
151 kind: "review-comment",
152 contextId: reviewCommentContextId(comment.id),
153 label: reviewCommentContextLabel(comment),
154 };
156​
157export function previewAnnotationContextReference(
158 annotation: PreviewAnnotationPayload,
159): ComposerContextReference {
160 return {
161 kind: "preview-annotation",
162 contextId: previewAnnotationContextId(annotation.id),
163 label: previewAnnotationContextLabel(annotation),
164 };
166​
167export function threadContextReference(record: ThreadContextRecord): ComposerContextReference {
168 return { kind: "thread", contextId: record.contextId, label: record.label };
170​
171export function isResponseContextRecord(
172 record: ComposerContextRecord,
173): record is ResponseContextRecord {
174 return record.kind === "response" && "citation" in record;
176​
177export function responseContextReference(record: ResponseContextRecord): ComposerContextReference {
178 return { kind: "response", contextId: record.contextId, label: record.label };
180​
181/** One record per response, so replying to it again reuses the same reference. */
182export function responseContextRecord(
183 input: Pick<ResponseContextRecord, "citation" | "itemId"> & {
184 readonly subagents: ReadonlyArray<{
185 readonly threadId: ThreadId;
186 readonly title: string;
187 readonly environmentId?: EnvironmentId;
188 }>;
189 },
190): ResponseContextRecord {
191 return {
192 version: 1,
193 kind: "response",
194 contextId: toKindScopedComposerContextId("response", input.citation.messageId),
195 label: sanitizeComposerContextLabel(input.citation.text.slice(0, 64), "response"),
196 citation: input.citation,
197 itemId: input.itemId,
198 ...(input.subagents.length > 0
199 ? {
200 subagents: input.subagents.slice(0, 20).map(({ title, ...subagent }) => ({
201 ...subagent,
202 title: sanitizeComposerContextLabel(title, "response"),
203 })),
204 }
205 : {}),
206 };
208​
209/** One record per machine: mentioning the same machine twice reuses the chip. */
210export function environmentContextReference(
211 record: EnvironmentContextRecord,
212): ComposerContextReference {
213 return { kind: "environment", contextId: record.contextId, label: record.label };
215​
216export function environmentContextRecord(
217 environmentId: EnvironmentId,
218 label: string,
219): EnvironmentContextRecord {
220 return {
221 version: 1,
222 kind: "environment",
223 contextId: toKindScopedComposerContextId("environment", environmentId),
224 label: sanitizeComposerContextLabel(label, "environment"),
225 environmentId,
226 };
228​
229export function terminalContextRecord(context: TerminalContextDraft): TerminalContextRecord {
230 return {
231 version: 1,
232 contextId: toKindScopedComposerContextId("terminal", context.id),
233 kind: "terminal",
234 label: sanitizeComposerContextLabel(formatTerminalContextLabel(context), "terminal"),
235 terminalId: context.terminalId,
236 terminalLabel: context.terminalLabel,
237 lineStart: context.lineStart,
238 lineEnd: context.lineEnd,
239 text: clampContextText(
240 normalizeTerminalContextText(context.text),
241 COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS,
242 ),
243 };
245​
246export function reviewCommentContextRecord(
247 comment: ReviewCommentContext,
248): ReviewCommentContextRecord {
249 return {
250 version: 1,
251 contextId: reviewCommentContextId(comment.id),
252 kind: "review-comment",
253 label: sanitizeComposerContextLabel(reviewCommentContextLabel(comment), "review-comment"),
254 sectionId: comment.sectionId,
255 sectionTitle: comment.sectionTitle,
256 filePath: comment.filePath,
257 startIndex: comment.startIndex,
258 endIndex: comment.endIndex,
259 rangeLabel: comment.rangeLabel,
260 text: clampContextText(comment.text, COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS),
261 diff: clampContextText(comment.diff, COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS),
262 ...(comment.fenceLanguage !== undefined ? { fenceLanguage: comment.fenceLanguage } : {}),
263 ...(comment.pullRequest !== undefined ? { pullRequest: comment.pullRequest } : {}),
264 };
266​
267function previewAnnotationTargetSummary(annotation: PreviewAnnotationPayload): string {
268 const parts: string[] = [];
269 const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`;
270 if (annotation.elements.length > 0)
271 parts.push(plural(annotation.elements.length, "selected element"));
272 if (annotation.regions.length > 0) parts.push(plural(annotation.regions.length, "marked region"));
273 if (annotation.strokes.length > 0) parts.push(plural(annotation.strokes.length, "drawing"));
274 return parts.join(", ");
276​
277export function previewAnnotationContextRecord(
278 annotation: PreviewAnnotationPayload,
279 options?: { screenshotContextId?: string | undefined },
280): PreviewAnnotationContextRecord {
281 const targets = annotation.elements.flatMap((target) => {
282 const element = normalizeElementContextSelection(target.element);
283 return element ? [{ id: target.id, element }] : [];
284 });
285 return {
286 version: 1,
287 contextId: previewAnnotationContextId(annotation.id),
288 kind: "preview-annotation",
289 label: sanitizeComposerContextLabel(
290 previewAnnotationContextLabel(annotation),
291 "preview-annotation",
292 ),
293 annotationId: annotation.id,
294 pageUrl: annotation.pageUrl,
295 pageTitle: annotation.pageTitle,
296 comment: annotation.comment.trim(),
297 targetSummary: previewAnnotationTargetSummary(annotation),
298 styleChanges: annotation.styleChanges.map(
299 (change) => `${change.property}: ${change.previousValue || "(unset)"} → ${change.value}`,
300 ),
301 ...(targets.length > 0
302 ? {
303 elements: targets.map((target) => target.element),
304 elementIds: targets.map((target) => target.id),
305 }
306 : {}),
307 styleChangeDetails: annotation.styleChanges.map((change) => ({ ...change })),
308 ...(annotation.regions.length > 0 ? { regionCount: annotation.regions.length } : {}),
309 ...(annotation.strokes.length > 0 ? { strokeCount: annotation.strokes.length } : {}),
310 ...(options?.screenshotContextId !== undefined
311 ? { screenshotContextId: toKindScopedComposerContextId("image", options.screenshotContextId) }
312 : {}),
313 };
315​
316export function imageContextReference(image: ComposerImageAttachment): ComposerContextReference {
317 return {
318 kind: "image",
319 contextId: toKindScopedComposerContextId("image", image.id),
320 label: image.name,
321 };
323​
324export function fileContextReference(file: ComposerFileAttachment): ComposerContextReference {
325 return {
326 kind: "file",
327 contextId: toKindScopedComposerContextId("file", file.id),
328 label: file.name,
329 };
331​
332export interface BoundComposerAttachment {
333 attachment: ComposerImageAttachment | ComposerFileAttachment;
334 attachmentId: string;
336​
337/** Returns null until the attachment has a server id. */
338export function uploadedAttachmentContextRecord(
339 attachment: ComposerImageAttachment | ComposerFileAttachment,
340 upload: AttachmentUploadState | undefined,
341): ImageContextRecord | FileContextRecord | null {
342 const attachmentId =
343 attachment.type === "file" && attachment.uploadedAttachmentId !== undefined
344 ? attachment.uploadedAttachmentId
345 : upload?.status === "ready"
346 ? upload.attachmentId
347 : undefined;
348 return attachmentId === undefined ? null : attachmentContextRecord({ attachment, attachmentId });
350​
351export function attachmentContextRecord(
352 bound: BoundComposerAttachment,
353): ImageContextRecord | FileContextRecord {
354 const { attachment, attachmentId } = bound;
355 const base = {
356 version: 1 as const,
357 contextId: toKindScopedComposerContextId(attachment.type, attachment.id),
358 label: sanitizeComposerContextLabel(attachment.name, attachment.type),
359 attachmentId,
360 name: attachment.name,
361 mimeType: attachment.mimeType,
362 sizeBytes: attachment.sizeBytes,
363 };
364 return attachment.type === "image" ? { ...base, kind: "image" } : { ...base, kind: "file" };
366​
367export function buildMessageContext(input: {
368 terminalContexts: ReadonlyArray<TerminalContextDraft>;
369 reviewComments: ReadonlyArray<ReviewCommentContext>;
370 previewAnnotations: ReadonlyArray<PreviewAnnotationPayload>;
371 threadContexts?: ReadonlyArray<ThreadContextRecord>;
372 environmentContexts?: ReadonlyArray<EnvironmentContextRecord>;
373 replyTo?: ResponseContextRecord | null;
374 attachments?: ReadonlyArray<BoundComposerAttachment>;
375}): OrchestrationMessageContext | undefined {
376 const screenshotAttachmentIds = new Set(
377 (input.attachments ?? []).flatMap(({ attachment }) =>
378 attachment.type === "image" ? [attachment.id] : [],
379 ),
380 );
381 const records: ComposerContextRecord[] = [
382 ...input.terminalContexts.map(terminalContextRecord),
383 ...input.reviewComments.map(reviewCommentContextRecord),
384 ...(input.threadContexts ?? []),
385 ...(input.environmentContexts ?? []),
386 ...(input.replyTo ? [input.replyTo] : []),
387 ...input.previewAnnotations.map((annotation) =>
388 previewAnnotationContextRecord(annotation, {
389 screenshotContextId: screenshotAttachmentIds.has(annotation.id) ? annotation.id : undefined,
390 }),
391 ),
392 ...(input.attachments ?? []).map(attachmentContextRecord),
393 ];
394 return records.length === 0 ? undefined : { version: 1, records };
396​
397export function asKnownContextRecord(
398 record: ComposerContextRecord | undefined,
399): KnownComposerContextRecord | undefined {
400 if (!record || "payload" in record) return undefined;
401 return record;
403​
404export function composerContextImportLookupIds(
405 record: KnownComposerContextRecord,
406): ReadonlyArray<ComposerContextId> {
407 const destinationId = toKindScopedComposerContextId(
408 record.kind,
409 producerIdFromComposerContextId(record.kind, record.contextId),
410 );
411 return destinationId === record.contextId
412 ? [record.contextId]
413 : [destinationId, record.contextId];
415​
416export interface ResolvedUserMessageContext {
417 text: string;
418 records: ReadonlyArray<ComposerContextRecord>;
419 recordsById: ReadonlyMap<string, ComposerContextRecord>;
421​
422export function selectedMessageContextFragment(input: {
423 readonly markdown: string;
424 readonly records: ReadonlyArray<ComposerContextRecord>;
425 readonly environmentId: EnvironmentId;
426 readonly threadId?: ThreadId;
427 readonly messageId: MessageId;
428}): string | null {
429 const selectedIds = new Set(
430 collectComposerContextReferences(input.markdown).map((occurrence) => occurrence.contextId),
431 );
432 const records = input.records.filter((record) => selectedIds.has(record.contextId));
433 if (records.length === 0) return null;
434 return encodeComposerContextFragment({
435 version: 1,
436 source: {
437 environmentId: input.environmentId,
438 ...(input.threadId ? { threadId: input.threadId } : {}),
439 messageId: input.messageId,
440 },
441 records,
442 });
444​
445export function resolveUserMessageContext(message: {
446 text: string;
447 context?: OrchestrationMessageContext | undefined;
448}): ResolvedUserMessageContext {
449 const resolved = message.context
450 ? { text: message.text, records: message.context.records }
451 : upgradeLegacyContextMessage(message.text);
452 return {
453 text: resolved.text,
454 records: resolved.records,
455 recordsById: new Map(resolved.records.map((record) => [record.contextId, record])),
456 };
458​
459export function terminalContextDraftFromRecord(
460 record: TerminalContextRecord,
461 threadId: ThreadId,
462): TerminalContextDraft {
463 return {
464 id: producerIdFromComposerContextId("terminal", record.contextId),
465 threadId,
466 createdAt: new Date().toISOString(),
467 terminalId: record.terminalId,
468 terminalLabel: record.terminalLabel,
469 lineStart: record.lineStart,
470 lineEnd: record.lineEnd,
471 text: record.text,
472 };
474​
475export function reviewCommentFromRecord(record: ReviewCommentContextRecord): ReviewCommentContext {
476 return {
477 id: producerIdFromComposerContextId("review-comment", record.contextId),
478 sectionId: record.sectionId,
479 sectionTitle: record.sectionTitle,
480 filePath: record.filePath,
481 startIndex: record.startIndex,
482 endIndex: record.endIndex,
483 rangeLabel: record.rangeLabel,
484 text: record.text,
485 diff: record.diff,
486 ...(record.fenceLanguage !== undefined ? { fenceLanguage: record.fenceLanguage } : {}),
487 ...(record.pullRequest !== undefined ? { pullRequest: record.pullRequest } : {}),
488 };
490​
491export function previewAnnotationFromRecord(
492 record: PreviewAnnotationContextRecord,
493): PreviewAnnotationPayload {
494 return {
495 id:
496 record.annotationId ||
497 producerIdFromComposerContextId("preview-annotation", record.contextId),
498 pageUrl: record.pageUrl,
499 pageTitle: record.pageTitle,
500 comment: record.comment,
501 elements: (record.elements ?? []).map((element, index) => ({
502 id: record.elementIds?.[index] ?? `${record.contextId}-element-${index + 1}`,
503 rect: { x: 0, y: 0, width: 0, height: 0 },
504 element: { ...element, stack: [], pickedAt: new Date().toISOString() },
505 })),
506 regions: Array.from({ length: record.regionCount ?? 0 }, (_unused, index) => ({
507 id: `${record.contextId}-region-${index + 1}`,
508 rect: { x: 0, y: 0, width: 0, height: 0 },
509 })),
510 strokes: Array.from({ length: record.strokeCount ?? 0 }, (_unused, index) => ({
511 id: `${record.contextId}-stroke-${index + 1}`,
512 color: "",
513 width: 0,
514 points: [],
515 bounds: { x: 0, y: 0, width: 0, height: 0 },
516 })),
517 styleChanges:
518 record.styleChangeDetails ??
519 record.styleChanges.flatMap((change) => {
520 const match = /^(.+?): ([\s\S]*?) → ([\s\S]*)$/.exec(change);
521 if (!match) return [];
522 return [
523 {
524 targetId: record.elementIds?.[0] ?? `${record.contextId}-element-1`,
525 selector: null,
526 property: match[1]!,
527 previousValue: match[2] === "(unset)" ? "" : match[2]!,
528 value: match[3]!,
529 },
530 ];
531 }),
532 screenshot: null,
533 createdAt: new Date().toISOString(),
534 };
536​
537/** Labels are display-only; folded ids can collide, so paste deduplication compares payloads. */
538export function isSameComposerContextPayload(
539 left: ComposerContextRecord,
540 right: ComposerContextRecord,
541): boolean {
542 if (left.kind !== right.kind) return false;
543 const stableKey = (record: ComposerContextRecord) => {
544 const { label: _label, contextId: _contextId, ...rest } = record;
545 const sortDeep = (value: unknown): unknown => {
546 if (Array.isArray(value)) return value.map(sortDeep);
547 if (value === null || typeof value !== "object") return value;
548 return Object.fromEntries(
549 Object.entries(value)
550 .toSorted(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
551 .map(([key, nested]) => [key, sortDeep(nested)]),
552 );
553 };
554 return JSON.stringify(sortDeep(rest));
555 };
556 return stableKey(left) === stableKey(right);