1import { documentChipKindForFileName } from "~/pierre-icons";
2import { isSvgAttachment } from "@t3tools/shared/image";
3import { useRenderedAppearance } from "../../hooks/useTheme";
4import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
5import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview";
6import { useRightPanelStore } from "~/rightPanelStore";
7import { COMPOSER_CONTEXT_KINDS } from "@t3tools/contracts";
8import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
9import { use, type ReactNode } from "react";
10import { FileDiff } from "@pierre/diffs/react";
11import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
12import {
13 type ChatFileAttachment,
14 type ChatImageAttachment,
15 isImageAttachment,
16 isVideoAttachment,
17} from "../../types";
18import {
19 getRenderablePatch,
20 resolveDiffThemeName,
21 resolveFileDiffPath,
22} from "../../lib/diffRendering";
23import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
24import ChatMarkdown from "../ChatMarkdown";
25import { EnvironmentContextChip } from "../EnvironmentContextChip";
26import { ThreadContextChip } from "../ThreadContextChip";
27import { ResponseReplyCard } from "./AssistantCitationChip";
28import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon } from "lucide-react";
29import type { ComposerContextId, KnownComposerContextRecord } from "@t3tools/contracts";
30import { buildExpandedImagePreview } from "./ExpandedImagePreview";
31import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon";
32import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
33import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
34import {
35 ContextChipPopover as UserMessageContextPopover,
36 FileChip,
37 ImageChipButton,
38 PULL_REQUEST_CHIP_KINDS,
39 PullRequestChip,
40 UnresolvedChip,
41} from "../contextChipParts";
42import {
43 isPullRequestSummaryContext,
44 pullRequestContextDisplayState,
45 pullRequestContextKindLabel,
46 reviewCommentContextLabel,
47} from "~/lib/composerContextRecords";
48import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
49import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
50import { createContextPresentationRegistry } from "../contextPresentationRegistry";
51import type { ChatMarkdownContextReference } from "../ChatMarkdown";
52import {
53 SkillChipPopover,
54 skillInstructionsOpener,
55 skillPopoverDescription,
56} from "./SkillInlineText";
57import { formatWorkspaceRelativePath } from "../../filePathDisplay";
58import {
59 buildReviewCommentRenderablePatch,
60 formatReviewCommentFence,
61 type ReviewCommentContext,
62} from "../../reviewCommentContext";
63import { TimelineRowCtx } from "./timelineRowContext";
64import { userMessageFileLabel } from "./TimelineUserMessageRow.logic";
65
66function UserMessageMentionChip(props: {
67 record: Extract<KnownComposerContextRecord, { kind: "mention" }>;
68 copyMarkdown: string;
69}) {
70 const ctx = use(TimelineRowCtx);
71 return (
72 <Tooltip>
73 <TooltipTrigger
74 render={
75 <ContextChip
76 kind={documentChipKindForFileName(props.record.path) ?? "mention"}
77 render={<button type="button" />}
78 aria-label={`Preview ${props.record.path}`}
79 data-markdown-copy={props.copyMarkdown}
80 onClick={() => {
81 if (ctx.threadRef)
82 useRightPanelStore.getState().openFile(ctx.threadRef, props.record.path);
83 }}
84 >
85 <WorkspaceEntryIcon
86 path={props.record.path}
87 environmentId={ctx.activeThreadEnvironmentId}
88 cwd={ctx.workspaceRoot}
89 theme={ctx.resolvedTheme}
90 />
91 <ContextChipLabel>{props.record.label}</ContextChipLabel>
92 </ContextChip>
93 }
94 />
95 <TooltipPopup>
96 <WorkspaceEntryTooltip
97 path={props.record.path}
98 environmentId={ctx.activeThreadEnvironmentId}
99 cwd={ctx.workspaceRoot}
100 />
101 </TooltipPopup>
102 </Tooltip>
103 );
104}
105
106function UserMessageSkillChip(props: {
107 record: Extract<KnownComposerContextRecord, { kind: "skill" }>;
108 copyMarkdown: string;
109}) {
110 const { skills, threadRef } = use(TimelineRowCtx);
111 const skill = skills.find((candidate) => candidate.name === props.record.name);
112 return (
113 <SkillChipPopover
114 label={props.record.label || props.record.name}
115 description={skill ? skillPopoverDescription(skill) : null}
116 copyMarkdown={props.copyMarkdown}
117 onViewInstructions={skillInstructionsOpener(threadRef, skill)}
118 />
119 );
120}
121
122function UserMessagePullRequestContextChip(props: {
123 record: Extract<KnownComposerContextRecord, { kind: "review-comment" }>;
124 copyMarkdown: string;
125 kind: ContextChipKind;
126}) {
127 const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx);
128 const metadata = props.record.pullRequest;
129 if (metadata === undefined) return null;
130 return (
131 <PullRequestChip
132 metadata={metadata}
133 environmentId={activeThreadEnvironmentId}
134 label={reviewCommentContextLabel(props.record)}
135 kindLabel={pullRequestContextKindLabel(props.record)}
136 kind={props.kind}
137 copyMarkdown={props.copyMarkdown}
138 onOpen={openPullRequest}
139 />
140 );
141}
142
143function UserMessagePreviewAnnotationDetails(props: {
144 record: Extract<KnownComposerContextRecord, { kind: "preview-annotation" }>;
145 image: ChatImageAttachment | null;
146}) {
147 const ctx = use(TimelineRowCtx);
148 const visibleElements = props.record.elements ?? [];
149 return (
150 <div className="max-w-full overflow-hidden rounded-lg border border-border/70 bg-background/70">
151 {props.image?.previewUrl ? (
152 <button
153 type="button"
154 className="block max-h-64 w-full cursor-zoom-in overflow-hidden border-b border-border/70 bg-muted"
155 aria-label={`Preview ${props.image.name}`}
156 onClick={() => {
157 if (!props.image) return;
158 const preview = buildExpandedImagePreview([props.image], props.image.id);
159 if (preview) ctx.onImageExpand(preview);
160 }}
161 >
162 <img
163 src={props.image.previewUrl}
164 alt="Annotated preview crop"
165 className="max-h-64 w-full object-contain"
166 />
167 </button>
168 ) : (
169 <div className="border-b border-border/70 bg-muted/40 px-3 py-2 text-secondary-label text-xs">
170 Screenshot unavailable
171 </div>
172 )}
173 <div className="min-w-0 px-3 py-2.5">
174 <div className="text-message-foreground text-xs font-medium">
175 {props.record.pageTitle?.trim() || props.record.pageUrl || "Preview annotation"}
176 </div>
177 {props.record.comment ? (
178 <div className="mt-1 whitespace-pre-wrap wrap-break-word text-sm">
179 {props.record.comment}
180 </div>
181 ) : null}
182 <div className="mt-1 flex items-center gap-2 text-secondary-label text-3xs">
183 {props.record.targetSummary ? (
184 <span className="truncate">{props.record.targetSummary}</span>
185 ) : null}
186 {(props.record.styleChanges?.length ?? 0) > 0 ? (
187 <span className="inline-flex shrink-0 items-center gap-1">
188 <PaintbrushIcon className="size-3" />
189 {props.record.styleChanges?.length ?? 0}
190 </span>
191 ) : null}
192 </div>
193 {visibleElements.length > 0 ? (
194 <div className="mt-2 space-y-2 border-t border-border/60 pt-2">
195 {visibleElements.map((element) => {
196 const source = element.source;
197 const sourceLabel = source?.fileName
198 ? `${source.fileName}${source.lineNumber === null ? "" : `:${source.lineNumber}`}`
199 : null;
200 return (
201 <div
202 key={`${element.selector}\u0000${element.tagName}\u0000${sourceLabel ?? ""}\u0000${element.htmlPreview}`}
203 className="min-w-0"
204 >
205 <div className="flex min-w-0 items-center gap-2 text-xs">
206 <code className="truncate text-message-foreground">
207 {element.selector || `<${element.tagName}>`}
208 </code>
209 {sourceLabel ? (
210 <span className="ml-auto shrink-0 text-secondary-label">{sourceLabel}</span>
211 ) : null}
212 </div>
213 {element.htmlPreview?.trim() ? (
214 <pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap rounded bg-muted/60 px-2 py-1.5 text-3xs leading-relaxed">
215 {element.htmlPreview.trim()}
216 </pre>
217 ) : null}
218 </div>
219 );
220 })}
221 </div>
222 ) : null}
223 </div>
224 </div>
225 );
226}
227
228function UserMessageElementDetails({
229 record,
230}: {
231 record: Extract<KnownComposerContextRecord, { kind: "element" }>;
232}) {
233 const sourceLabel = record.source?.fileName
234 ? `${record.source.fileName}${record.source.lineNumber === null ? "" : `:${record.source.lineNumber}`}`
235 : null;
236 return (
237 <div className="max-w-full overflow-hidden rounded-lg border border-border/70 bg-background/70">
238 <div className="border-b border-border/70 px-3 py-2.5">
239 <div className="truncate text-message-foreground text-xs font-medium">
240 {record.pageTitle?.trim() || record.pageUrl}
241 </div>
242 <div className="mt-0.5 truncate text-secondary-label text-3xs">{record.pageUrl}</div>
243 </div>
244 <div className="space-y-2 px-3 py-2.5">
245 <div className="flex min-w-0 items-center gap-2 text-xs">
246 <code className="truncate text-message-foreground">
247 {record.selector || `<${record.tagName}>`}
248 </code>
249 {sourceLabel ? (
250 <span className="ml-auto shrink-0 text-secondary-label">{sourceLabel}</span>
251 ) : null}
252 </div>
253 {record.htmlPreview?.trim() ? (
254 <div className="flex h-40 flex-col overflow-hidden rounded border border-border">
255 <ReadOnlySourcePreview name="element.html" text={record.htmlPreview} />
256 </div>
257 ) : null}
258 {record.styles?.trim() ? (
259 <div className="flex h-32 flex-col overflow-hidden rounded border border-border">
260 <ReadOnlySourcePreview name="styles.css" text={record.styles} />
261 </div>
262 ) : null}
263 </div>
264 </div>
265 );
266}
267
268interface UserMessageContextRenderContext {
269 environmentId: import("@t3tools/contracts").EnvironmentId;
270 reference: ChatMarkdownContextReference;
271 annotationImage: ChatImageAttachment | null;
272 attachment: ChatImageAttachment | ChatFileAttachment | null;
273 resolvedTheme: "light" | "dark";
274 copyMarkdown: string;
275 onExpandImage: (image: ChatImageAttachment) => void;
276 onExpandVideo: (file: ChatFileAttachment) => void;
277 onOpenFile: (file: ChatFileAttachment) => void;
278}
279
280function UnavailableUserMessageContextChip(props: UserMessageContextRenderContext) {
281 return (
282 <UnresolvedChip
283 label={props.reference.label}
284 copyMarkdown={props.copyMarkdown}
285 tooltip="This context is no longer available."
286 />
287 );
288}
289
290const userMessageContextPresentationRegistry = createContextPresentationRegistry<
291 KnownComposerContextRecord,
292 UserMessageContextRenderContext,
293 ReactNode
294>({
295 requiredKinds: COMPOSER_CONTEXT_KINDS,
296 handlers: [
297 {
298 kind: "mention",
299 canRender: (record) => record.kind === "mention",
300 render: (record, context) =>
301 record.kind === "mention" ? (
302 <UserMessageMentionChip record={record} copyMarkdown={context.copyMarkdown} />
303 ) : (
304 <UnavailableUserMessageContextChip {...context} />
305 ),
306 },
307 {
308 kind: "skill",
309 canRender: (record) => record.kind === "skill",
310 render: (record, context) =>
311 record.kind === "skill" ? (
312 <UserMessageSkillChip record={record} copyMarkdown={context.copyMarkdown} />
313 ) : (
314 <UnavailableUserMessageContextChip {...context} />
315 ),
316 },
317 {
318 kind: "thread",
319 canRender: (record) => record.kind === "thread",
320 render: (record, context) =>
321 record.kind === "thread" ? (
322 <ThreadContextChip record={record} copyMarkdown={context.copyMarkdown} />
323 ) : (
324 <UnavailableUserMessageContextChip {...context} />
325 ),
326 },
327 {
328 kind: "response",
329 canRender: (record) => record.kind === "response",
330 render: (record, context) =>
331 record.kind === "response" ? (
332 <ResponseReplyCard
333 record={record}
334 copyMarkdown={context.copyMarkdown}
335 leading={context.reference.leading ?? false}
336 trailing={context.reference.trailing ?? false}
337 />
338 ) : (
339 <UnavailableUserMessageContextChip {...context} />
340 ),
341 },
342 {
343 kind: "environment",
344 canRender: (record) => record.kind === "environment",
345 render: (record, context) =>
346 record.kind === "environment" ? (
347 <EnvironmentContextChip record={record} copyMarkdown={context.copyMarkdown} />
348 ) : (
349 <UnavailableUserMessageContextChip {...context} />
350 ),
351 },
352 {
353 kind: "image",
354 canRender: (record, context) =>
355 record.kind === "image" &&
356 context.attachment !== null &&
357 isImageAttachment(context.attachment),
358 render: (record, context) => {
359 if (
360 record.kind !== "image" ||
361 context.attachment === null ||
362 !isImageAttachment(context.attachment)
363 ) {
364 return <UnavailableUserMessageContextChip {...context} />;
365 }
366 const attachment = context.attachment;
367 return (
368 <ImageChipButton
369 name={record.name}
370 previewUrl={attachment.previewUrl}
371 size={formatAttachmentSize(record.sizeBytes)}
372 data-markdown-copy={context.copyMarkdown}
373 onClick={() => context.onExpandImage(attachment)}
374 />
375 );
376 },
377 },
378 {
379 kind: "file",
380 canRender: (record, context) =>
381 record.kind === "file" && context.attachment !== null && context.attachment.type === "file",
382 render: (record, context) => {
383 if (
384 record.kind !== "file" ||
385 context.attachment === null ||
386 context.attachment.type !== "file"
387 ) {
388 return <UnavailableUserMessageContextChip {...context} />;
389 }
390 const attachment = context.attachment;
391 const isVideo = isVideoAttachment(attachment);
392 const label = userMessageFileLabel(attachment);
393 const disabled =
394 attachment.downloadable === false && (!isVideo || attachment.previewUrl === undefined);
395 const size =
396 attachment.downloadable === false ? "Sending…" : formatAttachmentSize(record.sizeBytes);
397 return (
398 <FileChip
399 name={label}
400 svgPreview={
401 isSvgAttachment(attachment)
402 ? { environmentId: context.environmentId, attachmentId: attachment.id }
403 : undefined
404 }
405 size={size}
406 isVideo={isVideo}
407 theme={context.resolvedTheme}
408 disabled={disabled}
409 accessibleLabel={`${isVideo ? "Video" : "File"} attachment, ${label}, ${size}`}
410 copyMarkdown={context.copyMarkdown}
411 onOpen={() =>
412 isVideo ? context.onExpandVideo(attachment) : context.onOpenFile(attachment)
413 }
414 tooltip={`${label}\n${size}`}
415 />
416 );
417 },
418 },
419 {
420 kind: "terminal",
421 canRender: (record) => record.kind === "terminal",
422 render: (record, context, definition) =>
423 record.kind === "terminal" ? (
424 <span data-markdown-copy={context.copyMarkdown}>
425 <TerminalContextInlineChip
426 label={record.label}
427 terminalLabel={record.terminalLabel}
428 lineStart={record.lineStart}
429 lineEnd={record.lineEnd}
430 text={record.text}
431 detailsMode={definition.capabilities.details}
432 />
433 </span>
434 ) : (
435 <UnavailableUserMessageContextChip {...context} />
436 ),
437 },
438 {
439 kind: "element",
440 canRender: (record) => record.kind === "element",
441 render: (record, context) =>
442 record.kind === "element" ? (
443 <UserMessageContextPopover
444 copyMarkdown={context.copyMarkdown}
445 accessibleLabel={`Browser element, ${record.label}`}
446 kind="element"
447 icon={<MousePointerClickIcon />}
448 label={record.label}
449 >
450 <UserMessageElementDetails record={record} />
451 </UserMessageContextPopover>
452 ) : (
453 <UnavailableUserMessageContextChip {...context} />
454 ),
455 },
456 {
457 kind: "review-comment",
458 canRender: (record) => record.kind === "review-comment",
459 render: (record, context) => {
460 if (record.kind !== "review-comment") {
461 return <UnavailableUserMessageContextChip {...context} />;
462 }
463 const isPullRequest = isPullRequestSummaryContext(record);
464 const label = reviewCommentContextLabel(record);
465 const kindLabel = isPullRequest ? pullRequestContextKindLabel(record) : "Review comment";
466 const pullRequestState = pullRequestContextDisplayState(record) ?? "unknown";
467 if (isPullRequest && record.pullRequest !== undefined) {
468 return (
469 <UserMessagePullRequestContextChip
470 record={record}
471 copyMarkdown={context.copyMarkdown}
472 kind={PULL_REQUEST_CHIP_KINDS[pullRequestState]}
473 />
474 );
475 }
476 return (
477 <UserMessageContextPopover
478 copyMarkdown={context.copyMarkdown}
479 accessibleLabel={`${kindLabel}, ${label}${record.pullRequest ? `, ${record.pullRequest.title}` : ""}`}
480 kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
481 icon={isPullRequest ? <PullRequestGlyph.pullRequest /> : <MessageCircleIcon />}
482 label={label}
483 >
484 <UserMessageReviewCommentCard
485 comment={{
486 id: record.contextId,
487 sectionId: record.sectionId,
488 sectionTitle: record.sectionTitle,
489 filePath: record.filePath,
490 startIndex: record.startIndex,
491 endIndex: record.endIndex,
492 rangeLabel: record.rangeLabel,
493 text: record.text,
494 diff: record.diff,
495 ...(record.fenceLanguage !== undefined
496 ? { fenceLanguage: record.fenceLanguage }
497 : {}),
498 ...(record.pullRequest !== undefined ? { pullRequest: record.pullRequest } : {}),
499 }}
500 />
501 </UserMessageContextPopover>
502 );
503 },
504 },
505 {
506 kind: "preview-annotation",
507 canRender: (record) => record.kind === "preview-annotation",
508 render: (record, context) =>
509 record.kind === "preview-annotation" ? (
510 <UserMessageContextPopover
511 copyMarkdown={context.copyMarkdown}
512 accessibleLabel={`Preview annotation, ${record.label}`}
513 kind="preview-annotation"
514 icon={<MousePointerClickIcon />}
515 label={record.label}
516 >
517 <UserMessagePreviewAnnotationDetails record={record} image={context.annotationImage} />
518 </UserMessageContextPopover>
519 ) : (
520 <UnavailableUserMessageContextChip {...context} />
521 ),
522 },
523 ],
524 fallback: (_kind, _record, context) => <UnavailableUserMessageContextChip {...context} />,
525});
526
527export function UserMessageContextReferenceChip(props: {
528 reference: ChatMarkdownContextReference;
529 record: KnownComposerContextRecord | undefined;
530 annotationImage: ChatImageAttachment | null;
531 attachment: ChatImageAttachment | ChatFileAttachment | null;
532 onExpandImage: (image: ChatImageAttachment) => void;
533 onExpandVideo: (file: ChatFileAttachment) => void;
534 onOpenFile: (file: ChatFileAttachment) => void;
535}) {
536 const { resolvedTheme, activeThreadEnvironmentId } = use(TimelineRowCtx);
537 const copyMarkdown = formatComposerContextReference({
538 kind: props.reference.kind,
539 contextId: props.reference.contextId as ComposerContextId,
540 label: props.reference.label,
541 });
542 return userMessageContextPresentationRegistry.render(props.reference.kind, props.record, {
543 environmentId: activeThreadEnvironmentId,
544 reference: props.reference,
545 annotationImage: props.annotationImage,
546 attachment: props.attachment,
547 resolvedTheme,
548 copyMarkdown,
549 onExpandImage: props.onExpandImage,
550 onExpandVideo: props.onExpandVideo,
551 onOpenFile: props.onOpenFile,
552 });
553}
554
555function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) {
556 const renderedAppearance = useRenderedAppearance();
557 const ctx = use(TimelineRowCtx);
558 const fenceLanguage = comment.fenceLanguage ?? "diff";
559 const renderablePatch = getRenderablePatch(
560 buildReviewCommentRenderablePatch(comment),
561 `review-comment:${comment.id}`,
562 );
563
564 return (
565 <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3">
566 <div className="space-y-1">
567 <div className="text-xs font-medium text-foreground">
568 {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
569 </div>
570 <div className="text-2xs text-muted-foreground">
571 {comment.sectionTitle} · {comment.rangeLabel}
572 </div>
573 </div>
574 {comment.text.length > 0 && (
575 <div className="wrap-break-word text-sm">
576 <ChatMarkdown
577 text={comment.text}
578 cwd={ctx.markdownCwd}
579 threadRef={ctx.threadRef ?? undefined}
580 skills={ctx.skills}
581 parseRawHtml={false}
582 lineBreaks
583 />
584 </div>
585 )}
586 {fenceLanguage !== "diff" && comment.diff.trim().length > 0 && (
587 <ChatMarkdown
588 text={formatReviewCommentFence(fenceLanguage, comment.diff)}
589 cwd={ctx.markdownCwd}
590 threadRef={ctx.threadRef ?? undefined}
591 skills={ctx.skills}
592 className="text-foreground"
593 />
594 )}
595 {renderablePatch?.kind === "files" && (
596 <DiffWorkerPoolProvider>
597 {renderablePatch.files.map((fileDiff) => (
598 <FileDiff
599 key={resolveFileDiffPath(fileDiff)}
600 fileDiff={fileDiff}
601 options={{
602 collapsed: false,
603 diffStyle: "unified",
604 theme: resolveDiffThemeName(renderedAppearance),
605 preferredHighlighter: PREFERRED_HIGHLIGHTER,
606 }}
607 />
608 ))}
609 </DiffWorkerPoolProvider>
610 )}
611 {renderablePatch?.kind === "raw" && (
612 <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs">
613 {renderablePatch.text}
614 </pre>
615 )}
616 </div>
617 );
618}