Open History to see the last commit
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-card border border-border bg-popover 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 <span className="block">{comment.rangeLabel}</span>
295 <span className="block">{comment.sectionTitle}</span>
296 </div>
297 </div>
298 {comment.text.trim() ? <ChatMarkdown text={comment.text.trim()} cwd={undefined} /> : null}
299 {comment.diff.trim() ? (
300 <div className="flex h-64 min-h-0 flex-col overflow-hidden rounded-md border border-border">
301 <ReadOnlySourcePreview name="review.diff" text={comment.diff} />
302 </div>
303 ) : null}
304 </div>
305 );
306}
307
308function ComposerPreviewAnnotationDetails({
309 annotation,
310 screenshot,
311}: {
312 annotation: PreviewAnnotationPayload;
313 screenshot: ComposerImageAttachment | undefined;
314}) {
315 const summary = previewAnnotationTooltip(annotation);
316 const screenshotUrl = annotation.screenshot?.dataUrl || screenshot?.previewUrl;
317 return (
318 <div className="overflow-hidden rounded-card border border-border bg-popover">
319 {screenshotUrl ? (
320 <img
321 src={screenshotUrl}
322 alt="Annotated preview crop"
323 className="max-h-64 w-full border-border/70 border-b bg-muted object-contain"
324 />
325 ) : (
326 <div className="border-border border-b bg-muted px-3 py-2 text-secondary-label text-xs">
327 Screenshot unavailable
328 </div>
329 )}
330 <div className="whitespace-pre-wrap wrap-break-word px-3 py-2.5 text-sm text-foreground">
331 {summary}
332 </div>
333 </div>
334 );
335}
336
337function ComposerThreadContextChip(props: { record: ThreadContextRecord }) {
338 const { environmentId } = use(ComposerContextActionsContext);
339 const linkedEnvironments = useLinkedEnvironments(environmentId);
340 return (
341 <ThreadContextChip
342 record={props.record}
343 unreadable={
344 environmentId !== null &&
345 props.record.environmentId !== environmentId &&
346 !linkedEnvironments.has(props.record.environmentId)
347 }
348 />
349 );
350}
351
352function UnresolvedContextChip(props: { label: string }) {
353 return (
354 <UnresolvedChip
355 label={props.label}
356 tooltip="This context is no longer available. Remove it or attach it again."
357 />
358 );
359}
360
361interface ComposerContextRenderContext {
362 label: string;
363}
364
365const composerContextPresentationRegistry = createContextPresentationRegistry<
366 ComposerDraftContextRecord,
367 ComposerContextRenderContext,
368 ReactElement
369>({
370 requiredKinds: [
371 "image",
372 "file",
373 "terminal",
374 "review-comment",
375 "preview-annotation",
376 "thread",
377 "environment",
378 ],
379 handlers: [
380 {
381 kind: "terminal",
382 canRender: (entry) => entry.kind === "terminal",
383 render: (entry, context, definition) =>
384 entry.kind === "terminal" ? (
385 <ComposerPendingTerminalContextChip
386 context={entry.record}
387 detailsMode={definition.capabilities.details}
388 />
389 ) : (
390 <UnresolvedContextChip label={context.label} />
391 ),
392 },
393 {
394 kind: "image",
395 canRender: (entry) => entry.kind === "image",
396 render: (entry, context) =>
397 entry.kind === "image" ? (
398 <ImageContextChip record={entry.record} upload={entry.upload} />
399 ) : (
400 <UnresolvedContextChip label={context.label} />
401 ),
402 },
403 {
404 kind: "file",
405 canRender: (entry) => entry.kind === "file",
406 render: (entry, context) =>
407 entry.kind === "file" ? (
408 <FileContextChip record={entry.record} upload={entry.upload} />
409 ) : (
410 <UnresolvedContextChip label={context.label} />
411 ),
412 },
413 {
414 kind: "review-comment",
415 canRender: (entry) => entry.kind === "review-comment",
416 render: (entry, context, definition) => {
417 if (entry.kind !== "review-comment") {
418 return <UnresolvedContextChip label={context.label} />;
419 }
420 const isPullRequest = isPullRequestSummaryContext(entry.record);
421 const pullRequestState = pullRequestContextDisplayState(entry.record) ?? "unknown";
422 if (isPullRequest && entry.record.pullRequest !== undefined) {
423 return (
424 <PullRequestContextChip
425 record={entry.record}
426 kind={PULL_REQUEST_CHIP_KINDS[pullRequestState]}
427 />
428 );
429 }
430 return (
431 <ContextChip
432 icon={isPullRequest ? <PullRequestGlyph.pullRequest /> : <MessageCircleIcon />}
433 label={reviewCommentContextLabel(entry.record)}
434 kindLabel={isPullRequest ? pullRequestContextKindLabel(entry.record) : "Review comment"}
435 details={<ComposerReviewCommentDetails comment={entry.record} />}
436 detailsMode={definition.capabilities.details}
437 kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
438 />
439 );
440 },
441 },
442 {
443 kind: "preview-annotation",
444 canRender: (entry) => entry.kind === "preview-annotation",
445 render: (entry, context, definition) =>
446 entry.kind === "preview-annotation" ? (
447 <ContextChip
448 icon={<MousePointerClickIcon />}
449 label={previewAnnotationContextLabel(entry.record)}
450 kindLabel="Preview annotation"
451 details={
452 <ComposerPreviewAnnotationDetails
453 annotation={entry.record}
454 screenshot={entry.screenshot}
455 />
456 }
457 detailsMode={definition.capabilities.details}
458 kind="preview-annotation"
459 />
460 ) : (
461 <UnresolvedContextChip label={context.label} />
462 ),
463 },
464 {
465 kind: "thread",
466 canRender: (entry) => entry.kind === "thread",
467 render: (entry, context) =>
468 entry.kind === "thread" ? (
469 <ComposerThreadContextChip record={entry.record} />
470 ) : (
471 <UnresolvedContextChip label={context.label} />
472 ),
473 },
474 {
475 kind: "environment",
476 canRender: (entry) => entry.kind === "environment",
477 render: (entry, context) =>
478 entry.kind === "environment" ? (
479 <EnvironmentContextChip record={entry.record} />
480 ) : (
481 <UnresolvedContextChip label={context.label} />
482 ),
483 },
484 ],
485 fallback: (_kind, _entry, context) => <UnresolvedContextChip label={context.label} />,
486});
487
488export function ComposerContextReferenceChip(props: {
489 kind: string;
490 contextId: string;
491 label: string;
492}): ReactElement {
493 const records = use(ComposerContextRecordsContext);
494 return composerContextPresentationRegistry.render(props.kind, records.get(props.contextId), {
495 label: props.label,
496 });
497}