| 1 | import ChatMarkdown from "./ChatMarkdown"; |
| 2 | import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview"; |
| 3 | import type { PreviewAnnotationPayload, ThreadContextRecord } from "@t3tools/contracts"; |
| 4 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 5 | import { videoMimeType } from "@t3tools/shared/video"; |
| 6 | import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react"; |
| 7 | import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react"; |
| 8 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 9 | |
| 10 | import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore"; |
| 11 | import { composerFileNeedsReattach } from "~/composerDraftStore"; |
| 12 | import { useTheme } from "~/hooks/useTheme"; |
| 13 | import { |
| 14 | formatAttachmentUploadProgress, |
| 15 | type AttachmentUploadState, |
| 16 | } from "~/lib/attachmentUploadState"; |
| 17 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 18 | import { |
| 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"; |
| 31 | import type { TerminalContextDraft } from "~/lib/terminalContext"; |
| 32 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 33 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; |
| 34 | import { ThreadContextChip } from "./ThreadContextChip"; |
| 35 | import { |
| 36 | createContextPresentationRegistry, |
| 37 | type ContextPresentationCapability, |
| 38 | } from "./contextPresentationRegistry"; |
| 39 | import type { ContextChipKind } from "./ContextChip"; |
| 40 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 41 | import { |
| 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 | */ |
| 55 | export 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. */ |
| 69 | export 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 | |
| 79 | export const ComposerContextActionsContext = createContext<ComposerContextActions>({ |
| 80 | environmentId: null, |
| 81 | expandImage: () => {}, |
| 82 | expandVideo: () => {}, |
| 83 | openFile: () => {}, |
| 84 | openMention: () => {}, |
| 85 | openPullRequest: () => {}, |
| 86 | }); |
| 87 | |
| 88 | export type ComposerDraftContextRecords = ReadonlyMap<string, ComposerDraftContextRecord>; |
| 89 | |
| 90 | export const EMPTY_COMPOSER_CONTEXT_RECORDS: ComposerDraftContextRecords = new Map(); |
| 91 | |
| 92 | export function uploadedContextRecordFromDraft(entry: ComposerDraftContextRecord) { |
| 93 | if (entry.kind !== "image" && entry.kind !== "file") return null; |
| 94 | return uploadedAttachmentContextRecord(entry.record, entry.upload); |
| 95 | } |
| 96 | |
| 97 | export const ComposerContextRecordsContext = createContext<ComposerDraftContextRecords>( |
| 98 | EMPTY_COMPOSER_CONTEXT_RECORDS, |
| 99 | ); |
| 100 | |
| 101 | export 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 | |
| 145 | function 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 | |
| 178 | function 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 | |
| 184 | function 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 | |
| 193 | function 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 | |
| 218 | function 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 | |
| 253 | function 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 | |
| 269 | function 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 | |
| 284 | function 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 | |
| 303 | function 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 | |
| 333 | function 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 | |
| 342 | interface ComposerContextRenderContext { |
| 343 | label: string; |
| 344 | } |
| 345 | |
| 346 | const 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. */ |
| 452 | export 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 | } |