Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion apps/local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ receiver's envelope retention limit when replaying a large corpus.
by session/client reports. Feedback links to associated errors in the capture.
- Attachments show safe text/JSON/image/video previews and downloads. Captured
HTML, SVG and code remain inert; the viewer never executes them or fetches
captured resource URLs.
captured resource URLs. Attachments inside a raw envelope stay collapsed and
are decoded only when you show their preview.

Use severity/logger filters and sorting in event/log views. The Columns control
chooses which metadata appears beside each message. In a trace, search spans,
Expand Down
92 changes: 67 additions & 25 deletions apps/local/src/components/attachment-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { attachmentBytes } from "@/lib/attachments.ts";
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { LocalFeedItem } from "@/lib/spotlight.ts";
import { text } from "@/lib/inspection.ts";
import { JsonView } from "./json-view.tsx";

export function AttachmentDetail({ item }: { item: LocalFeedItem }) {
/**
* Render one attachment. Collapsible attachments stay undecoded until the user
* opens their preview, so envelopes with many or large files render cheaply.
*/
export function AttachmentDetail({
item,
collapsible = false,
}: {
item: LocalFeedItem;
collapsible?: boolean;
}) {
const [expanded, setExpanded] = useState(!collapsible);
const [url, setUrl] = useState<string>();
const name = text(item.itemHeader?.filename) ?? "Attachment";
const attachment = attachmentBytes(item);
const attachment = useMemo(
() => (expanded ? attachmentBytes(item) : undefined),
[expanded, item],
);
useEffect(() => {
const decoded = attachmentBytes(item);
if (!decoded || typeof URL.createObjectURL !== "function") return;
if (!attachment || typeof URL.createObjectURL !== "function") return;
// Downloads stay inert even when the captured MIME type is HTML or SVG.
const objectUrl = URL.createObjectURL(
new Blob([decoded.bytes], {
type: decoded.downloadType,
new Blob([attachment.bytes], {
type: attachment.downloadType,
}),
);
// Object URLs are browser resources: create after commit and revoke on cleanup.
Expand All @@ -24,33 +38,61 @@ export function AttachmentDetail({ item }: { item: LocalFeedItem }) {
URL.revokeObjectURL(objectUrl);
setUrl(undefined);
};
}, [item]);
const content = attachment?.isText
? new TextDecoder().decode(attachment.bytes)
: undefined;
}, [attachment]);
const content = useMemo(
() =>
attachment?.isText
? new TextDecoder().decode(attachment.bytes)
: undefined,
[attachment],
);
const headerLength = item.itemHeader?.length;
const byteLength =
attachment?.bytes.byteLength ??
(typeof headerLength === "number" && Number.isFinite(headerLength)
? headerLength
: undefined);
return (
<section aria-label="Attachment" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="break-all text-sm font-medium">{name}</h3>
{url ? (
<a
className="text-sm text-primary underline"
href={url}
download={name}
>
Download attachment
</a>
) : null}
<div className="flex items-center gap-3">
{url ? (
<a
className="text-sm text-primary underline"
href={url}
download={name}
>
Download attachment
</a>
) : null}
{collapsible ? (
// Keep one mounted toggle so keyboard focus survives expanding and collapsing.
<button
type="button"
aria-expanded={expanded}
aria-label={`${expanded ? "Hide" : "Show"} ${name} preview`}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? "Hide preview" : "Show preview"}
<ChevronDown
aria-hidden="true"
className={`size-3.5 transition-transform motion-reduce:transition-none ${expanded ? "rotate-180" : ""}`}
/>
</button>
) : null}
</div>
</div>
<p className="text-xs text-muted-foreground">
{attachment?.contentType ??
text(item.itemHeader?.content_type) ??
"Unknown type"}
{attachment
? ` · ${attachment.bytes.byteLength.toLocaleString()} bytes`
: ""}
{byteLength === undefined
? ""
: ` · ${byteLength.toLocaleString()} bytes`}
</p>
{!attachment ? (
{!expanded ? null : !attachment ? (
<p role="status" className="text-sm text-muted-foreground">
This attachment could not be decoded. Its original payload is
available in JSON.
Expand Down Expand Up @@ -96,7 +138,7 @@ export function EnvelopeAttachments({
return (
<section aria-label="Envelope attachments" className="space-y-4">
{attachments.map((attachment) => (
<AttachmentDetail key={attachment.id} item={attachment} />
<AttachmentDetail key={attachment.id} item={attachment} collapsible />
))}
</section>
);
Expand Down
39 changes: 36 additions & 3 deletions apps/local/src/components/semantic-inspection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ test.each([
},
);

test("raw SSE envelopes show attachment previews and keep the original JSON", () => {
test("raw SSE envelopes decode attachment previews on demand and keep the original JSON", () => {
const raw = JSON.stringify([
{},
[
Expand All @@ -321,19 +321,52 @@ test("raw SSE envelopes show attachment previews and keep the original JSON", ()
type: "attachment",
filename: "notes.txt",
content_type: "text/plain",
length: 14,
},
{ data: "captured notes" },
],
],
]);
const create = vi.fn((_blob: Blob) => "blob:attachment");
class TestURL extends URL {
static createObjectURL = create;
static revokeObjectURL = vi.fn();
}
vi.stubGlobal("URL", TestURL);
render(
<EventDetail
item={{ id: "envelope", type: "envelope", payload: raw, text: raw }}
/>,
);
const attachments = screen.getByRole("region", {
name: "Envelope attachments",
});
// Collapsed attachments show header metadata without decoding the payload.
expect(attachments.textContent).toContain("notes.txt");
expect(attachments.textContent).toContain("text/plain · 14 bytes");
expect(attachments.textContent).not.toContain("captured notes");
expect(create).not.toHaveBeenCalled();

const toggle = screen.getByRole("button", { name: "Show notes.txt preview" });
fireEvent.click(toggle);
expect(attachments.textContent).toContain("captured notes");
expect(create).toHaveBeenCalledTimes(1);
expect(
screen.getByRole("link", { name: "Download attachment" }),
).toBeDefined();

// The same button stays mounted, so keyboard focus survives the toggle.
expect(toggle.getAttribute("aria-expanded")).toBe("true");
fireEvent.click(
screen.getByRole("button", { name: "Hide notes.txt preview" }),
);
expect(screen.getByRole("button", { name: "Show notes.txt preview" })).toBe(
toggle,
);
expect(attachments.textContent).not.toContain("captured notes");
expect(
screen.getByRole("region", { name: "Envelope attachments" }).textContent,
).toContain("captured notes");
screen.queryByRole("link", { name: "Download attachment" }),
).toBeNull();
fireEvent.click(screen.getByRole("tab", { name: "JSON" }));
expect(screen.getByTestId("highlighted-json").textContent).toContain(
"notes.txt",
Expand Down
Loading