Skip to content

Commit 1b29190

Browse files
authored
Scope preview open state to thread (#476)
- Persist preview visibility per thread instead of per project - Update preview toggles, layout actions, and app-browser open flow - Bump preview state storage and add migration coverage
1 parent 2414a0c commit 1b29190

10 files changed

Lines changed: 137 additions & 59 deletions

apps/web/src/components/ChatView.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -434,10 +434,10 @@ export default function ChatView({
434434
const navigate = useNavigate();
435435
const activeProjectId = threads.find((t) => t.id === threadId)?.projectId ?? null;
436436
const previewOpen = usePreviewStateStore((state) =>
437-
activeProjectId ? (state.openByProjectId[activeProjectId] ?? false) : false,
437+
activeThreadId ? (state.openByThreadId[activeThreadId] ?? false) : false,
438438
);
439-
const togglePreviewOpen = usePreviewStateStore((state) => state.toggleProjectOpen);
440-
const setPreviewOpen = usePreviewStateStore((state) => state.setProjectOpen);
439+
const togglePreviewOpen = usePreviewStateStore((state) => state.toggleThreadOpen);
440+
const setPreviewOpen = usePreviewStateStore((state) => state.setThreadOpen);
441441
const previewDock = usePreviewStateStore((state) =>
442442
activeProjectId ? (state.dockByProjectId[activeProjectId] ?? "top") : "top",
443443
);
@@ -1747,7 +1747,7 @@ export default function ChatView({
17471747
const handlePreviewUrl = useCallback(
17481748
(url: string) => {
17491749
if (!activeProject || !activeThread) return;
1750-
setPreviewOpen(activeProject.id, true);
1750+
setPreviewOpen(activeThread.id, true);
17511751
void previewBridgeRef?.createTab({ url });
17521752
},
17531753
[activeProject, activeThread, setPreviewOpen, previewBridgeRef],
@@ -4937,7 +4937,7 @@ export default function ChatView({
49374937
onImportProjectScripts={importProjectScripts}
49384938
onToggleTerminal={toggleTerminalVisibility}
49394939
onPrefetchTerminal={preloadThreadTerminalDrawer}
4940-
onTogglePreview={() => activeProjectId && togglePreviewOpen(activeProjectId)}
4940+
onTogglePreview={() => activeThreadId && togglePreviewOpen(activeThreadId)}
49414941
onTogglePreviewLayout={() => activeProjectId && togglePreviewLayout(activeProjectId)}
49424942
onMinimize={onMinimize}
49434943
/>
@@ -4980,7 +4980,7 @@ export default function ChatView({
49804980
key={previewPanelKey ?? undefined}
49814981
projectId={activeProject!.id}
49824982
threadId={threadId}
4983-
onClose={() => setPreviewOpen(activeProject!.id, false)}
4983+
onClose={() => setPreviewOpen(threadId, false)}
49844984
/>
49854985
</div>
49864986
<div
@@ -5003,7 +5003,7 @@ export default function ChatView({
50035003
key={previewPanelKey ?? undefined}
50045004
projectId={activeProject!.id}
50055005
threadId={threadId}
5006-
onClose={() => setPreviewOpen(activeProject!.id, false)}
5006+
onClose={() => setPreviewOpen(threadId, false)}
50075007
/>
50085008
</div>
50095009
) : null}
@@ -5884,7 +5884,7 @@ export default function ChatView({
58845884
key={previewPanelKey ?? undefined}
58855885
projectId={activeProject!.id}
58865886
threadId={threadId}
5887-
onClose={() => setPreviewOpen(activeProject!.id, false)}
5887+
onClose={() => setPreviewOpen(threadId, false)}
58885888
/>
58895889
</div>
58905890
</>

apps/web/src/components/GitActionsControl.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ export default function GitActionsControl({
367367
activeProjectId,
368368
}: GitActionsControlProps) {
369369
const { settings } = useAppSettings();
370-
const setPreviewOpen = usePreviewStateStore((state) => state.setProjectOpen);
370+
const setPreviewOpen = usePreviewStateStore((state) => state.setThreadOpen);
371371
const openFileInViewer = useFileViewNavigation();
372372
const threadToastData = useMemo(
373373
() => (activeThreadId ? { threadId: activeThreadId } : undefined),

apps/web/src/components/PreviewPanel.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PreviewTabsState, PreviewTabState, ProjectId } from "@okcode/contracts";
1+
import type { PreviewTabsState, PreviewTabState, ProjectId, ThreadId } from "@okcode/contracts";
22
import { type FormEvent, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
33
import {
44
ChevronLeftIcon,
@@ -111,7 +111,7 @@ function tabDisplayTitle(tab: PreviewTabState): string {
111111

112112
interface PreviewPanelProps {
113113
projectId: ProjectId;
114-
threadId: string;
114+
threadId: ThreadId;
115115
onClose: () => void;
116116
}
117117

@@ -149,7 +149,7 @@ function resolveViewportDimensions(
149149
export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps) {
150150
const { settings } = useAppSettings();
151151
const previewBridge = readDesktopPreviewBridge();
152-
const setProjectOpen = usePreviewStateStore((state) => state.setProjectOpen);
152+
const setThreadOpen = usePreviewStateStore((state) => state.setThreadOpen);
153153
const favoriteUrls = usePreviewStateStore((state) => state.favoriteUrls);
154154
const toggleFavoriteUrl = usePreviewStateStore((state) => state.toggleFavoriteUrl);
155155
const presetId = usePreviewStateStore((state) => state.presetByProjectId[projectId] ?? null);
@@ -422,7 +422,7 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps
422422
};
423423

424424
const onClosePreview = () => {
425-
setProjectOpen(projectId, false);
425+
setThreadOpen(threadId, false);
426426
void previewBridge?.closeAll();
427427
onClose();
428428
};

apps/web/src/hooks/useLayoutActions.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export function useLayoutActions(): UseLayoutActionsResult {
137137
const diffViewerOpen = diffState.isOpen;
138138
const simulationOpen = useSimulationViewerStore.getState().isOpen;
139139
const previewState = usePreviewStateStore.getState();
140-
const previewOpen = projectId ? (previewState.openByProjectId[projectId] ?? false) : false;
140+
const previewOpen = threadId ? (previewState.openByThreadId[threadId] ?? false) : false;
141141

142142
const terminalStoreState = useTerminalStateStore.getState();
143143
const threadTerminal = threadId
@@ -184,8 +184,8 @@ export function useLayoutActions(): UseLayoutActionsResult {
184184
if (codeViewerStore.isOpen) codeViewerStore.close();
185185
if (diffViewerStore.isOpen) diffViewerStore.close();
186186
if (simulationStore.isOpen) simulationStore.close();
187-
if (projectId && previewStore.openByProjectId[projectId]) {
188-
previewStore.setProjectOpen(projectId, false);
187+
if (threadId && previewStore.openByThreadId[threadId]) {
188+
previewStore.setThreadOpen(threadId, false);
189189
}
190190

191191
// ── 2. Open the target panel ───────────────────────────────
@@ -199,8 +199,8 @@ export function useLayoutActions(): UseLayoutActionsResult {
199199
}
200200
break;
201201
case "preview":
202-
if (projectId) {
203-
usePreviewStateStore.getState().setProjectOpen(projectId, true);
202+
if (threadId) {
203+
usePreviewStateStore.getState().setThreadOpen(threadId, true);
204204
}
205205
break;
206206
case "simulation":

apps/web/src/lib/openGitHubUrl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface OpenGitHubUrlInput {
1010
threadId: ThreadId | null;
1111
nativeApi?: NativeApi | undefined;
1212
previewBridge?: DesktopBridge["preview"] | null | undefined;
13-
setPreviewOpen?: ((projectId: ProjectId, open: boolean) => void) | undefined;
13+
setPreviewOpen?: ((threadId: ThreadId, open: boolean) => void) | undefined;
1414
}
1515

1616
function isGitHubHttpUrl(url: string): boolean {

apps/web/src/lib/openUrlInAppBrowser.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe("openUrlInAppBrowser", () => {
2929
});
3030

3131
expect(result).toBe("preview");
32-
expect(setPreviewOpen).toHaveBeenCalledWith(projectId("project-1"), true);
32+
expect(setPreviewOpen).toHaveBeenCalledWith(threadId("thread-1"), true);
3333
expect(createTab).toHaveBeenCalledWith({
3434
url: "https://tweakcn.com",
3535
threadId: threadId("thread-1"),

apps/web/src/lib/openUrlInAppBrowser.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@ export interface OpenUrlInAppBrowserInput {
1010
threadId: ThreadId | null;
1111
nativeApi?: NativeApi | undefined;
1212
previewBridge?: DesktopBridge["preview"] | null | undefined;
13-
setPreviewOpen?: ((projectId: ProjectId, open: boolean) => void) | undefined;
13+
setPreviewOpen?: ((threadId: ThreadId, open: boolean) => void) | undefined;
1414
popOut?: boolean | undefined;
1515
}
1616

1717
export async function openUrlInAppBrowser(
1818
input: OpenUrlInAppBrowserInput,
1919
): Promise<"preview" | "popout" | "external"> {
2020
const previewBridge = input.previewBridge ?? readDesktopPreviewBridge();
21-
const setPreviewOpen = input.setPreviewOpen ?? usePreviewStateStore.getState().setProjectOpen;
21+
const setPreviewOpen = input.setPreviewOpen ?? usePreviewStateStore.getState().setThreadOpen;
2222

2323
if (previewBridge !== null && input.projectId !== null && input.threadId !== null) {
24-
setPreviewOpen(input.projectId, true);
24+
setPreviewOpen(input.threadId, true);
2525
await previewBridge.createTab({ url: input.url, threadId: input.threadId });
2626
if (input.popOut) {
2727
await previewBridge.popOut();

apps/web/src/previewStateStore.test.ts

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const STORAGE_KEY = "okcode:desktop-preview:v5";
3+
const STORAGE_KEY = "okcode:desktop-preview:v6";
44

55
let usePreviewStateStore: typeof import("./previewStateStore").usePreviewStateStore;
66
let storage: Map<string, string>;
@@ -26,7 +26,7 @@ describe("previewStateStore", () => {
2626

2727
({ usePreviewStateStore } = await import("./previewStateStore"));
2828
usePreviewStateStore.setState({
29-
openByProjectId: {},
29+
openByThreadId: {},
3030
dockByProjectId: {},
3131
sizeByProjectId: {},
3232
presetByProjectId: {},
@@ -50,29 +50,47 @@ describe("previewStateStore", () => {
5050
expect(usePreviewStateStore.getState().favoriteUrls).not.toContain("http://localhost:3000/");
5151
});
5252

53-
it("toggles project open state", () => {
53+
it("toggles thread open state", () => {
5454
const store = usePreviewStateStore.getState();
55-
const projectId = "test-project-id" as any;
55+
const threadId = "test-thread-id" as any;
5656

57-
store.setProjectOpen(projectId, true);
58-
expect(usePreviewStateStore.getState().openByProjectId[projectId]).toBe(true);
59-
expect(storage.get(STORAGE_KEY)).toContain('"openByProjectId"');
57+
store.setThreadOpen(threadId, true);
58+
expect(usePreviewStateStore.getState().openByThreadId[threadId]).toBe(true);
59+
expect(storage.get(STORAGE_KEY)).toContain('"openByThreadId"');
6060

61-
store.toggleProjectOpen(projectId);
62-
expect(usePreviewStateStore.getState().openByProjectId[projectId]).toBe(false);
61+
store.toggleThreadOpen(threadId);
62+
expect(usePreviewStateStore.getState().openByThreadId[threadId]).toBe(false);
6363
});
6464

65-
it("scopes open state per project", () => {
65+
it("scopes open state per thread", () => {
6666
const store = usePreviewStateStore.getState();
67-
const projectA = "project-a" as any;
68-
const projectB = "project-b" as any;
67+
const threadA = "thread-a" as any;
68+
const threadB = "thread-b" as any;
6969

70-
store.setProjectOpen(projectA, true);
71-
expect(usePreviewStateStore.getState().openByProjectId[projectA]).toBe(true);
72-
expect(usePreviewStateStore.getState().openByProjectId[projectB]).toBeUndefined();
70+
store.setThreadOpen(threadA, true);
71+
expect(usePreviewStateStore.getState().openByThreadId[threadA]).toBe(true);
72+
expect(usePreviewStateStore.getState().openByThreadId[threadB]).toBeUndefined();
7373

74-
store.setProjectOpen(projectB, false);
75-
expect(usePreviewStateStore.getState().openByProjectId[projectA]).toBe(true);
76-
expect(usePreviewStateStore.getState().openByProjectId[projectB]).toBe(false);
74+
store.setThreadOpen(threadB, false);
75+
expect(usePreviewStateStore.getState().openByThreadId[threadA]).toBe(true);
76+
expect(usePreviewStateStore.getState().openByThreadId[threadB]).toBe(false);
77+
});
78+
79+
it("does not migrate old project-scoped open state into the new thread-scoped field", async () => {
80+
storage.set(
81+
"okcode:desktop-preview:v5",
82+
JSON.stringify({
83+
openByProjectId: { "project-a": true },
84+
dockByProjectId: { "project-a": "top" },
85+
sizeByProjectId: { "project-a": 420 },
86+
}),
87+
);
88+
89+
vi.resetModules();
90+
({ usePreviewStateStore } = await import("./previewStateStore"));
91+
92+
expect(usePreviewStateStore.getState().openByThreadId).toEqual({});
93+
expect(usePreviewStateStore.getState().dockByProjectId["project-a"]).toBe("top");
94+
expect(usePreviewStateStore.getState().sizeByProjectId["project-a"]).toBe(420);
7795
});
7896
});

apps/web/src/previewStateStore.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ProjectId } from "@okcode/contracts";
1+
import type { ProjectId, ThreadId } from "@okcode/contracts";
22
import { create } from "zustand";
33

44
import type { BrowserPresetId } from "./lib/browserPresets";
@@ -13,7 +13,7 @@ export interface CustomViewport {
1313
}
1414

1515
interface PersistedPreviewUiState {
16-
openByProjectId: Record<string, boolean>;
16+
openByThreadId: Record<string, boolean>;
1717
dockByProjectId: Record<string, PreviewDock>;
1818
sizeByProjectId: Record<string, number>;
1919
presetByProjectId: Record<string, BrowserPresetId>;
@@ -26,8 +26,8 @@ interface PersistedPreviewUiState {
2626
}
2727

2828
interface PreviewStateStore extends PersistedPreviewUiState {
29-
setProjectOpen: (projectId: ProjectId, open: boolean) => void;
30-
toggleProjectOpen: (projectId: ProjectId) => void;
29+
setThreadOpen: (threadId: ThreadId, open: boolean) => void;
30+
toggleThreadOpen: (threadId: ThreadId) => void;
3131
setProjectDock: (projectId: ProjectId, dock: PreviewDock) => void;
3232
toggleProjectLayout: (projectId: ProjectId) => void;
3333
setProjectSize: (projectId: ProjectId, size: number) => void;
@@ -42,7 +42,8 @@ interface PreviewStateStore extends PersistedPreviewUiState {
4242
toggleFullscreen: (projectId: ProjectId) => void;
4343
}
4444

45-
const PREVIEW_STATE_STORAGE_KEY = "okcode:desktop-preview:v5";
45+
const PREVIEW_STATE_STORAGE_KEY = "okcode:desktop-preview:v6";
46+
const PREVIEW_STATE_STORAGE_KEY_V5 = "okcode:desktop-preview:v5";
4647
const PREVIEW_STATE_STORAGE_KEY_V4 = "okcode:desktop-preview:v4";
4748

4849
const VALID_PRESETS = new Set<string>([
@@ -88,7 +89,7 @@ function clampCustomViewport(viewport: CustomViewport): CustomViewport {
8889

8990
function createEmptyPersistedPreviewUiState(): PersistedPreviewUiState {
9091
return {
91-
openByProjectId: {},
92+
openByThreadId: {},
9293
dockByProjectId: {},
9394
sizeByProjectId: {},
9495
presetByProjectId: {},
@@ -113,8 +114,11 @@ function readPersistedPreviewUiState(): PersistedPreviewUiState {
113114
}
114115

115116
try {
116-
// Try v5 first, fall back to v4 for migration
117+
// Try v6 first, then older keys for migration.
117118
let raw = window.localStorage.getItem(PREVIEW_STATE_STORAGE_KEY);
119+
if (!raw) {
120+
raw = window.localStorage.getItem(PREVIEW_STATE_STORAGE_KEY_V5);
121+
}
118122
if (!raw) {
119123
raw = window.localStorage.getItem(PREVIEW_STATE_STORAGE_KEY_V4);
120124
}
@@ -124,10 +128,10 @@ function readPersistedPreviewUiState(): PersistedPreviewUiState {
124128

125129
const parsed = JSON.parse(raw) as Partial<PersistedPreviewUiState>;
126130
return {
127-
openByProjectId:
128-
parsed.openByProjectId && typeof parsed.openByProjectId === "object"
131+
openByThreadId:
132+
parsed.openByThreadId && typeof parsed.openByThreadId === "object"
129133
? Object.fromEntries(
130-
Object.entries(parsed.openByProjectId).filter(
134+
Object.entries(parsed.openByThreadId).filter(
131135
(entry): entry is [string, boolean] =>
132136
typeof entry[0] === "string" && typeof entry[1] === "boolean",
133137
),
@@ -224,7 +228,7 @@ function persistPreviewUiState(state: PersistedPreviewUiState): void {
224228
window.localStorage.setItem(
225229
PREVIEW_STATE_STORAGE_KEY,
226230
JSON.stringify({
227-
openByProjectId: state.openByProjectId,
231+
openByThreadId: state.openByThreadId,
228232
dockByProjectId: state.dockByProjectId,
229233
sizeByProjectId: state.sizeByProjectId,
230234
presetByProjectId: state.presetByProjectId,
@@ -242,7 +246,7 @@ function persistPreviewUiState(state: PersistedPreviewUiState): void {
242246

243247
function snapshotState(state: PreviewStateStore): PersistedPreviewUiState {
244248
return {
245-
openByProjectId: state.openByProjectId,
249+
openByThreadId: state.openByThreadId,
246250
dockByProjectId: state.dockByProjectId,
247251
sizeByProjectId: state.sizeByProjectId,
248252
presetByProjectId: state.presetByProjectId,
@@ -259,16 +263,16 @@ const initialState = readPersistedPreviewUiState();
259263
export const usePreviewStateStore = create<PreviewStateStore>((set, get) => ({
260264
...initialState,
261265

262-
setProjectOpen: (projectId, open) => {
266+
setThreadOpen: (threadId, open) => {
263267
set((state) => {
264-
const nextOpenByProjectId = { ...state.openByProjectId, [projectId]: open };
265-
persistPreviewUiState({ ...snapshotState(state), openByProjectId: nextOpenByProjectId });
266-
return { openByProjectId: nextOpenByProjectId };
268+
const nextOpenByThreadId = { ...state.openByThreadId, [threadId]: open };
269+
persistPreviewUiState({ ...snapshotState(state), openByThreadId: nextOpenByThreadId });
270+
return { openByThreadId: nextOpenByThreadId };
267271
});
268272
},
269273

270-
toggleProjectOpen: (projectId) => {
271-
get().setProjectOpen(projectId, !(get().openByProjectId[projectId] ?? false));
274+
toggleThreadOpen: (threadId) => {
275+
get().setThreadOpen(threadId, !(get().openByThreadId[threadId] ?? false));
272276
},
273277

274278
setProjectDock: (projectId, _dock) => {

0 commit comments

Comments
 (0)