diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index 4610844e..5125eaab 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -42,9 +42,16 @@ via their pre-hooks. - **Entry** — `src/main.tsx` mounts ``. The persisted theme is applied *before* first paint to avoid a light flash. - **State** — `src/store.tsx` holds all app state and actions (`route`, - `resolveConflict`, `send`, view/selection setters) in one context. Callbacks - read the freshest values through refs so they don't re-subscribe. State is - in-memory only — reloads reset it. + `resolveConflict`, `send`, view/selection setters) in **three** contexts, split + by how often each changes: `data` (engine answers + every action, and every + action has a stable identity), `nav` (view and selection), `input` (the search + box and the chat composer — changes per keystroke). `useStoreData()` / + `useStoreNav()` / `useStoreInput()` are the narrow hooks; `useStore()` merges + all three and re-renders on any of them — it has no production callers left, + only test mocks, and new code should not add one. Callbacks read the freshest + values through refs so they don't re-subscribe. State is in-memory only — + reloads reset it. See the subscribe-narrowly gotcha below before adding a + consumer. - **Views** — `src/views/` (Canvas, Overview, Sources, Triage, Conflicts, Concepts, Files). `App.tsx` is the shell: topbar + subbar + routed view, plus the Triage S/R/D keyboard handler. The canvas view stays full-height inside @@ -107,6 +114,22 @@ Key files: `src/store.tsx` (state), `src/theme.ts` (`css()` + tokens), `src/theme.ts`. An unregistered hex renders fine in light mode and silently fails to adapt in dark mode. Prefer the `C.*` variable refs for new code; if you must write a hex, add it to `HEX_VARS`. +- **Subscribing to the wrong store context fails silently.** Every view root is + `React.memo`'d with no props, so the only thing that re-renders it is a context + it actually subscribes to. A component that reads query-derived data without + calling `useStoreInput()` does not throw, does not warn, and does not + re-render — it just quietly stops updating. That shipped: `Triage` read the + query through a store callback closed over a ref, subscribed to `data` + `nav` + only, and the Queue's search box stopped filtering entirely. The + render-count test could not see it, because "did not re-render" was what it + was asserting. Two rules follow: **derive from values you subscribed to, never + from a ref inside a stable callback** (which is why `filterSignals` is an + exported pure function taking `query`, not a `store.filtered(tab)` method — + the argument is what forces the caller to have subscribed); and any view in + `SEARCHABLE_VIEWS` (`shell-navigation.ts`) must be in the case table in + `render-hygiene.test.tsx`, which types a query that matches nothing and + asserts the list actually empties. That suite deliberately holds both halves: + the sidebar must NOT repaint on a keystroke, and the active view MUST. - **Prefer `C.*` / `css()` over raw styles** so both themes and the reduced-motion / focus-visible rules keep working. - **`css()` is a simple `;`/`:` splitter** — no nested rules, no `url(...)` with diff --git a/apps/console/src/App.test.tsx b/apps/console/src/App.test.tsx index b4eb8a93..fb4b3667 100644 --- a/apps/console/src/App.test.tsx +++ b/apps/console/src/App.test.tsx @@ -166,6 +166,10 @@ describe('Mac-first application shell', () => { }) expect(sidebar?.dataset.collapsed).toBe('false') expect(sidebar?.style.width).toBe('232px') + // Persistence is debounced — a drag would otherwise write on every + // pointermove. The value still has to land, so wait out the quiet period + // rather than dropping the assertion. + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 320)) }) expect(JSON.parse(window.localStorage.getItem('contextcake.sidebar') ?? '{}')).toEqual({ collapsed: false, width: 232 }) await act(async () => { diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 2106dae1..57e81299 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useStore } from './store' +import { useStoreData, useStoreNav } from './store' import { C, css, MONO } from './theme' import { Sidebar } from './components/Sidebar' import { Header } from './components/Header' @@ -11,6 +11,7 @@ import { Conflicts } from './views/Conflicts' import { Concepts } from './views/Concepts' import { Files } from './views/Files' import { ChatPanel } from './components/ChatPanel' +import { EngineBanner } from './components/EngineBanner' import { SetupWizard } from './components/SetupWizard' import { ConnectAgentDialog } from './components/ConnectAgentDialog' import { SettingsView } from './components/SettingsView' @@ -66,7 +67,11 @@ function ErrorState({ kind, message, reload }: { kind: LiveErrorKind; message: s } export function App() { - const { view, setView, chatOpen, openChat, closeChat, route, loading, load, error, reload, retryNow, mode, sources, loadErrors, openFilesScope } = useStore() + // Deliberately three narrow subscriptions rather than `useStore()`: the shell + // owns every memoized child below, so an App render is a whole-tree render. + // Typing in the toolbar search must not cause one. + const { setView, openChat, closeChat, route, loading, load, error, reload, retryNow, mode, sources, loadErrors, openFilesScope } = useStoreData() + const { view, chatOpen } = useStoreNav() // Undefined = not yet decided by the auto-trigger effect below; true/false // once the user (or the trigger) has taken an explicit stance. Kept separate // from `needsSetup` so the wizard's own Success step stays visible even @@ -102,15 +107,18 @@ export function App() { const showWizard = wizardOpen === true const closeWizard = () => setWizardOpen(false) - const reopenWizard = () => setWizardOpen(true) - const openConnect = () => { + // Handlers that reach a memoized child (Sidebar, Header, Sources, ChatPanel) + // are stable identities. A fresh arrow function per render would re-render + // the child through its memo and give the whole split back. + const reopenWizard = useCallback(() => setWizardOpen(true), []) + const openConnect = useCallback(() => { if (sources.length === 0 && !sourceSetupComplete) { setWizardOpen(true) return } setConnectOpen(true) - } - const openSettings = () => { + }, [sources.length, sourceSetupComplete]) + const openSettings = useCallback(() => { if (window.__CC_DESKTOP?.windows) { setDrawerOpen(false) // Omit a pane so the native window can restore the user's last one. @@ -121,7 +129,7 @@ export function App() { settingsOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null setDrawerOpen(false) setSettingsOpen(true) - } + }, []) const openPalette = () => { paletteOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null setDrawerOpen(false) @@ -156,7 +164,7 @@ export function App() { setDrawerOpen(false) window.requestAnimationFrame(() => opener?.isConnected && opener.focus()) }, []) - const toggleSidebar = () => { + const toggleSidebar = useCallback(() => { if (window.innerWidth < 900) { if (drawerOpen) closeDrawer() else { @@ -165,7 +173,7 @@ export function App() { } } else window.dispatchEvent(new Event('contextcake:toggle-sidebar')) - } + }, [closeDrawer, drawerOpen]) const paletteCommands = useMemo(() => [ { id: 'home', label: 'Go to Home', keywords: 'overview', shortcut: '⌘1', run: () => setView('overview') }, @@ -189,7 +197,7 @@ export function App() { { id: 'ask', label: 'Ask ContextCake', shortcut: '⇧⌘A', run: openAskFromPalette }, { id: 'settings', label: 'Open Settings', shortcut: '⌘,', run: openSettings }, { id: 'sidebar', label: 'Toggle Sidebar', run: toggleSidebar }, - ], [isDesktop, mode, openAskFromPalette, openFilesScope, setView, sources, sourceSetupComplete]) + ], [isDesktop, mode, openAskFromPalette, openConnect, openFilesScope, openSettings, reopenWizard, setView, sources, toggleSidebar]) const closeSettings = () => { const opener = settingsOpener.current setSettingsOpen(false) @@ -373,6 +381,12 @@ export function App() {
{backgroundAnnouncement}
+ {/* + Above the refresh banner on purpose: a wedged engine is the CAUSE + of the failing refresh below it, and holds its own state so this + app-wide render never runs for it. + */} + {load.refreshError && load.refreshError.message !== dismissedRefreshError && (
diff --git a/apps/console/src/components/BackgroundActivity.test.tsx b/apps/console/src/components/BackgroundActivity.test.tsx index 7c8f2d30..df66da17 100644 --- a/apps/console/src/components/BackgroundActivity.test.tsx +++ b/apps/console/src/components/BackgroundActivity.test.tsx @@ -11,7 +11,11 @@ import { LiveDataError } from '../api' import type { BackgroundTask, Store } from '../store' const mocks = vi.hoisted(() => ({ store: { current: null as unknown as Store } })) -vi.mock('../store', () => ({ useStore: () => mocks.store.current })) +// The store is three contexts now; this component reads the data half. +vi.mock('../store', () => { + const store = () => mocks.store.current + return { useStore: store, useStoreData: store, useStoreNav: store, useStoreInput: store } +}) let container: HTMLDivElement let root: Root diff --git a/apps/console/src/components/BackgroundActivity.tsx b/apps/console/src/components/BackgroundActivity.tsx index b8a52052..65ae9bbd 100644 --- a/apps/console/src/components/BackgroundActivity.tsx +++ b/apps/console/src/components/BackgroundActivity.tsx @@ -13,7 +13,7 @@ // quiet note, never a spinner in front of an answer). import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { progressPercent } from '../api' -import { useStore } from '../store' +import { useStoreData } from '../store' import type { BackgroundTask } from '../store' import { C, css, MONO } from '../theme' @@ -149,7 +149,7 @@ function TaskRow({ task }: { task: BackgroundTask }) { } export function BackgroundActivity() { - const { load, retryNow, setView } = useStore() + const { load, retryNow, setView } = useStoreData() const { tasks, refreshError, lastRefreshAt } = load const [open, setOpen] = useState(false) const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null) diff --git a/apps/console/src/components/ChatPanel.tsx b/apps/console/src/components/ChatPanel.tsx index 710eaeee..58c369bb 100644 --- a/apps/console/src/components/ChatPanel.tsx +++ b/apps/console/src/components/ChatPanel.tsx @@ -1,12 +1,13 @@ -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { C, css, lc, MONO } from '../theme' -import { useStore } from '../store' +import { useStoreData, useStoreInput } from '../store' const SUGGESTIONS = ['What database do we use?', 'How do we handle on-call?'] const MCP_CONFIG_CMD = 'claude mcp add contextcake -- node mcp-server.mjs --manifest layers.json' -export function ChatPanel({ keyboardSuspended = false, onConnectAgent, onClose }: { keyboardSuspended?: boolean; onConnectAgent?: () => void; onClose: () => void }) { - const { chatMessages, chatBusy, chatInput, setChatInput, send } = useStore() +function ChatPanelInner({ keyboardSuspended = false, onConnectAgent, onClose }: { keyboardSuspended?: boolean; onConnectAgent?: () => void; onClose: () => void }) { + const { setChatInput, send } = useStoreData() + const { chatMessages, chatBusy, chatInput } = useStoreInput() const scrollRef = useRef(null) const inputRef = useRef(null) const panelRef = useRef(null) @@ -163,3 +164,11 @@ export function ChatPanel({ keyboardSuspended = false, onConnectAgent, onClose }
) } + +/** + * Memoized. The shell re-renders for its own reasons — a drawer, a dialog, a + * background-activity tick — and this view has no business repainting for any + * of them. It re-renders when the store slices it subscribes to change, and + * otherwise not at all. + */ +export const ChatPanel = memo(ChatPanelInner) diff --git a/apps/console/src/components/ConceptDetail.tsx b/apps/console/src/components/ConceptDetail.tsx index c0351bdc..66821873 100644 --- a/apps/console/src/components/ConceptDetail.tsx +++ b/apps/console/src/components/ConceptDetail.tsx @@ -3,7 +3,7 @@ import { C, css, lc, MONO, conceptTypeStyle } from '../theme' import { layerName } from '../data' import type { Concept } from '../data' import { filesRevalidation, useLayerFiles } from '../layer-files' -import { useStore } from '../store' +import { useStoreData } from '../store' import { LayerChip } from './LayerChip' /** Which document extension wins when one concept id has several files behind it. */ @@ -24,7 +24,7 @@ const contributorKey = (layer: string, conceptId: string) => JSON.stringify([lay * link, and so no affordance that opens on an error. */ function useFileByContributor(): Map { - const { mode, sources, reloadKey } = useStore() + const { mode, sources, reloadKey } = useStoreData() const { layers } = useLayerFiles(mode, filesRevalidation(sources, reloadKey)) return useMemo(() => { const best = new Map() @@ -43,7 +43,7 @@ function useFileByContributor(): Map { /** "Open file" for one contributor, or nothing when that layer keeps no file here. */ function OpenFile({ layer, path, conceptId }: { layer: string; path: string | undefined; conceptId: string }) { - const { openFilesScope } = useStore() + const { openFilesScope } = useStoreData() if (!path) return null return ( + )} + + ) +} diff --git a/apps/console/src/components/Header.tsx b/apps/console/src/components/Header.tsx index 0449a829..8900a17a 100644 --- a/apps/console/src/components/Header.tsx +++ b/apps/console/src/components/Header.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from 'react' -import { useStore, type ViewId } from '../store' +import { memo, useEffect, useRef } from 'react' +import { useStoreData, useStoreInput, useStoreNav, type ViewId } from '../store' import { destinationForView, SEARCHABLE_VIEWS } from '../shell-navigation' import { AgentIcon, PlusIcon, SidebarIcon, SparkleIcon } from './icons' import { BackgroundActivity } from './BackgroundActivity' @@ -10,7 +10,7 @@ const TITLES: Record = { conflicts: 'Review', concepts: 'Knowledge', files: 'Knowledge', } -export function Header({ +function HeaderInner({ onToggleSidebar, onAsk, onAddSource, onConnectAgent, }: { onToggleSidebar: () => void @@ -18,7 +18,9 @@ export function Header({ onAddSource?: () => void onConnectAgent?: () => void }) { - const { view, setView, query, setQuery, loadErrors, mode, signals, conflicts } = useStore() + const { setView, setQuery, loadErrors, mode, signals, conflicts } = useStoreData() + const { view } = useStoreNav() + const { query } = useStoreInput() const search = useRef(null) const destination = destinationForView(view) const searchable = SEARCHABLE_VIEWS.has(view) @@ -69,3 +71,11 @@ export function Header({ ) } + +/** + * Memoized. The shell re-renders for its own reasons — a drawer, a dialog, a + * background-activity tick — and this view has no business repainting for any + * of them. It re-renders when the store slices it subscribes to change, and + * otherwise not at all. + */ +export const Header = memo(HeaderInner) diff --git a/apps/console/src/components/SettingsView.test.tsx b/apps/console/src/components/SettingsView.test.tsx index 1e15739c..40c0da73 100644 --- a/apps/console/src/components/SettingsView.test.tsx +++ b/apps/console/src/components/SettingsView.test.tsx @@ -14,22 +14,47 @@ type TestPreferences = { updateCheck: boolean anonymousMetrics: boolean | null reducedTransparency: boolean + reducedTransparencyPreference: boolean | null + systemReducedTransparency: boolean highContrast: boolean } function preferences(overrides: Partial = {}) { let current: TestPreferences = { theme: 'system', density: 'comfortable', updateCheck: true, - anonymousMetrics: true, reducedTransparency: false, highContrast: false, + anonymousMetrics: true, reducedTransparency: false, + reducedTransparencyPreference: null, systemReducedTransparency: false, + highContrast: false, ...overrides, } + // Mirrors the main process: a patch carries the *choice*, and the effective + // value is recomputed from it. A mock that just merged the patch would let a + // renderer bug that confuses the two pass. const set = vi.fn().mockImplementation(async (patch: Partial) => { - current = { ...current, ...patch } + const next = { ...current, ...patch } + if ('reducedTransparency' in patch) { + const choice = (patch.reducedTransparency ?? null) as boolean | null + next.reducedTransparencyPreference = choice + next.reducedTransparency = choice ?? current.systemReducedTransparency + } + current = next return current }) return { initial: current, get: vi.fn().mockImplementation(async () => current), set, onChanged: vi.fn(() => () => {}) } } +function withDesktopPreferences(overrides: Partial = {}) { + const bridge = preferences(overrides) + window.__CC_DESKTOP = { + getApiToken: vi.fn().mockResolvedValue('token'), + version: '0.0.0-test', + authState: { signedIn: false, available: false }, + preferences: bridge, + cli: { getStatus: vi.fn(), install: vi.fn() }, + } as unknown as typeof window.__CC_DESKTOP + return bridge +} + function button(label: string): HTMLButtonElement { const match = findButton(label) if (!match) throw new Error(`Button not found: ${label}`) @@ -40,6 +65,15 @@ function findButton(label: string): HTMLButtonElement | undefined { return Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === label) } +/** Scoped by the segmented group's accessible name — "System" is a Theme option too. */ +function groupButton(group: string, label: string): HTMLButtonElement { + const scope = container.querySelector(`[role="group"][aria-label="${group}"]`) + if (!scope) throw new Error(`Segmented group not found: ${group}`) + const match = Array.from(scope.querySelectorAll('button')).find((item) => item.textContent?.trim() === label) + if (!match) throw new Error(`Button not found in ${group}: ${label}`) + return match +} + /** A build packaged with CC_ACCOUNTS=1. The default build ships without them. */ function withAccountsEnabled(auth: Partial> = {}) { window.__CC_DESKTOP = { @@ -209,6 +243,117 @@ describe('SettingsView', () => { expect(document.documentElement.dataset.theme).toBe('dark') }) + it('lets a Mac user override Reduce Transparency and hand the choice back', async () => { + // This Mac says "reduce": that is what makes System distinguishable from + // Off. With a Mac that says no, every wrong fallback still renders `false` + // and the test proves nothing. + const bridge = withDesktopPreferences({ systemReducedTransparency: true, reducedTransparency: true }) + await act(async () => root.render( + + + , + )) + await act(async () => {}) + + // Following this Mac, which asks for reduced transparency. + expect(container.textContent).toContain('which is currently on') + expect(groupButton('Reduce transparency', 'System').getAttribute('aria-pressed')).toBe('true') + expect(document.documentElement.dataset.reducedTransparency).toBe('true') + + // Off is the override that disagrees with the Mac — the whole reason the + // control exists, and it must take effect without waiting for a round trip. + await act(async () => groupButton('Reduce transparency', 'Off').click()) + expect(bridge.set).toHaveBeenCalledWith({ reducedTransparency: false }) + expect(document.documentElement.dataset.reducedTransparency).toBe('false') + + await act(async () => groupButton('Reduce transparency', 'On').click()) + expect(bridge.set).toHaveBeenCalledWith({ reducedTransparency: true }) + expect(document.documentElement.dataset.reducedTransparency).toBe('true') + + // Back to System resolves against the Mac's value, not the last override: + // it lands on true because the Mac says so, not because On was just picked. + await act(async () => groupButton('Reduce transparency', 'Off').click()) + await act(async () => groupButton('Reduce transparency', 'System').click()) + expect(bridge.set).toHaveBeenCalledWith({ reducedTransparency: null }) + expect(document.documentElement.dataset.reducedTransparency).toBe('true') + }) + + it('starts from an override the app already stored, not from this Mac\'s setting', async () => { + withDesktopPreferences({ + reducedTransparencyPreference: true, reducedTransparency: true, systemReducedTransparency: false, + }) + await act(async () => root.render( + + + , + )) + await act(async () => {}) + + expect(groupButton('Reduce transparency', 'On').getAttribute('aria-pressed')).toBe('true') + expect(document.documentElement.dataset.reducedTransparency).toBe('true') + }) + + it('offers no transparency control outside the Mac app', async () => { + // The browser and demo surfaces have nowhere to persist it; a control that + // forgets on reload is worse than no control. + await act(async () => root.render( + + + , + )) + await act(async () => {}) + expect(container.textContent).not.toContain('Reduce transparency') + }) + + it('holds a setting the Mac could not save, and says it will not survive a restart', async () => { + // The main process rejects `preferences.set` when the disk write fails, but + // it has already applied the change and keeps serving it. Snapping the + // control back would show a state the app is not in — the user would read + // "on" while updates are genuinely off. Hold the value; report durability. + const bridge = withDesktopPreferences() + bridge.set.mockRejectedValue(new Error('settings could not be saved')) + await act(async () => root.render( + + + , + )) + await act(async () => {}) + + const updates = container.querySelector('input[aria-label="Check for updates automatically"]') + expect(updates?.checked).toBe(true) + expect(container.textContent).not.toContain('could not be saved to this Mac') + + await act(async () => updates?.click()) + expect(updates?.checked).toBe(false) + expect(container.textContent).toContain('could not be saved to this Mac') + + // An appearance change routes through the same file and must report the + // same way rather than failing silently, which is what it used to do. + bridge.set.mockClear() + await act(async () => groupButton('Density', 'Compact').click()) + expect(bridge.set).toHaveBeenCalledWith({ density: 'compact' }) + expect(document.documentElement.dataset.density).toBe('compact') + expect(container.textContent).toContain('could not be saved to this Mac') + }) + + it('clears the unsaved notice once a write lands', async () => { + const bridge = withDesktopPreferences() + bridge.set.mockRejectedValueOnce(new Error('settings could not be saved')) + await act(async () => root.render( + + + , + )) + await act(async () => {}) + + await act(async () => groupButton('Density', 'Compact').click()) + expect(container.textContent).toContain('could not be saved to this Mac') + + await act(async () => groupButton('Density', 'Comfortable').click()) + await act(async () => {}) + expect(container.textContent).not.toContain('could not be saved to this Mac') + }) + it('explains anonymous metrics and lets desktop users opt out', async () => { const setEnabled = withDesktopMetrics(true) await act(async () => root.render( diff --git a/apps/console/src/components/SettingsView.tsx b/apps/console/src/components/SettingsView.tsx index b821b270..91a185d0 100644 --- a/apps/console/src/components/SettingsView.tsx +++ b/apps/console/src/components/SettingsView.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useThemeMode } from '../theme-mode' import { isUpdateCheckEnabled, setUpdateCheckEnabled } from '../update' import type { Mode } from '../api' +import { C, css } from '../theme' import { AccountPanel } from './AccountPanel' import { IndexingSettings } from './IndexingSettings' import { IntegrationsPanel } from './IntegrationsPanel' @@ -28,8 +29,12 @@ export function SettingsView({ appMode, onClose, onIndexingChange, surface = 'ov const [pane, setPaneState] = useState(VALID_PANES.has(requestedInitial as SettingsPane) ? requestedInitial as SettingsPane : 'general') const [updatesEnabled, setUpdatesEnabled] = useState(() => isUpdateCheckEnabled(appMode)) const [metricsEnabled, setMetricsEnabled] = useState(null) + const [writeFailed, setWriteFailed] = useState(false) const rootRef = useRef(null) - const { preference: theme, density, setPreference: setTheme, setDensity } = useThemeMode() + const { preference: theme, density, setPreference: setTheme, setDensity, transparency, systemReducedTransparency, setTransparency, saveFailed } = useThemeMode() + // Appearance writes and this view's own toggles hit the same file for the same + // reasons, so they get one notice rather than two competing ones. + const settingsUnsaved = saveFailed || writeFailed const accountsAvailable = window.__CC_DESKTOP?.authState?.available === true && Boolean(window.__CC_AUTH) const integrationsAvailable = Boolean(window.__CC_INTEGRATIONS) @@ -91,22 +96,29 @@ export function SettingsView({ appMode, onClose, onIndexingChange, surface = 'ov return () => window.removeEventListener('keydown', onKey) }, [onClose, surface]) + // A rejected `preferences.set` means the Mac app could not write the choice to + // disk. It does NOT mean the choice was ignored: the main process applied it + // and keeps serving it, so snapping the switch back would show a state the app + // is not in — the user would read "off" while updates are genuinely off, and + // turn it off again. Hold the new value and report the part that actually + // failed, which is that it will not survive a restart. const toggleUpdates = async () => { - const previous = updatesEnabled - const next = !previous + const next = !updatesEnabled setUpdatesEnabled(next) - if (window.__CC_DESKTOP?.preferences) { - try { setUpdatesEnabled((await window.__CC_DESKTOP.preferences.set({ updateCheck: next })).updateCheck) } - catch { setUpdatesEnabled(previous) } - } else setUpdateCheckEnabled(next) + if (!window.__CC_DESKTOP?.preferences) { setUpdateCheckEnabled(next); return } + try { + setUpdatesEnabled((await window.__CC_DESKTOP.preferences.set({ updateCheck: next })).updateCheck) + setWriteFailed(false) + } catch { setWriteFailed(true) } } const toggleMetrics = async () => { if (!window.__CC_DESKTOP?.preferences || metricsEnabled === null) return - const previous = metricsEnabled - setMetricsEnabled(!previous) - try { setMetricsEnabled((await window.__CC_DESKTOP.preferences.set({ anonymousMetrics: !previous })).anonymousMetrics) } - catch { setMetricsEnabled(previous) } + setMetricsEnabled(!metricsEnabled) + try { + setMetricsEnabled((await window.__CC_DESKTOP.preferences.set({ anonymousMetrics: !metricsEnabled })).anonymousMetrics) + setWriteFailed(false) + } catch { setWriteFailed(true) } } const indexingChanged = () => { @@ -129,11 +141,20 @@ export function SettingsView({ appMode, onClose, onIndexingChange, surface = 'ov
{pane === 'general' && <>

General

Adjust how ContextCake looks and behaves.
+ {settingsUnsaved &&
+ + + These settings are in effect but could not be saved to this Mac, so they will + revert when ContextCake restarts. Check that the disk is not full and that + ContextCake can write to its configuration folder. + +
}

Appearance

Theme{desktop ? 'System follows the current appearance of this Mac.' : 'System follows your browser and operating system.'}
DensityComfortable gives controls more room. Compact fits more knowledge on screen.
+ {desktop &&
Reduce transparencyTurns off the translucent sidebar material. System follows Accessibility on this Mac, which is currently {systemReducedTransparency ? 'on' : 'off'}.
}
diff --git a/apps/console/src/components/SetupWizard.test.tsx b/apps/console/src/components/SetupWizard.test.tsx index aadeeaca..a3c86c0d 100644 --- a/apps/console/src/components/SetupWizard.test.tsx +++ b/apps/console/src/components/SetupWizard.test.tsx @@ -10,7 +10,10 @@ vi.mock('../api', async (importOriginal) => ({ ...(await importOriginal()), apiFetch: mocks.apiFetch, })) -vi.mock('../store', () => ({ useStore: () => ({ reload: mocks.reload }) })) +vi.mock('../store', () => { + const store = () => ({ reload: mocks.reload }) + return { useStore: store, useStoreData: store, useStoreNav: store, useStoreInput: store } +}) let container: HTMLDivElement let root: Root diff --git a/apps/console/src/components/SetupWizard.tsx b/apps/console/src/components/SetupWizard.tsx index 13db02b7..ff071e8f 100644 --- a/apps/console/src/components/SetupWizard.tsx +++ b/apps/console/src/components/SetupWizard.tsx @@ -9,7 +9,7 @@ // MCP server lands under its own name instead of colliding with "team". import { useEffect, useRef, useState } from 'react' import { C, css, MONO } from '../theme' -import { useStore } from '../store' +import { useStoreData } from '../store' import { apiFetch, isTimeout, progressLabel, progressPercent } from '../api' import type { GraphSummary, SourceStatus, StatusSummary } from '../types' @@ -649,7 +649,7 @@ export function SetupWizard({ onConnectAgent?: () => void addingSource?: boolean }) { - const { reload } = useStore() + const { reload } = useStoreData() // Frozen at mount: adding the first source flips the shell's live // `sources.length > 0` mid-flow (reload() lands while the success fetch is // in flight), and letting that swap the step array under a live stepIdx diff --git a/apps/console/src/components/Sidebar.tsx b/apps/console/src/components/Sidebar.tsx index cac1dd14..6b005d41 100644 --- a/apps/console/src/components/Sidebar.tsx +++ b/apps/console/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' -import { useStore } from '../store' +import { memo, useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { useStoreData, useStoreNav } from '../store' import { destinationForView, readBrowserGroupedViews, viewForDestination, type ShellDestination } from '../shell-navigation' import { CascadeIcon, HomeIcon, KnowledgeIcon, ReviewIcon, SettingsIcon, SourcesIcon } from './icons' @@ -9,6 +9,14 @@ const COLLAPSED_WIDTH = 64 const MIN_WIDTH = 208 const DEFAULT_WIDTH = 232 const MAX_WIDTH = 300 +/** + * Quiet period before the width is persisted. The resizer updates `sidebar` on + * every `pointermove`, and each write is an IPC round trip that the desktop + * main process answers with a settings read-write-rename — 60–120 of them a + * second, on the thread that draws. Nobody needs an intermediate width on disk; + * only where the drag ends. A pointer-up flush makes sure that one lands. + */ +const PERSIST_DEBOUNCE_MS = 250 type SidebarPreference = { collapsed: boolean; width: number } const clampWidth = (width: number) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width)) @@ -30,8 +38,9 @@ const NAV: Array<{ id: ShellDestination; label: string; icon: ReactNode }> = [ { id: 'review', label: 'Review', icon: }, ] -export function Sidebar({ onOpenSettings, onNavigate }: { onOpenSettings?: () => void; onNavigate?: () => void }) { - const { view, setView, signals, conflicts, sources } = useStore() +function SidebarInner({ onOpenSettings, onNavigate }: { onOpenSettings?: () => void; onNavigate?: () => void }) { + const { setView, signals, conflicts, sources } = useStoreData() + const { view } = useStoreNav() const [sidebar, setSidebar] = useState(readPreference) const [resizing, setResizing] = useState(false) const resizeCleanup = useRef<(() => void) | null>(null) @@ -50,12 +59,39 @@ export function Sidebar({ onOpenSettings, onNavigate }: { onOpenSettings?: () => onNavigate?.() } - useEffect(() => { - window.__CC_DESKTOP?.uiState?.set({ sidebar }).catch(() => {}) + const persist = useRef<{ timer: ReturnType | null; pending: SidebarPreference | null }>({ timer: null, pending: null }) + + const flushPreference = useCallback(() => { + const state = persist.current + if (state.timer !== null) { clearTimeout(state.timer); state.timer = null } + const value = state.pending + if (!value) return + state.pending = null + window.__CC_DESKTOP?.uiState?.set({ sidebar: value }).catch(() => {}) if (!window.__CC_DESKTOP) { - try { localStorage.setItem(BROWSER_KEY, JSON.stringify(sidebar)) } catch { /* optional */ } + try { localStorage.setItem(BROWSER_KEY, JSON.stringify(value)) } catch { /* optional */ } } - }, [sidebar]) + }, []) + + // Nothing is written for the value we just read back — the store already + // holds it. Every later change is written once the drag (or the arrow-key + // run) goes quiet, and immediately on unmount so a closing window still + // records where the user left the divider. + const hydrated = useRef(false) + useEffect(() => { + if (!hydrated.current) { hydrated.current = true; return } + const state = persist.current + state.pending = sidebar + if (state.timer !== null) clearTimeout(state.timer) + state.timer = setTimeout(() => { state.timer = null; flushPreference() }, PERSIST_DEBOUNCE_MS) + }, [flushPreference, sidebar]) + + // The drag is over: write where it ended now rather than 250ms from now. + // Declared after the effect above so that, in the commit where both fire, + // the final width is already the pending value. + useEffect(() => { if (!resizing) flushPreference() }, [flushPreference, resizing]) + + useEffect(() => () => flushPreference(), [flushPreference]) useEffect(() => { const toggle = () => setSidebar((current) => ({ ...current, collapsed: !current.collapsed })) @@ -157,3 +193,11 @@ export function Sidebar({ onOpenSettings, onNavigate }: { onOpenSettings?: () => ) } + +/** + * Memoized, and subscribed to the data and navigation halves of the store only. + * The shell re-renders on every keystroke in the toolbar search; the sidebar has + * nothing to say about a query, and its resizer state is local, so it should sit + * that render out entirely. + */ +export const Sidebar = memo(SidebarInner) diff --git a/apps/console/src/desktop.d.ts b/apps/console/src/desktop.d.ts index 150aacc4..edcb8942 100644 --- a/apps/console/src/desktop.d.ts +++ b/apps/console/src/desktop.d.ts @@ -35,7 +35,12 @@ type DesktopPreferences = { density: Density updateCheck: boolean anonymousMetrics: boolean | null + /** What the renderer should do: the user's choice, or the OS setting. */ reducedTransparency: boolean + /** What the user chose. null = still following this Mac's setting. */ + reducedTransparencyPreference: boolean | null + /** What this Mac's Accessibility setting says, regardless of the override. */ + systemReducedTransparency: boolean highContrast: boolean } @@ -88,7 +93,9 @@ declare global { preferences?: { initial: DesktopPreferences get(): Promise - set(patch: Partial>): Promise + set(patch: Partial> + /** null is a real value: hand the choice back to this Mac's setting. */ + & { reducedTransparency?: boolean | null }): Promise onChanged(cb: (preferences: DesktopPreferences) => void): () => void } uiState?: { @@ -106,6 +113,18 @@ declare global { requestReload(): Promise<{ requested: boolean }> onReloadRequested(cb: () => void): () => void } + /** + * Liveness of the local engine, as measured by the desktop shell. + * + * Optional like everything else here, and optional a second time within + * itself: a packaged app older than this channel exposes `__CC_DESKTOP` + * without it, and the console has to keep working against that build. + */ + engine?: { + onStatus?(cb: (state: import('./components/EngineBanner').EngineHealth) => void): () => void + /** Restart the engine process and reload this window at its new origin. */ + relaunch?(): Promise<{ ok: boolean; reason?: string }> + } /** Open the native macOS directory picker. Null means the user canceled. */ chooseFolder?: () => Promise /** diff --git a/apps/console/src/render-hygiene.test.tsx b/apps/console/src/render-hygiene.test.tsx new file mode 100644 index 00000000..fbfd656b --- /dev/null +++ b/apps/console/src/render-hygiene.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +// Two properties that are invisible in a functional test and were both false of +// the shell before this suite existed: a sidebar drag wrote a preference on +// every pointermove (an IPC round trip that the desktop main process answered +// with a synchronous settings read-write-rename), and a keystroke in the +// toolbar search re-rendered the entire tree. +// +// Renders are counted through the leaf icons each component renders INLINE — +// one render of the parent is one render of the icon. `React.Profiler` was +// tried first and does not work here: onRender is not called for a subtree +// re-rendered by context propagation, so it reported zero either way. +import { act, type ComponentType } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Sidebar } from './components/Sidebar' +import { Header } from './components/Header' +import { StoreProvider } from './store' +import { SEARCHABLE_VIEWS, type ViewId } from './shell-navigation' +import { Concepts } from './views/Concepts' +import { Conflicts } from './views/Conflicts' +import { Files } from './views/Files' +import { Sources } from './views/Sources' +import { Triage } from './views/Triage' + +type AnyComponent = (props: Record) => unknown + +const renders: Record = {} + +vi.mock('./components/icons', async () => { + const actual = await vi.importActual>>('./components/icons') + const counted: Record = {} + for (const [name, Component] of Object.entries(actual)) { + counted[name] = (props: Record) => { + renders[name] = (renders[name] ?? 0) + 1 + return (Component as unknown as AnyComponent)(props) + } + } + return counted +}) + +let container: HTMLDivElement +let root: Root + +/** jsdom has no PointerEvent; the resizer only reads button/clientX/pointerId. */ +function pointer(kind: string, clientX: number): PointerEvent { + const event = new MouseEvent(kind, { bubbles: true, button: 0, clientX }) as unknown as PointerEvent + Object.defineProperty(event, 'pointerId', { value: 1 }) + return event +} + +function typeInto(input: HTMLInputElement, text: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + act(() => { + setter?.call(input, text) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + Element.prototype.setPointerCapture = () => {} + Element.prototype.releasePointerCapture = () => {} + Element.prototype.hasPointerCapture = () => false + for (const key of Object.keys(renders)) delete renders[key] + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() + delete (window as unknown as Record).__CC_DESKTOP + window.location.hash = '' +}) + +describe('render hygiene', () => { + it('persists a sidebar drag once, not once per frame', () => { + vi.useFakeTimers() + const set = vi.fn(() => Promise.resolve({})) + ;(window as unknown as Record).__CC_DESKTOP = { + uiState: { + initial: { + sidebar: { collapsed: false, width: 232 }, lastView: 'overview', + knowledgeView: 'concepts', reviewView: 'triage', settingsPane: 'general', + }, + set, + }, + } + act(() => root.render()) + const handle = container.querySelector('.cc-sidebar-resizer') + expect(handle).toBeTruthy() + + act(() => { handle!.dispatchEvent(pointer('pointerdown', 232)) }) + // 120 Hz for two seconds — a real trackpad drag. + for (let frame = 0; frame < 240; frame += 1) { + act(() => { + window.dispatchEvent(pointer('pointermove', 232 + (frame % 60))) + vi.advanceTimersByTime(1000 / 120) + }) + } + act(() => { window.dispatchEvent(pointer('pointermove', 276)) }) + act(() => { window.dispatchEvent(pointer('pointerup', 276)) }) + act(() => { vi.advanceTimersByTime(1000) }) + + const patches = (set.mock.calls as unknown as { sidebar?: { width: number } }[][]) + .map((call) => call[0]) + .filter((patch) => patch?.sidebar !== undefined) + // One write, on pointer-up. Undebounced this was 240. + expect(patches).toHaveLength(1) + // And it is where the drag ENDED, not some frame in the middle. + expect(patches[0]?.sidebar?.width).toBe(276) + }) + + it('does not re-render the sidebar while the user types in the toolbar search', () => { + window.location.hash = '#/concepts' + act(() => root.render( + + +
{}} onAsk={() => {}} /> + , + )) + const input = container.querySelector('input[data-context-search]') + expect(input).toBeTruthy() + + const sidebarBefore = renders.SettingsIcon ?? 0 + const headerBefore = renders.SparkleIcon ?? 0 + for (const text of ['p', 'po', 'pos', 'post', 'postg']) typeInto(input!, text) + + // The header owns the field and has to repaint; five characters, five + // renders. The sidebar has nothing to say about a query and sits them out. + expect((renders.SparkleIcon ?? 0) - headerBefore).toBe(5) + expect((renders.SettingsIcon ?? 0) - sidebarBefore).toBe(0) + }) +}) + +/** + * The other half of the render budget, and the half that shipped broken. + * + * The suite above measures what must NOT repaint. On its own it is satisfiable + * by a component that never repaints at all: `Triage` subscribed to the data and + * nav contexts only, read the query indirectly through a `filtered()` callback + * with an empty dependency array, and so sat out every keystroke — the Queue + * silently stopped filtering and the negative assertion stayed green, because it + * only ever rendered the sidebar and the header. + * + * So this pairs it: for every view the shell offers a search box, typing a query + * nothing can match has to actually empty the list. The case table is checked + * against SEARCHABLE_VIEWS itself, so adding a searchable view without teaching + * it to react to a keystroke fails here rather than in the field. + */ +const NO_MATCH = 'zzzzznomatchzzzzz' + +/** `rows` names the list this view filters — the thing a query has to shrink. */ +const SEARCH_CASES: { view: ViewId; Component: ComponentType; rows: string }[] = [ + // Signal cards; the decision panel beside them uses h2. + { view: 'triage', Component: Triage, rows: 'h3' }, + { view: 'concepts', Component: Concepts, rows: '.cc-navigator-detail > div > button.cc-h-bd-strong' }, + { view: 'conflicts', Component: Conflicts, rows: '.cc-conflict-list > button[role="option"]' }, + { view: 'sources', Component: Sources, rows: 'button[role="option"]' }, + { view: 'files', Component: Files, rows: '[role="treeitem"]' }, +] + +async function mountView(view: ViewId, Component: ComponentType) { + window.location.hash = `#/${view}` + await act(async () => root.render( + +
{}} onAsk={() => {}} /> + + , + )) + // The demo bundle resolves through a promise chain; let it land. + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) +} + +describe('a search keystroke reaches the view', () => { + it('covers every view the shell offers a search box for', () => { + expect(new Set(SEARCH_CASES.map((entry) => entry.view))).toEqual(SEARCHABLE_VIEWS) + }) + + for (const { view, Component, rows } of SEARCH_CASES) { + it(`filters ${view} down to nothing on a query that matches nothing`, async () => { + await mountView(view, Component) + const input = container.querySelector('input[data-context-search]') + expect(input, `${view} is searchable but the toolbar rendered no search field`).toBeTruthy() + + const before = container.querySelectorAll(rows).length + // A view with nothing in it cannot demonstrate filtering, and a selector + // that has drifted off its rows would silently pass every assertion below. + expect(before, `${view} rendered no rows to filter — the fixture or the selector is wrong`).toBeGreaterThan(0) + const textBefore = container.textContent + + typeInto(input!, NO_MATCH) + + expect(container.querySelectorAll(rows).length).toBe(0) + expect(container.textContent).not.toBe(textBefore) + }) + } +}) diff --git a/apps/console/src/store.tsx b/apps/console/src/store.tsx index 741da370..f8cf0955 100644 --- a/apps/console/src/store.tsx +++ b/apps/console/src/store.tsx @@ -58,6 +58,24 @@ const TAB_TO_ROUTE: Record = { review: 'review_required', captured: 'team_candidate', ignored: 'ignore', } +/** + * The Queue's one tab, filtered by the toolbar search. + * + * Pure, and exported rather than handed out as a store callback, because the + * callback version is what broke the Queue: it closed over a `queryRef` so its + * identity never changed, which meant `Triage` could call it while subscribing + * only to the data and nav contexts — and then sat out every keystroke. Taking + * `query` as an argument puts the dependency in the type, so a caller has to + * have subscribed to it before it can call this at all. + */ +export function filterSignals(signals: Signal[], tab: TriageTab, query: string): Signal[] { + const route = TAB_TO_ROUTE[tab] + const q = query.trim().toLowerCase() + return signals.filter( + (s) => s.route === route && (!q || `${s.title} ${s.repo} ${s.owner}`.toLowerCase().includes(q)), + ) +} + /** A compact textual view of the resolved cascade, for the chat prompt. */ function buildContext(concepts: Concept[]): string { return concepts @@ -156,27 +174,32 @@ function asLiveDataError(e: unknown): LiveDataError { return new LiveDataError('bad-shape', e instanceof Error ? e.message : String(e)) } -export interface Store { +/** + * The store is three contexts, not one, and the split is by how often each + * changes rather than by subject. + * + * A single context value memoized over ~25 dependencies meant one keystroke in + * the toolbar search re-rendered the sidebar, the header and the active view — + * every consumer, for a value only the view cares about. Splitting by cadence + * is what lets a component subscribe to what it actually reads: + * + * data — engine answers and every action. Changes when the cascade changes. + * nav — where the user is. Changes on navigation and selection. + * input — the search box and the chat composer. Changes per keystroke. + * + * Actions all live in `data` and are all stable identities, so a memoized child + * that takes one as a prop keeps its memo. + * + * `useStore()` still hands back all three merged, for consumers that genuinely + * read across them; it re-renders on any of the three, which is the cost of + * that convenience. Prefer the narrow hooks in anything on a hot path. + */ +export interface StoreData { mode: Mode loading: boolean load: LoadState error: LiveDataError | null - view: ViewId - triageTab: TriageTab - selSignal: string | null - selConflict: string - selConcept: string - /** Files navigator: the one source it is scoped to, or null for every source. */ - filesScope: string | null - /** The open file as the engine names it (`/`), or null. */ - filesPath: string | null - query: string - chatOpen: boolean - chatBusy: boolean - chatInput: string - chatMessages: ChatMessage[] - concepts: Concept[] sources: Source[] signals: Signal[] @@ -208,7 +231,6 @@ export interface Store { closeChat: () => void setChatInput: (v: string) => void - filtered: (tab: TriageTab) => Signal[] /** Poll again right now — the "Retry now" affordance on the refresh banner. */ retryNow: () => void route: (target: RouteId) => void @@ -226,7 +248,38 @@ export interface Store { reloadKey: number } -const StoreContext = createContext(null) +/** Where the user is. Changes on navigation and selection, never on a keystroke. */ +export interface StoreNav { + view: ViewId + triageTab: TriageTab + selSignal: string | null + selConflict: string + selConcept: string + /** Files navigator: the one source it is scoped to, or null for every source. */ + filesScope: string | null + /** The open file as the engine names it (`/`), or null. */ + filesPath: string | null + /** + * The Ask panel. Navigation rather than input: the shell reads it to decide + * what is on screen, and a shell that re-rendered for the chat *composer* + * would re-render for the search box beside it too. + */ + chatOpen: boolean +} + +/** The two things a user types into. Changes per keystroke — subscribe narrowly. */ +export interface StoreInput { + query: string + chatBusy: boolean + chatInput: string + chatMessages: ChatMessage[] +} + +export type Store = StoreData & StoreNav & StoreInput + +const StoreDataContext = createContext(null) +const StoreNavContext = createContext(null) +const StoreInputContext = createContext(null) export function StoreProvider({ children }: { children: ReactNode }) { const source = useMemo(() => createDataSource(), []) @@ -260,6 +313,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { const initial = useMemo(initialRoute, []) const [view, setViewState] = useState(initial.view) + // Read by `setView` and `setQuery` so both keep a stable identity. An action + // whose identity changed with the current view would put `view` back into the + // data context's dependency list, and with it every consumer this split + // exists to keep out of a navigation render. + const viewRef = useRef(view); viewRef.current = view const [triageTab, setTriageTab] = useState('review') const [selSignal, setSelSignal] = useState(mode === 'demo' ? 'sig-1' : null) const [selConflict, setSelConflict] = useState('') @@ -276,7 +334,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, []) const [queries, setQueries] = useState>>({}) const query = queries[view] ?? '' - const setQuery = useCallback((value: string) => setQueries((current) => ({ ...current, [view]: value })), [view]) + const setQuery = useCallback((value: string) => setQueries((current) => ({ ...current, [viewRef.current]: value })), []) const [chatOpen, setChatOpen] = useState(false) const [chatBusy, setChatBusy] = useState(false) const [chatInput, setChatInput] = useState('') @@ -562,11 +620,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { const prevViewRef = useRef(view) const setView = useCallback((next: ViewId) => { - if (next === view) return + if (next === viewRef.current) return if (!dispatchNavigationGuard()) return if (next === 'concepts') setConceptRouteMode('bare') setViewState(next) - }, [view]) + }, []) const setFilesScope = useCallback((layer: string | null) => setFilesScopeState(layer), []) const setFilesPath = useCallback((path: string | null) => setFilesPathState(path), []) @@ -692,31 +750,24 @@ export function StoreProvider({ children }: { children: ReactNode }) { return () => window.removeEventListener('popstate', onPop) }, [currentHash, view]) - const filtered = useCallback((tab: TriageTab): Signal[] => { - const route = TAB_TO_ROUTE[tab] - const q = queryRef.current.trim().toLowerCase() - return signalsRef.current.filter( - (s) => s.route === route && (!q || `${s.title} ${s.repo} ${s.owner}`.toLowerCase().includes(q)), - ) - }, []) - const route = useCallback((target: RouteId) => { if (modeRef.current !== 'demo') return // live triage is read-only (D6) const sig = signalsRef.current.find((s) => s.id === selSignalRef.current) if (!sig) return + // An action, not a render: reading the freshest query off the ref is the + // point here, because the keyboard shortcut fires outside the view. const currentTab = triageTabRef.current const currentRoute = TAB_TO_ROUTE[currentTab] - const q = queryRef.current.trim().toLowerCase() - const matches = (s: Signal) => !q || `${s.title} ${s.repo} ${s.owner}`.toLowerCase().includes(q) - const before = signalsRef.current.filter((s) => s.route === currentRoute && matches(s)) + const q = queryRef.current + const before = filterSignals(signalsRef.current, currentTab, q) const pos = before.findIndex((s) => s.id === sig.id) const nextSignals = signalsRef.current.map((s) => (s.id === sig.id ? { ...s, route: target } : s)) signalsRef.current = nextSignals setSignals(nextSignals) - const after = nextSignals.filter((s) => s.route === currentRoute && matches(s)) + const after = filterSignals(nextSignals, currentTab, q) const stayed = target === currentRoute const next = stayed ? after[pos + 1] ?? after[pos] ?? null @@ -833,22 +884,62 @@ export function StoreProvider({ children }: { children: ReactNode }) { [loading, conceptsLoading, indexingSources, tasks, refreshError, lastRefreshAt], ) - const value = useMemo(() => ({ + const data = useMemo(() => ({ mode, loading, load, error, - view, triageTab, selSignal, selConflict, selConcept, filesScope, filesPath, query, - chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, setView, setTriageTab, setSelSignal, setSelConflict, setSelConcept, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat, setChatInput, - filtered, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, - }), [mode, loading, load, error, view, triageTab, selSignal, selConflict, selConcept, filesScope, filesPath, query, chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, filtered, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, setView, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat]) + retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, + }), [mode, loading, load, error, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, setView, setSelConcept, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat]) + + const nav = useMemo( + () => ({ view, triageTab, selSignal, selConflict, selConcept, filesScope, filesPath, chatOpen }), + [view, triageTab, selSignal, selConflict, selConcept, filesScope, filesPath, chatOpen], + ) + + const input = useMemo( + () => ({ query, chatBusy, chatInput, chatMessages }), + [query, chatBusy, chatInput, chatMessages], + ) - return {children} + return ( + + + {children} + + + ) } -export function useStore(): Store { - const ctx = useContext(StoreContext) - if (!ctx) throw new Error('useStore must be used within StoreProvider') +function required(ctx: T | null, name: string): T { + if (!ctx) throw new Error(`${name} must be used within StoreProvider`) return ctx } + +/** Engine answers and every action. Does not re-render on navigation or typing. */ +export function useStoreData(): StoreData { + return required(useContext(StoreDataContext), 'useStoreData') +} + +/** Current view and selection. Does not re-render on typing. */ +export function useStoreNav(): StoreNav { + return required(useContext(StoreNavContext), 'useStoreNav') +} + +/** Search box and chat composer. Re-renders per keystroke — subscribe last. */ +export function useStoreInput(): StoreInput { + return required(useContext(StoreInputContext), 'useStoreInput') +} + +/** + * All three at once. Convenient, and correspondingly expensive: a consumer of + * this re-renders on every keystroke whether or not it reads `query`. Reach for + * the narrow hooks in anything that renders more than a few nodes. + */ +export function useStore(): Store { + const data = useStoreData() + const nav = useStoreNav() + const input = useStoreInput() + return useMemo(() => ({ ...data, ...nav, ...input }), [data, nav, input]) +} diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css index d611bdea..ac52d2d5 100644 --- a/apps/console/src/styles.css +++ b/apps/console/src/styles.css @@ -187,14 +187,11 @@ button { font-family: inherit; } gap: 16px; } -.cc-sidebar, -.cc-subbar, -.cc-soft-panel { +.cc-sidebar { background: var(--cc-header-bg); border: 0; border-radius: var(--cc-radius-xl); box-shadow: var(--cc-panel-shadow); - backdrop-filter: blur(18px); } /* ---- Left sidebar ---------------------------------------------------------- */ @@ -722,15 +719,6 @@ button { font-family: inherit; } gap: 16px; } -.cc-subbar { - flex: 0 0 auto; - min-height: 76px; - display: flex; - align-items: center; - gap: 14px; - padding: 14px 20px; -} - .cc-menu-btn { display: none; /* mobile only — see drawer media query */ width: 40px; @@ -864,17 +852,6 @@ button { font-family: inherit; } font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -.cc-settings-sidebar { - min-height: 100vh; - display: flex; - flex-direction: column; - gap: 18px; - padding: 22px 14px 18px; - background: var(--cc-header-bg); - border-right: 1px solid var(--cc-line); - backdrop-filter: blur(18px); -} - .cc-settings-back, .cc-settings-nav button { display: flex; @@ -1133,13 +1110,6 @@ button { font-family: inherit; } @media (max-width: 720px) { .cc-settings-screen { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } - .cc-settings-sidebar { - min-height: 0; - padding: 12px; - gap: 10px; - border-right: 0; - border-bottom: 1px solid var(--cc-line); - } .cc-settings-brand { display: none; } .cc-settings-nav { flex-direction: row; } .cc-settings-nav button { justify-content: center; } @@ -1345,20 +1315,6 @@ button { font-family: inherit; } .cc-menu-btn { display: inline-grid; } } -.cc-soft-panel, -.cc-soft-card { - border: 0 !important; - border-radius: var(--cc-radius-xl) !important; - background: var(--cc-surface) !important; - box-shadow: var(--cc-panel-shadow); - backdrop-filter: blur(18px); -} - -.cc-soft-card { - border-radius: var(--cc-radius-lg) !important; - box-shadow: var(--cc-soft-shadow); -} - @media (max-width: 1120px) { /* The vertical nav's badges already carry the Queue/Resolve counts. */ .cc-status-pill { display: none; } @@ -1372,7 +1328,6 @@ button { font-family: inherit; } @media (max-width: 640px) { /* Let the search drop to its own row instead of colliding with the title. */ - .cc-subbar { flex-wrap: wrap; } .cc-sub-actions { flex-basis: 100%; } .cc-search { width: 100%; } } @@ -2172,6 +2127,20 @@ html, body, #root { width: 100%; height: 100%; overflow: hidden; } .cc-shell-inner { width: 100%; max-width: none; height: 100vh; margin: 0; gap: 0; } .cc-sidebar, .cc-toolbar { border-radius: 0; box-shadow: none; backdrop-filter: none; } .cc-sidebar { padding: 40px 10px 10px; gap: 8px; border-right: 1px solid var(--cc-line); background: var(--cc-header-bg); overflow: hidden; } +/* The app's only blurred chrome, and this rule alone is what makes it blurred. + The line above sets `backdrop-filter: none` on .cc-sidebar unconditionally; + this one wins on specificity — :root + [attr] + .class is (0,3,0) against + (0,1,0) — and only while the user has not asked for reduced transparency. + Nothing earlier in the file contributes: a `.cc-sidebar { blur }` declared up + in the shared panel block would be overridden here regardless, so do not add + one and assume it does anything. + + It is worth the compositing cost here because the sidebar sits over the + window's macOS vibrancy — real desktop content is behind this glass. The other + blurred surface in the app is the Canvas legend (views/Canvas.tsx), which + floats over the pan/zoom viewport. Those two share the property that something + MOVES behind them; a panel in normal flow over a static page gradient does + not, and does not get blur. */ :root[data-reduced-transparency="false"] .cc-sidebar { backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } .cc-brand { min-height: 36px; padding: 0 8px 8px; gap: 9px; } .cc-brand-logo { width: 24px; height: 24px; border-radius: 6px; } diff --git a/apps/console/src/theme-mode.tsx b/apps/console/src/theme-mode.tsx index 3a573271..3004e785 100644 --- a/apps/console/src/theme-mode.tsx +++ b/apps/console/src/theme-mode.tsx @@ -9,13 +9,29 @@ const DENSITY_KEY = 'cc-density' const THEME_VALUES = new Set(['system', 'light', 'dark']) const DENSITY_VALUES = new Set(['comfortable', 'compact']) +/** + * Reduce transparency is three values, not one. `reducedTransparency` is what + * the renderer does; `reducedTransparencyPreference` is what the user chose, + * with null meaning "still following this Mac"; `systemReducedTransparency` is + * what the Mac says, kept so Settings can show what "System" resolves to + * without asking the main process again. + */ type Appearance = { preference: ThemePreference density: Density reducedTransparency: boolean + reducedTransparencyPreference: boolean | null + systemReducedTransparency: boolean highContrast: boolean } +/** The user's three choices for reduce transparency, as a control can spell them. */ +export type TransparencyChoice = 'system' | 'on' | 'off' + +export function transparencyChoice(preference: boolean | null): TransparencyChoice { + return preference === null ? 'system' : preference ? 'on' : 'off' +} + function browserSystemTheme(): ResolvedTheme { return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } @@ -49,10 +65,29 @@ function initialAppearance(): Appearance { preference: desktop?.theme ?? initialPreference(), density: desktop?.density ?? initialDensity(), reducedTransparency: desktop?.reducedTransparency ?? false, + reducedTransparencyPreference: desktop?.reducedTransparencyPreference ?? null, + systemReducedTransparency: desktop?.systemReducedTransparency ?? false, highContrast: desktop?.highContrast ?? false, } } +/** The preference snapshot the desktop bridge hands back. `desktop.d.ts` keeps + * its own name for it module-scoped, so read the shape off the bridge itself + * rather than restating it here and letting the two drift. */ +type DesktopPreferenceSnapshot = NonNullable['preferences']>['initial'] + +/** Every appearance field the main process reports, in one place: three call sites read it. */ +function appearanceFrom(next: DesktopPreferenceSnapshot): Appearance { + return { + preference: next.theme, + density: next.density, + reducedTransparency: next.reducedTransparency, + reducedTransparencyPreference: next.reducedTransparencyPreference ?? null, + systemReducedTransparency: next.systemReducedTransparency ?? false, + highContrast: next.highContrast, + } +} + export function applyAppearance(appearance: Appearance) { const root = document.documentElement root.dataset.theme = resolveTheme(appearance.preference) @@ -74,16 +109,37 @@ export function initialMode(): ResolvedTheme { } export function applyMode(mode: ResolvedTheme) { - applyAppearance({ preference: mode, density: initialDensity(), reducedTransparency: false, highContrast: false }) + applyAppearance({ + preference: mode, + density: initialDensity(), + reducedTransparency: false, + reducedTransparencyPreference: null, + systemReducedTransparency: false, + highContrast: false, + }) } interface ThemeCtx { mode: ResolvedTheme preference: ThemePreference density: Density + /** What the renderer is doing right now. */ + reducedTransparency: boolean + /** What the user chose; null while still following this Mac's setting. */ + transparency: TransparencyChoice + /** What "System" currently resolves to, for the hint under the control. */ + systemReducedTransparency: boolean setPreference: (preference: ThemePreference) => void setDensity: (density: Density) => void + setTransparency: (choice: TransparencyChoice) => void toggle: () => void + /** + * The Mac app could not write the last appearance change to disk. The change + * is in effect — the main process applied it and every read returns it — but + * it will not survive a restart, which is the part a user cannot see and has + * to be told. Cleared by the next write that lands. + */ + saveFailed: boolean } const Ctx = createContext(null) @@ -104,18 +160,8 @@ export function ThemeModeProvider({ children }: { children: ReactNode }) { useEffect(() => { const preferences = window.__CC_DESKTOP?.preferences if (preferences) { - preferences.get().then((next) => setAppearance({ - preference: next.theme, - density: next.density, - reducedTransparency: next.reducedTransparency, - highContrast: next.highContrast, - })).catch(() => {}) - return preferences.onChanged((next) => setAppearance({ - preference: next.theme, - density: next.density, - reducedTransparency: next.reducedTransparency, - highContrast: next.highContrast, - })) + preferences.get().then((next) => setAppearance(appearanceFrom(next))).catch(() => {}) + return preferences.onChanged((next) => setAppearance(appearanceFrom(next))) } if (typeof matchMedia !== 'function') return @@ -137,17 +183,48 @@ export function ThemeModeProvider({ children }: { children: ReactNode }) { } }, []) + const [saveFailed, setSaveFailed] = useState(false) + + /** + * Persist an appearance change, and remember whether it reached the disk. + * + * `preferences.set` began rejecting when the write fails; swallowing that is + * what this codebase keeps getting wrong. Note what is deliberately NOT done + * here: the change is not rolled back. The main process applied it and every + * `readSettings()` returns it, so reverting the control would show a state + * the app is not in. What the user loses is durability, so that — and only + * that — is what gets reported. + */ + const persist = useCallback((patch: Parameters['preferences']>['set']>[0]) => { + const bridge = window.__CC_DESKTOP?.preferences + if (!bridge) return + bridge.set(patch).then(() => setSaveFailed(false), () => setSaveFailed(true)) + }, []) + const setPreference = useCallback((preference: ThemePreference) => { if (!THEME_VALUES.has(preference)) return setAppearance((current) => current.preference === preference ? current : { ...current, preference }) - window.__CC_DESKTOP?.preferences?.set({ theme: preference }).catch(() => {}) - }, []) + persist({ theme: preference }) + }, [persist]) const setDensity = useCallback((density: Density) => { if (!DENSITY_VALUES.has(density)) return setAppearance((current) => current.density === density ? current : { ...current, density }) - window.__CC_DESKTOP?.preferences?.set({ density }).catch(() => {}) - }, []) + persist({ density }) + }, [persist]) + + const setTransparency = useCallback((choice: TransparencyChoice) => { + const preference = choice === 'system' ? null : choice === 'on' + // Apply immediately against the system value we already hold, so the window + // changes on the click rather than on the IPC round trip; the main process's + // `preferences:changed` broadcast is what makes it authoritative. + setAppearance((current) => current.reducedTransparencyPreference === preference ? current : { + ...current, + reducedTransparencyPreference: preference, + reducedTransparency: preference ?? current.systemReducedTransparency, + }) + persist({ reducedTransparency: preference }) + }, [persist]) const mode = resolveTheme(appearance.preference) const toggle = useCallback(() => setPreference(mode === 'dark' ? 'light' : 'dark'), [mode, setPreference]) @@ -155,10 +232,19 @@ export function ThemeModeProvider({ children }: { children: ReactNode }) { mode, preference: appearance.preference, density: appearance.density, + reducedTransparency: appearance.reducedTransparency, + transparency: transparencyChoice(appearance.reducedTransparencyPreference), + systemReducedTransparency: appearance.systemReducedTransparency, setPreference, setDensity, + setTransparency, toggle, - }), [appearance.density, appearance.preference, mode, setDensity, setPreference, toggle]) + saveFailed, + }), [ + appearance.density, appearance.preference, appearance.reducedTransparency, + appearance.reducedTransparencyPreference, appearance.systemReducedTransparency, + mode, saveFailed, setDensity, setPreference, setTransparency, toggle, + ]) return {children} } diff --git a/apps/console/src/theme.ts b/apps/console/src/theme.ts index 522bd881..8a7d33a0 100644 --- a/apps/console/src/theme.ts +++ b/apps/console/src/theme.ts @@ -74,11 +74,35 @@ const HEX_VARS: Record = { } const HEX_RE = /#[0-9a-fA-F]{6}/g +/** + * Parsed declaration strings, keyed by the string itself. + * + * `css()` is called inline in JSX, so it re-parses on every render of every + * element that uses it — and the strings are overwhelmingly constant. Most keys + * are literals, but plenty interpolate a coordinate or a width, so the cache is + * capped and dropped wholesale when it fills rather than growing with the + * number of distinct pixel values a canvas has ever produced. + * + * Entries are shared between callers, so the returned object must be treated as + * read-only — which is what React expects of a `style` prop anyway. + */ +const cssCache = new Map() +const CSS_CACHE_MAX = 4096 + /** * Parse a semicolon-delimited CSS declaration string into a React style object, * remapping known literal hex colors to their theme variables along the way. */ export function css(decl: string): React.CSSProperties { + const cached = cssCache.get(decl) + if (cached) return cached + const parsed = parseDeclarations(decl) + if (cssCache.size >= CSS_CACHE_MAX) cssCache.clear() + cssCache.set(decl, parsed) + return parsed +} + +function parseDeclarations(decl: string): React.CSSProperties { const mapped = decl .replace(HEX_RE, (h) => { const v = HEX_VARS[h.toUpperCase()]; return v ? `var(${v})` : h }) .replace('rgba(241,240,234,0.82)', 'var(--cc-header-bg)') diff --git a/apps/console/src/views/Canvas.test.ts b/apps/console/src/views/Canvas.test.ts deleted file mode 100644 index e30fe8b0..00000000 --- a/apps/console/src/views/Canvas.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Concept } from '../data' -import { computeLayout } from './Canvas' - -function concept(id: string, layer: Concept['layers'][number], dissent?: Concept['layers'][number]): Concept { - return { - id, - title: id, - type: 'note', - layers: dissent ? [layer, dissent] : [layer], - sections: [{ - name: 'summary', - winner: layer, - sourceLayer: layer, - value: id, - dissents: dissent ? [{ layer: dissent, sourceLayer: dissent, value: `${id}-dissent` }] : undefined, - }], - } -} - -describe('computeLayout', () => { - it('reuses columns across non-overlapping lanes while reserving dissent lanes', () => { - const layout = computeLayout([ - concept('personal-with-company-dissent', 'personal', 'company'), - concept('team-a', 'team'), - concept('team-b', 'team'), - concept('company-b', 'company'), - ]) - const positions = Object.fromEntries(layout.nodes.map((node) => [node.c.id, node.x])) - - expect(positions['team-a']).toBe(positions['personal-with-company-dissent']) - expect(positions['team-b']).not.toBe(positions['team-a']) - expect(positions['company-b']).toBe(positions['team-b']) - expect(layout.ghosts[0]?.x).toBe(positions['personal-with-company-dissent'] + 9) - }) -}) diff --git a/apps/console/src/views/Canvas.test.tsx b/apps/console/src/views/Canvas.test.tsx new file mode 100644 index 00000000..995548d6 --- /dev/null +++ b/apps/console/src/views/Canvas.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import type { Concept } from '../data' +import { computeLayout } from './Canvas' + +function concept(id: string, layer: Concept['layers'][number], dissent?: Concept['layers'][number]): Concept { + return { + id, + title: id, + type: 'note', + layers: dissent ? [layer, dissent] : [layer], + sections: [{ + name: 'summary', + winner: layer, + sourceLayer: layer, + value: id, + dissents: dissent ? [{ layer: dissent, sourceLayer: dissent, value: `${id}-dissent` }] : undefined, + }], + } +} + +describe('computeLayout', () => { + it('reuses columns across non-overlapping lanes while reserving dissent lanes', () => { + const layout = computeLayout([ + concept('personal-with-company-dissent', 'personal', 'company'), + concept('team-a', 'team'), + concept('team-b', 'team'), + concept('company-b', 'company'), + ]) + const positions = Object.fromEntries(layout.nodes.map((node) => [node.c.id, node.x])) + + expect(positions['team-a']).toBe(positions['personal-with-company-dissent']) + expect(positions['team-b']).not.toBe(positions['team-a']) + expect(positions['company-b']).toBe(positions['team-b']) + expect(layout.ghosts[0]?.x).toBe(positions['personal-with-company-dissent'] + 9) + }) +}) + +describe('the canvas legend', () => { + it('stays translucent, because the graph moves underneath it', async () => { + // Not a style preference: the legend is absolutely positioned over the + // pan/zoom viewport, so nodes and conflict edges slide behind it while the + // user drags. It was once flattened to an opaque surface in a pass that + // removed blur from four chrome selectors "for the same reason (nothing + // behind it)" — true of those four, false of this one, and the four turned + // out to render nowhere at all. Users who want the glass gone have the + // reduce-transparency preference, which kills backdrop-filter app-wide. + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const { act } = await import('react') + const { createRoot } = await import('react-dom/client') + const { Canvas } = await import('./Canvas') + const { StoreProvider } = await import('../store') + + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + await act(async () => root.render()) + + const legend = Array.from(container.querySelectorAll('div')) + .find((node) => node.firstElementChild?.textContent === 'The cascade — higher lanes win') + expect(legend, 'legend not found — its caption changed, so this guard is blind').toBeTruthy() + expect(legend!.style.getPropertyValue('backdrop-filter')).toBe('blur(10px)') + // An opaque background would make the blur pointless even if it survived. + expect(legend!.style.getPropertyValue('background')).toBe('var(--cc-header-bg)') + + await act(async () => root.unmount()) + container.remove() + }) +}) diff --git a/apps/console/src/views/Canvas.tsx b/apps/console/src/views/Canvas.tsx index fcdd485f..df777304 100644 --- a/apps/console/src/views/Canvas.tsx +++ b/apps/console/src/views/Canvas.tsx @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { C, css, lc, MONO, type LayerId } from '../theme' import { layerLevel, layers, type Concept } from '../data' import { LayerChip } from '../components/LayerChip' import { ConceptDetail } from '../components/ConceptDetail' -import { useStore } from '../store' +import { useStoreData } from '../store' // ---- layout constants (world coordinates) ---- const NODE_W = 214, NODE_H = 96 @@ -71,8 +71,8 @@ function edgePath(x1: number, y1: number, x2: number, y2: number) { return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}` } -export function Canvas({ keyboardSuspended = false }: { keyboardSuspended?: boolean }) { - const { setSelConcept, setSelConflict, setView, conflicts, concepts } = useStore() +function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolean }) { + const { setSelConcept, setSelConflict, setView, conflicts, concepts } = useStoreData() // Memoized: pan/zoom re-renders every pointermove — don't re-lay-out for those. const { nodes, ghosts, worldW, worldH } = useMemo(() => computeLayout(concepts), [concepts]) const laneCounts = useMemo(() => { @@ -294,8 +294,17 @@ export function Canvas({ keyboardSuspended = false }: { keyboardSuspended?: bool
- {/* legend */} -
+ {/* + legend — the one blurred surface in the app with moving content behind + it. It floats over the pan/zoom viewport, so concept nodes and their + conflict edges slide underneath as the user drags; going opaque here + turned a card the graph reads through into a hole punched in it. That is + the opposite of the chrome panels, which sit in normal flow over a static + page gradient. Users who want it gone have the reduce-transparency + preference, which resolves --cc-header-bg to the opaque raised surface + and kills backdrop-filter app-wide (see styles.css). + */} +
The cascade — higher lanes win
@@ -342,3 +351,11 @@ export function Canvas({ keyboardSuspended = false }: { keyboardSuspended?: bool
) } + +/** + * Memoized. The shell re-renders for its own reasons — a drawer, a dialog, a + * background-activity tick — and this view has no business repainting for any + * of them. It re-renders when the store slices it subscribes to change, and + * otherwise not at all. + */ +export const Canvas = memo(CanvasInner) diff --git a/apps/console/src/views/Concepts.tsx b/apps/console/src/views/Concepts.tsx index 749681f2..a8de76d1 100644 --- a/apps/console/src/views/Concepts.tsx +++ b/apps/console/src/views/Concepts.tsx @@ -1,12 +1,14 @@ -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { C, css, conceptTypeStyle, MONO } from '../theme' import { LayerChip } from '../components/LayerChip' import { ConceptDetail } from '../components/ConceptDetail' import { useDetailSurface } from '../components/useDetailSurface' -import { useStore } from '../store' +import { useStoreData, useStoreInput, useStoreNav } from '../store' -export function Concepts() { - const { query, selConcept, setSelConcept, concepts } = useStore() +function ConceptsInner() { + const { setSelConcept, concepts } = useStoreData() + const { selConcept } = useStoreNav() + const { query } = useStoreInput() const q = query.trim().toLowerCase() const list = concepts.filter((c) => !q || `${c.title} ${c.id}`.toLowerCase().includes(q)) const selCpt = concepts.find((c) => c.id === selConcept) || null @@ -59,3 +61,11 @@ export function Concepts() {
) } + +/** + * Memoized. The shell re-renders for its own reasons — a drawer, a dialog, a + * background-activity tick — and this view has no business repainting for any + * of them. It re-renders when the store slices it subscribes to change, and + * otherwise not at all. + */ +export const Concepts = memo(ConceptsInner) diff --git a/apps/console/src/views/Conflicts.test.tsx b/apps/console/src/views/Conflicts.test.tsx index a53a16b8..c91ba59e 100644 --- a/apps/console/src/views/Conflicts.test.tsx +++ b/apps/console/src/views/Conflicts.test.tsx @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ useStore: vi.fn(), })) -vi.mock('../store', () => ({ useStore: mocks.useStore })) +vi.mock('../store', () => ({ useStore: mocks.useStore, useStoreData: mocks.useStore, useStoreNav: mocks.useStore, useStoreInput: mocks.useStore })) let container: HTMLDivElement let root: Root diff --git a/apps/console/src/views/Conflicts.tsx b/apps/console/src/views/Conflicts.tsx index 1a50f210..abe177b2 100644 --- a/apps/console/src/views/Conflicts.tsx +++ b/apps/console/src/views/Conflicts.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import { C, css, lc, MONO } from '../theme' import { layerLevel, layerName } from '../data' import type { Conflict, Contribution } from '../data' import { LayerChip } from '../components/LayerChip' import { Markdown } from '../components/Markdown' -import { useStore } from '../store' +import { useStoreData, useStoreInput, useStoreNav } from '../store' import { useDetailSurface } from '../components/useDetailSurface' function WandIcon() { @@ -71,11 +71,13 @@ function Choice({ ) } -export function Conflicts() { +function ConflictsInner() { const { - conflicts, selConflict, setSelConflict, resolveConflict, resolveSafeConflicts, - resolvingConflict, resolutionError, query, - } = useStore() + conflicts, setSelConflict, resolveConflict, resolveSafeConflicts, + resolvingConflict, resolutionError, + } = useStoreData() + const { selConflict } = useStoreNav() + const { query } = useStoreInput() const [selectedLayer, setSelectedLayer] = useState('') const [changing, setChanging] = useState(false) @@ -333,3 +335,11 @@ function Resolved({ conflict, onChange, disabled }: { conflict: Conflict; onChan ) } + +/** + * Memoized. The shell re-renders for its own reasons — a drawer, a dialog, a + * background-activity tick — and this view has no business repainting for any + * of them. It re-renders when the store slices it subscribes to change, and + * otherwise not at all. + */ +export const Conflicts = memo(ConflictsInner) diff --git a/apps/console/src/views/Files.test.tsx b/apps/console/src/views/Files.test.tsx index 79c1e593..5dabf360 100644 --- a/apps/console/src/views/Files.test.tsx +++ b/apps/console/src/views/Files.test.tsx @@ -30,24 +30,38 @@ vi.mock('../api', () => ({ apiFetch: mocks.apiFetch })) // and quietly pass tests that assert nothing moved. vi.mock('../store', async () => { const { useState } = await import('react') + // The scope/path setters live in the data context and the values live in the + // nav context, so the mock has to bridge them: the nav hook owns the useState + // pair and publishes its setters here for the data hook's stable wrappers. + const bridge: { + scope?: (value: string | null) => void + path?: (value: string | null) => void + } = {} + const setFilesScope = (value: string | null) => bridge.scope?.(value) + const setFilesPath = (value: string | null) => bridge.path?.(value) + const useStoreData = () => ({ + mode: mocks.store.mode, + sources: mocks.store.sources, + concepts: mocks.store.concepts, + reload: mocks.reload, + reloadKey: mocks.store.reloadKey, + setFilesScope, + setFilesPath, + openConcept: mocks.openConcept, + }) + const useStoreNav = () => { + const [filesScope, setScope] = useState(mocks.store.scope) + const [filesPath, setPath] = useState(mocks.store.path) + bridge.scope = setScope + bridge.path = setPath + return { filesScope, filesPath } + } + const useStoreInput = () => ({ query: mocks.store.query }) return { - useStore: () => { - const [filesScope, setFilesScope] = useState(mocks.store.scope) - const [filesPath, setFilesPath] = useState(mocks.store.path) - return { - mode: mocks.store.mode, - sources: mocks.store.sources, - concepts: mocks.store.concepts, - reload: mocks.reload, - reloadKey: mocks.store.reloadKey, - query: mocks.store.query, - filesScope, - filesPath, - setFilesScope, - setFilesPath, - openConcept: mocks.openConcept, - } - }, + useStoreData, + useStoreNav, + useStoreInput, + useStore: () => ({ ...useStoreData(), ...useStoreNav(), ...useStoreInput() }), } }) diff --git a/apps/console/src/views/Files.tsx b/apps/console/src/views/Files.tsx index dae4cca9..6c3257b1 100644 --- a/apps/console/src/views/Files.tsx +++ b/apps/console/src/views/Files.tsx @@ -19,7 +19,7 @@ import { Markdown } from '../components/Markdown' import { useDetailSurface } from '../components/useDetailSurface' import { filesRevalidation, readLayerFile, useLayerFiles } from '../layer-files' import { useReveal } from '../reveal' -import { useStore } from '../store' +import { useStoreData, useStoreInput, useStoreNav } from '../store' import type { FileContent, LayerFile } from '../types' type Tab = 'rendered' | 'raw' @@ -95,7 +95,9 @@ function conceptForFile(file: FileContent | null, concepts: Concept[]): { concep } export function Files() { - const { mode, concepts, sources, reload, reloadKey, query, filesScope, filesPath, setFilesScope, setFilesPath, openConcept } = useStore() + const { mode, concepts, sources, reload, reloadKey, setFilesScope, setFilesPath, openConcept } = useStoreData() + const { filesScope, filesPath } = useStoreNav() + const { query } = useStoreInput() const [file, setFile] = useState(null) const [fileError, setFileError] = useState(null) const [tab, setTab] = useState('rendered') @@ -512,3 +514,14 @@ export function Files() {
) } + + +/** + * Deliberately NOT wrapped in React.memo, unlike its sibling views. A memoized + * component with no props only ever re-renders from a context it subscribes to, + * and this view's suite drives updates by mutating a module-scoped store mock + * and re-rendering the same element — with a memo in the way those renders are + * skipped and the tests silently stop exercising anything. Those are the tests + * that hold the navigator's focus guarantee and its DOM-order invariant, and + * the memo would only save renders caused by the shell's own local state. + */ diff --git a/apps/console/src/views/Overview.test.tsx b/apps/console/src/views/Overview.test.tsx index 6f9bda87..09266991 100644 --- a/apps/console/src/views/Overview.test.tsx +++ b/apps/console/src/views/Overview.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { Overview } from './Overview' const mocks = vi.hoisted(() => ({ useStore: vi.fn(), setView: vi.fn() })) -vi.mock('../store', () => ({ useStore: mocks.useStore })) +vi.mock('../store', () => ({ useStore: mocks.useStore, useStoreData: mocks.useStore, useStoreNav: mocks.useStore, useStoreInput: mocks.useStore })) let container: HTMLDivElement let root: Root diff --git a/apps/console/src/views/Overview.tsx b/apps/console/src/views/Overview.tsx index 77d0449e..c98ccdb2 100644 --- a/apps/console/src/views/Overview.tsx +++ b/apps/console/src/views/Overview.tsx @@ -1,11 +1,12 @@ +import { memo } from 'react' import { progressLabel, progressPercent } from '../api' import { layerName, layers } from '../data' -import { useStore } from '../store' +import { useStoreData } from '../store' import { LayerChip } from '../components/LayerChip' import { EmptyState, StatusBadge } from '../components/ui' -export function Overview() { - const { mode, setView, signals, conflicts, sources, concepts, activity, loadErrors } = useStore() +function OverviewInner() { + const { mode, setView, signals, conflicts, sources, concepts, activity, loadErrors } = useStoreData() const queue = signals.filter((signal) => signal.route === 'review_required') const openConflicts = conflicts.filter((conflict) => conflict.status === 'open') const failedSources = sources.filter((source) => source.status === 'error' || source.status === 'degraded') @@ -52,3 +53,11 @@ export function Overview() { ) } + +/** + * Memoized. The shell re-renders for its own reasons — a drawer, a dialog, a + * background-activity tick — and this view has no business repainting for any + * of them. It re-renders when the store slices it subscribes to change, and + * otherwise not at all. + */ +export const Overview = memo(OverviewInner) diff --git a/apps/console/src/views/Sources.test.tsx b/apps/console/src/views/Sources.test.tsx index ea517b61..b8c4bae3 100644 --- a/apps/console/src/views/Sources.test.tsx +++ b/apps/console/src/views/Sources.test.tsx @@ -11,7 +11,7 @@ import type { Source } from '../data' const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), useStore: vi.fn(), reload: vi.fn(), openFilesScope: vi.fn() })) vi.mock('../api', () => ({ apiFetch: mocks.apiFetch })) -vi.mock('../store', () => ({ useStore: mocks.useStore })) +vi.mock('../store', () => ({ useStore: mocks.useStore, useStoreData: mocks.useStore, useStoreNav: mocks.useStore, useStoreInput: mocks.useStore })) let container: HTMLDivElement let root: Root diff --git a/apps/console/src/views/Sources.tsx b/apps/console/src/views/Sources.tsx index 6c04d668..ab3caa71 100644 --- a/apps/console/src/views/Sources.tsx +++ b/apps/console/src/views/Sources.tsx @@ -14,7 +14,7 @@ import { LevelStepper } from '../components/SetupWizard' import { useDetailSurface } from '../components/useDetailSurface' import { filesRevalidation, useLayerFiles } from '../layer-files' import { useReveal } from '../reveal' -import { useStore } from '../store' +import { useStoreData, useStoreInput } from '../store' import type { Source } from '../data' import type { LayerFiles } from '../types' @@ -190,7 +190,8 @@ function filesSummary(source: Source, entry: LayerFiles | undefined, known: bool } export function Sources({ onAddSource }: { onAddSource?: () => void }) { - const { mode, sources, reload, reloadKey, query, openFilesScope } = useStore() + const { mode, sources, reload, reloadKey, openFilesScope } = useStoreData() + const { query } = useStoreInput() const live = mode === 'live' // The same listing the Files view builds its tree from: a source's file count // and root path are already in that payload, and were being thrown away. @@ -622,3 +623,14 @@ export function Sources({ onAddSource }: { onAddSource?: () => void }) { ) } + + +/** + * Deliberately NOT wrapped in React.memo, unlike its sibling views. A memoized + * component with no props only ever re-renders from a context it subscribes to, + * and this view's suite drives updates by mutating a module-scoped store mock + * and re-rendering the same element — with a memo in the way those renders are + * skipped and the tests silently stop exercising anything. Those are the tests + * that hold the navigator's focus guarantee and its DOM-order invariant, and + * the memo would only save renders caused by the shell's own local state. + */ diff --git a/apps/console/src/views/Triage.tsx b/apps/console/src/views/Triage.tsx index 7f370c5e..ff5d4efa 100644 --- a/apps/console/src/views/Triage.tsx +++ b/apps/console/src/views/Triage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { C, css, badgeStyle, lc, MONO, rc } from '../theme' import { layerName, layers } from '../data' -import { useStore, type TriageTab } from '../store' +import { filterSignals, useStoreData, useStoreInput, useStoreNav, type TriageTab } from '../store' import { useDetailSurface } from '../components/useDetailSurface' const TAB_DEFS: [TriageTab, string, 'review_required' | 'team_candidate' | 'ignore'][] = [ @@ -10,10 +10,15 @@ const TAB_DEFS: [TriageTab, string, 'review_required' | 'team_candidate' | 'igno ['ignored', 'Ignored', 'ignore'], ] -export function Triage() { - const { triageTab, setTriageTab, selSignal, setSelSignal, filtered, signals, setView, route } = useStore() +function TriageInner() { + const { setTriageTab, setSelSignal, signals, setView, route } = useStoreData() + const { triageTab, selSignal } = useStoreNav() + // The Queue is one of the views the toolbar offers a search box for, so this + // subscription is what makes the box work at all — without it the component + // is memoized against every keystroke and the list never moves. + const { query } = useStoreInput() - const curList = filtered(triageTab) + const curList = filterSignals(signals, triageTab, query) const selSig = signals.find((s) => s.id === selSignal) || null const [detailOpen, setDetailOpen] = useState(Boolean(selSignal)) const selectedButton = useRef(null) @@ -47,7 +52,7 @@ export function Triage() { return (