diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 958ce136a..345cd3271 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -382,17 +382,24 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); - const previewOpen = usePreviewStateStore((state) => state.globalOpen); - const togglePreviewOpen = usePreviewStateStore((state) => state.toggleGlobalOpen); - const setPreviewOpen = usePreviewStateStore((state) => state.setGlobalOpen); - const previewDock = usePreviewStateStore((state) => state.dockByThreadId[threadId] ?? "right"); - const previewSize = usePreviewStateStore( - (state) => state.sizeByThreadId[threadId] ?? PREVIEW_SPLIT_DEFAULT_SIZE_PX, + const activeProjectId = threads.find((t) => t.id === threadId)?.projectId ?? null; + const previewOpen = usePreviewStateStore((state) => + activeProjectId ? (state.openByProjectId[activeProjectId] ?? false) : false, + ); + const togglePreviewOpen = usePreviewStateStore((state) => state.toggleProjectOpen); + const setPreviewOpen = usePreviewStateStore((state) => state.setProjectOpen); + const previewDock = usePreviewStateStore((state) => + activeProjectId ? (state.dockByProjectId[activeProjectId] ?? "right") : "right", + ); + const previewSize = usePreviewStateStore((state) => + activeProjectId + ? (state.sizeByProjectId[activeProjectId] ?? PREVIEW_SPLIT_DEFAULT_SIZE_PX) + : PREVIEW_SPLIT_DEFAULT_SIZE_PX, ); const previewStacked = previewDock === "top" || previewDock === "bottom"; - const setPreviewDock = usePreviewStateStore((state) => state.setThreadDock); - const togglePreviewLayout = usePreviewStateStore((state) => state.toggleThreadLayout); - const setPreviewSize = usePreviewStateStore((state) => state.setThreadSize); + const setPreviewDock = usePreviewStateStore((state) => state.setProjectDock); + const togglePreviewLayout = usePreviewStateStore((state) => state.toggleProjectLayout); + const setPreviewSize = usePreviewStateStore((state) => state.setProjectSize); const previewSplitRef = useRef(null); const previewResizeStateRef = useRef<{ pointerId: number; @@ -671,9 +678,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); - const previewPanelKey = activeThread - ? `${activeThread.id}:${activeProject?.id ?? "no-project"}:${previewDock}` - : null; + const previewPanelKey = activeProject ? `${activeProject.id}:${previewDock}` : null; const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1593,7 +1598,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const handlePreviewUrl = useCallback( (url: string) => { if (!activeProject || !activeThread) return; - setPreviewOpen(true); + setPreviewOpen(activeProject.id, true); void previewBridgeRef?.createTab({ url }); }, [activeProject, activeThread, setPreviewOpen, previewBridgeRef], @@ -4549,9 +4554,9 @@ export default function ChatView({ threadId }: ChatViewProps) { PREVIEW_SPLIT_MIN_SIZE_PX, Math.min(Math.round(nextSizeUnclamped), maxSize), ); - setPreviewSize(threadId, nextSize); + if (activeProjectId) setPreviewSize(activeProjectId, nextSize); }, - [previewDock, previewStacked, setPreviewSize, threadId], + [activeProjectId, previewDock, previewStacked, setPreviewSize], ); const handlePreviewResizePointerEnd = useCallback((event: React.PointerEvent) => { @@ -4604,20 +4609,22 @@ export default function ChatView({ threadId }: ChatViewProps) { } event.preventDefault(); + if (!activeProjectId) return; + if (previewOpen && previewDock === targetDock) { - setPreviewOpen(false); + setPreviewOpen(activeProjectId, false); return; } - setPreviewOpen(true); - setPreviewDock(threadId, targetDock); + setPreviewOpen(activeProjectId, true); + setPreviewDock(activeProjectId, targetDock); }; window.addEventListener("keydown", onWindowKeyDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [activeProject, previewDock, previewOpen, setPreviewDock, setPreviewOpen, threadId]); + }, [activeProject, activeProjectId, previewDock, previewOpen, setPreviewDock, setPreviewOpen]); // Empty state: no active thread if (!activeThread) { @@ -4670,8 +4677,8 @@ export default function ChatView({ threadId }: ChatViewProps) { onImportProjectScripts={importProjectScripts} onToggleTerminal={toggleTerminalVisibility} onToggleDiff={onToggleDiff} - onTogglePreview={() => togglePreviewOpen()} - onTogglePreviewLayout={() => togglePreviewLayout(activeThread.id)} + onTogglePreview={() => activeProjectId && togglePreviewOpen(activeProjectId)} + onTogglePreviewLayout={() => activeProjectId && togglePreviewLayout(activeProjectId)} /> @@ -4706,8 +4713,8 @@ export default function ChatView({ threadId }: ChatViewProps) { > setPreviewOpen(false)} + projectId={activeProject!.id} + onClose={() => setPreviewOpen(activeProject!.id, false)} />
setPreviewOpen(false)} + projectId={activeProject!.id} + onClose={() => setPreviewOpen(activeProject!.id, false)} />
diff --git a/apps/web/src/components/PreviewPanel.tsx b/apps/web/src/components/PreviewPanel.tsx index 20ca08592..f87f0f66a 100644 --- a/apps/web/src/components/PreviewPanel.tsx +++ b/apps/web/src/components/PreviewPanel.tsx @@ -1,4 +1,4 @@ -import type { PreviewTabsState, PreviewTabState, ThreadId } from "@okcode/contracts"; +import type { PreviewTabsState, PreviewTabState, ProjectId } from "@okcode/contracts"; import { type FormEvent, useEffect, useLayoutEffect, useRef, useState } from "react"; import { ChevronLeftIcon, @@ -99,17 +99,17 @@ function tabDisplayTitle(tab: PreviewTabState): string { } interface PreviewPanelProps { - threadId: ThreadId; + projectId: ProjectId; onClose: () => void; } -export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) { +export function PreviewPanel({ projectId, onClose }: PreviewPanelProps) { const previewBridge = readDesktopPreviewBridge(); - const setGlobalOpen = usePreviewStateStore((state) => state.setGlobalOpen); + const setProjectOpen = usePreviewStateStore((state) => state.setProjectOpen); const favoriteUrls = usePreviewStateStore((state) => state.favoriteUrls); const toggleFavoriteUrl = usePreviewStateStore((state) => state.toggleFavoriteUrl); - const presetId = usePreviewStateStore((state) => state.presetByThreadId[threadId] ?? null); - const setThreadPreset = usePreviewStateStore((state) => state.setThreadPreset); + const presetId = usePreviewStateStore((state) => state.presetByProjectId[projectId] ?? null); + const setProjectPreset = usePreviewStateStore((state) => state.setProjectPreset); const activePreset = presetId ? getBrowserPreset(presetId) : null; const PresetIcon = presetId ? PRESET_ICONS[presetId] : null; @@ -239,7 +239,7 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) { visualViewport?.removeEventListener("scroll", invalidateBounds); void previewBridge.setBounds(HIDDEN_PREVIEW_BOUNDS); }; - }, [previewBridge, tabsState.tabs.length, threadId]); + }, [previewBridge, tabsState.tabs.length, projectId]); // Cleanup on unmount useEffect(() => { @@ -281,7 +281,7 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) { }; const onClosePreview = () => { - setGlobalOpen(false); + setProjectOpen(projectId, false); void previewBridge?.closeAll(); onClose(); }; @@ -378,8 +378,8 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) { { - setThreadPreset( - threadId, + setProjectPreset( + projectId, value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId), ); }} diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index ba8e3877d..d6da06135 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const STORAGE_KEY = "okcode:desktop-preview:v3"; +const STORAGE_KEY = "okcode:desktop-preview:v4"; let usePreviewStateStore: typeof import("./previewStateStore").usePreviewStateStore; let storage: Map; @@ -26,10 +26,10 @@ describe("previewStateStore", () => { ({ usePreviewStateStore } = await import("./previewStateStore")); usePreviewStateStore.setState({ - globalOpen: false, - dockByThreadId: {}, - sizeByThreadId: {}, - presetByThreadId: {}, + openByProjectId: {}, + dockByProjectId: {}, + sizeByProjectId: {}, + presetByProjectId: {}, favoriteUrls: [], }); storage.clear(); @@ -46,14 +46,29 @@ describe("previewStateStore", () => { expect(usePreviewStateStore.getState().favoriteUrls).not.toContain("http://localhost:3000/"); }); - it("toggles globalOpen", () => { + it("toggles project open state", () => { const store = usePreviewStateStore.getState(); + const projectId = "test-project-id" as any; - store.setGlobalOpen(true); - expect(usePreviewStateStore.getState().globalOpen).toBe(true); - expect(storage.get(STORAGE_KEY)).toContain('"globalOpen":true'); + store.setProjectOpen(projectId, true); + expect(usePreviewStateStore.getState().openByProjectId[projectId]).toBe(true); + expect(storage.get(STORAGE_KEY)).toContain('"openByProjectId"'); - store.toggleGlobalOpen(); - expect(usePreviewStateStore.getState().globalOpen).toBe(false); + store.toggleProjectOpen(projectId); + expect(usePreviewStateStore.getState().openByProjectId[projectId]).toBe(false); + }); + + it("scopes open state per project", () => { + const store = usePreviewStateStore.getState(); + const projectA = "project-a" as any; + const projectB = "project-b" as any; + + store.setProjectOpen(projectA, true); + expect(usePreviewStateStore.getState().openByProjectId[projectA]).toBe(true); + expect(usePreviewStateStore.getState().openByProjectId[projectB]).toBeUndefined(); + + store.setProjectOpen(projectB, false); + expect(usePreviewStateStore.getState().openByProjectId[projectA]).toBe(true); + expect(usePreviewStateStore.getState().openByProjectId[projectB]).toBe(false); }); }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 999f1956c..88e33f19a 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -1,4 +1,4 @@ -import type { ThreadId } from "@okcode/contracts"; +import type { ProjectId } from "@okcode/contracts"; import { create } from "zustand"; import type { BrowserPresetId } from "./lib/browserPresets"; @@ -6,26 +6,26 @@ import type { BrowserPresetId } from "./lib/browserPresets"; export type PreviewDock = "left" | "right" | "top" | "bottom"; interface PersistedPreviewUiState { - globalOpen: boolean; - dockByThreadId: Record; - sizeByThreadId: Record; - presetByThreadId: Record; + openByProjectId: Record; + dockByProjectId: Record; + sizeByProjectId: Record; + presetByProjectId: Record; favoriteUrls: string[]; } interface PreviewStateStore extends PersistedPreviewUiState { - setGlobalOpen: (open: boolean) => void; - toggleGlobalOpen: () => void; - setThreadDock: (threadId: ThreadId, dock: PreviewDock) => void; - toggleThreadLayout: (threadId: ThreadId) => void; - setThreadSize: (threadId: ThreadId, size: number) => void; - setThreadPreset: (threadId: ThreadId, preset: BrowserPresetId | null) => void; + setProjectOpen: (projectId: ProjectId, open: boolean) => void; + toggleProjectOpen: (projectId: ProjectId) => void; + setProjectDock: (projectId: ProjectId, dock: PreviewDock) => void; + toggleProjectLayout: (projectId: ProjectId) => void; + setProjectSize: (projectId: ProjectId, size: number) => void; + setProjectPreset: (projectId: ProjectId, preset: BrowserPresetId | null) => void; addFavoriteUrl: (url: string) => void; removeFavoriteUrl: (url: string) => void; toggleFavoriteUrl: (url: string) => void; } -const PREVIEW_STATE_STORAGE_KEY = "okcode:desktop-preview:v3"; +const PREVIEW_STATE_STORAGE_KEY = "okcode:desktop-preview:v4"; const VALID_PRESETS = new Set(["mobile", "tablet", "laptop", "desktop", "ultrawide"]); @@ -35,10 +35,10 @@ function isValidPresetId(value: unknown): value is BrowserPresetId { function createEmptyPersistedPreviewUiState(): PersistedPreviewUiState { return { - globalOpen: false, - dockByThreadId: {}, - sizeByThreadId: {}, - presetByThreadId: {}, + openByProjectId: {}, + dockByProjectId: {}, + sizeByProjectId: {}, + presetByProjectId: {}, favoriteUrls: [], }; } @@ -63,11 +63,19 @@ function readPersistedPreviewUiState(): PersistedPreviewUiState { const parsed = JSON.parse(raw) as Partial; return { - globalOpen: parsed.globalOpen === true, - dockByThreadId: - parsed.dockByThreadId && typeof parsed.dockByThreadId === "object" + openByProjectId: + parsed.openByProjectId && typeof parsed.openByProjectId === "object" ? Object.fromEntries( - Object.entries(parsed.dockByThreadId).filter( + Object.entries(parsed.openByProjectId).filter( + (entry): entry is [string, boolean] => + typeof entry[0] === "string" && typeof entry[1] === "boolean", + ), + ) + : {}, + dockByProjectId: + parsed.dockByProjectId && typeof parsed.dockByProjectId === "object" + ? Object.fromEntries( + Object.entries(parsed.dockByProjectId).filter( (entry): entry is [string, PreviewDock] => typeof entry[0] === "string" && (entry[1] === "left" || @@ -77,21 +85,21 @@ function readPersistedPreviewUiState(): PersistedPreviewUiState { ), ) : {}, - sizeByThreadId: - parsed.sizeByThreadId && typeof parsed.sizeByThreadId === "object" + sizeByProjectId: + parsed.sizeByProjectId && typeof parsed.sizeByProjectId === "object" ? Object.fromEntries( - Object.entries(parsed.sizeByThreadId).flatMap(([threadId, size]) => { + Object.entries(parsed.sizeByProjectId).flatMap(([projectId, size]) => { const normalizedSize = normalizePreviewSize(size); - return typeof threadId === "string" && normalizedSize !== null - ? [[threadId, normalizedSize] as const] + return typeof projectId === "string" && normalizedSize !== null + ? [[projectId, normalizedSize] as const] : []; }), ) : {}, - presetByThreadId: - parsed.presetByThreadId && typeof parsed.presetByThreadId === "object" + presetByProjectId: + parsed.presetByProjectId && typeof parsed.presetByProjectId === "object" ? Object.fromEntries( - Object.entries(parsed.presetByThreadId).filter( + Object.entries(parsed.presetByProjectId).filter( (entry): entry is [string, BrowserPresetId] => typeof entry[0] === "string" && isValidPresetId(entry[1]), ), @@ -117,10 +125,10 @@ function persistPreviewUiState(state: PersistedPreviewUiState): void { window.localStorage.setItem( PREVIEW_STATE_STORAGE_KEY, JSON.stringify({ - globalOpen: state.globalOpen, - dockByThreadId: state.dockByThreadId, - sizeByThreadId: state.sizeByThreadId, - presetByThreadId: state.presetByThreadId, + openByProjectId: state.openByProjectId, + dockByProjectId: state.dockByProjectId, + sizeByProjectId: state.sizeByProjectId, + presetByProjectId: state.presetByProjectId, favoriteUrls: state.favoriteUrls, } satisfies PersistedPreviewUiState), ); @@ -131,10 +139,10 @@ function persistPreviewUiState(state: PersistedPreviewUiState): void { function snapshotState(state: PreviewStateStore): PersistedPreviewUiState { return { - globalOpen: state.globalOpen, - dockByThreadId: state.dockByThreadId, - sizeByThreadId: state.sizeByThreadId, - presetByThreadId: state.presetByThreadId, + openByProjectId: state.openByProjectId, + dockByProjectId: state.dockByProjectId, + sizeByProjectId: state.sizeByProjectId, + presetByProjectId: state.presetByProjectId, favoriteUrls: state.favoriteUrls, }; } @@ -144,36 +152,36 @@ const initialState = readPersistedPreviewUiState(); export const usePreviewStateStore = create((set, get) => ({ ...initialState, - setGlobalOpen: (open) => { + setProjectOpen: (projectId, open) => { set((state) => { - const next = { ...snapshotState(state), globalOpen: open }; - persistPreviewUiState(next); - return { globalOpen: open }; + const nextOpenByProjectId = { ...state.openByProjectId, [projectId]: open }; + persistPreviewUiState({ ...snapshotState(state), openByProjectId: nextOpenByProjectId }); + return { openByProjectId: nextOpenByProjectId }; }); }, - toggleGlobalOpen: () => { - get().setGlobalOpen(!get().globalOpen); + toggleProjectOpen: (projectId) => { + get().setProjectOpen(projectId, !(get().openByProjectId[projectId] ?? false)); }, - setThreadDock: (threadId, dock) => { + setProjectDock: (projectId, dock) => { set((state) => { - const nextDockByThreadId = { - ...state.dockByThreadId, - [threadId]: dock, + const nextDockByProjectId = { + ...state.dockByProjectId, + [projectId]: dock, }; persistPreviewUiState({ ...snapshotState(state), - dockByThreadId: nextDockByThreadId, + dockByProjectId: nextDockByProjectId, }); - return { dockByThreadId: nextDockByThreadId }; + return { dockByProjectId: nextDockByProjectId }; }); }, - toggleThreadLayout: (threadId) => { - const current = get().dockByThreadId[threadId] ?? "right"; - get().setThreadDock( - threadId, + toggleProjectLayout: (projectId) => { + const current = get().dockByProjectId[projectId] ?? "right"; + get().setProjectDock( + projectId, current === "left" ? "top" : current === "right" @@ -184,37 +192,37 @@ export const usePreviewStateStore = create((set, get) => ({ ); }, - setThreadSize: (threadId, size) => { + setProjectSize: (projectId, size) => { const normalizedSize = normalizePreviewSize(size); if (normalizedSize === null) { return; } set((state) => { - const nextSizeByThreadId = { - ...state.sizeByThreadId, - [threadId]: normalizedSize, + const nextSizeByProjectId = { + ...state.sizeByProjectId, + [projectId]: normalizedSize, }; persistPreviewUiState({ ...snapshotState(state), - sizeByThreadId: nextSizeByThreadId, + sizeByProjectId: nextSizeByProjectId, }); - return { sizeByThreadId: nextSizeByThreadId }; + return { sizeByProjectId: nextSizeByProjectId }; }); }, - setThreadPreset: (threadId, preset) => { + setProjectPreset: (projectId, preset) => { set((state) => { - const nextPresetByThreadId = { ...state.presetByThreadId }; + const nextPresetByProjectId = { ...state.presetByProjectId }; if (preset === null) { - delete nextPresetByThreadId[threadId]; + delete nextPresetByProjectId[projectId]; } else { - nextPresetByThreadId[threadId] = preset; + nextPresetByProjectId[projectId] = preset; } persistPreviewUiState({ ...snapshotState(state), - presetByThreadId: nextPresetByThreadId, + presetByProjectId: nextPresetByProjectId, }); - return { presetByThreadId: nextPresetByThreadId }; + return { presetByProjectId: nextPresetByProjectId }; }); }, diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 430477180..4a096bbed 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -306,9 +306,15 @@ function ChatThreadRouteView() { const codeViewerOpen = useCodeViewerStore((state) => state.isOpen); const closeCodeViewerStore = useCodeViewerStore((state) => state.close); - // Preview state from Zustand store - const previewOpen = usePreviewStateStore((state) => state.globalOpen); - const setPreviewOpen = usePreviewStateStore((state) => state.setGlobalOpen); + // Preview state from Zustand store (project-scoped) + const activeProjectId = useStore((store) => { + const thread = store.threads.find((t) => t.id === threadId); + return thread?.projectId ?? null; + }); + const previewOpen = usePreviewStateStore((state) => + activeProjectId ? (state.openByProjectId[activeProjectId] ?? false) : false, + ); + const setPreviewOpen = usePreviewStateStore((state) => state.setProjectOpen); // Simulation viewer state from Zustand store const simulationOpen = useSimulationViewerStore((state) => state.isOpen); @@ -343,8 +349,8 @@ function ChatThreadRouteView() { }, [closeCodeViewerStore]); const closePreview = useCallback(() => { - setPreviewOpen(false); - }, [setPreviewOpen]); + if (activeProjectId) setPreviewOpen(activeProjectId, false); + }, [activeProjectId, setPreviewOpen]); const closeSimulation = useCallback(() => { closeSimulationStore();