Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
4354e9b
fix(site/src/pages/AgentsPage): stop attachment downloads from trappi…
ibetitsmike Aug 4, 2026
7546235
test(site/src/pages/AgentsPage): cover iOS standalone download interc…
ibetitsmike Aug 4, 2026
6ed7b29
refactor(site/src/pages/AgentsPage): access browser globals without w…
ibetitsmike Aug 4, 2026
88157ef
fix(site/src/pages/AgentsPage): surface intercepted attachment downlo…
ibetitsmike Aug 4, 2026
6baa5cb
fix(site/src/pages/AgentsPage): recover when user activation expires …
ibetitsmike Aug 4, 2026
54d3189
fix(site/src/pages/AgentsPage): offer share retry only for expired ac…
ibetitsmike Aug 4, 2026
39b8727
test(site/src/pages/AgentsPage): exercise the Save retry through the …
ibetitsmike Aug 4, 2026
e579823
chore(site/src/pages/AgentsPage): tighten comments added on this branch
ibetitsmike Aug 4, 2026
28b395e
fix(site/src/pages/AgentsPage): give extensionless attachment names a…
ibetitsmike Aug 6, 2026
244ba71
fix(site/src/pages/AgentsPage): derive download extensions from the m…
ibetitsmike Aug 6, 2026
671bc31
fix(site/src/pages/AgentsPage): suppress duplicate clicks while an iO…
ibetitsmike Aug 6, 2026
a5f53df
fix(site/src/pages/AgentsPage): preserve source-file suffixes for tex…
ibetitsmike Aug 6, 2026
36e4de8
fix(site/src/pages/AgentsPage): offer a tab fallback for unshareable …
ibetitsmike Aug 6, 2026
bb64fb7
test(site/src/pages/AgentsPage): exercise the unshareable-file Open a…
ibetitsmike Aug 6, 2026
954356f
test(site/src/pages/AgentsPage): cover permanent share failures in St…
ibetitsmike Aug 6, 2026
8fcea68
fix(site/src/pages/AgentsPage): decode inline attachments locally and…
ibetitsmike Aug 6, 2026
d709502
fix(site/src/pages/AgentsPage): open inline attachments through blob …
ibetitsmike Aug 6, 2026
137d9ba
test(site/src/pages/AgentsPage): exercise inline Open action and deco…
ibetitsmike Aug 6, 2026
20d169d
fix(site/src/pages/AgentsPage): preserve long text/plain suffixes in …
ibetitsmike Aug 6, 2026
f6d16f9
fix(site/src/pages/AgentsPage): recognize alternate JPEG and image su…
ibetitsmike Aug 6, 2026
2872ad8
fix(site/src/pages/AgentsPage): preserve dotfile names in attachment …
ibetitsmike Aug 6, 2026
b13ccbd
fix(site/src/pages/AgentsPage): preserve suffixes for all text-like a…
ibetitsmike Aug 6, 2026
f9ce076
fix(site/src/pages/AgentsPage): map structured MIME subtypes to real …
ibetitsmike Aug 6, 2026
c7787a7
fix(site/src/pages/AgentsPage): derive extensions only from image sub…
ibetitsmike Aug 6, 2026
03b0872
fix(site/src/pages/AgentsPage): map XML, CSV, and HTML media types to…
ibetitsmike Aug 6, 2026
10e509e
fix(site/src/pages/AgentsPage): share one data URL decoder and keep X…
ibetitsmike Aug 6, 2026
0d5d8b0
fix(site/src/pages/AgentsPage): abort pending attachment downloads on…
ibetitsmike Aug 6, 2026
0d11cc1
fix(site/src/pages/AgentsPage): keep suffixes for structured +json an…
ibetitsmike Aug 6, 2026
75d12f2
fix(site/src/pages/AgentsPage): suppress share rejection toasts after…
ibetitsmike Aug 6, 2026
3738929
test(site/src/pages/AgentsPage): cover dismissed share sheet in Story…
ibetitsmike Aug 6, 2026
7aa5cab
test(site/src/pages/AgentsPage): compress attachment download tests i…
ibetitsmike Aug 7, 2026
5a242e8
refactor(site/src/pages/AgentsPage): simplify attachment download han…
ibetitsmike Aug 7, 2026
1bc0410
refactor(site/src/pages/AgentsPage): drop uncommon JPEG suffix aliases
ibetitsmike Aug 7, 2026
4726abe
refactor(site/src/pages/AgentsPage): drop JSON and XML suffix preserv…
ibetitsmike Aug 7, 2026
ec38887
refactor(site/src/pages/AgentsPage): drop unsupported text MIME mappings
ibetitsmike Aug 7, 2026
9c46b3b
fix(site/src/pages/AgentsPage): keep download abort state current
ibetitsmike Aug 7, 2026
5e705a8
chore(site/src/pages/AgentsPage): clean up attachment comments
ibetitsmike Aug 7, 2026
a306915
test(site/src/pages/AgentsPage): restore Save retry interaction in St…
ibetitsmike Aug 7, 2026
f3290b6
refactor(site/src/pages/AgentsPage): reduce iOS PWA download to share…
ibetitsmike Aug 11, 2026
187f344
chore(site/src/pages/AgentsPage): fold cleanup-gate findings into dow…
ibetitsmike Aug 11, 2026
d9d7b7b
refactor(site/src/pages/AgentsPage): simplify download naming and sha…
ibetitsmike Aug 11, 2026
a2e88ea
refactor(site/src/pages/AgentsPage): inline toast failures and reuse …
ibetitsmike Aug 11, 2026
65454a0
test(site/src/pages/AgentsPage/utils): exercise the Save retry action…
ibetitsmike Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type AttachmentFailure,
attachmentFailureFromError,
getChatFileURL,
handleAttachmentDownloadClick,
isAbortError,
probeAttachmentFailure,
} from "../../utils/chatAttachments";
Expand Down Expand Up @@ -54,17 +55,40 @@ const ATTACHMENT_FALLBACK_EXTENSIONS: Record<string, string> = {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"docx",
"application/x-tar": "tar",
"application/xml": "xml",
"image/jpeg": "jpg",
"image/svg+xml": "svg",
"text/csv": "csv",
"text/markdown": "md",
"text/plain": "txt",
};

const sanitizeAttachmentExtension = (value: string): string => {
const sanitized = value
const sanitizeAttachmentExtension = (value: string): string =>
value
.replace(/[^a-z0-9]/gi, "")
.slice(0, 4)
.toLowerCase();
return sanitized || "file";
.toLowerCase() || "file";

const getMediaTypeExtension = (mediaType: string): string | null => {
if (mediaType === "application/octet-stream") {
return null;
}
const mapped = ATTACHMENT_FALLBACK_EXTENSIONS[mediaType];
if (mapped) {
return mapped;
}
const [type, subtype = ""] = mediaType.split("/");
// Unmapped non-image subtypes are not assumed to be filename extensions.
return type === "image" && /^[a-z0-9]{1,8}$/i.test(subtype)
? subtype.toLowerCase()
: null;
};
Comment thread
ibetitsmike marked this conversation as resolved.

const getNameExtension = (name: string): string | null => {
const lastDot = name.lastIndexOf(".");
return lastDot > 0 && lastDot < name.length - 1
? name.slice(lastDot + 1)
: null;
};

const getAttachmentExtension = (
Expand All @@ -74,20 +98,14 @@ const getAttachmentExtension = (
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";
const nameExtension = getNameExtension(block.name?.trim() ?? "");
if (nameExtension) {
return sanitizeAttachmentExtension(nameExtension);
}
return sanitizeAttachmentExtension(subtype);
return (
getMediaTypeExtension(block.media_type) ??
sanitizeAttachmentExtension(block.media_type.split("/")[1] ?? "")
);
};

const isTextPreviewAttachmentMediaType = (mediaType: string): boolean =>
Expand Down Expand Up @@ -123,11 +141,16 @@ const getAttachmentDownloadName = (
block: Pick<FileAttachmentBlock, "media_type" | "name">,
): string => {
const name = block.name?.trim();
if (name) {
if (!name) {
const extension = getAttachmentExtension(block);
return extension === "file" ? "attachment" : `attachment.${extension}`;
}
// Kept even when the name's extension disagrees with the media type.
if (name.startsWith(".") || getNameExtension(name)) {
return name;
}
const extension = getAttachmentExtension(block);
return extension === "file" ? "attachment" : `attachment.${extension}`;
const mediaExtension = getMediaTypeExtension(block.media_type);
return mediaExtension ? `${name}.${mediaExtension}` : name;
};

const getAttachmentBadgeLabel = (
Expand All @@ -141,24 +164,35 @@ 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 size-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="size-3.5" />
</a>
);
mediaType: string;
}> = ({ href, displayName, downloadName, mediaType }) => {
return (
<a
href={href}
download={downloadName}
onClick={(event) => {
event.stopPropagation();
void handleAttachmentDownloadClick(event, {
href,
fileName: downloadName,
mediaType,
});
}}
aria-label={`Download ${displayName}`}
className="invisible absolute right-1 top-1 flex size-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="size-3.5" />
</a>
);
};

const AttachmentPreviewFrame: FC<{
href: string | null;
displayName: string;
downloadName: string;
mediaType: string;
children: ReactNode;
}> = ({ href, displayName, downloadName, children }) => {
}> = ({ href, displayName, downloadName, mediaType, children }) => {
return (
<div className="group/attachment relative inline-flex flex-col items-start">
{children}
Expand All @@ -167,6 +201,7 @@ const AttachmentPreviewFrame: FC<{
href={href}
displayName={displayName}
downloadName={downloadName}
mediaType={mediaType}
/>
) : null}
</div>
Expand Down Expand Up @@ -385,6 +420,7 @@ const RemoteTextAttachmentButton: FC<{
href={frameHref}
displayName={fileName ?? "Pasted text"}
downloadName={downloadName}
mediaType={mediaType ?? ""}
>
{button}
</AttachmentPreviewFrame>
Expand Down Expand Up @@ -530,7 +566,14 @@ const FileCard: FC<{
<a
href={href}
download={downloadName}
onClick={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
void handleAttachmentDownloadClick(event, {
href,
fileName: downloadName,
mediaType: block.media_type,
});
}}
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"
>
Expand Down Expand Up @@ -616,6 +659,7 @@ export const AttachmentBlock: FC<{
href={href}
displayName={displayName}
downloadName={downloadName}
mediaType={block.media_type}
>
{button}
</AttachmentPreviewFrame>
Expand All @@ -641,6 +685,7 @@ export const AttachmentBlock: FC<{
href={href}
displayName={displayName}
downloadName={downloadName}
mediaType={block.media_type}
>
{image}
</AttachmentPreviewFrame>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ const ATTACHMENT_RESPONSES = new Map<string, AttachmentResponse>([
},
],
["storybook-text-error", { body: "Temporary failure", status: 503 }],
[
"storybook-ios-share-report",
{ status: 200, body: "pdf-bytes", contentType: "application/pdf" },
],
["storybook-ios-error-report", { status: 500, body: "" }],
]);

let attachmentFetchCounts = new Map<string, number>();
Expand Down Expand Up @@ -1294,6 +1299,83 @@ export const AssistantMessageWithUnnamedDownloadableFile: Story = {
},
};

export const AssistantMessageWithMismatchedExtensionFile: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{ type: "text", text: "Here are the release notes." },
{
type: "file",
media_type: "application/pdf",
file_id: "storybook-mismatched-notes",
name: "release-notes.txt",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const downloadLink = canvas.getByRole("link", {
name: "Download release-notes.txt",
});
expect(downloadLink).toHaveAttribute("download", "release-notes.txt");
},
};

const iosDownloadStoryArgs: Story["args"] = buildStoryArgs(
buildUserMessage({
text: "I attached the deployment report.",
files: [
buildFilePart({
media_type: "application/pdf",
file_id: "storybook-ios-share-report",
name: "deployment-report.pdf",
}),
],
}),
);

export const DownloadInIOSStandaloneSharesFile: Story = {
args: iosDownloadStoryArgs,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const share = fn().mockResolvedValue(undefined);
// Read-only Navigator values must be shadowed with removable own
// properties.
const overrides: Record<string, unknown> = {
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
standalone: true,
share,
canShare: fn().mockReturnValue(true),
};
for (const [key, value] of Object.entries(overrides)) {
Object.defineProperty(navigator, key, { value, configurable: true });
}
try {
await userEvent.click(
canvas.getByRole("link", { name: "Download deployment-report.pdf" }),
);
await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
const shared: { files: File[] } = share.mock.calls[0][0];
expect(shared.files).toHaveLength(1);
expect(shared.files[0].name).toBe("deployment-report.pdf");
expect(shared.files[0].type).toBe("application/pdf");
expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(1);
} finally {
for (const key of Object.keys(overrides)) {
Reflect.deleteProperty(navigator, key);
}
}
},
};

/** Images and file-references coexist without interfering. */
export const UserMessageWithImagesAndFileRefs: Story = {
args: {
Expand Down
Loading
Loading