Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
56 changes: 45 additions & 11 deletions apps/console/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,21 @@ 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 **three** contexts, split
`resolveConflict`, `send`, view/selection setters) in **four** 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.
action has a stable identity), `nav` (view and selection), `input` (the toolbar
search box — changes per keystroke), `chat` (the Ask composer, its transcript
and its busy flag — changes per keystroke). `useStoreData()` / `useStoreNav()`
/ `useStoreInput()` / `useStoreChat()` are the narrow hooks; `useStore()`
merges all four and re-renders on any of them — it has no production callers
left, only test mocks, and new code should not add one. The two typing
surfaces are deliberately separate contexts: `query` is read by the Header
that owns the field and by all five searchable views, the composer only by
`ChatPanel`, and while they shared one context a question typed into the Ask
slide-over repainted the view under it per character. 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 @@ -101,7 +106,10 @@ via their pre-hooks.
triage/activity fixtures. Live errors are typed (`LiveDataError`) and
rendered honestly — never a silent fallback to demo.
- **Chat** — `src/components/ChatPanel.tsx` + `store.send()` call
`window.claude.complete` when present and fall back to canned answers.
`window.claude.complete` when present and fall back to canned answers. The
panel is the only component that calls `useStoreChat()` (the other caller is
`useStore()` itself), and should stay that way: the hook re-renders its caller
for every character typed into the composer.

Key files: `src/store.tsx` (state), `src/theme.ts` (`css()` + tokens),
`src/styles.css` (shell/theme variables), `src/views/Canvas.tsx` (pan/zoom layout),
Expand Down Expand Up @@ -129,7 +137,33 @@ Key files: `src/store.tsx` (state), `src/theme.ts` (`css()` + tokens),
`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.
the sidebar must NOT repaint on a search keystroke, and the active view MUST.
It holds the same pair for the composer, table-driven over the same
`SEARCHABLE_VIEWS` gate, with the Ask panel open over each view: the view must
NOT repaint, and the composer must still hold what was typed.
- **Count renders inside the memo boundary, not at a leaf.** Views render no
icons, so the chat cases needed a probe of their own, and the first one —
counting `LayerChip`, which `Concepts` renders per row — was blind by
construction: one `memo` between the view and the chip (match-highlighting,
virtualization) zeroes the probe, and zero is what the test asserts. `counted()`
in `render-hygiene.test.tsx` instead re-exports the view module with a counter
wrapping the view's own inner function (unwrapping `React.memo`), so the view's
hooks become the counter's hooks and every context it subscribes to is
observed. A wrapper AROUND the view counts the parent, which a context-driven
re-render never touches — the same reason `React.Profiler` reports zero here.
- **A `data` value that changes identity per provider render defeats the whole
split, and demo-mode tests cannot see it.** `App` subscribes to `data` and
owns every memoized child, so `data` changing identity on every provider
render *is* a whole-tree repaint per keystroke. That shipped: `activity` was
`mode === 'demo' ? demoActivity : []`, and the inline `[]` — live mode only —
gave `data` a new identity every render. Both context splits measured zero in
`render-hygiene.test.tsx` and bought nothing in the Mac app, because
`createDataSource()` with no query string picks demo, the one mode that took
the stable branch. Hence `NO_ACTIVITY` in `store.tsx`, and
`render-hygiene.live.test.tsx`, which mounts the store with a source that
reports `mode: 'live'` while still answering from the demo bundle and pins the
same properties there. Anything added to the `data` memo's dependency list
must be stable across a render in **both** modes.
- **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
6 changes: 4 additions & 2 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ function ErrorState({ kind, message, reload }: { kind: LiveErrorKind; message: s
}

export function App() {
// Deliberately three narrow subscriptions rather than `useStore()`: the shell
// Deliberately two 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.
// Typing — in the toolbar search or the chat composer — must not cause one,
// which is also why `data` must not change identity per provider render (see
// NO_ACTIVITY in store.tsx).
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
Expand Down
7 changes: 5 additions & 2 deletions apps/console/src/components/BackgroundActivity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import { LiveDataError } from '../api'
import type { BackgroundTask, Store } from '../store'

const mocks = vi.hoisted(() => ({ store: { current: null as unknown as Store } }))
// The store is three contexts now; this component reads the data half.
// The store is four contexts now; this component reads the data half. All four
// hooks are declared even though this component reads one, because the factory
// REPLACES the module: a hook left out is a `not a function` crash the day some
// child reaches for it, and tsc cannot see into an untyped factory.
vi.mock('../store', () => {
const store = () => mocks.store.current
return { useStore: store, useStoreData: store, useStoreNav: store, useStoreInput: store }
return { useStore: store, useStoreData: store, useStoreNav: store, useStoreInput: store, useStoreChat: store }
})

let container: HTMLDivElement
Expand Down
4 changes: 2 additions & 2 deletions apps/console/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { memo, useEffect, useRef, useState } from 'react'
import { C, css, lc, MONO } from '../theme'
import { useStoreData, useStoreInput } from '../store'
import { useStoreChat, useStoreData } 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'

function ChatPanelInner({ keyboardSuspended = false, onConnectAgent, onClose }: { keyboardSuspended?: boolean; onConnectAgent?: () => void; onClose: () => void }) {
const { setChatInput, send } = useStoreData()
const { chatMessages, chatBusy, chatInput } = useStoreInput()
const { chatMessages, chatBusy, chatInput } = useStoreChat()
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const panelRef = useRef<HTMLElement>(null)
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/components/SetupWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ vi.mock('../api', async (importOriginal) => ({
}))
vi.mock('../store', () => {
const store = () => ({ reload: mocks.reload })
return { useStore: store, useStoreData: store, useStoreNav: store, useStoreInput: store }
return { useStore: store, useStoreData: store, useStoreNav: store, useStoreInput: store, useStoreChat: store }
})

let container: HTMLDivElement
Expand Down
188 changes: 188 additions & 0 deletions apps/console/src/render-hygiene.live.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// @vitest-environment jsdom
// The render-budget suite next door mounts the store in demo mode, because that
// is what `createDataSource()` picks with no query string. Demo mode is also the
// only mode in which the provider's `activity` is a stable module constant — in
// live mode it was a fresh `[]` on every provider render, which changed the
// identity of the whole `data` context every time ANY provider state moved.
//
// That defeated the context split outright: `App` subscribes to `data` and owns
// every memoized child, so a keystroke repainted the tree in exactly the mode
// the Mac app ships in, while the suite next door measured zero and passed.
//
// So this file pins the same properties with the store in live mode. It is a
// separate file because `vi.mock` is per-file and the suite next door must stay
// on the demo path.
//
// ONE INVARIANT HOLDS THESE TESTS UP: every window between capturing a baseline
// count and asserting on it must stay synchronous. Live mode really does arm
// the poll loop (one 5s timer per test, cleared on unmount), and a poll that
// landed mid-window would legitimately move the numbers — `setLastRefreshAt`
// feeds `load`, and `load` is a dependency of the `data` memo. No poll can
// interleave with straight-line code, which is why the counts are exact today.
// Add an `await` between a baseline and its assertion and this becomes a
// five-second timing flake that CI will find long before you do.
import { act, type ComponentType } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { App } from './App'
import { Header } from './components/Header'
import { ChatPanel } from './components/ChatPanel'
import { StoreProvider, useStoreData } from './store'
import { ThemeModeProvider } from './theme-mode'
import { Concepts } from './views/Concepts'

type AnyComponent = (props: Record<string, unknown>) => unknown

const renders: Record<string, number> = {}

// A live-mode source that still answers from the demo bundle. The mode flag is
// the whole variable under test: the payloads are identical either way, and
// swapping in a real LiveSource would only measure jsdom's missing network.
//
// The Proxy returns unbound methods, which then run with `this` set to the
// proxy. That resolves only because DemoSource's fields are TypeScript
// `private` — a compile-time marker over an ordinary property, so `this.bundle`
// re-enters the trap and finds the target's value. Convert one of them to a
// real `#private` field and every call through here throws, surfacing as the
// store's generic `refreshError` rather than as an obvious test failure. Bind
// to the target here if that day comes.
vi.mock('./api', async () => {
const actual = await vi.importActual<typeof import('./api')>('./api')
return {
...actual,
createDataSource: () => new Proxy(actual.createDataSource('demo'), {
get: (target, key) => (key === 'mode' ? 'live' : Reflect.get(target, key, target)),
}),
}
})

/** Render counter inside the memo boundary — see the note in render-hygiene.test.tsx. */
async function counted(name: string, actual: Record<string, unknown>) {
const { memo } = await import('react')
const exported = actual[name]
const wasMemo = typeof exported !== 'function'
const Inner = (wasMemo ? (exported as { type: unknown }).type : exported) as AnyComponent
const Counted = (props: Record<string, unknown>) => {
renders[name] = (renders[name] ?? 0) + 1
return Inner(props)
}
return { ...actual, [name]: wasMemo ? memo(Counted as unknown as ComponentType) : Counted }
}

vi.mock('./views/Concepts', async () => counted('Concepts', await vi.importActual('./views/Concepts')))
vi.mock('./App', async () => counted('App', await vi.importActual('./App')))

let container: HTMLDivElement
let root: Root

/**
* Stands in for `App`: subscribes to `data` and nothing else. App owns every
* memoized child in the shell, so one render of this is one render of the tree
* — and unlike App it drags in no routing, no wizard and no IPC.
*/
function DataConsumerProbe() {
useStoreData()
renders.probe = (renders.probe ?? 0) + 1
return null
}

function typeInto(field: HTMLInputElement | HTMLTextAreaElement, text: string) {
const proto = field instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
act(() => {
setter?.call(field, text)
field.dispatchEvent(new Event('input', { bubbles: true }))
})
}

beforeEach(() => {
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
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.unstubAllGlobals()
window.history.replaceState(null, '', '/')
document.documentElement.removeAttribute('data-theme')
})

describe('the render budget holds in live mode too', () => {
it('keeps a keystroke off the data context, in either typing surface', async () => {
window.location.hash = '#/concepts'
await act(async () => root.render(
<StoreProvider>
<DataConsumerProbe />
<Header onToggleSidebar={() => {}} onAsk={() => {}} />
<Concepts />
<ChatPanel onClose={() => {}} />
</StoreProvider>,
))
await act(async () => { await Promise.resolve() })
await act(async () => { await Promise.resolve() })

const composer = container.querySelector<HTMLTextAreaElement>('.cc-ask-panel textarea')
expect(composer, 'the chat panel rendered no composer').toBeTruthy()
const search = container.querySelector<HTMLInputElement>('input[data-context-search]')
expect(search, 'the toolbar rendered no search field').toBeTruthy()
const conceptId = container.querySelector('.cc-navigator-detail > div > button.cc-h-bd-strong code')?.textContent
expect(conceptId, 'the view rendered no concept rows — live mode served nothing').toBeTruthy()

const viewBefore = renders.Concepts ?? 0
const probeBefore = renders.probe ?? 0
expect(viewBefore, 'the view never rendered — the probe is counting nothing').toBeGreaterThan(0)
expect(probeBefore, 'the data probe never rendered').toBeGreaterThan(0)

// A question typed over the top of a view touches neither the view nor the
// shell that hosts it.
for (const text of ['w', 'wh', 'wha', 'what']) typeInto(composer!, text)
expect(composer!.value).toBe('what')
expect((renders.Concepts ?? 0) - viewBefore).toBe(0)
expect((renders.probe ?? 0) - probeBefore).toBe(0)

// And a search keystroke reaches the view it filters — without repainting
// the shell, which is the property the original context split bought.
const viewBeforeSearch = renders.Concepts ?? 0
const probeBeforeSearch = renders.probe ?? 0
typeInto(search!, conceptId!)
expect(renders.Concepts ?? 0).toBeGreaterThan(viewBeforeSearch)
expect((renders.probe ?? 0) - probeBeforeSearch).toBe(0)
})

/**
* The probe above stands in for `App`'s data subscription, which is what this
* bug traveled through — but it is a stand-in, and it cannot see a NEW
* subscription added to the real App. Adding `useStoreChat()` there would
* repaint the shell's own inline JSX on every character and leave the suite
* green, because every child it renders is memoized and would bail.
*
* So this mounts the real thing and opens the panel the way a user does.
*/
it('does not re-render the shell itself while a question is typed into it', async () => {
window.history.replaceState(null, '', '/#/concepts')
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0))
await act(async () => root.render(
<ThemeModeProvider><StoreProvider><App /></StoreProvider></ThemeModeProvider>,
))
await act(async () => { await Promise.resolve() })
await act(async () => { await Promise.resolve() })

const ask = container.querySelector<HTMLButtonElement>('.cc-toolbar-ask')
expect(ask, 'the toolbar rendered no Ask button').toBeTruthy()
await act(async () => { ask!.click() })
const composer = container.querySelector<HTMLTextAreaElement>('.cc-ask-panel textarea')
expect(composer, 'clicking Ask opened no composer').toBeTruthy()

const before = renders.App ?? 0
expect(before, 'the shell never rendered — the probe is counting nothing').toBeGreaterThan(0)
for (const text of ['w', 'wh', 'wha', 'what']) typeInto(composer!, text)
expect(composer!.value).toBe('what')
expect((renders.App ?? 0) - before).toBe(0)
})
})
Loading