1import ChatMarkdown from "./ChatMarkdown";
2import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview";
3import type { PreviewAnnotationPayload, ThreadContextRecord } from "@t3tools/contracts";
4import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
5import { videoMimeType } from "@t3tools/shared/video";
6import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
7import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react";
8import type { EnvironmentId } from "@t3tools/contracts";
9
10import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";
11import { composerFileNeedsReattach } from "~/composerDraftStore";
12import { useTheme } from "~/hooks/useTheme";
13import {
14 formatAttachmentUploadProgress,
15 type AttachmentUploadState,
16} from "~/lib/attachmentUploadState";
17import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
18import {
19 fileContextReference,
20 imageContextReference,
21 isPullRequestSummaryContext,
22 pullRequestContextDisplayState,
23 pullRequestContextKindLabel,
24 previewAnnotationContextId,
25 previewAnnotationContextLabel,
26 reviewCommentContextId,
27 reviewCommentContextLabel,
28 terminalContextReference,
29 uploadedAttachmentContextRecord,
30} from "~/lib/composerContextRecords";
31import type { TerminalContextDraft } from "~/lib/terminalContext";
32import type { ReviewCommentContext } from "~/reviewCommentContext";
33import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
34import { ThreadContextChip } from "./ThreadContextChip";
35import {
36 createContextPresentationRegistry,
37 type ContextPresentationCapability,
38} from "./contextPresentationRegistry";
39import type { ContextChipKind } from "./ContextChip";
40import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
41import {
42 ContextChipPopover,
43 ContextChipShell,
44 FileChip,
45 ImageChipButton,
46 PULL_REQUEST_CHIP_KINDS,
47 PullRequestChip,
48 UnresolvedChip,
49} from "./contextChipParts";
50
51/**
52 * Draft-side payload behind a context reference chip. Each kind keeps its existing draft
53 * shape; the editor only needs a way to look one up by id.
54 */
55export type ComposerDraftContextRecord =
56 | { kind: "terminal"; record: TerminalContextDraft }
57 | { kind: "review-comment"; record: ReviewCommentContext }
58 | {
59 kind: "preview-annotation";
60 record: PreviewAnnotationPayload;
61 /** The crop, which the draft stores as an image sharing the annotation's id. */
62 screenshot?: ComposerImageAttachment | undefined;
63 }
64 | { kind: "image"; record: ComposerImageAttachment; upload?: AttachmentUploadState | undefined }
65 | { kind: "file"; record: ComposerFileAttachment; upload?: AttachmentUploadState | undefined }
66 | { kind: "thread"; record: ThreadContextRecord };
67
68/** What a chip can do beyond showing itself; the composer supplies the handlers. */
69export interface ComposerContextActions {
70 environmentId: EnvironmentId | null;
71 cwd?: string | undefined;
72 expandImage: (imageId: string) => void;
73 expandVideo: (fileId: string) => void;
74 openFile: (fileId: string) => void;
75 openMention: (path: string) => void;
76 openPullRequest: (event: MouseEvent<HTMLElement>, url: string) => void;
77}
78
79export const ComposerContextActionsContext = createContext<ComposerContextActions>({
80 environmentId: null,
81 expandImage: () => {},
82 expandVideo: () => {},
83 openFile: () => {},
84 openMention: () => {},
85 openPullRequest: () => {},
86});
87
88export type ComposerDraftContextRecords = ReadonlyMap<string, ComposerDraftContextRecord>;
89
90export const EMPTY_COMPOSER_CONTEXT_RECORDS: ComposerDraftContextRecords = new Map();
91
92export function uploadedContextRecordFromDraft(entry: ComposerDraftContextRecord) {
93 if (entry.kind !== "image" && entry.kind !== "file") return null;
94 return uploadedAttachmentContextRecord(entry.record, entry.upload);
95}
96
97export const ComposerContextRecordsContext = createContext<ComposerDraftContextRecords>(
98 EMPTY_COMPOSER_CONTEXT_RECORDS,
99);
100
101export function composerContextRecordsFromDraft(input: {
102 terminalContexts: ReadonlyArray<TerminalContextDraft>;
103 reviewComments?: ReadonlyArray<ReviewCommentContext>;
104 previewAnnotations?: ReadonlyArray<PreviewAnnotationPayload>;
105 threadContexts?: ReadonlyArray<ThreadContextRecord>;
106 images?: ReadonlyArray<ComposerImageAttachment>;
107 files?: ReadonlyArray<ComposerFileAttachment>;
108 uploadsByImageId?: Readonly<Record<string, AttachmentUploadState>>;
109}): ComposerDraftContextRecords {
110 const records = new Map<string, ComposerDraftContextRecord>();
111 for (const record of input.images ?? []) {
112 records.set(imageContextReference(record).contextId, {
113 kind: "image",
114 record,
115 upload: input.uploadsByImageId?.[record.id],
116 });
117 }
118 for (const record of input.files ?? []) {
119 records.set(fileContextReference(record).contextId, {
120 kind: "file",
121 record,
122 upload: input.uploadsByImageId?.[record.id],
123 });
124 }
125 for (const record of input.terminalContexts) {
126 records.set(terminalContextReference(record).contextId, { kind: "terminal", record });
127 }
128 for (const record of input.reviewComments ?? []) {
129 records.set(reviewCommentContextId(record.id), { kind: "review-comment", record });
130 }
131 const imagesById = new Map((input.images ?? []).map((image) => [image.id, image]));
132 for (const record of input.previewAnnotations ?? []) {
133 records.set(previewAnnotationContextId(record.id), {
134 kind: "preview-annotation",
135 record,
136 screenshot: imagesById.get(record.id),
137 });
138 }
139 for (const record of input.threadContexts ?? []) {
140 records.set(record.contextId, { kind: "thread", record });
141 }
142 return records;
143}
144
145function ContextChip(props: {
146 icon: ReactElement;
147 label: string;
148 kindLabel: string;
149 details: ReactNode;
150 detailsMode: ContextPresentationCapability["details"];
151 kind: ContextChipKind;
152}) {
153 if (props.detailsMode === "popover") {
154 return (
155 <ContextChipPopover
156 kind={props.kind}
157 icon={props.icon}
158 label={props.label}
159 accessibleLabel={props.kindLabel + ", " + props.label}
160 >
161 {props.details}
162 </ContextChipPopover>
163 );
164 }
165 return (
166 <ContextChipShell
167 kind={props.kind}
168 icon={props.icon}
169 label={props.label}
170 aria-label={
171 props.detailsMode === "tooltip" ? `${props.kindLabel}, ${props.label}` : undefined
172 }
173 tooltip={props.detailsMode === "tooltip" ? props.details : undefined}
174 />
175 );
176}
177
178function uploadStatusSuffix(upload: AttachmentUploadState | undefined): string | null {
179 if (upload?.status === "uploading") return formatAttachmentUploadProgress(upload.progress);
180 if (upload?.status === "failed") return "upload failed";
181 return null;
182}
183
184function attachmentTooltip(
185 attachment: ComposerImageAttachment | ComposerFileAttachment,
186 upload: AttachmentUploadState | undefined,
187): string {
188 const lines = [attachment.name, formatAttachmentSize(attachment.sizeBytes)];
189 if (upload?.status === "failed") lines.push("", upload.reason);
190 return lines.join("\n");
191}
192
193function ImageContextChip(props: {
194 record: ComposerImageAttachment;
195 upload: AttachmentUploadState | undefined;
196}) {
197 const actions = use(ComposerContextActionsContext);
198 return (
199 <Tooltip>
200 <TooltipTrigger
201 render={
202 <ImageChipButton
203 name={props.record.name}
204 previewUrl={props.record.previewUrl}
205 size={formatAttachmentSize(props.record.sizeBytes)}
206 suffix={uploadStatusSuffix(props.upload)}
207 onClick={() => actions.expandImage(props.record.id)}
208 />
209 }
210 />
211 <TooltipPopup side="top" className="whitespace-pre-wrap">
212 {attachmentTooltip(props.record, props.upload)}
213 </TooltipPopup>
214 </Tooltip>
215 );
216}
217
218function FileContextChip(props: {
219 record: ComposerFileAttachment;
220 upload: AttachmentUploadState | undefined;
221}) {
222 const actions = use(ComposerContextActionsContext);
223 const { resolvedTheme } = useTheme();
224 const needsReattach = composerFileNeedsReattach(props.record);
225 const suffix = needsReattach ? "attach again" : uploadStatusSuffix(props.upload);
226 const size = formatAttachmentSize(props.record.sizeBytes);
227 const isVideo = videoMimeType(props.record) !== null;
228 return (
229 <FileChip
230 name={props.record.name}
231 size={size}
232 isVideo={isVideo}
233 theme={resolvedTheme}
234 error={props.upload?.status === "failed"}
235 unresolved={needsReattach}
236 suffix={suffix}
237 accessibleLabel={`${isVideo && !needsReattach ? "Preview video" : "File"} attachment, ${props.record.name}, ${size}`}
238 onOpen={
239 !needsReattach
240 ? () =>
241 isVideo ? actions.expandVideo(props.record.id) : actions.openFile(props.record.id)
242 : undefined
243 }
244 tooltip={
245 needsReattach
246 ? `${props.record.name} was not saved with this draft. Attach it again to send it.`
247 : attachmentTooltip(props.record, props.upload)
248 }
249 />
250 );
251}
252
253function PullRequestContextChip(props: { record: ReviewCommentContext; kind: ContextChipKind }) {
254 const actions = use(ComposerContextActionsContext);
255 const metadata = props.record.pullRequest;
256 if (metadata === undefined) return null;
257 return (
258 <PullRequestChip
259 metadata={metadata}
260 environmentId={actions.environmentId}
261 label={reviewCommentContextLabel(props.record)}
262 kindLabel={pullRequestContextKindLabel(props.record)}
263 kind={props.kind}
264 onOpen={actions.openPullRequest}
265 />
266 );
267}
268
269function previewAnnotationTooltip(annotation: PreviewAnnotationPayload): string {
270 const lines = [annotation.pageTitle?.trim() || annotation.pageUrl];
271 if (annotation.comment.trim()) lines.push("", annotation.comment.trim());
272 const targets: string[] = [];
273 const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`;
274 if (annotation.elements.length > 0) targets.push(plural(annotation.elements.length, "element"));
275 if (annotation.regions.length > 0) targets.push(plural(annotation.regions.length, "region"));
276 if (annotation.strokes.length > 0) targets.push(plural(annotation.strokes.length, "drawing"));
277 if (annotation.styleChanges.length > 0) {
278 targets.push(plural(annotation.styleChanges.length, "style change"));
279 }
280 if (targets.length > 0) lines.push("", targets.join(", "));
281 return lines.join("\n");
282}
283
284function ComposerReviewCommentDetails({ comment }: { comment: ReviewCommentContext }) {
285 return (
286 <div className="space-y-2 overflow-hidden rounded-lg border border-border/70 bg-background/70 p-3">
287 <div className="space-y-1">
288 <div className="truncate text-xs font-medium text-foreground">{comment.filePath}</div>
289 <div className="text-secondary-label text-2xs">
290 {comment.sectionTitle} · {comment.rangeLabel}
291 </div>
292 </div>
293 {comment.text.trim() ? <ChatMarkdown text={comment.text.trim()} cwd={undefined} /> : null}
294 {comment.diff.trim() ? (
295 <div className="flex h-64 min-h-0 flex-col overflow-hidden rounded-md border border-border">
296 <ReadOnlySourcePreview name="review.diff" text={comment.diff} />
297 </div>
298 ) : null}
299 </div>
300 );
301}
302
303function ComposerPreviewAnnotationDetails({
304 annotation,
305 screenshot,
306}: {
307 annotation: PreviewAnnotationPayload;
308 screenshot: ComposerImageAttachment | undefined;
309}) {
310 const summary = previewAnnotationTooltip(annotation);
311 // The draft drops the annotation's own data URL and keeps the crop as an image.
312 const screenshotUrl = annotation.screenshot?.dataUrl || screenshot?.previewUrl;
313 return (
314 <div className="overflow-hidden rounded-lg border border-border/70 bg-background/70">
315 {screenshotUrl ? (
316 <img
317 src={screenshotUrl}
318 alt="Annotated preview crop"
319 className="max-h-64 w-full border-border/70 border-b bg-muted object-contain"
320 />
321 ) : (
322 <div className="border-border/70 border-b bg-muted/40 px-3 py-2 text-secondary-label text-xs">
323 Screenshot unavailable
324 </div>
325 )}
326 <div className="whitespace-pre-wrap wrap-break-word px-3 py-2.5 text-sm text-foreground">
327 {summary}
328 </div>
329 </div>
330 );
331}
332
333function UnresolvedContextChip(props: { label: string }) {
334 return (
335 <UnresolvedChip
336 label={props.label}
337 tooltip="This context is no longer available. Remove it or attach it again."
338 />
339 );
340}
341
342interface ComposerContextRenderContext {
343 label: string;
344}
345
346const composerContextPresentationRegistry = createContextPresentationRegistry<
347 ComposerDraftContextRecord,
348 ComposerContextRenderContext,
349 ReactElement
350>({
351 requiredKinds: ["image", "file", "terminal", "review-comment", "preview-annotation", "thread"],
352 handlers: [
353 {
354 kind: "terminal",
355 canRender: (entry) => entry.kind === "terminal",
356 render: (entry, context, definition) =>
357 entry.kind === "terminal" ? (
358 <ComposerPendingTerminalContextChip
359 context={entry.record}
360 detailsMode={definition.capabilities.details}
361 />
362 ) : (
363 <UnresolvedContextChip label={context.label} />
364 ),
365 },
366 {
367 kind: "image",
368 canRender: (entry) => entry.kind === "image",
369 render: (entry, context) =>
370 entry.kind === "image" ? (
371 <ImageContextChip record={entry.record} upload={entry.upload} />
372 ) : (
373 <UnresolvedContextChip label={context.label} />
374 ),
375 },
376 {
377 kind: "file",
378 canRender: (entry) => entry.kind === "file",
379 render: (entry, context) =>
380 entry.kind === "file" ? (
381 <FileContextChip record={entry.record} upload={entry.upload} />
382 ) : (
383 <UnresolvedContextChip label={context.label} />
384 ),
385 },
386 {
387 kind: "review-comment",
388 canRender: (entry) => entry.kind === "review-comment",
389 render: (entry, context, definition) => {
390 if (entry.kind !== "review-comment") {
391 return <UnresolvedContextChip label={context.label} />;
392 }
393 const isPullRequest = isPullRequestSummaryContext(entry.record);
394 const pullRequestState = pullRequestContextDisplayState(entry.record) ?? "unknown";
395 if (isPullRequest && entry.record.pullRequest !== undefined) {
396 return (
397 <PullRequestContextChip
398 record={entry.record}
399 kind={PULL_REQUEST_CHIP_KINDS[pullRequestState]}
400 />
401 );
402 }
403 return (
404 <ContextChip
405 icon={isPullRequest ? <PullRequestGlyph.pullRequest /> : <MessageCircleIcon />}
406 label={reviewCommentContextLabel(entry.record)}
407 kindLabel={isPullRequest ? pullRequestContextKindLabel(entry.record) : "Review comment"}
408 details={<ComposerReviewCommentDetails comment={entry.record} />}
409 detailsMode={definition.capabilities.details}
410 kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
411 />
412 );
413 },
414 },
415 {
416 kind: "preview-annotation",
417 canRender: (entry) => entry.kind === "preview-annotation",
418 render: (entry, context, definition) =>
419 entry.kind === "preview-annotation" ? (
420 <ContextChip
421 icon={<MousePointerClickIcon />}
422 label={previewAnnotationContextLabel(entry.record)}
423 kindLabel="Preview annotation"
424 details={
425 <ComposerPreviewAnnotationDetails
426 annotation={entry.record}
427 screenshot={entry.screenshot}
428 />
429 }
430 detailsMode={definition.capabilities.details}
431 kind="preview-annotation"
432 />
433 ) : (
434 <UnresolvedContextChip label={context.label} />
435 ),
436 },
437 {
438 kind: "thread",
439 canRender: (entry) => entry.kind === "thread",
440 render: (entry, context) =>
441 entry.kind === "thread" ? (
442 <ThreadContextChip record={entry.record} />
443 ) : (
444 <UnresolvedContextChip label={context.label} />
445 ),
446 },
447 ],
448 fallback: (_kind, _entry, context) => <UnresolvedContextChip label={context.label} />,
449});
450
451/** Compact chip for one reference. Unknown kinds and missing records use the registry fallback. */
452export function ComposerContextReferenceChip(props: {
453 kind: string;
454 contextId: string;
455 label: string;
456}): ReactElement {
457 const records = use(ComposerContextRecordsContext);
458 return composerContextPresentationRegistry.render(props.kind, records.get(props.contextId), {
459 label: props.label,
460 });
461}