Skip to content

Commit f39ffcb

Browse files
committed
Implement preview panel functionality and state management
- Introduce a new PreviewPanel component for displaying local previews within the chat interface. - Integrate preview state management using Zustand for handling thread-specific preview states. - Enhance ChatView and ChatHeader components to support toggling the preview panel. - Add tests for preview state validation and bounds sanitization. - Refactor existing components to accommodate the new preview functionality while maintaining existing features. Made-with: Cursor
1 parent 9182a02 commit f39ffcb

11 files changed

Lines changed: 648 additions & 21 deletions

File tree

apps/desktop/src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,9 @@ function getPreviewController(window: BrowserWindow): DesktopPreviewController {
762762
}
763763

764764
function resolvePreviewWindow(sender: Electron.WebContents): BrowserWindow | null {
765-
return BrowserWindow.fromWebContents(sender) ?? mainWindow ?? BrowserWindow.getFocusedWindow() ?? null;
765+
return (
766+
BrowserWindow.fromWebContents(sender) ?? mainWindow ?? BrowserWindow.getFocusedWindow() ?? null
767+
);
766768
}
767769

768770
function setUpdateState(patch: Partial<DesktopUpdateState>): void {

apps/desktop/src/previewController.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
type BrowserWindow,
3-
type HandlerDetails,
4-
shell,
5-
WebContentsView,
6-
} from "electron";
1+
import { type BrowserWindow, type HandlerDetails, shell, WebContentsView } from "electron";
72
import type {
83
DesktopPreviewBounds,
94
DesktopPreviewState,
@@ -66,7 +61,8 @@ export class DesktopPreviewController {
6661
return { accepted: false, state: this.state };
6762
}
6863

69-
const nextTitle = typeof input.title === "string" && input.title.trim().length > 0 ? input.title : null;
64+
const nextTitle =
65+
typeof input.title === "string" && input.title.trim().length > 0 ? input.title : null;
7066
const view = this.ensureView();
7167
this.setState({
7268
status: "loading",
@@ -90,10 +86,14 @@ export class DesktopPreviewController {
9086
return;
9187
}
9288
this.setState(
93-
createPreviewErrorState("load-failed", error instanceof Error ? error.message : String(error), {
94-
url: validatedUrl.url,
95-
title: this.state.title,
96-
}),
89+
createPreviewErrorState(
90+
"load-failed",
91+
error instanceof Error ? error.message : String(error),
92+
{
93+
url: validatedUrl.url,
94+
title: this.state.title,
95+
},
96+
),
9797
);
9898
});
9999

apps/web/src/components/ChatView.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ import {
182182
SendPhase,
183183
} from "./ChatView.logic";
184184
import { useLocalStorage } from "~/hooks/useLocalStorage";
185+
import { usePreviewStateStore } from "~/previewStateStore";
185186

186187
const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
187188
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
@@ -253,6 +254,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
253254
const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel);
254255
const timestampFormat = settings.timestampFormat;
255256
const navigate = useNavigate();
257+
const previewOpen = usePreviewStateStore((state) => state.openByThreadId[threadId] === true);
258+
const togglePreviewOpen = usePreviewStateStore((state) => state.toggleThreadOpen);
256259
const rawSearch = useSearch({
257260
strict: false,
258261
select: (params) => parseDiffRouteSearch(params),
@@ -3501,6 +3504,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
35013504
terminalOpen={terminalState.terminalOpen}
35023505
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
35033506
diffToggleShortcutLabel={diffPanelShortcutLabel}
3507+
previewAvailable={isElectron && activeProject !== undefined}
3508+
previewOpen={previewOpen}
35043509
gitCwd={gitCwd}
35053510
diffOpen={diffOpen}
35063511
onRunProjectScript={(script) => {
@@ -3511,6 +3516,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
35113516
onDeleteProjectScript={deleteProjectScript}
35123517
onToggleTerminal={toggleTerminalVisibility}
35133518
onToggleDiff={onToggleDiff}
3519+
onTogglePreview={() => togglePreviewOpen(activeThread.id)}
35143520
/>
35153521
</header>
35163522

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { resolvePreviewStatusCopy } from "./PreviewPanel";
4+
5+
describe("resolvePreviewStatusCopy", () => {
6+
it("returns actionable copy for closed, loading, and ready states", () => {
7+
expect(
8+
resolvePreviewStatusCopy({
9+
status: "closed",
10+
url: null,
11+
title: null,
12+
visible: false,
13+
error: null,
14+
}),
15+
).toContain("localhost URL");
16+
17+
expect(
18+
resolvePreviewStatusCopy({
19+
status: "loading",
20+
url: "http://localhost:3000/",
21+
title: null,
22+
visible: true,
23+
error: null,
24+
}),
25+
).toContain("Loading");
26+
27+
expect(
28+
resolvePreviewStatusCopy({
29+
status: "ready",
30+
url: "http://localhost:3000/",
31+
title: "App",
32+
visible: true,
33+
error: null,
34+
}),
35+
).toContain("http://localhost:3000/");
36+
});
37+
38+
it("prefers explicit preview errors", () => {
39+
expect(
40+
resolvePreviewStatusCopy({
41+
status: "error",
42+
url: "http://localhost:3000/",
43+
title: null,
44+
visible: false,
45+
error: {
46+
code: "load-failed",
47+
message: "Dev server did not respond.",
48+
},
49+
}),
50+
).toBe("Dev server did not respond.");
51+
});
52+
});

0 commit comments

Comments
 (0)