mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): display file attachments in chat UI (#24281)
Renders the durable file attachments introduced in #24280 in the chat interface. Without this, attachments were stored and served correctly but the UI showed raw file parts with no previews or download UX. Every attachment gets a download affordance, split into three rendering tiers: - **Images** — thumbnail with a hover/focus overlay containing a download link. `onFocusCapture`/`onBlurCapture` with `contains(relatedTarget)` keeps the overlay open while tabbing between the image and its download link. - **Text-like files** (`text/*`, `application/json`) — expandable preview button with loading + error-with-retry states and the same download overlay. Preview fetches throw a typed `FetchTextAttachmentError` with a `.status` field instead of a stringly-typed error. - **Everything else** — compact `FileCard` with extension badge, filename, and download link. User-side and assistant-side rendering now share `AttachmentBlocks.tsx` (`AttachmentPreviewFrame`, `TextAttachmentButton`, `ImageAttachmentButton`, `FileCard`, plus `getAttachmentHref`/`getAttachmentName`) instead of two near-duplicate implementations. The text-attachment overlay anchors to the preview surface so the download button stays pinned even when a loading/error status line widens the row below. `ComputerRenderer` detects when a screenshot was stored as a durable attachment (`attachment_file_id`) and suppresses the stale base64 rendering — the screenshot appears as a proper file part instead. `ToolLabel` shows the attached filename for `attach_file` tool calls. Storybook coverage in `ConversationTimeline.stories.tsx` was expanded to cover every tier (single/multiple images, inline + file-id text, JSON, download-only files, fetch-failure retry, mixed attachments + file references) with play-function assertions. <img width="811" height="150" alt="image" src="https://github.com/user-attachments/assets/27c71081-3502-4e80-92a7-d8adf1ff9323" /> ## Cleanup Per Mathias' post-merge suggestion on #24280, this PR also relocates `coderd/chatfiles` → `coderd/x/chatfiles` so the durable-attachment helpers live beside the rest of the `chatd` experimental surface. Closes CODAGT-91
This commit is contained in:
@@ -164,7 +164,7 @@ export const TextAttachment: Story = {
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const textCard = await canvas.findByRole("button", {
|
||||
name: "View text attachment",
|
||||
name: "View clipboard.txt",
|
||||
});
|
||||
expect(textCard).toHaveTextContent(/This is the pasted text content\./i);
|
||||
await userEvent.click(textCard);
|
||||
@@ -207,7 +207,7 @@ export const ThreeTextAttachments: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await canvas.findAllByRole("button", { name: "View text attachment" }),
|
||||
await canvas.findAllByRole("button", { name: /View paste-[1-3]\.txt/ }),
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
canvas.getByText(
|
||||
@@ -269,7 +269,7 @@ export const MixedImageAndText: Story = {
|
||||
await canvas.findByRole("img", { name: "photo.png" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByRole("button", { name: "View text attachment" }),
|
||||
canvas.getByRole("button", { name: "View clipboard.txt" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertTriangleIcon, ClipboardPasteIcon, XIcon } from "lucide-react";
|
||||
import type { FC, ReactEventHandler } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -12,6 +13,7 @@ import { isAbortError } from "../utils/chatAttachments";
|
||||
import {
|
||||
fetchTextAttachmentContent,
|
||||
formatTextAttachmentPreview,
|
||||
getTextAttachmentErrorMessage,
|
||||
} from "../utils/fetchTextAttachment";
|
||||
|
||||
export type UploadState = {
|
||||
@@ -82,7 +84,10 @@ export const AttachmentPreview: FC<{
|
||||
if (result.kind === "loaded") {
|
||||
return result.content;
|
||||
}
|
||||
console.warn("Failed to load text attachment:", result);
|
||||
const resultMessage = getTextAttachmentErrorMessage(result);
|
||||
if (resultMessage !== null) {
|
||||
toast.error(resultMessage);
|
||||
}
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
if (!textAttachmentRequest.clear(controller)) {
|
||||
@@ -91,7 +96,12 @@ export const AttachmentPreview: FC<{
|
||||
if (isAbortError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
console.warn("Failed to load text attachment:", err);
|
||||
const errorMessage = getTextAttachmentErrorMessage(err);
|
||||
if (errorMessage === null) {
|
||||
return undefined;
|
||||
}
|
||||
console.error("Failed to load text attachment:", err);
|
||||
toast.error(errorMessage);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -125,7 +135,7 @@ export const AttachmentPreview: FC<{
|
||||
) : hasTextAttachment ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View text attachment"
|
||||
aria-label={`View ${file.name}`}
|
||||
className="flex h-16 w-28 flex-col items-start justify-start overflow-hidden rounded-md border-0 bg-surface-tertiary p-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
|
||||
onClick={async () => {
|
||||
const nextContent = await loadTextAttachmentContent(
|
||||
@@ -159,7 +169,10 @@ export const AttachmentPreview: FC<{
|
||||
className="absolute -bottom-2 -right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
|
||||
aria-label="Paste inline"
|
||||
>
|
||||
<ClipboardPasteIcon className="h-3.5 w-3.5" />
|
||||
<ClipboardPasteIcon
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{uploadState?.status === "uploading" && (
|
||||
@@ -191,7 +204,7 @@ export const AttachmentPreview: FC<{
|
||||
className="absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
<XIcon aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
DownloadIcon,
|
||||
FileIcon,
|
||||
FileTextIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { useLatestAbortController } from "../../hooks/useLatestAbortController";
|
||||
import {
|
||||
type AttachmentFailure,
|
||||
attachmentFailureFromError,
|
||||
getChatFileURL,
|
||||
isAbortError,
|
||||
probeAttachmentFailure,
|
||||
} from "../../utils/chatAttachments";
|
||||
import {
|
||||
decodeInlineTextAttachment,
|
||||
fetchTextAttachmentContent,
|
||||
formatTextAttachmentPreview,
|
||||
} from "../../utils/fetchTextAttachment";
|
||||
import { ImageThumbnail } from "../AgentChatInput";
|
||||
import { useExpiredFileIds } from "./ExpiredFileIdsContext";
|
||||
import type { RenderBlock } from "./types";
|
||||
|
||||
export type PreviewTextAttachment = {
|
||||
content: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
type FileAttachmentBlock = Extract<RenderBlock, { type: "file" }>;
|
||||
|
||||
const TEXT_ATTACHMENT_MEDIA_TYPES = new Set([
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
]);
|
||||
|
||||
const ATTACHMENT_FALLBACK_EXTENSIONS: Record<string, string> = {
|
||||
"application/json": "json",
|
||||
"application/octet-stream": "bin",
|
||||
"application/pdf": "pdf",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
"pptx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
"docx",
|
||||
"application/x-tar": "tar",
|
||||
"image/jpeg": "jpg",
|
||||
"text/markdown": "md",
|
||||
"text/plain": "txt",
|
||||
};
|
||||
|
||||
const sanitizeAttachmentExtension = (value: string): string => {
|
||||
const sanitized = value
|
||||
.replace(/[^a-z0-9]/gi, "")
|
||||
.slice(0, 4)
|
||||
.toLowerCase();
|
||||
return sanitized || "file";
|
||||
};
|
||||
|
||||
const getAttachmentExtension = (
|
||||
block: Pick<FileAttachmentBlock, "media_type" | "name">,
|
||||
): string => {
|
||||
const mapped = ATTACHMENT_FALLBACK_EXTENSIONS[block.media_type];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
const trimmedName = block.name?.trim();
|
||||
if (trimmedName) {
|
||||
const lastDot = trimmedName.lastIndexOf(".");
|
||||
// Keep dotfiles like `.env` out of the extension path, while still
|
||||
// allowing ordinary `name.ext` filenames to contribute a fallback.
|
||||
if (lastDot > 0 && lastDot < trimmedName.length - 1) {
|
||||
return sanitizeAttachmentExtension(trimmedName.slice(lastDot + 1));
|
||||
}
|
||||
}
|
||||
const subtype = block.media_type.split("/")[1] ?? "";
|
||||
if (subtype.endsWith("+json")) {
|
||||
return "json";
|
||||
}
|
||||
return sanitizeAttachmentExtension(subtype);
|
||||
};
|
||||
|
||||
const isTextPreviewAttachmentMediaType = (mediaType: string): boolean =>
|
||||
TEXT_ATTACHMENT_MEDIA_TYPES.has(mediaType);
|
||||
|
||||
const getAttachmentHref = (block: FileAttachmentBlock): string | null => {
|
||||
if (block.file_id) {
|
||||
return getChatFileURL(block.file_id);
|
||||
}
|
||||
if (block.data) {
|
||||
return `data:${block.media_type};base64,${block.data}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getAttachmentDisplayName = (
|
||||
block: Pick<FileAttachmentBlock, "media_type" | "name">,
|
||||
): string => {
|
||||
const name = block.name?.trim();
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (block.media_type.startsWith("image/")) {
|
||||
return "Attached image";
|
||||
}
|
||||
if (isTextPreviewAttachmentMediaType(block.media_type)) {
|
||||
return "Pasted text";
|
||||
}
|
||||
return "Attached file";
|
||||
};
|
||||
|
||||
const getAttachmentDownloadName = (
|
||||
block: Pick<FileAttachmentBlock, "media_type" | "name">,
|
||||
): string => {
|
||||
const name = block.name?.trim();
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
const extension = getAttachmentExtension(block);
|
||||
return extension === "file" ? "attachment" : `attachment.${extension}`;
|
||||
};
|
||||
|
||||
const getAttachmentBadgeLabel = (
|
||||
block: Pick<FileAttachmentBlock, "media_type" | "name">,
|
||||
): string => {
|
||||
const extension = getAttachmentExtension(block);
|
||||
return extension === "file" ? "" : extension.toUpperCase();
|
||||
};
|
||||
|
||||
const DownloadOverlay: FC<{
|
||||
href: string;
|
||||
displayName: string;
|
||||
downloadName: string;
|
||||
}> = ({ href, displayName, downloadName }) => (
|
||||
<a
|
||||
href={href}
|
||||
download={downloadName}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={`Download ${displayName}`}
|
||||
className="invisible absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded bg-surface-primary/80 text-content-secondary opacity-0 shadow-sm backdrop-blur-sm transition-opacity hover:text-content-primary group-hover/attachment:visible group-hover/attachment:opacity-100 group-focus-within/attachment:visible group-focus-within/attachment:opacity-100 [@media(hover:none)]:visible [@media(hover:none)]:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
);
|
||||
|
||||
const AttachmentPreviewFrame: FC<{
|
||||
href: string | null;
|
||||
displayName: string;
|
||||
downloadName: string;
|
||||
children: ReactNode;
|
||||
}> = ({ href, displayName, downloadName, children }) => {
|
||||
return (
|
||||
<div className="group/attachment relative inline-flex flex-col items-start">
|
||||
{children}
|
||||
{href ? (
|
||||
<DownloadOverlay
|
||||
href={href}
|
||||
displayName={displayName}
|
||||
downloadName={downloadName}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type AttachmentFailureState = { kind: "idle" } | AttachmentFailure;
|
||||
|
||||
type AttachmentFailureLabels = {
|
||||
expired: string;
|
||||
failed: string;
|
||||
};
|
||||
|
||||
const imageAttachmentFailureLabels: AttachmentFailureLabels = {
|
||||
expired: "Image expired",
|
||||
failed: "Image failed to load",
|
||||
};
|
||||
|
||||
const textAttachmentFailureLabels: AttachmentFailureLabels = {
|
||||
expired: "Attachment expired",
|
||||
failed: "Attachment failed to load",
|
||||
};
|
||||
|
||||
const AttachmentFallbackTile: FC<{
|
||||
state: AttachmentFailure;
|
||||
labels: AttachmentFailureLabels;
|
||||
className?: string;
|
||||
}> = ({ state, labels, className = "h-16 w-16" }) => {
|
||||
const label = state.kind === "expired" ? labels.expired : labels.failed;
|
||||
|
||||
const tile = (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 rounded-md border border-border-default bg-surface-tertiary px-1 text-center text-2xs text-content-secondary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertTriangleIcon
|
||||
className="size-icon-sm shrink-0 text-content-warning"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="leading-tight">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only surface a tooltip when we have something to add:
|
||||
// - "expired" explains the retention policy.
|
||||
// - "failed" with a detail surfaces the API error or network reason.
|
||||
// A bare "failed" (e.g. an inline base64 decode failure, where the
|
||||
// browser exposes nothing useful) stays a plain tile.
|
||||
const tooltipBody =
|
||||
state.kind === "expired"
|
||||
? "Chat attachments are deleted after the retention window set for this deployment."
|
||||
: state.detail;
|
||||
if (!tooltipBody) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{tile}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{tooltipBody}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineTextAttachmentButton: FC<{
|
||||
content: string;
|
||||
fileName?: string;
|
||||
onPreview?: (attachment: PreviewTextAttachment) => void | Promise<void>;
|
||||
isPlaceholder?: boolean;
|
||||
icon?: ReactNode;
|
||||
}> = ({ content, fileName, onPreview, isPlaceholder, icon }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
fileName && fileName !== "Pasted text"
|
||||
? `View ${fileName}`
|
||||
: "View text attachment"
|
||||
}
|
||||
className="inline-flex h-16 max-w-sm items-center gap-2 rounded-md border-0 bg-surface-tertiary px-3 py-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onPreview?.({ content, fileName });
|
||||
}}
|
||||
>
|
||||
{icon ?? (
|
||||
<FileTextIcon
|
||||
aria-hidden="true"
|
||||
className="size-icon-sm shrink-0 text-content-secondary"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-2 min-w-0 text-content-secondary",
|
||||
isPlaceholder ? "text-sm" : "font-mono text-xs",
|
||||
)}
|
||||
>
|
||||
{isPlaceholder ? content : formatTextAttachmentPreview(content)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const RemoteTextAttachmentButton: FC<{
|
||||
fileId: string;
|
||||
fileName?: string;
|
||||
frameHref?: string | null;
|
||||
downloadName: string;
|
||||
onPreview?: (attachment: PreviewTextAttachment) => void | Promise<void>;
|
||||
showStatus?: boolean;
|
||||
}> = ({
|
||||
fileId,
|
||||
fileName,
|
||||
frameHref,
|
||||
downloadName,
|
||||
onPreview,
|
||||
showStatus = false,
|
||||
}) => {
|
||||
const { hasExpired, markExpired } = useExpiredFileIds();
|
||||
const isKnownExpired = hasExpired(fileId);
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [failureState, setFailureState] = useState<AttachmentFailureState>(
|
||||
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
|
||||
);
|
||||
const request = useLatestAbortController(isKnownExpired);
|
||||
|
||||
if (isKnownExpired) {
|
||||
return (
|
||||
<AttachmentFallbackTile
|
||||
state={{ kind: "expired" }}
|
||||
labels={textAttachmentFailureLabels}
|
||||
className="h-16 w-28"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (failureState.kind !== "idle") {
|
||||
return (
|
||||
<AttachmentFallbackTile
|
||||
state={failureState}
|
||||
labels={textAttachmentFailureLabels}
|
||||
className="h-16 w-28"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const button = (
|
||||
<InlineTextAttachmentButton
|
||||
content={content ?? fileName ?? "Pasted text"}
|
||||
fileName={fileName}
|
||||
icon={
|
||||
showStatus && isLoading ? (
|
||||
<Spinner
|
||||
size="sm"
|
||||
loading
|
||||
className="shrink-0 text-content-secondary"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
isPlaceholder={content === null}
|
||||
onPreview={async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (content !== null) {
|
||||
void onPreview?.({ content, fileName });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = request.start();
|
||||
setIsLoading(true);
|
||||
|
||||
let result: Awaited<ReturnType<typeof fetchTextAttachmentContent>>;
|
||||
try {
|
||||
result = await fetchTextAttachmentContent(fileId, controller.signal);
|
||||
} catch (error) {
|
||||
if (!request.clear(controller)) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
console.warn("Failed to load text attachment:", error);
|
||||
setFailureState(attachmentFailureFromError(error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.clear(controller)) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
if (result.kind !== "loaded") {
|
||||
if (result.kind === "expired") {
|
||||
markExpired(fileId);
|
||||
}
|
||||
setFailureState(result);
|
||||
return;
|
||||
}
|
||||
setContent(result.content);
|
||||
void onPreview?.({ content: result.content, fileName });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const framedButton = frameHref ? (
|
||||
<AttachmentPreviewFrame
|
||||
href={frameHref}
|
||||
displayName={fileName ?? "Pasted text"}
|
||||
downloadName={downloadName}
|
||||
>
|
||||
{button}
|
||||
</AttachmentPreviewFrame>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{framedButton}
|
||||
{showStatus && isLoading ? (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-xs text-content-secondary"
|
||||
>
|
||||
Loading attachment preview…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RemoteImageBlock: FC<{
|
||||
fileId?: string;
|
||||
href: string;
|
||||
displayName: string;
|
||||
onImageClick?: (src: string) => void;
|
||||
}> = ({ fileId, href, displayName, onImageClick }) => {
|
||||
const { hasExpired, markExpired } = useExpiredFileIds();
|
||||
const isKnownExpired = fileId !== undefined && hasExpired(fileId);
|
||||
const [failureState, setFailureState] = useState<AttachmentFailureState>(
|
||||
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
|
||||
);
|
||||
const probeRequest = useLatestAbortController(isKnownExpired);
|
||||
|
||||
if (isKnownExpired) {
|
||||
return (
|
||||
<AttachmentFallbackTile
|
||||
state={{ kind: "expired" }}
|
||||
labels={imageAttachmentFailureLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (failureState.kind !== "idle") {
|
||||
return (
|
||||
<AttachmentFallbackTile
|
||||
state={failureState}
|
||||
labels={imageAttachmentFailureLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`View ${displayName}`}
|
||||
className="inline-block rounded-md border-0 bg-transparent p-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onImageClick?.(href);
|
||||
}}
|
||||
>
|
||||
<ImageThumbnail
|
||||
previewUrl={href}
|
||||
name={displayName}
|
||||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
onError={() => {
|
||||
// Inline (data:) images can't be probed; the browser
|
||||
// already failed to decode them, so fall straight to a
|
||||
// generic failure tile.
|
||||
if (fileId === undefined) {
|
||||
setFailureState({ kind: "failed" });
|
||||
return;
|
||||
}
|
||||
if (hasExpired(fileId)) {
|
||||
setFailureState({ kind: "expired" });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = probeRequest.start();
|
||||
// Optimistically swap to the generic failure tile. The
|
||||
// probe will either upgrade it to "expired" or fill in
|
||||
// a detail; showing a tile without a label flash is
|
||||
// preferable to leaving the broken-image icon up.
|
||||
setFailureState({ kind: "failed" });
|
||||
|
||||
void probeAttachmentFailure(href, controller.signal)
|
||||
.then((reason) => {
|
||||
if (!probeRequest.clear(controller)) {
|
||||
return;
|
||||
}
|
||||
if (reason.kind === "expired") {
|
||||
markExpired(fileId);
|
||||
}
|
||||
setFailureState(reason);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!probeRequest.clear(controller)) {
|
||||
return;
|
||||
}
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
setFailureState(attachmentFailureFromError(error));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FileCard: FC<{
|
||||
block: FileAttachmentBlock;
|
||||
href: string;
|
||||
}> = ({ block, href }) => {
|
||||
const displayName = getAttachmentDisplayName(block);
|
||||
const downloadName = getAttachmentDownloadName(block);
|
||||
const badgeLabel = getAttachmentBadgeLabel(block);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
download={downloadName}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={`Download ${displayName}`}
|
||||
className="inline-flex h-16 max-w-sm items-center gap-3 rounded-md border border-solid border-border-default bg-surface-tertiary px-3 py-2 no-underline transition-colors hover:bg-surface-quaternary"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-surface-secondary">
|
||||
{badgeLabel ? (
|
||||
<span className="text-[10px] font-semibold tracking-wide text-content-secondary">
|
||||
{badgeLabel}
|
||||
</span>
|
||||
) : (
|
||||
<FileIcon
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 text-content-secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-content-primary">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-xs text-content-secondary">Download file</div>
|
||||
</div>
|
||||
<DownloadIcon
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 text-content-secondary"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentBlock: FC<{
|
||||
block: FileAttachmentBlock;
|
||||
onImageClick?: (src: string) => void;
|
||||
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
|
||||
framePreview?: boolean;
|
||||
showTextStatus?: boolean;
|
||||
}> = ({
|
||||
block,
|
||||
onImageClick,
|
||||
onTextFileClick,
|
||||
framePreview = false,
|
||||
showTextStatus = false,
|
||||
}) => {
|
||||
const [revealedInlineText, setRevealedInlineText] = useState(false);
|
||||
const href = getAttachmentHref(block);
|
||||
const displayName = getAttachmentDisplayName(block);
|
||||
const downloadName = getAttachmentDownloadName(block);
|
||||
|
||||
if (isTextPreviewAttachmentMediaType(block.media_type)) {
|
||||
if (block.file_id) {
|
||||
return (
|
||||
<RemoteTextAttachmentButton
|
||||
fileId={block.file_id}
|
||||
fileName={displayName}
|
||||
frameHref={framePreview ? href : undefined}
|
||||
downloadName={downloadName}
|
||||
onPreview={onTextFileClick}
|
||||
showStatus={showTextStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (block.data == null) {
|
||||
return null;
|
||||
}
|
||||
const content = decodeInlineTextAttachment(block.data);
|
||||
const button = (
|
||||
<InlineTextAttachmentButton
|
||||
content={revealedInlineText ? content : "Pasted text"}
|
||||
fileName={displayName}
|
||||
isPlaceholder={!revealedInlineText}
|
||||
onPreview={() => {
|
||||
setRevealedInlineText(true);
|
||||
void onTextFileClick?.({ content, fileName: displayName });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return framePreview && href ? (
|
||||
<AttachmentPreviewFrame
|
||||
href={href}
|
||||
displayName={displayName}
|
||||
downloadName={downloadName}
|
||||
>
|
||||
{button}
|
||||
</AttachmentPreviewFrame>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
}
|
||||
|
||||
if (block.media_type.startsWith("image/")) {
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
const image = (
|
||||
<RemoteImageBlock
|
||||
fileId={block.file_id ?? undefined}
|
||||
href={href}
|
||||
displayName={displayName}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
);
|
||||
return framePreview ? (
|
||||
<AttachmentPreviewFrame
|
||||
href={href}
|
||||
displayName={displayName}
|
||||
downloadName={downloadName}
|
||||
>
|
||||
{image}
|
||||
</AttachmentPreviewFrame>
|
||||
) : (
|
||||
image
|
||||
);
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <FileCard block={block} href={href} />;
|
||||
};
|
||||
+198
-11
@@ -11,6 +11,7 @@ import {
|
||||
} from "storybook/test";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { getChatFileURL } from "../../utils/chatAttachments";
|
||||
import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment";
|
||||
import { ConversationTimeline } from "./ConversationTimeline";
|
||||
import { parseMessagesWithMergedTools } from "./messageParsing";
|
||||
|
||||
@@ -87,6 +88,10 @@ const ATTACHMENT_RESPONSES = new Map<string, AttachmentResponse>([
|
||||
body: "Quarterly revenue increased 18% year over year after the new pricing rollout stabilized customer expansion.",
|
||||
},
|
||||
],
|
||||
[
|
||||
"storybook-json-text",
|
||||
{ status: 200, body: '{"status":"ok","items":[1,2,3]}' },
|
||||
],
|
||||
[
|
||||
"storybook-text-only",
|
||||
{
|
||||
@@ -140,6 +145,7 @@ const ATTACHMENT_RESPONSES = new Map<string, AttachmentResponse>([
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
["storybook-text-error", { body: "Temporary failure", status: 503 }],
|
||||
]);
|
||||
|
||||
let attachmentFetchCounts = new Map<string, number>();
|
||||
@@ -395,7 +401,7 @@ export const UserMessageWithExpiredImage: Story = {
|
||||
const expiredTile = await findAttachmentTile(canvas, "Image expired");
|
||||
expect(canvas.getByText("This upload has expired")).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: "View image" }),
|
||||
canvas.queryByRole("button", { name: "View Attached image" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The tooltip explains the retention policy generically so the
|
||||
@@ -433,10 +439,11 @@ export const UserMessageWithRepeatedExpiredImage: Story = {
|
||||
);
|
||||
expect(getAttachmentFetchCount("storybook-expired-image")).toBe(1);
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: "View image" }),
|
||||
canvas.queryByRole("button", { name: "View Attached image" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** File-id images that fail with a non-404 status render a generic failure tile. */
|
||||
export const UserMessageWithFailedRemoteImage: Story = {
|
||||
args: buildStoryArgs(
|
||||
@@ -452,7 +459,7 @@ export const UserMessageWithFailedRemoteImage: Story = {
|
||||
await findAttachmentTile(canvas, "Image failed to load");
|
||||
expect(canvas.getByText("This image failed to load")).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: "View image" }),
|
||||
canvas.queryByRole("button", { name: "View Attached image" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When the probe returns a structured error body, the tooltip
|
||||
@@ -488,6 +495,7 @@ export const UserMessageWithUndisplayableRemoteImage: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Invalid inline image data skips the probe and renders the generic failure tile. */
|
||||
export const UserMessageWithInvalidInlineImage: Story = {
|
||||
args: buildStoryArgs(
|
||||
@@ -505,7 +513,7 @@ export const UserMessageWithInvalidInlineImage: Story = {
|
||||
canvas.getByText("Inline image data is corrupt"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: "View image" }),
|
||||
canvas.queryByRole("button", { name: "View Attached image" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -531,6 +539,70 @@ export const UserMessageWithTextAttachment: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const UserMessageWithJSONAttachment: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: parseMessagesWithMergedTools([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Here is the structured report." },
|
||||
{
|
||||
type: "file",
|
||||
file_id: "storybook-json-text",
|
||||
media_type: "application/json",
|
||||
name: "report.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const textButton = await canvas.findByRole("button", {
|
||||
name: "View report.json",
|
||||
});
|
||||
expect(textButton).toHaveTextContent("report.json");
|
||||
await userEvent.click(textButton);
|
||||
expect(await canvas.findByText(/"status":"ok"/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const UserMessageWithDownloadableFile: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: parseMessagesWithMergedTools([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "I attached the deployment report." },
|
||||
{
|
||||
type: "file",
|
||||
media_type: "application/pdf",
|
||||
file_id: "storybook-user-deployment-report",
|
||||
name: "deployment-report.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const downloadLink = canvas.getByRole("link", {
|
||||
name: "Download deployment-report.pdf",
|
||||
});
|
||||
expect(downloadLink).toHaveAttribute(
|
||||
"href",
|
||||
"/api/experimental/chats/files/storybook-user-deployment-report",
|
||||
);
|
||||
expect(canvas.getByText("deployment-report.pdf")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const UserMessageWithMultipleTextAttachments: Story = {
|
||||
args: buildStoryArgs(
|
||||
buildUserMessage({
|
||||
@@ -630,6 +702,79 @@ export const UserMessageWithFailedTextAttachment: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const UserMessageWithInlineTextAttachment: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: parseMessagesWithMergedTools([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Here is inline context:" },
|
||||
{
|
||||
type: "file",
|
||||
media_type: "text/plain",
|
||||
data: encodeInlineTextAttachment(
|
||||
"Inline deployment note: verify the feature flag before rollout.",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const textButton = await canvas.findByRole("button", {
|
||||
name: "View text attachment",
|
||||
});
|
||||
expect(textButton).toHaveTextContent(/Pasted text/i);
|
||||
await userEvent.click(textButton);
|
||||
expect(
|
||||
await canvas.findByText(/Inline deployment note/i),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-JSON error bodies (a bare `Temporary failure` text body with status 503)
|
||||
* still surface the shared failure tile, and the raw body must not leak into
|
||||
* the message stream where it would look like assistant content.
|
||||
*/
|
||||
export const UserMessageWithFailedTextAttachmentNonJSONBody: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: parseMessagesWithMergedTools([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "The preview fetch will fail." },
|
||||
{
|
||||
type: "file",
|
||||
file_id: "storybook-text-error",
|
||||
media_type: "text/plain",
|
||||
name: "preview.txt",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const textButton = await canvas.findByRole("button", {
|
||||
name: "View preview.txt",
|
||||
});
|
||||
await userEvent.click(textButton);
|
||||
await findAttachmentTile(canvas, "Attachment failed to load");
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: "View preview.txt" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/Temporary failure/i)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Visual regression: text and image attachments render at the same height. */
|
||||
export const UserMessageWithMixedAttachments: Story = {
|
||||
args: buildStoryArgs(
|
||||
@@ -681,12 +826,6 @@ export const AssistantMessageWithImage: Story = {
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Generate an image" }],
|
||||
},
|
||||
{
|
||||
...baseMessage,
|
||||
id: 2,
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Here is the generated image:" },
|
||||
@@ -694,6 +833,7 @@ export const AssistantMessageWithImage: Story = {
|
||||
type: "file",
|
||||
media_type: "image/png",
|
||||
data: TEST_PNG_B64,
|
||||
name: "generated-image.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -701,8 +841,55 @@ export const AssistantMessageWithImage: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const images = canvas.getAllByRole("img", { name: "Attached image" });
|
||||
const images = canvas.getAllByRole("img", { name: "generated-image.png" });
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0]).toHaveAttribute(
|
||||
"src",
|
||||
`data:image/png;base64,${TEST_PNG_B64}`,
|
||||
);
|
||||
expect(
|
||||
canvas.queryByRole("link", { name: "Download generated-image.png" }),
|
||||
).not.toBeInTheDocument();
|
||||
const viewButton = canvas.getByRole("button", {
|
||||
name: "View generated-image.png",
|
||||
});
|
||||
viewButton.focus();
|
||||
expect(viewButton).toHaveFocus();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("link", { name: "Download generated-image.png" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const AssistantMessageWithUnnamedDownloadableFile: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I attached the file without a custom name." },
|
||||
{
|
||||
type: "file",
|
||||
media_type: "application/pdf",
|
||||
file_id: "storybook-unnamed-report",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const downloadLink = canvas.getByRole("link", {
|
||||
name: "Download Attached file",
|
||||
});
|
||||
expect(downloadLink).toBeInTheDocument();
|
||||
expect(downloadLink).toHaveAttribute("download", "attachment.pdf");
|
||||
expect(canvas.getByText("Attached file")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ import { WebSearchSources } from "../ChatElements/tools";
|
||||
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { TextPreviewDialog } from "../TextPreviewDialog";
|
||||
import {
|
||||
AttachmentBlock,
|
||||
type PreviewTextAttachment,
|
||||
} from "./AttachmentBlocks";
|
||||
import { ExpiredFileIdsProvider } from "./ExpiredFileIdsContext";
|
||||
import { deriveMessageDisplayState } from "./messageHelpers";
|
||||
import { getEditableUserMessagePayload } from "./messageParsing";
|
||||
@@ -39,7 +43,7 @@ import type {
|
||||
ParsedMessageEntry,
|
||||
RenderBlock,
|
||||
} from "./types";
|
||||
import { FileBlock, UserMessageContent } from "./UserMessageContent";
|
||||
import { UserMessageContent } from "./UserMessageContent";
|
||||
|
||||
const getChatMessageTextContent = (
|
||||
content: readonly TypesGen.ChatMessagePart[] | undefined,
|
||||
@@ -135,7 +139,7 @@ export const BlockList: FC<{
|
||||
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
|
||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||
onImageClick?: (src: string) => void;
|
||||
onTextFileClick?: (content: string) => void;
|
||||
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
|
||||
onImplementPlan?: () => Promise<void> | void;
|
||||
onSendAskUserQuestionResponse?: (message: string) => Promise<void> | void;
|
||||
isChatCompleted?: boolean;
|
||||
@@ -283,11 +287,13 @@ export const BlockList: FC<{
|
||||
}
|
||||
case "file":
|
||||
return (
|
||||
<FileBlock
|
||||
<AttachmentBlock
|
||||
key={`${keyPrefix}-file-${block.file_id ?? index}`}
|
||||
block={block}
|
||||
onImageClick={onImageClick}
|
||||
onTextFileClick={onTextFileClick}
|
||||
framePreview
|
||||
showTextStatus
|
||||
/>
|
||||
);
|
||||
case "sources":
|
||||
@@ -388,7 +394,8 @@ const ChatMessageItem = memo<{
|
||||
}) => {
|
||||
const isUser = message.role === "user";
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewText, setPreviewText] = useState<string | null>(null);
|
||||
const [previewText, setPreviewText] =
|
||||
useState<PreviewTextAttachment | null>(null);
|
||||
const displayState = deriveMessageDisplayState({
|
||||
message,
|
||||
parsed,
|
||||
@@ -510,7 +517,8 @@ const ChatMessageItem = memo<{
|
||||
)}
|
||||
{previewText !== null && (
|
||||
<TextPreviewDialog
|
||||
content={previewText}
|
||||
content={previewText.content}
|
||||
fileName={previewText.fileName}
|
||||
onClose={() => setPreviewText(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,327 +1,16 @@
|
||||
import { AlertTriangleIcon, FileTextIcon } from "lucide-react";
|
||||
import { type FC, Fragment, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { type FC, Fragment } from "react";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { useLatestAbortController } from "../../hooks/useLatestAbortController";
|
||||
import {
|
||||
type AttachmentFailure,
|
||||
attachmentFailureFromError,
|
||||
getChatFileURL,
|
||||
isAbortError,
|
||||
probeAttachmentFailure,
|
||||
} from "../../utils/chatAttachments";
|
||||
import {
|
||||
decodeInlineTextAttachment,
|
||||
fetchTextAttachmentContent,
|
||||
formatTextAttachmentPreview,
|
||||
} from "../../utils/fetchTextAttachment";
|
||||
import { ImageThumbnail } from "../AgentChatInput";
|
||||
import { Message, MessageContent } from "../ChatElements";
|
||||
import { FileReferenceChip } from "../ChatMessageInput/FileReferenceNode";
|
||||
import { useExpiredFileIds } from "./ExpiredFileIdsContext";
|
||||
import {
|
||||
AttachmentBlock,
|
||||
type PreviewTextAttachment,
|
||||
} from "./AttachmentBlocks";
|
||||
import type {
|
||||
MessageDisplayState,
|
||||
UserFileRenderBlock,
|
||||
UserInlineRenderBlock,
|
||||
} from "./messageHelpers";
|
||||
|
||||
type ChatImageSource =
|
||||
| { kind: "file"; fileId: string; src: string }
|
||||
| { kind: "inline"; src: string };
|
||||
|
||||
type AttachmentFailureState = { kind: "idle" } | AttachmentFailure;
|
||||
|
||||
type AttachmentFailureLabels = {
|
||||
expired: string;
|
||||
failed: string;
|
||||
};
|
||||
|
||||
const attachmentRetentionTooltip =
|
||||
"Chat attachments are deleted after the retention window set for this deployment.";
|
||||
|
||||
const imageAttachmentFailureLabels: AttachmentFailureLabels = {
|
||||
expired: "Image expired",
|
||||
failed: "Image failed to load",
|
||||
};
|
||||
|
||||
const textAttachmentFailureLabels: AttachmentFailureLabels = {
|
||||
expired: "Attachment expired",
|
||||
failed: "Attachment failed to load",
|
||||
};
|
||||
|
||||
const InlineTextAttachmentButton: FC<{
|
||||
content: string;
|
||||
onPreview?: (content: string) => void;
|
||||
isPlaceholder?: boolean;
|
||||
}> = ({ content, onPreview, isPlaceholder }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View text attachment"
|
||||
className="inline-flex h-16 max-w-sm items-center gap-2 rounded-md border-0 bg-surface-tertiary px-3 py-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onPreview?.(content);
|
||||
}}
|
||||
>
|
||||
<FileTextIcon className="size-icon-sm shrink-0 text-content-secondary" />
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-2 min-w-0 text-content-secondary",
|
||||
isPlaceholder ? "text-sm" : "font-mono text-xs",
|
||||
)}
|
||||
>
|
||||
{isPlaceholder ? content : formatTextAttachmentPreview(content)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const TextAttachmentButton: FC<{
|
||||
fileId: string;
|
||||
onPreview?: (content: string) => void;
|
||||
}> = ({ fileId, onPreview }) => {
|
||||
const { hasExpired, markExpired } = useExpiredFileIds();
|
||||
const isKnownExpired = hasExpired(fileId);
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [failureState, setFailureState] = useState<AttachmentFailureState>(
|
||||
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
|
||||
);
|
||||
const request = useLatestAbortController(isKnownExpired);
|
||||
|
||||
if (failureState.kind === "expired" || isKnownExpired) {
|
||||
return (
|
||||
<AttachmentFallbackTile
|
||||
state={{ kind: "expired" }}
|
||||
labels={textAttachmentFailureLabels}
|
||||
className="h-16 w-28"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (failureState.kind === "failed") {
|
||||
return (
|
||||
<AttachmentFallbackTile
|
||||
state={failureState}
|
||||
labels={textAttachmentFailureLabels}
|
||||
className="h-16 w-28"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineTextAttachmentButton
|
||||
content={content ?? "Pasted text"}
|
||||
isPlaceholder={content === null}
|
||||
onPreview={() => {
|
||||
if (content !== null) {
|
||||
onPreview?.(content);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = request.start();
|
||||
|
||||
void fetchTextAttachmentContent(fileId, controller.signal)
|
||||
.then((result) => {
|
||||
if (!request.clear(controller)) {
|
||||
return;
|
||||
}
|
||||
if (result.kind === "loaded") {
|
||||
setContent(result.content);
|
||||
onPreview?.(result.content);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "expired") {
|
||||
markExpired(fileId);
|
||||
}
|
||||
setFailureState(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!request.clear(controller)) {
|
||||
return;
|
||||
}
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
console.warn("Failed to load text attachment:", error);
|
||||
setFailureState(attachmentFailureFromError(error));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentFallbackTile: FC<{
|
||||
state: AttachmentFailure;
|
||||
labels: AttachmentFailureLabels;
|
||||
className?: string;
|
||||
}> = ({ state, labels, className = "h-16 w-16" }) => {
|
||||
const label = state.kind === "expired" ? labels.expired : labels.failed;
|
||||
|
||||
const tile = (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 rounded-md border border-border-default bg-surface-tertiary px-1 text-center text-2xs text-content-secondary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertTriangleIcon
|
||||
className="size-icon-sm shrink-0 text-content-warning"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="leading-tight">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only surface a tooltip when we have something to add:
|
||||
// - "expired" explains the retention policy.
|
||||
// - "failed" with a detail surfaces the API error or network reason.
|
||||
// A bare "failed" (e.g. an inline base64 decode failure, where the
|
||||
// browser exposes nothing useful) stays a plain tile.
|
||||
const tooltipBody =
|
||||
state.kind === "expired" ? attachmentRetentionTooltip : state.detail;
|
||||
if (!tooltipBody) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{tile}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{tooltipBody}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatImageBlock: FC<{
|
||||
source: ChatImageSource;
|
||||
onImageClick?: (src: string) => void;
|
||||
}> = ({ source, onImageClick }) => {
|
||||
const { hasExpired, markExpired } = useExpiredFileIds();
|
||||
const isKnownExpired = source.kind === "file" && hasExpired(source.fileId);
|
||||
const [failureState, setFailureState] = useState<AttachmentFailureState>(
|
||||
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
|
||||
);
|
||||
const probeRequest = useLatestAbortController(isKnownExpired);
|
||||
|
||||
if (failureState.kind === "expired" || isKnownExpired) {
|
||||
return (
|
||||
<AttachmentFallbackTile
|
||||
state={{ kind: "expired" }}
|
||||
labels={imageAttachmentFailureLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (failureState.kind === "failed") {
|
||||
return (
|
||||
<AttachmentFallbackTile
|
||||
state={failureState}
|
||||
labels={imageAttachmentFailureLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View image"
|
||||
className="inline-block rounded-md border-0 bg-transparent p-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onImageClick?.(source.src);
|
||||
}}
|
||||
>
|
||||
<ImageThumbnail
|
||||
previewUrl={source.src}
|
||||
name="Attached image"
|
||||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
onError={() => {
|
||||
if (source.kind !== "file") {
|
||||
setFailureState({ kind: "failed" });
|
||||
return;
|
||||
}
|
||||
if (hasExpired(source.fileId)) {
|
||||
setFailureState({ kind: "expired" });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = probeRequest.start();
|
||||
// Optimistically swap to the generic failure tile. The
|
||||
// probe will either upgrade it to "expired" or fill in
|
||||
// a detail; showing a tile without a label flash is
|
||||
// preferable to leaving the broken-image icon up.
|
||||
setFailureState({ kind: "failed" });
|
||||
|
||||
void probeAttachmentFailure(source.src, controller.signal)
|
||||
.then((reason) => {
|
||||
if (!probeRequest.clear(controller)) {
|
||||
return;
|
||||
}
|
||||
if (reason.kind === "expired") {
|
||||
markExpired(source.fileId);
|
||||
}
|
||||
setFailureState(reason);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!probeRequest.clear(controller)) {
|
||||
return;
|
||||
}
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
setFailureState(attachmentFailureFromError(error));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileBlock: FC<{
|
||||
block: UserFileRenderBlock;
|
||||
onImageClick?: (src: string) => void;
|
||||
onTextFileClick?: (content: string) => void;
|
||||
}> = ({ block, onImageClick, onTextFileClick }) => {
|
||||
if (block.media_type === "text/plain") {
|
||||
if (block.file_id) {
|
||||
return (
|
||||
<TextAttachmentButton
|
||||
fileId={block.file_id}
|
||||
onPreview={onTextFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (block.data != null) {
|
||||
return (
|
||||
<InlineTextAttachmentButton
|
||||
content={decodeInlineTextAttachment(block.data)}
|
||||
onPreview={onTextFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!block.media_type.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
const source: ChatImageSource = block.file_id
|
||||
? {
|
||||
kind: "file",
|
||||
fileId: block.file_id,
|
||||
src: getChatFileURL(block.file_id),
|
||||
}
|
||||
: {
|
||||
kind: "inline",
|
||||
src: `data:${block.media_type};base64,${block.data ?? ""}`,
|
||||
};
|
||||
return <ChatImageBlock source={source} onImageClick={onImageClick} />;
|
||||
};
|
||||
|
||||
const renderUserInlineBlock = (block: UserInlineRenderBlock, index: number) => {
|
||||
if (block.type === "response") {
|
||||
return <Fragment key={index}>{block.text}</Fragment>;
|
||||
@@ -344,7 +33,7 @@ export const UserMessageContent: FC<{
|
||||
isEditing?: boolean;
|
||||
fadeFromBottom?: boolean;
|
||||
onImageClick?: (src: string) => void;
|
||||
onTextFileClick?: (content: string) => void;
|
||||
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
|
||||
}> = ({
|
||||
displayState,
|
||||
markdown,
|
||||
@@ -388,11 +77,12 @@ export const UserMessageContent: FC<{
|
||||
)}
|
||||
>
|
||||
{displayState.userFileBlocks.map((block, index) => (
|
||||
<FileBlock
|
||||
<AttachmentBlock
|
||||
key={`user-file-${block.file_id ?? index}`}
|
||||
block={block}
|
||||
onImageClick={onImageClick}
|
||||
onTextFileClick={onTextFileClick}
|
||||
showTextStatus
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ export type UserInlineRenderBlock =
|
||||
| Extract<RenderBlock, { type: "response" }>
|
||||
| Extract<RenderBlock, { type: "file-reference" }>;
|
||||
|
||||
export type UserFileRenderBlock = Extract<RenderBlock, { type: "file" }>;
|
||||
type UserFileRenderBlock = Extract<RenderBlock, { type: "file" }>;
|
||||
|
||||
export type MessageDisplayState = {
|
||||
shouldHide: boolean;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated";
|
||||
import { getSubagentDescriptor } from "../ChatElements/tools/subagentDescriptor";
|
||||
import {
|
||||
buildSubagentMaps,
|
||||
getEditableUserMessagePayload,
|
||||
mergeTools,
|
||||
parseMessageContent,
|
||||
parseMessagesWithMergedTools,
|
||||
@@ -73,6 +74,86 @@ describe("parseToolResultIsError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEditableUserMessagePayload", () => {
|
||||
it("keeps only editable stored attachments", () => {
|
||||
const cases = [
|
||||
{
|
||||
message: {
|
||||
id: 1,
|
||||
chat_id: "chat-1",
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Please edit this draft." },
|
||||
{ type: "file", media_type: "image/png", file_id: "image-file" },
|
||||
{
|
||||
type: "file",
|
||||
media_type: "application/json",
|
||||
file_id: "json-file",
|
||||
name: "report.json",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
media_type: "application/pdf",
|
||||
file_id: "pdf-file",
|
||||
name: "manual.pdf",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
media_type: "application/zip",
|
||||
file_id: "zip-file",
|
||||
name: "archive.zip",
|
||||
},
|
||||
],
|
||||
} satisfies ChatMessage,
|
||||
want: {
|
||||
text: "Please edit this draft.",
|
||||
fileBlocks: [
|
||||
{ type: "file", media_type: "image/png", file_id: "image-file" },
|
||||
{
|
||||
type: "file",
|
||||
media_type: "application/json",
|
||||
file_id: "json-file",
|
||||
name: "report.json",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
media_type: "application/pdf",
|
||||
file_id: "pdf-file",
|
||||
name: "manual.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
message: {
|
||||
id: 2,
|
||||
chat_id: "chat-1",
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Share the archive instead." },
|
||||
{
|
||||
type: "file",
|
||||
media_type: "application/zip",
|
||||
file_id: "zip-file",
|
||||
name: "archive.zip",
|
||||
},
|
||||
],
|
||||
} satisfies ChatMessage,
|
||||
want: {
|
||||
text: "Share the archive instead.",
|
||||
fileBlocks: undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const { message, want } of cases) {
|
||||
expect(getEditableUserMessagePayload(message)).toEqual(want);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMessageContent", () => {
|
||||
it("returns empty result for undefined content", () => {
|
||||
const result = parseMessageContent(undefined);
|
||||
|
||||
@@ -237,11 +237,18 @@ export const parseMessageContent = (
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const isEditableAttachmentMediaType = (mediaType: string): boolean =>
|
||||
mediaType.startsWith("image/") ||
|
||||
mediaType === "text/plain" ||
|
||||
mediaType === "text/markdown" ||
|
||||
mediaType === "text/csv" ||
|
||||
mediaType === "application/json" ||
|
||||
mediaType === "application/pdf";
|
||||
|
||||
const isEditableUserMessageFileBlock = (
|
||||
block: RenderBlock,
|
||||
): block is TypesGen.ChatFilePart =>
|
||||
block.type === "file" &&
|
||||
(block.media_type.startsWith("image/") || block.media_type === "text/plain");
|
||||
block.type === "file" && isEditableAttachmentMediaType(block.media_type);
|
||||
|
||||
export const getEditableUserMessagePayload = (
|
||||
message: TypesGen.ChatMessage,
|
||||
|
||||
@@ -1343,7 +1343,7 @@ export const ComputerTextFallback: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Text-only results are collapsed by default (no image).
|
||||
const toggle = canvas.getByRole("button", { name: /Screenshot/ });
|
||||
const toggle = canvas.getByRole("button", { name: "Screenshot" });
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(canvas.queryByRole("img")).toBeNull();
|
||||
|
||||
@@ -1398,6 +1398,50 @@ export const ComputerArrayResult: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ComputerPromotedAttachmentArrayResult: Story = {
|
||||
args: {
|
||||
name: "computer",
|
||||
status: "completed",
|
||||
result: [
|
||||
{
|
||||
type: "image",
|
||||
data: DESKTOP_SCREENSHOT_BASE64,
|
||||
mime_type: "image/jpeg",
|
||||
attachment_file_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
attachment_name: "screenshot-2026-04-21T00-00-00Z.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = canvas.getByRole("button", { name: "Screenshot" });
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("img", { name: "Screenshot from computer tool" }),
|
||||
).toBeNull();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
expect(
|
||||
canvas.getByText("Attached screenshot-2026-04-21T00-00-00Z.png"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AttachFileLabelFallsBackToPathBasename: Story = {
|
||||
args: {
|
||||
name: "attach_file",
|
||||
status: "completed",
|
||||
args: {
|
||||
path: "docs/runbooks/incident.md",
|
||||
},
|
||||
result: {},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Attached incident.md")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool failure display stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -707,26 +707,30 @@ const ComputerRenderer: FC<ToolRendererProps> = ({
|
||||
result,
|
||||
isError,
|
||||
}) => {
|
||||
// The result can be a single object with {data, text, mime_type}
|
||||
// or an array of content blocks.
|
||||
let imageData = "";
|
||||
let mimeType = "image/png";
|
||||
let text = "";
|
||||
let attachmentFileId = "";
|
||||
let attachmentName = "";
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
for (const block of result) {
|
||||
const blockRec = asRecord(block);
|
||||
if (blockRec) {
|
||||
if (blockRec.type === "image" || asString(blockRec.data)) {
|
||||
imageData = asString(blockRec.data);
|
||||
mimeType = asString(blockRec.mime_type) || "image/png";
|
||||
}
|
||||
if (
|
||||
blockRec.type === "text" ||
|
||||
(!imageData && asString(blockRec.text))
|
||||
) {
|
||||
text = asString(blockRec.text);
|
||||
}
|
||||
if (!blockRec) {
|
||||
continue;
|
||||
}
|
||||
if (blockRec.type === "image" || asString(blockRec.data)) {
|
||||
imageData = asString(blockRec.data);
|
||||
mimeType = asString(blockRec.mime_type) || "image/png";
|
||||
}
|
||||
if (blockRec.type === "text" || (!imageData && asString(blockRec.text))) {
|
||||
text = asString(blockRec.text);
|
||||
}
|
||||
if (!attachmentFileId) {
|
||||
attachmentFileId = asString(blockRec.attachment_file_id);
|
||||
}
|
||||
if (!attachmentName) {
|
||||
attachmentName = asString(blockRec.attachment_name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -735,6 +739,17 @@ const ComputerRenderer: FC<ToolRendererProps> = ({
|
||||
imageData = asString(rec.data);
|
||||
mimeType = asString(rec.mime_type) || "image/png";
|
||||
text = asString(rec.text);
|
||||
attachmentFileId = asString(rec.attachment_file_id);
|
||||
attachmentName = asString(rec.attachment_name);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentFileId) {
|
||||
imageData = "";
|
||||
if (!text) {
|
||||
text = attachmentName
|
||||
? `Attached ${attachmentName}`
|
||||
: "Attached screenshot";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,18 @@ export const ToolLabel: React.FC<{
|
||||
Summarized
|
||||
</span>
|
||||
);
|
||||
case "attach_file": {
|
||||
const attachedName =
|
||||
(parsedResult ? asString(parsedResult.name) : "") ||
|
||||
(parsed ? asString(parsed.name) : "") ||
|
||||
(parsed ? asString(parsed.path).split("/").pop() : "") ||
|
||||
"file";
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
{`Attached ${attachedName}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "computer":
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
|
||||
@@ -7,7 +7,7 @@ export type AttachmentFailure =
|
||||
| { kind: "failed"; detail?: string };
|
||||
|
||||
export const getChatFileURL = (fileId: string) =>
|
||||
`/api/experimental/chats/files/${fileId}`;
|
||||
`/api/experimental/chats/files/${encodeURIComponent(fileId)}`;
|
||||
|
||||
export const isAbortError = (error: unknown): error is Error =>
|
||||
error instanceof Error && error.name === "AbortError";
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
decodeInlineTextAttachment,
|
||||
encodeInlineTextAttachment,
|
||||
fetchTextAttachmentContent,
|
||||
formatTextAttachmentPreview,
|
||||
getTextAttachmentErrorMessage,
|
||||
} from "./fetchTextAttachment";
|
||||
|
||||
const encodeUtf8Base64 = (value: string) => {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
};
|
||||
|
||||
describe("formatTextAttachmentPreview", () => {
|
||||
it('returns "Pasted text" for empty content', () => {
|
||||
expect(formatTextAttachmentPreview("")).toBe("Pasted text");
|
||||
@@ -41,7 +39,9 @@ describe("decodeInlineTextAttachment", () => {
|
||||
it("decodes base64-encoded UTF-8 text", () => {
|
||||
const text = "Hello 👋 café";
|
||||
|
||||
expect(decodeInlineTextAttachment(encodeUtf8Base64(text))).toBe(text);
|
||||
expect(decodeInlineTextAttachment(encodeInlineTextAttachment(text))).toBe(
|
||||
text,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the raw string when base64 decoding fails", () => {
|
||||
@@ -52,3 +52,70 @@ describe("decodeInlineTextAttachment", () => {
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchTextAttachmentContent", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns a loaded result when the fetch succeeds", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("hello from the server", { status: 200 }),
|
||||
);
|
||||
|
||||
const fileId = "folder/file-1?preview=yes";
|
||||
await expect(fetchTextAttachmentContent(fileId)).resolves.toEqual({
|
||||
kind: "loaded",
|
||||
content: "hello from the server",
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/experimental/chats/files/folder%2Ffile-1%3Fpreview%3Dyes",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the API message for unauthorized attachment fetches", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ message: "Sign in again to view files." }),
|
||||
{
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchTextAttachmentContent("file-2")).resolves.toEqual({
|
||||
kind: "failed",
|
||||
detail: "Sign in again to view files.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a classified failure when the fetch responds non-OK", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("nope", { status: 503 }),
|
||||
);
|
||||
|
||||
const result = await fetchTextAttachmentContent("file-3");
|
||||
expect(result.kind).not.toBe("loaded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTextAttachmentErrorMessage", () => {
|
||||
it("suppresses DOMException abort errors", () => {
|
||||
expect(
|
||||
getTextAttachmentErrorMessage(new DOMException("aborted", "AbortError")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses structural abort errors", () => {
|
||||
expect(getTextAttachmentErrorMessage({ name: "AbortError" })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the retry message for other failures", () => {
|
||||
expect(getTextAttachmentErrorMessage(new Error("boom"))).toBe(
|
||||
"Couldn't load preview. Select again to retry.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,27 @@ export function decodeInlineTextAttachment(content: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes UTF-8 text as base64. Inverse of decodeInlineTextAttachment.
|
||||
*/
|
||||
export function encodeInlineTextAttachment(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
export function getTextAttachmentErrorMessage(error: unknown): string | null {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
error.name === "AbortError"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "Couldn't load preview. Select again to retry.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the text content of a chat file attachment by its ID.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user