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