diff --git a/apps/web/src/components/app/chat/chat-attachment-views.tsx b/apps/web/src/components/app/chat/chat-attachment-views.tsx new file mode 100644 index 00000000..40cd1a9e --- /dev/null +++ b/apps/web/src/components/app/chat/chat-attachment-views.tsx @@ -0,0 +1,301 @@ +/** + * The blocks a chat post hangs under itself: files, links, PRs, code and + * pins. Presentational leaves — each takes an attachment and renders it; + * none of them reads the feed beyond the three fields `AttachmentCtx` + * names. Split out of chat-entries.tsx, which composes them into posts. + */ +import { type ReactNode } from "react"; +import type { ChatAttachment } from "@dispatch/shared"; +import { ExternalLink, FileText, GitPullRequest } from "lucide-react"; + +import type { FeedContext } from "@/components/app/chat/chat-entries"; +import { FeedImage } from "@/components/app/chat/feed-image"; +import { usePinShortcuts } from "@/components/app/chat/pin-shortcut-context"; +import { PinItem } from "@/components/app/pin-item"; +import { formatBytes } from "@/components/app/service-resources-format"; +import { Markdown } from "@/components/ui/markdown"; +import { cn } from "@/lib/utils"; + +import { isImageFile } from "../../../../../server/src/shared/media-file-types"; + +/** What the attachment views read off the feed they are rendered in. */ +type AttachmentCtx = Pick; + +/** The URL a media file is served from. */ +export function mediaFileUrl(agentId: string, fileName: string): string { + return `/api/v1/agents/${agentId}/media/${encodeURIComponent(fileName)}`; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return ""; + } +} + +/** The left-accented block Slack hangs under a post. */ +export function AttachmentBlock({ + children, + className, + accent = "border-border", + ...rest +}: { + children: ReactNode; + className?: string; + accent?: string; + [dataAttr: `data-${string}`]: string | undefined; +}): JSX.Element { + return ( +
+ {children} +
+ ); +} + +function LinkAttachment({ + href, + title, + icon, + testId, +}: { + href: string; + title: string | undefined; + icon: JSX.Element; + testId: string; +}): JSX.Element { + const host = hostOf(href); + return ( + + + {icon} + + + {title ?? href} + + {title && host ? ( + + {host} + + ) : null} + + + + ); +} + +function FileAttachment({ + attachment, + ctx, +}: { + attachment: Extract; + ctx: AttachmentCtx; +}): JSX.Element { + const url = mediaFileUrl(ctx.agentId, attachment.fileName); + const open = () => ctx.onOpenMedia(attachment.mediaId); + // By stored name or by the media row's type: a file shared without an + // extension still renders as the image it is. + const isImage = + isImageFile(attachment.fileName) || + (attachment.mimeType?.startsWith("image/") ?? false); + if (isImage) { + return ( + +
+ {attachment.fileName} · {formatBytes(attachment.sizeBytes)} +
+ +
+ ); + } + return ( + + + + ); +} + +function CodeAttachment({ + attachment, +}: { + attachment: Extract; +}): JSX.Element { + const fence = "```"; + const source = `${fence}${attachment.language ?? ""}\n${attachment.code}\n${fence}`; + return ( + + {attachment.path ? ( +
+ {attachment.path} +
+ ) : null} + {source} +
+ ); +} + +/** + * A pin rendered live from the agent's current pins — the sidebar's own + * `PinItem`, so the stream and the sidebar never disagree, and a shortcut + * fires from either place. `label` names a pin that is no longer there. + */ +export function LivePin({ + pinId, + label, + ctx, + testId, +}: { + pinId: string; + label?: string; + ctx: AttachmentCtx; + testId: string; +}): JSX.Element { + const shortcuts = usePinShortcuts(); + const pin = shortcuts.pins.find((p) => p.id === pinId); + if (!pin) { + return ( + + {label ? ( + <> + {label} · pin no + longer available + + ) : ( + "Pin no longer available" + )} + + ); + } + // A card rather than the accent bar the other attachments use: a pin's + // copy button sits at the right edge of its own box, and without a drawn + // edge that box is invisible — the button reads as floating somewhere + // short of where the post's copy action lives. A shortcut is already a + // button, so it gets no card; it is a sidebar-width button (w-full) that + // in the channel's wide measure would stretch into a banner, so here it + // hugs its label up to a cap instead. + return ( +
+ +
+ ); +} + +function PinAttachment({ + attachment, + ctx, +}: { + attachment: Extract; + ctx: AttachmentCtx; +}): JSX.Element { + return ( + + ); +} + +function AttachmentView({ + attachment, + ctx, +}: { + attachment: ChatAttachment; + ctx: AttachmentCtx; +}): JSX.Element { + switch (attachment.type) { + case "file": + return ; + case "link": + return ( + } + testId="chat-attachment-link" + /> + ); + case "pr": + return ( + } + testId="chat-attachment-pr" + /> + ); + case "code": + return ; + case "pin": + return ; + } +} + +export function AttachmentList({ + attachments, + ctx, +}: { + attachments: ChatAttachment[]; + ctx: AttachmentCtx; +}): JSX.Element | null { + if (attachments.length === 0) return null; + return ( +
+ {attachments.map((attachment, index) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/app/chat/chat-entries.tsx b/apps/web/src/components/app/chat/chat-entries.tsx index 9de6eb37..61692164 100644 --- a/apps/web/src/components/app/chat/chat-entries.tsx +++ b/apps/web/src/components/app/chat/chat-entries.tsx @@ -1,7 +1,6 @@ import { memo, type ReactNode } from "react"; import type { ChatAgentMessageEntry, - ChatAttachment, ChatMediaEntry, ChatMessage, ChatPinEntry, @@ -14,9 +13,6 @@ import { ArrowLeftRight, Check, Copy, - ExternalLink, - FileText, - GitPullRequest, Hourglass, Loader2, Pin, @@ -31,8 +27,6 @@ import { import { AgentRelationBadge } from "@/components/app/agent-relation-badge"; import { AgentTypeIcon } from "@/components/app/agent-type-icon"; import { FeedImage } from "@/components/app/chat/feed-image"; -import { usePinShortcuts } from "@/components/app/chat/pin-shortcut-context"; -import { PinItem } from "@/components/app/pin-item"; import { reviewerLabel, ReviewSummaryBlock, @@ -47,6 +41,12 @@ import { formatDateTime } from "@/lib/format"; import { cn } from "@/lib/utils"; import { isImageFile } from "../../../../../server/src/shared/media-file-types"; +import { + AttachmentBlock, + AttachmentList, + LivePin, + mediaFileUrl, +} from "./chat-attachment-views"; type EventType = Parameters[0]; @@ -62,10 +62,6 @@ function asEventType(type: string): EventType { return (EVENT_TYPES.includes(type) ? type : "idle") as EventType; } -function mediaFileUrl(agentId: string, fileName: string): string { - return `/api/v1/agents/${agentId}/media/${encodeURIComponent(fileName)}`; -} - /** "10:04 AM" — the wall-clock time a channel shows next to a post. */ function clockTime(iso: string): string { const time = new Date(iso); @@ -81,14 +77,6 @@ function gutterTime(iso: string): string { return clockTime(iso).replace(/\s?[AP]M$/i, ""); } -function hostOf(url: string): string { - try { - return new URL(url).host; - } catch { - return ""; - } -} - // --------------------------------------------------------------------------- // Authors and the post layout // --------------------------------------------------------------------------- @@ -476,276 +464,6 @@ export function DayDivider({ label }: { label: string }): JSX.Element { ); } -// --------------------------------------------------------------------------- -// Attachments -// --------------------------------------------------------------------------- - -/** The left-accented block Slack hangs under a post. */ -function AttachmentBlock({ - children, - className, - accent = "border-border", - ...rest -}: { - children: ReactNode; - className?: string; - accent?: string; - [dataAttr: `data-${string}`]: string | undefined; -}): JSX.Element { - return ( -
- {children} -
- ); -} - -function LinkAttachment({ - href, - title, - icon, - testId, -}: { - href: string; - title: string | undefined; - icon: JSX.Element; - testId: string; -}): JSX.Element { - const host = hostOf(href); - return ( - - - {icon} - - - {title ?? href} - - {title && host ? ( - - {host} - - ) : null} - - - - ); -} - -function FileAttachment({ - attachment, - ctx, -}: { - attachment: Extract; - ctx: FeedContext; -}): JSX.Element { - const url = mediaFileUrl(ctx.agentId, attachment.fileName); - const open = () => ctx.onOpenMedia(attachment.mediaId); - // By stored name or by the media row's type: a file shared without an - // extension still renders as the image it is. - const isImage = - isImageFile(attachment.fileName) || - (attachment.mimeType?.startsWith("image/") ?? false); - if (isImage) { - return ( - -
- {attachment.fileName} · {formatBytes(attachment.sizeBytes)} -
- -
- ); - } - return ( - - - - ); -} - -function CodeAttachment({ - attachment, -}: { - attachment: Extract; -}): JSX.Element { - const fence = "```"; - const source = `${fence}${attachment.language ?? ""}\n${attachment.code}\n${fence}`; - return ( - - {attachment.path ? ( -
- {attachment.path} -
- ) : null} - {source} -
- ); -} - -/** - * A pin rendered live from the agent's current pins — the sidebar's own - * `PinItem`, so the stream and the sidebar never disagree, and a shortcut - * fires from either place. `label` names a pin that is no longer there. - */ -function LivePin({ - pinId, - label, - ctx, - testId, -}: { - pinId: string; - label?: string; - ctx: FeedContext; - testId: string; -}): JSX.Element { - const shortcuts = usePinShortcuts(); - const pin = shortcuts.pins.find((p) => p.id === pinId); - if (!pin) { - return ( - - {label ? ( - <> - {label} · pin no - longer available - - ) : ( - "Pin no longer available" - )} - - ); - } - // A card rather than the accent bar the other attachments use: a pin's - // copy button sits at the right edge of its own box, and without a drawn - // edge that box is invisible — the button reads as floating somewhere - // short of where the post's copy action lives. A shortcut is already a - // button, so it gets no card; it is a sidebar-width button (w-full) that - // in the channel's wide measure would stretch into a banner, so here it - // hugs its label up to a cap instead. - return ( -
- -
- ); -} - -function PinAttachment({ - attachment, - ctx, -}: { - attachment: Extract; - ctx: FeedContext; -}): JSX.Element { - return ( - - ); -} - -function AttachmentView({ - attachment, - ctx, -}: { - attachment: ChatAttachment; - ctx: FeedContext; -}): JSX.Element { - switch (attachment.type) { - case "file": - return ; - case "link": - return ( - } - testId="chat-attachment-link" - /> - ); - case "pr": - return ( - } - testId="chat-attachment-pr" - /> - ); - case "code": - return ; - case "pin": - return ; - } -} - -function AttachmentList({ - attachments, - ctx, -}: { - attachments: ChatAttachment[]; - ctx: FeedContext; -}): JSX.Element | null { - if (attachments.length === 0) return null; - return ( -
- {attachments.map((attachment, index) => ( - - ))} -
- ); -} - // --------------------------------------------------------------------------- // Questions // ---------------------------------------------------------------------------