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
45 changes: 36 additions & 9 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,33 @@ export default function App() {
const sandboxSessionIdRef = useRef(sandboxSession?.id ?? "");
const sandboxActiveAssistantTurnIdRef = useRef("");
const sandboxUploadRunRef = useRef(0);
const sandboxPreviewUrlsRef = useRef<Set<string>>(new Set());
sandboxSessionIdRef.current = sandboxSession?.id ?? "";
useEffect(() => () => {
for (const previewUrl of sandboxPreviewUrlsRef.current) {
URL.revokeObjectURL(previewUrl);
}
sandboxPreviewUrlsRef.current.clear();
}, []);

function createSandboxPreviewUrl(file: File) {
const previewUrl = URL.createObjectURL(file);
sandboxPreviewUrlsRef.current.add(previewUrl);
return previewUrl;
}

function releaseSandboxPreviewUrl(previewUrl?: string) {
if (!previewUrl || !sandboxPreviewUrlsRef.current.delete(previewUrl)) return;
URL.revokeObjectURL(previewUrl);
}

function releaseAllSandboxPreviews() {
for (const previewUrl of sandboxPreviewUrlsRef.current) {
URL.revokeObjectURL(previewUrl);
}
sandboxPreviewUrlsRef.current.clear();
}

// Turns are stored PER SESSION, so a background stream can keep updating its
// own session's transcript while you view another one — no cross-session
// leak, no data loss, and no re-fetch when you switch back (its entry is
Expand Down Expand Up @@ -953,6 +979,7 @@ export default function App() {
},
onSnapshot: (snapshot) => {
const activeSessionId = sandboxSessionIdRef.current;
releaseAllSandboxPreviews();
setSandboxTurns(sandboxSnapshotTurns(snapshot));
setSandboxSession((current) =>
current?.id === activeSessionId
Expand Down Expand Up @@ -2286,6 +2313,7 @@ export default function App() {
setSkillCreating(false);
discardDraftAttachments(attachments);
setAttachments([]);
releaseAllSandboxPreviews();
setSandboxTurns([]);
setSandboxSession(nextSession);
setCreateView(null);
Expand Down Expand Up @@ -2330,6 +2358,7 @@ export default function App() {
setPendingTurns([]);
setInput("");
setInvocation(emptyInvocation());
releaseAllSandboxPreviews();
setSandboxTurns([]);
setSandboxSession(connected);
setSandboxAgentDetailTarget(null);
Expand Down Expand Up @@ -2375,8 +2404,8 @@ export default function App() {
sandboxSessionIdRef.current = "";
sandboxActiveAssistantTurnIdRef.current = "";
setSandboxBusy(false);
releaseAllSandboxPreviews();
setSandboxTurns([]);
releaseAttachmentPreviews(attachments);
setAttachments([]);
setInput("");
setError("");
Expand Down Expand Up @@ -2564,7 +2593,7 @@ export default function App() {
name: file.name,
sizeBytes: file.size,
status: "uploading",
previewUrl: URL.createObjectURL(file),
previewUrl: createSandboxPreviewUrl(file),
};
return { file, attachment };
});
Expand Down Expand Up @@ -2636,17 +2665,17 @@ export default function App() {
if (sandboxUploadRunRef.current === uploadRun) {
setSandboxUploadBusy(false);
} else {
releaseAttachmentPreviews(
drafts.map(({ attachment }) => attachment),
);
for (const { attachment } of drafts) {
releaseSandboxPreviewUrl(attachment.previewUrl);
}
}
}
}

function removeSandboxAttachment(id: string) {
const removed = attachments.find((item) => item.id === id);
if (!removed) return;
releaseAttachmentPreviews([removed]);
releaseSandboxPreviewUrl(removed.previewUrl);
setAttachments((current) => current.filter((item) => item.id !== id));
}

Expand Down Expand Up @@ -2690,6 +2719,7 @@ export default function App() {
mimeType: attachment.mimeType,
name: attachment.name,
sizeBytes: attachment.sizeBytes,
previewUrl: attachment.previewUrl,
})),
});
}
Expand Down Expand Up @@ -2808,14 +2838,11 @@ export default function App() {
}
return next;
});
releaseAttachmentPreviews(messageAttachments);
} catch (messageError) {
if ((messageError as Error)?.name === "AbortError") {
releaseAttachmentPreviews(messageAttachments);
return;
}
if (sandboxMessageAbortRef.current !== controller) {
releaseAttachmentPreviews(messageAttachments);
return;
}
setSandboxTurns((current) =>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface AttachmentView {
uri?: string;
name?: string;
sizeBytes?: number;
previewUrl?: string;
}

export type Block =
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ui/Media.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function MediaGroup({ appName, items, compact = false, onRemove }: MediaG
type="button"
className="media-card-main"
disabled={disabled}
onClick={() => setOpen(item)}
onClick={kind === "image" ? undefined : () => setOpen(item)}
aria-label={`预览 ${item.name ?? "附件"}`}
>
{kind === "image" && source ? (
Expand Down
19 changes: 19 additions & 0 deletions frontend/tests/mediaPreview.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

const mediaSource = readFileSync(
new URL("../src/ui/Media.tsx", import.meta.url),
"utf8",
);

test("image attachments open only the shared photo viewer", () => {
assert.match(
mediaSource,
/onClick=\{kind === "image" \? undefined : \(\) => setOpen\(item\)\}/,
);
assert.match(
mediaSource,
/kind === "image" && !disabled[\s\S]*?<PhotoView src=\{source\}>\{previewButton\}<\/PhotoView>/,
);
});
11 changes: 11 additions & 0 deletions frontend/tests/sandboxCodexControls.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ test("Codex token usage and approvals are presented per assistant turn", () => {
assert.match(controlsSource, /保存权限/);
});

test("Codex image attachments keep their preview until the transcript is cleared", () => {
assert.match(blocksSource, /previewUrl\?: string/);
assert.match(
appSource,
/files: readyAttachments\.map[\s\S]*?previewUrl: attachment\.previewUrl/,
);
assert.match(appSource, /sandboxPreviewUrlsRef/);
assert.match(appSource, /releaseAllSandboxPreviews\(\)/);
assert.doesNotMatch(appSource, /releaseAttachmentPreviews\(messageAttachments\)/);
});

test("sandbox dialogs provide explicit loading error and keyboard states", () => {
assert.match(controlsSource, /role="dialog"/);
assert.match(controlsSource, /if \(event\.key === "Escape"\)/);
Expand Down

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading