import { type ReactNode, createContext, useCallback, useContext, useMemo, useRef } from 'react' import { useQueryClient } from '@tanstack/react-query' import { type WorkspaceLayoutResponse, useSaveLayout, useWorkspaceLayout } from '@/client/features/workspace/api' import { workspaceKeys } from '@/client/api/workspace-keys' import type { WorkspaceLayout, WorkspaceType } from '@/lib/types' import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout' export type WorkspaceLayoutContextValue = { // The persisted layout (widget grid, layout mode, theme). Falls back to an // empty default while the query is still loading. layout: WorkspaceLayout // Merge a partial update into the layout: updates the query cache immediately // (optimistic) then debounces a PUT to the server via the save mutation. setLayout: (update: Partial) => void // Workspace metadata that rides along on the layout endpoint. name: string | null // Custom workspace icon (base64 data URL), or null to use the provider icon. icon: string | null cwd: string | null // The agent backend (claude-code / openclaw …). Exposed here so descendants // can reuse the shared layout query instead of spawning another observer // that could refetch on mount and clobber an optimistic layout update. provider: WorkspaceType | null // The workspace's registry id (the route param), so descendants can key // their own queries (e.g. the model picker) without prop-drilling. workspaceId: string isLoading: boolean } // Exported (with its value type) so fixture surfaces like /dev/chat-states can // provide a static value without mounting the query-backed provider. export const WorkspaceLayoutContext = createContext(null) export function useWorkspaceLayoutCtx(): WorkspaceLayoutContextValue { const ctx = useContext(WorkspaceLayoutContext) if (!ctx) throw new Error('useWorkspaceLayoutCtx must be used within ') return ctx } // The theme alone, for chrome that only needs to paint itself (widget frames, // previews). Unlike `useWorkspaceLayoutCtx` this tolerates being read outside a // provider and returns `undefined` there, which callers already treat as "use // the default theme" — so the same component still renders in isolation. export function useWorkspaceThemeSetting(): WorkspaceLayout['theme'] { return useContext(WorkspaceLayoutContext)?.layout.theme } // Strip the server-only metadata so what we PUT back (and expose as `layout`) // is just the persisted `WorkspaceLayout`. function stripMeta(data: WorkspaceLayoutResponse): WorkspaceLayout { const { cwd: _cwd, name: _name, provider: _provider, agentId: _agentId, ...layout } = data return layout } type WorkspaceLayoutProviderProps = { id: string children: ReactNode } // Owns the layout query + mutation for a workspace and hands the rest of the // tree a store-like `{ layout, setLayout }` API backed by React Query. export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProviderProps) { const qc = useQueryClient() const query = useWorkspaceLayout(id) const save = useSaveLayout(id) // `setLayout` must be referentially stable (it feeds effect deps in the grid // reconcile), so reach the latest mutate via a ref instead of closing over it. const saveRef = useRef(save.mutate) saveRef.current = save.mutate const timer = useRef | null>(null) const setLayout = useCallback( (update: Partial) => { const key = workspaceKeys.layout(id) const prev = qc.getQueryData(key) if (!prev) return // Optimistic: the grid/theme reflects the change before the PUT lands. const next = { ...prev, ...update } qc.setQueryData(key, next) if (timer.current) clearTimeout(timer.current) // Persist the value captured at call time, NOT the cache at fire time: a // `workspace:updated` broadcast can invalidate + refetch this query inside // the debounce window, and reading the cache then would PUT the server's // pre-change layout back (reverting the user's edit on disk). Rapid calls // still coalesce — each call's `prev` already includes the previous // optimistic write. timer.current = setTimeout(() => { timer.current = null saveRef.current(stripMeta(next)) }, 600) }, [id, qc] ) // Memoized so the context value keeps a stable identity across unrelated // renders — otherwise every consumer (WorkspaceView, Widgets, ModelPicker, // TurnView, …) would re-render whenever this provider re-renders. `setLayout` // is already stable; the rest derive from `query.data`. const value = useMemo( () => ({ layout: query.data ? stripMeta(query.data) : createDefaultWorkspaceLayout(), setLayout, name: query.data?.name ?? null, icon: query.data?.icon ?? null, cwd: query.data?.cwd ?? null, provider: query.data?.provider ?? null, workspaceId: id, isLoading: query.isLoading }), [query.data, query.isLoading, setLayout, id] ) return {children} }