Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0223ad9
perf(desktop): settings writes are debounced and async — no per-frame…
siracusa5 Aug 7, 2026
459ed96
perf(console): context split + memoization — keystrokes don't re-rend…
siracusa5 Aug 7, 2026
68c2745
perf(console,desktop): reduce-transparency is user-facing; blur only …
siracusa5 Aug 7, 2026
a458bee
feat(desktop): engine watchdog — wedged engine is visible and recover…
siracusa5 Aug 7, 2026
bfd5468
fix(desktop): bounded error buffer; settings-sync pull can't drop loc…
siracusa5 Aug 7, 2026
b7921b6
fix(console): the Queue's search box filters again
siracusa5 Aug 7, 2026
db7ba1e
fix(console): the blur budget was measured against rules that render …
siracusa5 Aug 7, 2026
4e2d46c
fix(console): a refused engine restart hands the button back
siracusa5 Aug 7, 2026
cfdb9f0
fix(desktop): the quit, the failed write and the failed restart all t…
siracusa5 Aug 7, 2026
016f114
fix(console): a setting the Mac could not save says so instead of sna…
siracusa5 Aug 7, 2026
15a61e2
fix(desktop): the watchdog's stop guard was untested, and it was the …
siracusa5 Aug 7, 2026
0277bba
test(desktop): the engine-latency gate passed with the bug present
siracusa5 Aug 7, 2026
4bd7c72
test(desktop): the ack deadline tests depended on something else keep…
siracusa5 Aug 7, 2026
e7b28a9
test(desktop): the isolation gate measures the machine instead of ass…
siracusa5 Aug 7, 2026
6f41b14
test(desktop): absorb runner variance the throughput calibration cann…
siracusa5 Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions apps/console/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,16 @@ via their pre-hooks.
- **Entry** — `src/main.tsx` mounts `<ThemeModeProvider><StoreProvider><App/>`.
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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/console/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
34 changes: 24 additions & 10 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -165,7 +173,7 @@ export function App() {
}
}
else window.dispatchEvent(new Event('contextcake:toggle-sidebar'))
}
}, [closeDrawer, drawerOpen])

const paletteCommands = useMemo<PaletteCommand[]>(() => [
{ id: 'home', label: 'Go to Home', keywords: 'overview', shortcut: '⌘1', run: () => setView('overview') },
Expand All @@ -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)
Expand Down Expand Up @@ -373,6 +381,12 @@ export function App() {
<div className="sr-only" aria-live="polite">
{backgroundAnnouncement}
</div>
{/*
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.
*/}
<EngineBanner />
{load.refreshError && load.refreshError.message !== dismissedRefreshError && (
<div role="status" style={css(`display:flex; align-items:center; gap:10px; padding:8px 16px; background:${C.amberFill}; border-bottom:1px solid ${C.amberStroke}; font-size:12px; color:${C.amberText};`)}>
<span aria-hidden="true">⚠</span>
Expand Down
6 changes: 5 additions & 1 deletion apps/console/src/components/BackgroundActivity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/console/src/components/BackgroundActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down
17 changes: 13 additions & 4 deletions apps/console/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const panelRef = useRef<HTMLElement>(null)
Expand Down Expand Up @@ -163,3 +164,11 @@ export function ChatPanel({ keyboardSuspended = false, onConnectAgent, onClose }
</div>
)
}

/**
* 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)
6 changes: 3 additions & 3 deletions apps/console/src/components/ConceptDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<string, string> {
const { mode, sources, reloadKey } = useStore()
const { mode, sources, reloadKey } = useStoreData()
const { layers } = useLayerFiles(mode, filesRevalidation(sources, reloadKey))
return useMemo(() => {
const best = new Map<string, { path: string; rank: number }>()
Expand All @@ -43,7 +43,7 @@ function useFileByContributor(): Map<string, string> {

/** "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 (
<button
Expand Down
Loading