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
27 changes: 4 additions & 23 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController";
import { isTimelineScrollTarget } from "./chat/timelineScrollTarget";
import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
import { ExpandedImageDialogHost, openExpandedImagePreview } from "./chat/ExpandedImageDialog";
import {
BranchRestoreConfirmHost,
DeviceSetupDialogHost,
Expand Down Expand Up @@ -417,7 +417,7 @@ import {
type PanelLayoutControlsProps,
RightPanelMaximizeControl,
} from "./chat/PanelLayoutControls";
import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import type { ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel";
import { NoActiveThreadState } from "./NoActiveThreadState";
import {
Expand Down Expand Up @@ -1275,13 +1275,6 @@ export default function ChatView(props: ChatViewProps) {
const citeAssistantTextStable = useStableCallback(citeAssistantText);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
useEffect(() => {
const item = expandedImage?.images[expandedImage.index];
if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return;
const src = item.src;
return () => revokeBlobPreviewUrl(src);
}, [expandedImage]);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
// Set by "Work locally": the draft whose restored message should be resent
// once the cancelled dispatch has settled and the draft is in local mode.
Expand Down Expand Up @@ -4657,19 +4650,13 @@ export default function ChatView(props: ChatViewProps) {
setRetiredOptimisticMessages(optimisticUserMessages);
setOptimisticUserMessages([]);
resetLocalDispatch();
setExpandedImage(null);
}
useEffect(() => {
for (const message of retiredOptimisticMessages) {
revokeUserMessagePreviewUrls(message);
}
}, [retiredOptimisticMessages]);

const closeExpandedImage = () => {
setExpandedImage(null);
};
const closeExpandedImageStable = useStableCallback(closeExpandedImage);

const activeWorktreePath = activeThread?.worktreePath ?? null;
const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
activeWorktreePath,
Expand Down Expand Up @@ -7695,7 +7682,7 @@ export default function ChatView(props: ChatViewProps) {
};

const onExpandTimelineImage = (preview: ExpandedImagePreview) => {
setExpandedImage(preview);
openExpandedImagePreview(routeThreadKey, preview);
};
const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage);
const onOpenTurnDiff = (runId: RunId, filePath?: string) => {
Expand Down Expand Up @@ -8672,13 +8659,7 @@ export default function ChatView(props: ChatViewProps) {

<RevertConfirmHost threadKey={routeThreadKey} onConfirm={onConfirmRevertStable} />
<LinkPullRequestDialogHost />
{expandedImage && (
<ExpandedImageDialog
key={expandedImageKey(expandedImage)}
preview={expandedImage}
onClose={closeExpandedImageStable}
/>
)}
<ExpandedImageDialogHost threadKey={routeThreadKey} />
</div>
);
return (
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { AppAtomRegistryProvider } from "~/rpc/atomRegistry";
import { ExpandedImageDialogHost, openExpandedImagePreview } from "./ExpandedImageDialog";

let root: Root;
let container: HTMLDivElement;

const preview = {
images: [
{ src: "data:image/gif;base64,R0lGODlhAQABAAAAACw=", name: "one.png" },
{ src: "data:image/gif;base64,R0lGODlhAQABAAAAACw=", name: "two.png" },
],
index: 0,
};

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});

afterEach(async () => {
await act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

const host = (threadKey: string) => (
<AppAtomRegistryProvider>
<ExpandedImageDialogHost threadKey={threadKey} />
</AppAtomRegistryProvider>
);

const dialog = () => document.querySelector('[role="dialog"]');

describe("ExpandedImageDialogHost", () => {
it("opens for its thread, steps with arrows, and closes on Escape with focus back on the opener", async () => {
const opener = document.createElement("button");
document.body.append(opener);
await act(() => root.render(host("env:a")));
opener.focus();

await act(() => openExpandedImagePreview("env:a", preview));
expect(dialog()?.textContent).toContain("one.png (1/2)");

await act(() => {
dialog()?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
});
expect(dialog()?.textContent).toContain("two.png (2/2)");

await act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
await vi.waitFor(() => expect(dialog()).toBeNull());
expect(document.activeElement).toBe(opener);
opener.remove();
});

it("ignores another thread's preview and drops its own when the thread changes", async () => {
await act(() => root.render(host("env:a")));
await act(() => openExpandedImagePreview("env:b", preview));
expect(dialog()).toBeNull();

await act(() => openExpandedImagePreview("env:a", preview));
expect(dialog()).not.toBeNull();

await act(() => root.render(host("env:b")));
await vi.waitFor(() => expect(dialog()).toBeNull());
});
});
48 changes: 48 additions & 0 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage";
import { composerFloatingLayerProps } from "./composerEventScope";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/reactivity";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { expandedImageKey } from "./ExpandedImagePreview";

interface ExpandedImageDialogProps {
preview: ExpandedImagePreview;
Expand Down Expand Up @@ -282,3 +286,47 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
</Dialog>
);
});

/**
* The chat view's open media preview. It lives outside ChatView so opening or
* closing it renders only the dialog host, not the whole chat chrome.
*/
const expandedImagePreviewAtom = Atom.make<{
readonly threadKey: string;
readonly preview: ExpandedImagePreview;
} | null>(null).pipe(Atom.keepAlive, Atom.withLabel("chat:expanded-image-preview"));

export function openExpandedImagePreview(threadKey: string, preview: ExpandedImagePreview): void {
appAtomRegistry.set(expandedImagePreviewAtom, { threadKey, preview });
}

function closeExpandedImagePreview(): void {
appAtomRegistry.set(expandedImagePreviewAtom, null);
}

function closeExpandedImagePreviewFor(threadKey: string): void {
if (appAtomRegistry.get(expandedImagePreviewAtom)?.threadKey === threadKey) {
closeExpandedImagePreview();
}
}

export function ExpandedImageDialogHost({ threadKey }: { readonly threadKey: string }) {
const state = useAtomValue(expandedImagePreviewAtom);
const preview = state?.threadKey === threadKey ? state.preview : null;
const item = preview?.images[preview.index];
const ownedSrc = item?.ownsSrc === true ? item.src : null;
useEffect(() => {
if (ownedSrc === null) return;
return () => URL.revokeObjectURL(ownedSrc);
}, [ownedSrc]);
// Leaving the thread (or the chat view) drops its preview.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
useEffect(() => () => closeExpandedImagePreviewFor(threadKey), [threadKey]);
if (preview === null) return null;
return (
<ExpandedImageDialog
key={expandedImageKey(preview)}
preview={preview}
onClose={closeExpandedImagePreview}
/>
);
}
11 changes: 10 additions & 1 deletion apps/web/src/components/chat/ExpandedImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface ExpandedImageItem {
src: string | null;
name: string;
type?: "video";
/** The preview created this object URL and must revoke it; other blob URLs belong to the timeline. */
ownsSrc?: boolean;
source?: SnapShotSource;
autoPlay?: boolean;
originalUrl?: string;
Expand Down Expand Up @@ -167,7 +169,14 @@ export function buildExpandedImagePreview(
const selected = images.find((image) => image.id === selectedImageId);
if (selected?.type === "file" && selected.file && isVideoAttachment(selected)) {
return {
images: [{ src: URL.createObjectURL(selected.file), name: selected.name, type: "video" }],
images: [
{
src: URL.createObjectURL(selected.file),
name: selected.name,
type: "video",
ownsSrc: true,
},
],
index: 0,
};
}
Expand Down
Loading