diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e5972bf760..d698c60b230 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ directories until the move is complete. | Directory | Status | |---|---| -| `apps/web/` | Active migration target — Vite + React Router v7 SPA for the assistant web app. Code is being incrementally migrated from a separate repo. See [`apps/web/README.md`](apps/web/README.md) for local dev setup. | +| `apps/web/` | Active migration target — Vite + React Router v7 SPA for the assistant web app. Code is being incrementally migrated from a separate repo. See [`apps/web/README.md`](apps/web/README.md) for local dev setup, [`apps/web/CONVENTIONS.md`](apps/web/CONVENTIONS.md) for architecture and state management patterns, and [`apps/web/STYLE_GUIDE.md`](apps/web/STYLE_GUIDE.md) for coding style. | | `apps/chrome-extension/` | Move in progress from [`clients/chrome-extension/`](https://github.com/vellum-ai/vellum-assistant/tree/main/clients/chrome-extension). | ## Submitting a pull request diff --git a/apps/web/CONVENTIONS.md b/apps/web/CONVENTIONS.md index f1d4bf6871e..2869d4144dd 100644 --- a/apps/web/CONVENTIONS.md +++ b/apps/web/CONVENTIONS.md @@ -262,12 +262,13 @@ References: - [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) - [Bulletproof React — project structure](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md) -Store creation pattern — separate `State` and `Actions` interfaces -so consumers can subscribe to only the slice they need: +Store creation pattern — separate `State` and `Actions` interfaces, +wrap with `createSelectors` for auto-generated per-field hooks: ```ts import { create } from "zustand"; -import { useShallow } from "zustand/shallow"; + +import { createSelectors } from "@/utils/create-selectors.js"; import type { Message } from "./types.js"; // State — the data @@ -286,7 +287,7 @@ export interface MessageActions { // Combined store type export type MessageStore = MessageState & MessageActions; -export const useMessageStore = create()((set) => ({ +const useMessageStoreBase = create()((set) => ({ messages: [], activeThreadId: null, addMessage: (message) => @@ -297,31 +298,19 @@ export const useMessageStore = create()((set) => ({ set({ messages: [], activeThreadId: null }), })); -// Convenience hooks for common access patterns -export function useMessageState(): MessageState { - return useMessageStore( - useShallow((s) => ({ - messages: s.messages, - activeThreadId: s.activeThreadId, - })), - ); -} - -export function useMessageActions(): MessageActions { - return useMessageStore( - useShallow((s) => ({ - addMessage: s.addMessage, - setActiveThread: s.setActiveThread, - clearMessages: s.clearMessages, - })), - ); -} +export const useMessageStore = createSelectors(useMessageStoreBase); ``` +Consumers use `.use.field()` in render bodies and `.getState()` in +callbacks — see +[Reading state: `.use.*` vs `.getState()`](#reading-state-use-vs-getstate). + Keep store definitions in their domain folder — adding or removing a domain means adding or removing a folder. -Reference: [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) +References: +- [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) +- [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors) ### Auth state lives in a Zustand store @@ -422,10 +411,46 @@ const { bears } = useBearStore.getState(); Prefer `.use.field()` over manual `(s) => s.field` selectors. For derived/computed values (e.g. `user?.id`), use `.use.user()` and -access the property from the result. +access the property from the result. See +[Reading state: `.use.*` vs `.getState()`](#reading-state-use-vs-getstate) +for when to use each API. Reference: [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors) +### Reading state: `.use.*` vs `.getState()` + +Zustand exposes two ways to read store state. Using the wrong one +causes either missed re-renders or unnecessary subscriptions. + +| Context | API | Why | +|---------|-----|-----| +| **React render body** (component/hook top level) | `useStore.use.field()` | Creates a subscription — component re-renders when `field` changes. Required for reactive UI. | +| **Event handlers, callbacks, effects, `useCallback` bodies** | `useStore.getState().field` | Reads the latest value at call time without creating a subscription. No stale-closure risk. | +| **Outside React** (middleware, interceptors, stream handlers, `main.tsx`) | `useStore.getState().field` | No React context available — `.use.*` would throw. | +| **Calling actions** (anywhere) | `useStore.getState().actionName()` | Actions are stable references — calling via `.getState()` is always correct and avoids adding the action to dependency arrays. | + +```ts +// Render body — reactive subscription +const count = useMessageStore.use.count(); + +// Event handler — imperative read + action +const handleClick = useCallback(() => { + useMessageStore.getState().increment(); +}, []); + +// Middleware — outside React +const { isLoggedIn } = useAuthStore.getState(); +``` + +Zustand's `set()` is synchronous — `.getState()` after an action +returns already-mutated values. Read state *before* calling an action +when the caller needs pre-mutation values. + +References: +- [Zustand — Updating state](https://zustand.docs.pmnd.rs/guides/updating-state) +- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/extracting-state-outside-components) +- [React — Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) + ### Data fetching: React Query vs direct SDK calls Use **React Query** for data consumed primarily by React components — diff --git a/apps/web/src/domains/chat/hooks/use-app-viewer-actions.ts b/apps/web/src/domains/chat/hooks/use-app-viewer-actions.ts index cd396f74689..a7bf7a1d55d 100644 --- a/apps/web/src/domains/chat/hooks/use-app-viewer-actions.ts +++ b/apps/web/src/domains/chat/hooks/use-app-viewer-actions.ts @@ -7,7 +7,7 @@ * is delegated to adapter callbacks (`pushConversationKeyParam`) so the hook * stays portable for the Vite + React Router v7 migration. * - * @see stores/viewer-store.ts — the reducer that owns viewer UI state + * @see stores/viewer-store.ts — Zustand store for viewer UI state */ import * as Sentry from "@sentry/react"; @@ -22,9 +22,8 @@ import { getVercelConfig, isCredentialError, publishApp } from "@/domains/chat/l import type { MainView, OpenedAppState, - ViewerAction, - ViewerState, } from "@/stores/viewer-store.js"; +import { useViewerStore } from "@/stores/viewer-store.js"; import type { Conversation } from "@/domains/chat/lib/api.js"; import type { ConversationListAction } from "@/domains/conversations/conversation-list-store.js"; import { haptic } from "@/utils/haptics.js"; @@ -43,9 +42,7 @@ export interface UseAppViewerActionsParams { isSharing: boolean; isDeploying: boolean; pendingDeployAppId: string | null; - dispatchViewer: Dispatch; dispatchConversationList: Dispatch; - viewerStateRef: MutableRefObject; lastConversationKeyRef: MutableRefObject; deepLinkAppId: RefObject; switchConversation: (key: string) => void; @@ -77,8 +74,7 @@ export interface UseAppViewerActionsParams { * - Deep-link auto-open on mount * - Pin-sync side-effect (navigates away when the active app is unpinned) * - * All handlers dispatch to `viewerReducer` / `conversationListReducer` and - * call domain API functions — no direct framework imports. + * Viewer state is managed via the Zustand `useViewerStore`. */ export function useAppViewerActions({ assistantId, @@ -88,9 +84,7 @@ export function useAppViewerActions({ isSharing, isDeploying, pendingDeployAppId, - dispatchViewer, dispatchConversationList, - viewerStateRef, lastConversationKeyRef, deepLinkAppId, switchConversation, @@ -121,47 +115,44 @@ export function useAppViewerActions({ async (appId: string) => { if (!assistantId) return; openAppRequestRef.current = appId; - dispatchViewer({ type: "OPEN_APP_START", appId }); + useViewerStore.getState().openApp(appId); try { const result = await openApp(assistantId, appId); if (openAppRequestRef.current !== appId) return; - dispatchViewer({ type: "APP_LOADED", app: { appId: result.appId, dirName: result.dirName, name: result.name, html: result.html } }); + useViewerStore.getState().setLoadedApp({ appId: result.appId, dirName: result.dirName, name: result.name, html: result.html }); } catch (err) { if (openAppRequestRef.current !== appId) return; Sentry.captureException(err, { tags: { context: "openApp" } }); - dispatchViewer({ type: "APP_LOAD_FAILED" }); + useViewerStore.getState().handleAppLoadFailed(); } }, - [assistantId, dispatchViewer], + [assistantId], ); const loadDocument = useCallback( async (documentSurfaceId: string) => { if (!assistantId) return; openDocumentRequestRef.current = documentSurfaceId; - dispatchViewer({ type: "OPEN_DOCUMENT_START" }); + useViewerStore.getState().openDocument(); try { const result = await fetchDocumentContent(assistantId, documentSurfaceId); if (openDocumentRequestRef.current !== documentSurfaceId) return; if (!result) { - dispatchViewer({ type: "DOCUMENT_LOAD_FAILED" }); + useViewerStore.getState().handleDocumentLoadFailed(); return; } - dispatchViewer({ - type: "DOCUMENT_LOADED", - document: { - surfaceId: result.surfaceId, - conversationId: result.conversationId, - documentName: result.title ?? "Untitled", - content: result.content ?? "", - }, + useViewerStore.getState().setLoadedDocument({ + surfaceId: result.surfaceId, + conversationId: result.conversationId, + documentName: result.title ?? "Untitled", + content: result.content ?? "", }); } catch { if (openDocumentRequestRef.current !== documentSurfaceId) return; - dispatchViewer({ type: "DOCUMENT_LOAD_FAILED" }); + useViewerStore.getState().handleDocumentLoadFailed(); } }, - [assistantId, dispatchViewer], + [assistantId], ); // --------------------------------------------------------------------------- @@ -227,28 +218,28 @@ export function useAppViewerActions({ ); const handleCloseDocument = useCallback(() => { - const prev = viewerStateRef.current.viewBeforeDocument; - dispatchViewer({ type: "CLOSE_DOCUMENT" }); + const prev = useViewerStore.getState().viewBeforeDocument; + useViewerStore.getState().closeDocument(); if (prev !== "library" && prev !== "intelligence") { if (lastConversationKeyRef.current) { switchConversationRef.current(lastConversationKeyRef.current); } } - }, [dispatchViewer, viewerStateRef, lastConversationKeyRef]); + }, [lastConversationKeyRef]); const handleCloseApp = useCallback(() => { - dispatchViewer({ type: "CLOSE_APP" }); + useViewerStore.getState().closeApp(); dispatchConversationList({ type: "SET_EDITING_KEY", key: null }); if (lastConversationKeyRef.current) { switchConversationRef.current(lastConversationKeyRef.current); } else { - dispatchViewer({ type: "SET_MAIN_VIEW", view: "chat" }); + useViewerStore.getState().setMainView("chat"); } - }, [dispatchViewer, dispatchConversationList, lastConversationKeyRef]); + }, [dispatchConversationList, lastConversationKeyRef]); const handleToggleAppMinimized = useCallback(() => { - dispatchViewer({ type: "TOGGLE_APP_MINIMIZED" }); - }, [dispatchViewer]); + useViewerStore.getState().toggleAppMinimized(); + }, []); // --------------------------------------------------------------------------- // Edit mode @@ -279,7 +270,7 @@ export function useAppViewerActions({ } dispatchConversationList({ type: "SET_EDITING_KEY", key: conversationKey }); - dispatchViewer({ type: "ENTER_APP_EDITING" }); + useViewerStore.getState().enterAppEditing(); if (activeConversationKey !== conversationKey) { pushConversationKeyParamRef.current(conversationKey); } @@ -289,7 +280,6 @@ export function useAppViewerActions({ conversations, activeConversationKey, dispatchConversationList, - dispatchViewer, ], ); @@ -299,22 +289,23 @@ export function useAppViewerActions({ }, [openedAppState, enterEditingForLoadedApp]); // Used when an app is opened outside the chat viewer (e.g. from the library - // grid, which keeps its own local viewer state). Hydrates the viewer reducer + // grid, which keeps its own local viewer state). Hydrates the viewer store // with the already-loaded app so the edit transition has a canonical // `openedAppState` to land on, then enters editing mode. const handleEditAppFromDetached = useCallback( (app: { appId: string; dirName?: string; name: string; html: string }) => { - dispatchViewer({ type: "OPEN_APP_START", appId: app.appId }); - dispatchViewer({ type: "APP_LOADED", app }); + const store = useViewerStore.getState(); + store.openApp(app.appId); + store.setLoadedApp(app); enterEditingForLoadedApp(app.appId); }, - [dispatchViewer, enterEditingForLoadedApp], + [enterEditingForLoadedApp], ); const handleCloseEditPanel = useCallback(() => { dispatchConversationList({ type: "SET_EDITING_KEY", key: null }); - dispatchViewer({ type: "EXIT_APP_EDITING" }); - }, [dispatchConversationList, dispatchViewer]); + useViewerStore.getState().exitAppEditing(); + }, [dispatchConversationList]); // --------------------------------------------------------------------------- // Share / Deploy @@ -322,7 +313,7 @@ export function useAppViewerActions({ const handleShareApp = useCallback(async () => { if (!openedAppState || !assistantId || isSharing) return; - dispatchViewer({ type: "START_SHARING" }); + useViewerStore.getState().startSharing(); try { await shareApp(assistantId, openedAppState.appId, openedAppState.name); toast.success("App exported", { description: `${openedAppState.name}.vellum` }); @@ -331,27 +322,27 @@ export function useAppViewerActions({ description: err instanceof Error ? err.message : undefined, }); } finally { - dispatchViewer({ type: "SHARING_DONE" }); + useViewerStore.getState().finishSharing(); } - }, [openedAppState, assistantId, isSharing, dispatchViewer]); + }, [openedAppState, assistantId, isSharing]); const handleDeployApp = useCallback(async () => { if (!openedAppState || !assistantId || isDeploying) return; if (openedAppState.html.includes("vellum.fetch") || openedAppState.html.includes("vellum.sendAction") || openedAppState.html.includes("/v1/x/") || openedAppState.html.includes("/v1/apps/") ) { - dispatchViewer({ type: "SET_COMPLEX_DEPLOY_APP", app: { appId: openedAppState.appId, name: openedAppState.name } }); + useViewerStore.getState().setComplexDeployApp({ appId: openedAppState.appId, name: openedAppState.name }); return; } - dispatchViewer({ type: "START_DEPLOYING" }); + useViewerStore.getState().startDeploying(); try { const config = await getVercelConfig(assistantId); if (!config.hasToken) { - dispatchViewer({ type: "SHOW_TOKEN_DIALOG", pendingAppId: openedAppState.appId }); + useViewerStore.getState().showTokenDialog(openedAppState.appId); return; } const result = await publishApp(assistantId, openedAppState.appId); if (!result.success) { if (isCredentialError(result)) { - dispatchViewer({ type: "SHOW_TOKEN_DIALOG", pendingAppId: openedAppState.appId }); + useViewerStore.getState().showTokenDialog(openedAppState.appId); } else { toast.error("Failed to deploy", { description: result.error }); } @@ -371,14 +362,14 @@ export function useAppViewerActions({ description: err instanceof Error ? err.message : undefined, }); } finally { - dispatchViewer({ type: "DEPLOYING_DONE" }); + useViewerStore.getState().finishDeploying(); } - }, [openedAppState, assistantId, isDeploying, dispatchViewer]); + }, [openedAppState, assistantId, isDeploying]); const handleDeployTokenSaved = useCallback(() => { - dispatchViewer({ type: "HIDE_TOKEN_DIALOG" }); + useViewerStore.getState().hideTokenDialog(); if (pendingDeployAppId && assistantId) { - dispatchViewer({ type: "START_DEPLOYING" }); + useViewerStore.getState().startDeploying(); void publishApp(assistantId, pendingDeployAppId) .then((result) => { if (!result.success) { @@ -401,10 +392,10 @@ export function useAppViewerActions({ }); }) .finally(() => { - dispatchViewer({ type: "DEPLOYING_DONE", clearPendingAppId: true }); + useViewerStore.getState().finishDeploying(true); }); } - }, [pendingDeployAppId, assistantId, dispatchViewer]); + }, [pendingDeployAppId, assistantId]); // --------------------------------------------------------------------------- // Pin-sync side-effect @@ -412,15 +403,16 @@ export function useAppViewerActions({ const handleActiveAppUnpinned = useCallback( (appId: string) => { - dispatchViewer({ type: "ACTIVE_APP_UNPINNED", appId }); + const { activeAppId, mainView } = useViewerStore.getState(); + useViewerStore.getState().handleAppUnpinned(appId); if ( - viewerStateRef.current.activeAppId === appId && - (viewerStateRef.current.mainView === "app" || viewerStateRef.current.mainView === "app-editing") + activeAppId === appId && + (mainView === "app" || mainView === "app-editing") ) { dispatchConversationList({ type: "SET_EDITING_KEY", key: null }); } }, - [dispatchViewer, dispatchConversationList, viewerStateRef], + [dispatchConversationList], ); useActiveAppPinSync(handleActiveAppUnpinned); diff --git a/apps/web/src/stores/viewer-store.test.ts b/apps/web/src/stores/viewer-store.test.ts index acd2f95531a..b02fb4fc8a1 100644 --- a/apps/web/src/stores/viewer-store.test.ts +++ b/apps/web/src/stores/viewer-store.test.ts @@ -1,15 +1,19 @@ -import { describe, it, expect } from "bun:test"; +import { beforeEach, describe, it, expect } from "bun:test"; -import { - type ViewerState, - INITIAL_VIEWER_STATE, - viewerReducer, -} from "@/stores/viewer-store.js"; +import { useViewerStore } from "@/stores/viewer-store.js"; -function stateWith(overrides: Partial): ViewerState { - return { ...INITIAL_VIEWER_STATE, ...overrides }; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getState() { + return useViewerStore.getState(); } +beforeEach(() => { + getState().reset(); +}); + const SAMPLE_APP = { appId: "app-1", dirName: "my-app", name: "My App", html: "

App

" }; const SAMPLE_DOC = { surfaceId: "surf-1", conversationId: "conv-1", documentName: "README.md", content: "# Hello" }; @@ -17,419 +21,368 @@ const SAMPLE_DOC = { surfaceId: "surf-1", conversationId: "conv-1", documentName // View navigation // --------------------------------------------------------------------------- -describe("viewerReducer", () => { - describe("SET_MAIN_VIEW", () => { - it("switches the main view", () => { - const next = viewerReducer(INITIAL_VIEWER_STATE, { - type: "SET_MAIN_VIEW", - view: "intelligence", - }); - expect(next.mainView).toBe("intelligence"); - }); +describe("setMainView", () => { + it("switches the main view", () => { + getState().setMainView("intelligence"); + expect(getState().mainView).toBe("intelligence"); + }); - it("returns the same state when view is unchanged", () => { - const state = stateWith({ mainView: "chat" }); - const next = viewerReducer(state, { type: "SET_MAIN_VIEW", view: "chat" }); - expect(next).toBe(state); - }); + it("is a no-op when view is unchanged", () => { + getState().setMainView("chat"); + expect(getState().mainView).toBe("chat"); }); +}); - describe("SET_INTELLIGENCE_TAB", () => { - it("switches the intelligence tab", () => { - const next = viewerReducer(INITIAL_VIEWER_STATE, { - type: "SET_INTELLIGENCE_TAB", - tab: "skills", - }); - expect(next.intelligenceTab).toBe("skills"); - }); +describe("setIntelligenceTab", () => { + it("switches the intelligence tab", () => { + getState().setIntelligenceTab("skills"); + expect(getState().intelligenceTab).toBe("skills"); + }); - it("returns the same state when tab is unchanged", () => { - const state = stateWith({ intelligenceTab: "identity" }); - const next = viewerReducer(state, { - type: "SET_INTELLIGENCE_TAB", - tab: "identity", - }); - expect(next).toBe(state); - }); + it("is a no-op when tab is unchanged", () => { + getState().setIntelligenceTab("identity"); + expect(getState().intelligenceTab).toBe("identity"); }); +}); - // --------------------------------------------------------------------------- - // App viewer - // --------------------------------------------------------------------------- - - describe("OPEN_APP_START", () => { - it("sets activeAppId, clears openedAppState, switches to app view, resets minimized", () => { - const state = stateWith({ - mainView: "chat", - openedAppState: SAMPLE_APP, - isAppMinimized: true, - }); - const next = viewerReducer(state, { - type: "OPEN_APP_START", - appId: "app-2", - }); - expect(next.mainView).toBe("app"); - expect(next.activeAppId).toBe("app-2"); - expect(next.openedAppState).toBeNull(); - expect(next.isAppMinimized).toBe(false); - }); +// --------------------------------------------------------------------------- +// App viewer +// --------------------------------------------------------------------------- + +describe("openApp", () => { + it("sets activeAppId, clears openedAppState, switches to app view, resets minimized", () => { + useViewerStore.setState({ openedAppState: SAMPLE_APP, isAppMinimized: true }); + getState().openApp("app-2"); + const state = getState(); + expect(state.mainView).toBe("app"); + expect(state.activeAppId).toBe("app-2"); + expect(state.openedAppState).toBeNull(); + expect(state.isAppMinimized).toBe(false); }); +}); - describe("APP_LOADED", () => { - it("sets the opened app state", () => { - const next = viewerReducer(INITIAL_VIEWER_STATE, { - type: "APP_LOADED", - app: SAMPLE_APP, - }); - expect(next.openedAppState).toBe(SAMPLE_APP); - }); +describe("setLoadedApp", () => { + it("sets the opened app state", () => { + getState().setLoadedApp(SAMPLE_APP); + expect(getState().openedAppState).toBe(SAMPLE_APP); }); +}); - describe("APP_LOAD_FAILED", () => { - it("resets to chat view and clears app state", () => { - const state = stateWith({ - mainView: "app", - activeAppId: "app-1", - openedAppState: SAMPLE_APP, - }); - const next = viewerReducer(state, { type: "APP_LOAD_FAILED" }); - expect(next.mainView).toBe("chat"); - expect(next.activeAppId).toBeNull(); - expect(next.openedAppState).toBeNull(); - }); +describe("handleAppLoadFailed", () => { + it("resets to chat view and clears app state", () => { + useViewerStore.setState({ mainView: "app", activeAppId: "app-1", openedAppState: SAMPLE_APP }); + getState().handleAppLoadFailed(); + const state = getState(); + expect(state.mainView).toBe("chat"); + expect(state.activeAppId).toBeNull(); + expect(state.openedAppState).toBeNull(); }); +}); - describe("CLOSE_APP", () => { - it("clears app state and resets minimized", () => { - const state = stateWith({ - activeAppId: "app-1", - openedAppState: SAMPLE_APP, - isAppMinimized: true, - }); - const next = viewerReducer(state, { type: "CLOSE_APP" }); - expect(next.activeAppId).toBeNull(); - expect(next.openedAppState).toBeNull(); - expect(next.isAppMinimized).toBe(false); - }); +describe("closeApp", () => { + it("clears app state and resets minimized", () => { + useViewerStore.setState({ activeAppId: "app-1", openedAppState: SAMPLE_APP, isAppMinimized: true }); + getState().closeApp(); + const state = getState(); + expect(state.activeAppId).toBeNull(); + expect(state.openedAppState).toBeNull(); + expect(state.isAppMinimized).toBe(false); + }); - it("does not change mainView (caller decides)", () => { - const state = stateWith({ mainView: "app" }); - const next = viewerReducer(state, { type: "CLOSE_APP" }); - expect(next.mainView).toBe("app"); - }); + it("does not change mainView (caller decides)", () => { + useViewerStore.setState({ mainView: "app" }); + getState().closeApp(); + expect(getState().mainView).toBe("app"); }); +}); - describe("TOGGLE_APP_MINIMIZED", () => { - it("toggles from false to true", () => { - const next = viewerReducer(INITIAL_VIEWER_STATE, { type: "TOGGLE_APP_MINIMIZED" }); - expect(next.isAppMinimized).toBe(true); - }); +describe("toggleAppMinimized", () => { + it("toggles from false to true", () => { + getState().toggleAppMinimized(); + expect(getState().isAppMinimized).toBe(true); + }); - it("toggles from true to false", () => { - const state = stateWith({ isAppMinimized: true }); - const next = viewerReducer(state, { type: "TOGGLE_APP_MINIMIZED" }); - expect(next.isAppMinimized).toBe(false); - }); + it("toggles from true to false", () => { + useViewerStore.setState({ isAppMinimized: true }); + getState().toggleAppMinimized(); + expect(getState().isAppMinimized).toBe(false); }); +}); - describe("ACTIVE_APP_UNPINNED", () => { - it("resets to chat when the pinned app matches the active app in 'app' view", () => { - const state = stateWith({ - mainView: "app", - activeAppId: "app-1", - openedAppState: SAMPLE_APP, - }); - const next = viewerReducer(state, { - type: "ACTIVE_APP_UNPINNED", - appId: "app-1", - }); - expect(next.mainView).toBe("chat"); - expect(next.activeAppId).toBeNull(); - expect(next.openedAppState).toBeNull(); - }); +describe("handleAppUnpinned", () => { + it("resets to chat when the pinned app matches the active app in 'app' view", () => { + useViewerStore.setState({ mainView: "app", activeAppId: "app-1", openedAppState: SAMPLE_APP }); + getState().handleAppUnpinned("app-1"); + const state = getState(); + expect(state.mainView).toBe("chat"); + expect(state.activeAppId).toBeNull(); + expect(state.openedAppState).toBeNull(); + }); - it("resets when in app-editing view", () => { - const state = stateWith({ - mainView: "app-editing", - activeAppId: "app-1", - }); - const next = viewerReducer(state, { - type: "ACTIVE_APP_UNPINNED", - appId: "app-1", - }); - expect(next.mainView).toBe("chat"); - }); + it("resets when in app-editing view", () => { + useViewerStore.setState({ mainView: "app-editing", activeAppId: "app-1" }); + getState().handleAppUnpinned("app-1"); + expect(getState().mainView).toBe("chat"); + }); - it("returns same state when appId does not match", () => { - const state = stateWith({ - mainView: "app", - activeAppId: "app-1", - }); - const next = viewerReducer(state, { - type: "ACTIVE_APP_UNPINNED", - appId: "app-2", - }); - expect(next).toBe(state); - }); + it("is a no-op when appId does not match", () => { + useViewerStore.setState({ mainView: "app", activeAppId: "app-1" }); + getState().handleAppUnpinned("app-2"); + expect(getState().mainView).toBe("app"); + expect(getState().activeAppId).toBe("app-1"); + }); - it("returns same state when not in app or app-editing view", () => { - const state = stateWith({ - mainView: "chat", - activeAppId: "app-1", - }); - const next = viewerReducer(state, { - type: "ACTIVE_APP_UNPINNED", - appId: "app-1", - }); - expect(next).toBe(state); - }); + it("is a no-op when not in app or app-editing view", () => { + useViewerStore.setState({ mainView: "chat", activeAppId: "app-1" }); + getState().handleAppUnpinned("app-1"); + expect(getState().mainView).toBe("chat"); + expect(getState().activeAppId).toBe("app-1"); }); +}); - describe("ENTER_APP_EDITING", () => { - it("switches to app-editing view", () => { - const state = stateWith({ mainView: "app" }); - const next = viewerReducer(state, { type: "ENTER_APP_EDITING" }); - expect(next.mainView).toBe("app-editing"); - }); +describe("enterAppEditing", () => { + it("switches to app-editing view", () => { + useViewerStore.setState({ mainView: "app" }); + getState().enterAppEditing(); + expect(getState().mainView).toBe("app-editing"); }); +}); - describe("EXIT_APP_EDITING", () => { - it("switches back to app view", () => { - const state = stateWith({ mainView: "app-editing" }); - const next = viewerReducer(state, { type: "EXIT_APP_EDITING" }); - expect(next.mainView).toBe("app"); - }); +describe("exitAppEditing", () => { + it("switches back to app view", () => { + useViewerStore.setState({ mainView: "app-editing" }); + getState().exitAppEditing(); + expect(getState().mainView).toBe("app"); }); +}); - // --------------------------------------------------------------------------- - // Subagent detail - // --------------------------------------------------------------------------- - - describe("OPEN_SUBAGENT_DETAIL", () => { - it("saves current view and switches to subagent-detail", () => { - const state = stateWith({ mainView: "chat" }); - const next = viewerReducer(state, { - type: "OPEN_SUBAGENT_DETAIL", - subagentId: "sa-1", - }); - expect(next.mainView).toBe("subagent-detail"); - expect(next.activeSubagentId).toBe("sa-1"); - expect(next.viewBeforeSubagentDetail).toBe("chat"); - }); +// --------------------------------------------------------------------------- +// Subagent detail +// --------------------------------------------------------------------------- - it("preserves existing viewBeforeSubagentDetail when already in subagent-detail", () => { - const state = stateWith({ - mainView: "subagent-detail", - viewBeforeSubagentDetail: "intelligence", - activeSubagentId: "sa-1", - }); - const next = viewerReducer(state, { - type: "OPEN_SUBAGENT_DETAIL", - subagentId: "sa-2", - }); - expect(next.viewBeforeSubagentDetail).toBe("intelligence"); - expect(next.activeSubagentId).toBe("sa-2"); - }); +describe("openSubagentDetail", () => { + it("saves current view and switches to subagent-detail", () => { + getState().openSubagentDetail("sa-1"); + const state = getState(); + expect(state.mainView).toBe("subagent-detail"); + expect(state.activeSubagentId).toBe("sa-1"); + expect(state.viewBeforeSubagentDetail).toBe("chat"); + }); - it("saves non-chat view correctly", () => { - const state = stateWith({ mainView: "app" }); - const next = viewerReducer(state, { - type: "OPEN_SUBAGENT_DETAIL", - subagentId: "sa-1", - }); - expect(next.viewBeforeSubagentDetail).toBe("app"); + it("preserves existing viewBeforeSubagentDetail when already in subagent-detail", () => { + useViewerStore.setState({ + mainView: "subagent-detail", + viewBeforeSubagentDetail: "intelligence", + activeSubagentId: "sa-1", }); + getState().openSubagentDetail("sa-2"); + const state = getState(); + expect(state.viewBeforeSubagentDetail).toBe("intelligence"); + expect(state.activeSubagentId).toBe("sa-2"); }); - describe("CLOSE_SUBAGENT_DETAIL", () => { - it("restores viewBeforeSubagentDetail and clears activeSubagentId", () => { - const state = stateWith({ - mainView: "subagent-detail", - viewBeforeSubagentDetail: "chat", - activeSubagentId: "sa-1", - }); - const next = viewerReducer(state, { type: "CLOSE_SUBAGENT_DETAIL" }); - expect(next.mainView).toBe("chat"); - expect(next.activeSubagentId).toBeNull(); - }); + it("saves non-chat view correctly", () => { + useViewerStore.setState({ mainView: "app" }); + getState().openSubagentDetail("sa-1"); + expect(getState().viewBeforeSubagentDetail).toBe("app"); + }); +}); + +describe("closeSubagentDetail", () => { + it("restores viewBeforeSubagentDetail and clears activeSubagentId", () => { + useViewerStore.setState({ + mainView: "subagent-detail", + viewBeforeSubagentDetail: "chat", + activeSubagentId: "sa-1", + }); + getState().closeSubagentDetail(); + const state = getState(); + expect(state.mainView).toBe("chat"); + expect(state.activeSubagentId).toBeNull(); + }); - it("restores a non-chat view", () => { - const state = stateWith({ - mainView: "subagent-detail", - viewBeforeSubagentDetail: "library", - activeSubagentId: "sa-1", - }); - const next = viewerReducer(state, { type: "CLOSE_SUBAGENT_DETAIL" }); - expect(next.mainView).toBe("library"); - expect(next.activeSubagentId).toBeNull(); + it("restores a non-chat view", () => { + useViewerStore.setState({ + mainView: "subagent-detail", + viewBeforeSubagentDetail: "library", + activeSubagentId: "sa-1", }); + getState().closeSubagentDetail(); + const state = getState(); + expect(state.mainView).toBe("library"); + expect(state.activeSubagentId).toBeNull(); }); +}); - // --------------------------------------------------------------------------- - // Document viewer - // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Document viewer +// --------------------------------------------------------------------------- - describe("OPEN_DOCUMENT_START", () => { - it("saves current view as viewBeforeDocument and switches to document", () => { - const state = stateWith({ mainView: "intelligence" }); - const next = viewerReducer(state, { type: "OPEN_DOCUMENT_START" }); - expect(next.mainView).toBe("document"); - expect(next.viewBeforeDocument).toBe("intelligence"); - expect(next.openedDocumentState).toBeNull(); - }); +describe("openDocument", () => { + it("saves current view as viewBeforeDocument and switches to document", () => { + useViewerStore.setState({ mainView: "intelligence" }); + getState().openDocument(); + const state = getState(); + expect(state.mainView).toBe("document"); + expect(state.viewBeforeDocument).toBe("intelligence"); + expect(state.openedDocumentState).toBeNull(); + }); - it("preserves existing viewBeforeDocument when already in document view", () => { - const state = stateWith({ - mainView: "document", - viewBeforeDocument: "library", - }); - const next = viewerReducer(state, { type: "OPEN_DOCUMENT_START" }); - expect(next.viewBeforeDocument).toBe("library"); + it("preserves existing viewBeforeDocument when already in document view", () => { + useViewerStore.setState({ + mainView: "document", + viewBeforeDocument: "library", }); + getState().openDocument(); + expect(getState().viewBeforeDocument).toBe("library"); }); +}); - describe("DOCUMENT_LOADED", () => { - it("sets the document state", () => { - const next = viewerReducer(INITIAL_VIEWER_STATE, { - type: "DOCUMENT_LOADED", - document: SAMPLE_DOC, - }); - expect(next.openedDocumentState).toBe(SAMPLE_DOC); - }); +describe("setLoadedDocument", () => { + it("sets the document state", () => { + getState().setLoadedDocument(SAMPLE_DOC); + expect(getState().openedDocumentState).toBe(SAMPLE_DOC); }); +}); - describe("DOCUMENT_LOAD_FAILED", () => { - it("restores viewBeforeDocument and clears document state", () => { - const state = stateWith({ - mainView: "document", - viewBeforeDocument: "library", - openedDocumentState: SAMPLE_DOC, - }); - const next = viewerReducer(state, { type: "DOCUMENT_LOAD_FAILED" }); - expect(next.mainView).toBe("library"); - expect(next.openedDocumentState).toBeNull(); - }); +describe("handleDocumentLoadFailed", () => { + it("restores viewBeforeDocument and clears document state", () => { + useViewerStore.setState({ + mainView: "document", + viewBeforeDocument: "library", + openedDocumentState: SAMPLE_DOC, + }); + getState().handleDocumentLoadFailed(); + const state = getState(); + expect(state.mainView).toBe("library"); + expect(state.openedDocumentState).toBeNull(); }); +}); - describe("CLOSE_DOCUMENT", () => { - it("restores viewBeforeDocument and clears document state", () => { - const state = stateWith({ - mainView: "document", - viewBeforeDocument: "app", - openedDocumentState: SAMPLE_DOC, - }); - const next = viewerReducer(state, { type: "CLOSE_DOCUMENT" }); - expect(next.mainView).toBe("app"); - expect(next.openedDocumentState).toBeNull(); - }); +describe("closeDocument", () => { + it("restores viewBeforeDocument and clears document state", () => { + useViewerStore.setState({ + mainView: "document", + viewBeforeDocument: "app", + openedDocumentState: SAMPLE_DOC, + }); + getState().closeDocument(); + const state = getState(); + expect(state.mainView).toBe("app"); + expect(state.openedDocumentState).toBeNull(); }); +}); - // --------------------------------------------------------------------------- - // Assets - // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Assets +// --------------------------------------------------------------------------- - describe("REFRESH_ASSETS", () => { - it("increments the refresh key", () => { - const state = stateWith({ assetsRefreshKey: 5 }); - const next = viewerReducer(state, { type: "REFRESH_ASSETS" }); - expect(next.assetsRefreshKey).toBe(6); - }); +describe("refreshAssets", () => { + it("increments the refresh key", () => { + useViewerStore.setState({ assetsRefreshKey: 5 }); + getState().refreshAssets(); + expect(getState().assetsRefreshKey).toBe(6); }); +}); - // --------------------------------------------------------------------------- - // Share / Deploy - // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Share / Deploy +// --------------------------------------------------------------------------- - describe("START_SHARING", () => { - it("sets isSharing to true", () => { - const next = viewerReducer(INITIAL_VIEWER_STATE, { type: "START_SHARING" }); - expect(next.isSharing).toBe(true); - }); +describe("startSharing", () => { + it("sets isSharing to true", () => { + getState().startSharing(); + expect(getState().isSharing).toBe(true); }); +}); - describe("SHARING_DONE", () => { - it("sets isSharing to false", () => { - const state = stateWith({ isSharing: true }); - const next = viewerReducer(state, { type: "SHARING_DONE" }); - expect(next.isSharing).toBe(false); - }); +describe("finishSharing", () => { + it("sets isSharing to false", () => { + useViewerStore.setState({ isSharing: true }); + getState().finishSharing(); + expect(getState().isSharing).toBe(false); }); +}); - describe("START_DEPLOYING", () => { - it("sets isDeploying to true", () => { - const next = viewerReducer(INITIAL_VIEWER_STATE, { type: "START_DEPLOYING" }); - expect(next.isDeploying).toBe(true); - }); +describe("startDeploying", () => { + it("sets isDeploying to true", () => { + getState().startDeploying(); + expect(getState().isDeploying).toBe(true); }); +}); - describe("DEPLOYING_DONE", () => { - it("sets isDeploying to false and keeps pendingDeployAppId by default", () => { - const state = stateWith({ isDeploying: true, pendingDeployAppId: "app-1" }); - const next = viewerReducer(state, { type: "DEPLOYING_DONE" }); - expect(next.isDeploying).toBe(false); - expect(next.pendingDeployAppId).toBe("app-1"); - }); +describe("finishDeploying", () => { + it("sets isDeploying to false and keeps pendingDeployAppId by default", () => { + useViewerStore.setState({ isDeploying: true, pendingDeployAppId: "app-1" }); + getState().finishDeploying(); + const state = getState(); + expect(state.isDeploying).toBe(false); + expect(state.pendingDeployAppId).toBe("app-1"); + }); - it("clears pendingDeployAppId when clearPendingAppId is true", () => { - const state = stateWith({ isDeploying: true, pendingDeployAppId: "app-1" }); - const next = viewerReducer(state, { - type: "DEPLOYING_DONE", - clearPendingAppId: true, - }); - expect(next.isDeploying).toBe(false); - expect(next.pendingDeployAppId).toBeNull(); - }); + it("clears pendingDeployAppId when clearPendingAppId is true", () => { + useViewerStore.setState({ isDeploying: true, pendingDeployAppId: "app-1" }); + getState().finishDeploying(true); + const state = getState(); + expect(state.isDeploying).toBe(false); + expect(state.pendingDeployAppId).toBeNull(); }); +}); - describe("SHOW_TOKEN_DIALOG", () => { - it("opens dialog, sets pending app, and stops deploying", () => { - const state = stateWith({ isDeploying: true }); - const next = viewerReducer(state, { - type: "SHOW_TOKEN_DIALOG", - pendingAppId: "app-1", - }); - expect(next.showTokenDialog).toBe(true); - expect(next.pendingDeployAppId).toBe("app-1"); - expect(next.isDeploying).toBe(false); - }); +describe("showTokenDialog", () => { + it("opens dialog, sets pending app, and stops deploying", () => { + useViewerStore.setState({ isDeploying: true }); + getState().showTokenDialog("app-1"); + const state = getState(); + expect(state.isTokenDialogOpen).toBe(true); + expect(state.pendingDeployAppId).toBe("app-1"); + expect(state.isDeploying).toBe(false); }); +}); - describe("HIDE_TOKEN_DIALOG", () => { - it("closes the dialog", () => { - const state = stateWith({ showTokenDialog: true }); - const next = viewerReducer(state, { type: "HIDE_TOKEN_DIALOG" }); - expect(next.showTokenDialog).toBe(false); - }); +describe("hideTokenDialog", () => { + it("closes the dialog", () => { + useViewerStore.setState({ isTokenDialogOpen: true }); + getState().hideTokenDialog(); + expect(getState().isTokenDialogOpen).toBe(false); }); +}); - describe("SET_COMPLEX_DEPLOY_APP", () => { - it("sets the complex deploy app", () => { - const app = { appId: "app-1", name: "My App" }; - const next = viewerReducer(INITIAL_VIEWER_STATE, { - type: "SET_COMPLEX_DEPLOY_APP", - app, - }); - expect(next.complexDeployApp).toBe(app); - }); +describe("setComplexDeployApp", () => { + it("sets the complex deploy app", () => { + const app = { appId: "app-1", name: "My App" }; + getState().setComplexDeployApp(app); + expect(getState().complexDeployApp).toBe(app); + }); - it("clears the complex deploy app when null", () => { - const state = stateWith({ complexDeployApp: { appId: "app-1", name: "My App" } }); - const next = viewerReducer(state, { - type: "SET_COMPLEX_DEPLOY_APP", - app: null, - }); - expect(next.complexDeployApp).toBeNull(); - }); + it("clears the complex deploy app when null", () => { + useViewerStore.setState({ complexDeployApp: { appId: "app-1", name: "My App" } }); + getState().setComplexDeployApp(null); + expect(getState().complexDeployApp).toBeNull(); }); +}); - // --------------------------------------------------------------------------- - // Unknown action passthrough - // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Reset +// --------------------------------------------------------------------------- - it("returns the same state for an unknown action type", () => { - const state = stateWith({ mainView: "app" }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const next = viewerReducer(state, { type: "UNKNOWN" } as any); - expect(next).toBe(state); +describe("reset", () => { + it("restores all state to defaults", () => { + useViewerStore.setState({ + mainView: "app", + activeAppId: "app-1", + openedAppState: SAMPLE_APP, + isSharing: true, + isDeploying: true, + isTokenDialogOpen: true, + }); + getState().reset(); + const state = getState(); + expect(state.mainView).toBe("chat"); + expect(state.activeAppId).toBeNull(); + expect(state.openedAppState).toBeNull(); + expect(state.isSharing).toBe(false); + expect(state.isDeploying).toBe(false); + expect(state.isTokenDialogOpen).toBe(false); }); }); diff --git a/apps/web/src/stores/viewer-store.ts b/apps/web/src/stores/viewer-store.ts index 6685ca0e308..88682622d1b 100644 --- a/apps/web/src/stores/viewer-store.ts +++ b/apps/web/src/stores/viewer-store.ts @@ -1,28 +1,27 @@ /** - * Viewer-state machine. + * Zustand store for viewer UI state. * - * Manages the panel / app-viewer / document-viewer state as a single - * `useReducer` with typed domain events. All state transitions go through - * `viewerReducer`, keeping updates atomic and testable. + * Manages panel navigation, app/document viewer lifecycle, and + * share/deploy operations as direct named actions. * * **State managed:** - * - `mainView` — which top-level panel is displayed (chat, intelligence, library, app, document) - * - `activeAppId` — ID of the app currently open in the viewer - * - `openedAppState` — fetched HTML + metadata for the active app - * - `openedDocumentState` — fetched content for an open document - * - `isAppMinimized` — mobile-only: app viewer slides down to a thin strip - * - `intelligenceTab` — which sub-tab is active inside the intelligence panel + * - `mainView` — which top-level panel is displayed + * - `activeAppId` / `openedAppState` — app viewer + * - `openedDocumentState` — document viewer + * - `isAppMinimized` — mobile-only: app viewer minimized + * - `intelligenceTab` — sub-tab inside the intelligence panel * - `assetsRefreshKey` — counter bumped to force asset re-fetches - * - `viewBeforeDocument` — remembers the previous view so "close document" can restore it - * - `isSharing` — in-flight share-app operation - * - `isDeploying` — in-flight deploy-to-Vercel operation - * - `showTokenDialog` — Vercel token dialog open - * - `pendingDeployAppId` — app awaiting token before deploy resumes - * - `complexDeployApp` — app that needs confirmation before complex deploy + * - `viewBeforeDocument` / `viewBeforeSubagentDetail` — previous view for restoration + * - `activeSubagentId` — subagent detail panel + * - Share/deploy in-flight state * - * @see https://react.dev/learn/extracting-state-logic-into-a-reducer + * Reference: {@link https://zustand.docs.pmnd.rs/} */ +import { create } from "zustand"; + +import { createSelectors } from "@/utils/create-selectors.js"; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -51,10 +50,9 @@ export interface ComplexDeployApp { } // --------------------------------------------------------------------------- -// State +// State & Actions // --------------------------------------------------------------------------- -/** All viewer UI state managed by `viewerReducer`. */ export interface ViewerState { mainView: MainView; activeAppId: string | null; @@ -68,13 +66,59 @@ export interface ViewerState { viewBeforeSubagentDetail: Exclude; isSharing: boolean; isDeploying: boolean; - showTokenDialog: boolean; + isTokenDialogOpen: boolean; pendingDeployAppId: string | null; complexDeployApp: ComplexDeployApp | null; } -/** Default initial state — used as the `useReducer` initializer. */ -export const INITIAL_VIEWER_STATE: ViewerState = { +export interface ViewerActions { + // --- View navigation --- + setMainView: (view: MainView) => void; + setIntelligenceTab: (tab: IntelligenceTab) => void; + + // --- App viewer --- + openApp: (appId: string) => void; + setLoadedApp: (app: OpenedAppState) => void; + handleAppLoadFailed: () => void; + closeApp: () => void; + toggleAppMinimized: () => void; + handleAppUnpinned: (appId: string) => void; + enterAppEditing: () => void; + exitAppEditing: () => void; + + // --- Subagent detail --- + openSubagentDetail: (subagentId: string) => void; + closeSubagentDetail: () => void; + + // --- Document viewer --- + openDocument: () => void; + setLoadedDocument: (document: OpenedDocumentState) => void; + handleDocumentLoadFailed: () => void; + closeDocument: () => void; + + // --- Assets --- + refreshAssets: () => void; + + // --- Share / Deploy --- + startSharing: () => void; + finishSharing: () => void; + startDeploying: () => void; + finishDeploying: (clearPendingAppId?: boolean) => void; + showTokenDialog: (pendingAppId: string) => void; + hideTokenDialog: () => void; + setComplexDeployApp: (app: ComplexDeployApp | null) => void; + + // --- Reset --- + reset: () => void; +} + +export type ViewerStore = ViewerState & ViewerActions; + +// --------------------------------------------------------------------------- +// Initial state +// --------------------------------------------------------------------------- + +const INITIAL_STATE: ViewerState = { mainView: "chat", activeAppId: null, openedAppState: null, @@ -87,343 +131,189 @@ export const INITIAL_VIEWER_STATE: ViewerState = { viewBeforeSubagentDetail: "chat", isSharing: false, isDeploying: false, - showTokenDialog: false, + isTokenDialogOpen: false, pendingDeployAppId: null, complexDeployApp: null, }; // --------------------------------------------------------------------------- -// Domain events (actions) +// Store // --------------------------------------------------------------------------- -// --- View navigation --- - -export interface SetMainView { - type: "SET_MAIN_VIEW"; - view: MainView; -} - -export interface SetIntelligenceTab { - type: "SET_INTELLIGENCE_TAB"; - tab: IntelligenceTab; -} - -// --- App viewer --- - -/** Begin loading an app — clears previous app state and switches to "app" view. */ -export interface OpenAppStart { - type: "OPEN_APP_START"; - appId: string; -} - -/** App HTML fetched successfully. */ -export interface AppLoaded { - type: "APP_LOADED"; - app: OpenedAppState; -} - -/** App fetch failed — fall back to chat view. */ -export interface AppLoadFailed { - type: "APP_LOAD_FAILED"; -} - -/** Close the app viewer and return to chat. */ -export interface CloseApp { - type: "CLOSE_APP"; -} - -export interface ToggleAppMinimized { - type: "TOGGLE_APP_MINIMIZED"; -} - -/** Pinned app was removed — reset if it's the active one. */ -export interface ActiveAppUnpinned { - type: "ACTIVE_APP_UNPINNED"; - appId: string; -} - -export interface EnterAppEditing { - type: "ENTER_APP_EDITING"; -} - -export interface ExitAppEditing { - type: "EXIT_APP_EDITING"; -} - -// --- Subagent detail --- - -/** Open the subagent detail panel — saves the current view for restoration. */ -export interface OpenSubagentDetail { - type: "OPEN_SUBAGENT_DETAIL"; - subagentId: string; -} - -/** Close the subagent detail panel and restore the previous view. */ -export interface CloseSubagentDetail { - type: "CLOSE_SUBAGENT_DETAIL"; -} - -// --- Document viewer --- - -/** Begin loading a document — saves the current view for restoration. */ -export interface OpenDocumentStart { - type: "OPEN_DOCUMENT_START"; -} - -export interface DocumentLoaded { - type: "DOCUMENT_LOADED"; - document: OpenedDocumentState; -} - -export interface DocumentLoadFailed { - type: "DOCUMENT_LOAD_FAILED"; -} - -export interface CloseDocument { - type: "CLOSE_DOCUMENT"; -} - -// --- Assets --- - -export interface RefreshAssets { - type: "REFRESH_ASSETS"; -} - -// --- Share / Deploy --- - -export interface StartSharing { - type: "START_SHARING"; -} - -export interface SharingDone { - type: "SHARING_DONE"; -} - -export interface StartDeploying { - type: "START_DEPLOYING"; -} - -export interface DeployingDone { - type: "DEPLOYING_DONE"; - clearPendingAppId?: boolean; -} - -export interface ShowTokenDialog { - type: "SHOW_TOKEN_DIALOG"; - pendingAppId: string; -} - -export interface HideTokenDialog { - type: "HIDE_TOKEN_DIALOG"; -} - -export interface SetComplexDeployApp { - type: "SET_COMPLEX_DEPLOY_APP"; - app: ComplexDeployApp | null; -} - -// --- Union --- - -export type ViewerAction = - | SetMainView - | SetIntelligenceTab - | OpenAppStart - | AppLoaded - | AppLoadFailed - | CloseApp - | ToggleAppMinimized - | ActiveAppUnpinned - | EnterAppEditing - | ExitAppEditing - | OpenSubagentDetail - | CloseSubagentDetail - | OpenDocumentStart - | DocumentLoaded - | DocumentLoadFailed - | CloseDocument - | RefreshAssets - | StartSharing - | SharingDone - | StartDeploying - | DeployingDone - | ShowTokenDialog - | HideTokenDialog - | SetComplexDeployApp; - -// --------------------------------------------------------------------------- -// Reducer -// --------------------------------------------------------------------------- - -/** - * Pure reducer for viewer state. - * - * Accepts a `ViewerAction` discriminated union and returns the next state. - * Compound actions like `OPEN_APP_START` and `CLOSE_APP` update multiple - * fields atomically — this is the primary benefit over scattered `useState` - * setters where multi-field updates could render intermediate states. - */ -export function viewerReducer( - state: ViewerState, - action: ViewerAction, -): ViewerState { - switch (action.type) { - // ----- View navigation ----- - - case "SET_MAIN_VIEW": - if (state.mainView === action.view) return state; - return { ...state, mainView: action.view }; - - case "SET_INTELLIGENCE_TAB": - if (state.intelligenceTab === action.tab) return state; - return { ...state, intelligenceTab: action.tab }; - - // ----- App viewer ----- - - case "OPEN_APP_START": - return { - ...state, - mainView: "app", - activeAppId: action.appId, - openedAppState: null, - isAppMinimized: false, - }; - - case "APP_LOADED": - return { ...state, openedAppState: action.app }; - - case "APP_LOAD_FAILED": - return { - ...state, - mainView: "chat", - activeAppId: null, - openedAppState: null, - }; - - case "CLOSE_APP": - return { - ...state, - activeAppId: null, - openedAppState: null, - isAppMinimized: false, - }; - - case "TOGGLE_APP_MINIMIZED": - return { ...state, isAppMinimized: !state.isAppMinimized }; - - case "ACTIVE_APP_UNPINNED": - if ( - state.activeAppId !== action.appId || - (state.mainView !== "app" && state.mainView !== "app-editing") - ) { - return state; - } - return { - ...state, - mainView: "chat", - activeAppId: null, - openedAppState: null, - }; - - case "ENTER_APP_EDITING": - return { ...state, mainView: "app-editing" }; - - case "EXIT_APP_EDITING": - return { ...state, mainView: "app" }; - - // ----- Subagent detail ----- - - case "OPEN_SUBAGENT_DETAIL": { - const viewBeforeSubagentDetail = - state.mainView === "subagent-detail" - ? state.viewBeforeSubagentDetail - : (state.mainView as Exclude); - return { - ...state, - mainView: "subagent-detail", - activeSubagentId: action.subagentId, - viewBeforeSubagentDetail, - }; +const useViewerStoreBase = create()((set, get) => ({ + ...INITIAL_STATE, + + // --- View navigation --- + + setMainView: (view) => { + if (get().mainView === view) return; + set({ mainView: view }); + }, + + setIntelligenceTab: (tab) => { + if (get().intelligenceTab === tab) return; + set({ intelligenceTab: tab }); + }, + + // --- App viewer --- + + openApp: (appId) => { + set({ + mainView: "app", + activeAppId: appId, + openedAppState: null, + isAppMinimized: false, + }); + }, + + setLoadedApp: (app) => { + set({ openedAppState: app }); + }, + + handleAppLoadFailed: () => { + set({ + mainView: "chat", + activeAppId: null, + openedAppState: null, + }); + }, + + closeApp: () => { + set({ + activeAppId: null, + openedAppState: null, + isAppMinimized: false, + }); + }, + + toggleAppMinimized: () => { + set({ isAppMinimized: !get().isAppMinimized }); + }, + + handleAppUnpinned: (appId) => { + const state = get(); + if ( + state.activeAppId !== appId || + (state.mainView !== "app" && state.mainView !== "app-editing") + ) { + return; } - - case "CLOSE_SUBAGENT_DETAIL": - return { - ...state, - mainView: state.viewBeforeSubagentDetail, - activeSubagentId: null, - }; - - // ----- Document viewer ----- - - case "OPEN_DOCUMENT_START": { - const viewBeforeDocument = - state.mainView === "document" - ? state.viewBeforeDocument - : (state.mainView as Exclude); - return { - ...state, - mainView: "document", - openedDocumentState: null, - viewBeforeDocument, - }; - } - - case "DOCUMENT_LOADED": - return { ...state, openedDocumentState: action.document }; - - case "DOCUMENT_LOAD_FAILED": - return { - ...state, - mainView: state.viewBeforeDocument, - openedDocumentState: null, - }; - - case "CLOSE_DOCUMENT": - return { - ...state, - mainView: state.viewBeforeDocument, - openedDocumentState: null, - }; - - // ----- Assets ----- - - case "REFRESH_ASSETS": - return { ...state, assetsRefreshKey: state.assetsRefreshKey + 1 }; - - // ----- Share / Deploy ----- - - case "START_SHARING": - return { ...state, isSharing: true }; - - case "SHARING_DONE": - return { ...state, isSharing: false }; - - case "START_DEPLOYING": - return { ...state, isDeploying: true }; - - case "DEPLOYING_DONE": - return { - ...state, - isDeploying: false, - pendingDeployAppId: action.clearPendingAppId - ? null - : state.pendingDeployAppId, - }; - - case "SHOW_TOKEN_DIALOG": - return { - ...state, - showTokenDialog: true, - pendingDeployAppId: action.pendingAppId, - isDeploying: false, - }; - - case "HIDE_TOKEN_DIALOG": - return { ...state, showTokenDialog: false }; - - case "SET_COMPLEX_DEPLOY_APP": - return { ...state, complexDeployApp: action.app }; - - default: - return state; - } -} + set({ + mainView: "chat", + activeAppId: null, + openedAppState: null, + }); + }, + + enterAppEditing: () => { + set({ mainView: "app-editing" }); + }, + + exitAppEditing: () => { + set({ mainView: "app" }); + }, + + // --- Subagent detail --- + + openSubagentDetail: (subagentId) => { + const state = get(); + const viewBeforeSubagentDetail = + state.mainView === "subagent-detail" + ? state.viewBeforeSubagentDetail + : (state.mainView as Exclude); + set({ + mainView: "subagent-detail", + activeSubagentId: subagentId, + viewBeforeSubagentDetail, + }); + }, + + closeSubagentDetail: () => { + set({ + mainView: get().viewBeforeSubagentDetail, + activeSubagentId: null, + }); + }, + + // --- Document viewer --- + + openDocument: () => { + const state = get(); + const viewBeforeDocument = + state.mainView === "document" + ? state.viewBeforeDocument + : (state.mainView as Exclude); + set({ + mainView: "document", + openedDocumentState: null, + viewBeforeDocument, + }); + }, + + setLoadedDocument: (document) => { + set({ openedDocumentState: document }); + }, + + handleDocumentLoadFailed: () => { + set({ + mainView: get().viewBeforeDocument, + openedDocumentState: null, + }); + }, + + closeDocument: () => { + set({ + mainView: get().viewBeforeDocument, + openedDocumentState: null, + }); + }, + + // --- Assets --- + + refreshAssets: () => { + set({ assetsRefreshKey: get().assetsRefreshKey + 1 }); + }, + + // --- Share / Deploy --- + + startSharing: () => { + set({ isSharing: true }); + }, + + finishSharing: () => { + set({ isSharing: false }); + }, + + startDeploying: () => { + set({ isDeploying: true }); + }, + + finishDeploying: (clearPendingAppId) => { + set({ + isDeploying: false, + ...(clearPendingAppId ? { pendingDeployAppId: null } : {}), + }); + }, + + showTokenDialog: (pendingAppId) => { + set({ + isTokenDialogOpen: true, + pendingDeployAppId: pendingAppId, + isDeploying: false, + }); + }, + + hideTokenDialog: () => { + set({ isTokenDialogOpen: false }); + }, + + setComplexDeployApp: (app) => { + set({ complexDeployApp: app }); + }, + + // --- Reset --- + + reset: () => set({ ...INITIAL_STATE }), +})); + +export const useViewerStore = createSelectors(useViewerStoreBase); diff --git a/apps/web/src/utils/create-selectors.ts b/apps/web/src/utils/create-selectors.ts index a0663375c39..e003aeabc9c 100644 --- a/apps/web/src/utils/create-selectors.ts +++ b/apps/web/src/utils/create-selectors.ts @@ -4,7 +4,20 @@ * Wraps a store so every state key is available as `store.use.key()`, * each backed by an individual selector for minimal re-renders. * - * Reference: https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors + * **Which API to use:** + * + * - `store.use.field()` — React render bodies. Creates a subscription; + * the component re-renders when `field` changes. + * - `store.getState().field` — Event handlers, callbacks, effects, + * middleware, and anywhere outside the React render cycle. Reads the + * latest value without creating a subscription. + * + * Zustand's `set()` is synchronous, so `getState()` after an action + * returns the already-mutated values. Read state *before* calling an + * action when the caller needs pre-mutation values. + * + * @see {@link https://zustand.docs.pmnd.rs/guides/auto-generating-selectors} + * @see {@link https://zustand.docs.pmnd.rs/guides/updating-state} */ import type { StoreApi, UseBoundStore } from "zustand";