🏗️ The createStore Pattern
O store primitivo fornece tres operacoes em apenas 35 linhas:
export function createStore<T>(
initialState: T,
onChange?: OnChange<T>,
): Store<T> {
let state = initialState
const listeners = new Set<Listener>()
return {
getState: () => state,
setState: (updater) => {
const prev = state
const next = updater(prev)
if (Object.is(next, prev)) return // bail if no change
state = next
onChange?.({ newState: next, oldState: prev }) // side-effect hook
for (const listener of listeners) listener() // notify React
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener) // unsubscribe
},
}
}
💡 Por que nao useState / useReducer?
React hooks vinculam o lifetime do estado a arvores de componentes. O Claude Code precisa de estado acessivel de codigo nao-React: headless mode, SDK print layer, sessoes de teammate per-process e a cadeia de side-effects onChangeAppState. Um objeto JavaScript simples com um Set de listeners evita essa restricao.
💡 Por que nao Zustand / Jotai?
Zero dependencia de bundle. A interface necessaria -- getState, setState, subscribe -- mapeia exatamente para os requisitos de useSyncExternalStore sem nada a mais.
⚠️ Invariante-chave
setState recebe uma updater function (prev) => next, nunca um objeto parcial. Isso forca imutabilidade no call site: callers devem spread o estado anterior e retornar uma nova referencia. Object.is equality checking significa que re-renders so disparam quando a referencia muda.
📊 The AppState Shape - 90+ Fields
AppState definido em state/AppStateStore.ts contem mais de 90 campos distintos organizados em seis categorias logicas. O tipo e DeepImmutable<{...}> para a porcao serializavel, com certos campos escapando o wrapper de imutabilidade.
Session Core
settings, verbose, mainLoopModel, thinkingEnabled, effortValue, fastMode
UI State
expandedView, footerSelection, spinnerTip, activeOverlays, statusLineText
Permissions
toolPermissionContext (mode, bypass flags), denialTracking, pendingPlanVerification
Agent & Tasks
tasks (keyed by taskId), agentNameRegistry, foregroundedTaskId, teamContext
Remote & Bridge
remoteSessionUrl, remoteConnectionStatus, replBridgeEnabled/Connected/Active
Subsystem State
mcp, plugins, speculation, promptSuggestion, notifications, todos, inbox
DeepImmutable e o Escape Hatch
export type AppState = DeepImmutable<{
settings: SettingsJson
verbose: boolean
// ... ~60 serializable fields ...
}> & {
// Excluded from DeepImmutable -- contain function types
tasks: { [taskId: string]: TaskState }
agentNameRegistry: Map<string, AgentId>
mcp: { clients: MCPServerConnection[]; /* ... */ }
// ...
}
tasks, agentNameRegistry, sessionHooks, activeOverlays e replContext nao sao wrappados em DeepImmutable porque contem tipos function que a transformacao recursiva readonly do TypeScript nao lida. Trate-os como logicamente imutaveis (sempre spread antes de mutar).
🔍 onChangeAppState - The Side-Effect Chokepoint
📊 O Problema que Resolveu
Antes deste padrao, mudancas de permission-mode eram relayed ao dashboard remoto (CCR) por apenas 2 de 8+ caminhos de mutacao. Os outros 6 mutavam AppState silenciosamente, deixando a web UI desatualizada. Centralizar o diff aqui significa que qualquer setState que muda o mode automaticamente sincroniza -- sem mudancas em call sites individuais.
Os 6 Diff Blocks Atuais
export function onChangeAppState({ newState, oldState }) {
// 1. Permission mode -- sync to CCR + SDK status stream
if (prevMode !== newMode) {
const prevExternal = toExternalPermissionMode(prevMode)
const newExternal = toExternalPermissionMode(newMode)
if (prevExternal !== newExternal) {
notifySessionMetadataChanged({ permission_mode: newExternal })
}
notifyPermissionModeChanged(newMode)
}
// 2. mainLoopModel -- persist to settings + bootstrap override
if (newState.mainLoopModel !== oldState.mainLoopModel) { ... }
// 3. expandedView -- persist to globalConfig
if (newState.expandedView !== oldState.expandedView) { ... }
// 4. verbose -- persist to globalConfig
if (newState.verbose !== oldState.verbose) { ... }
// 5. tungstenPanelVisible -- ant-only, persist to globalConfig
if (process.env.USER_TYPE === 'ant' && ...) { ... }
// 6. settings -- clear auth caches + re-apply env vars
if (newState.settings !== oldState.settings) {
clearApiKeyHelperCache()
clearAwsCredentialsCache()
clearGcpCredentialsCache()
}
}
💡 Externalization Guard
Nem todos os modos de permissao internos tem equivalentes externos. toExternalPermissionMode colapsa nomes internal-only como 'bubble' e 'ungated-auto' para 'default' antes de enviar ao CCR. Sem isso, o dashboard remoto receberia nomes de modo sem significado.
⚙️ React Hooks Layer
state/AppState.tsx exporta tres hooks e um provider:
// Read a slice -- re-renders only when selected value changes
const verbose = useAppState(s => s.verbose)
const model = useAppState(s => s.mainLoopModel)
// Write without subscribing -- stable reference, never causes re-renders
const setAppState = useSetAppState()
setAppState(prev => ({ ...prev, verbose: true }))
// Get the raw store -- for passing to non-React helpers
const store = useAppStateStore()
doSomethingOutsideReact(store.getState, store.setState)
Selector Rules
✅ Correto
// Good -- returns existing reference
const { text, promptId } =
useAppState(s => s.promptSuggestion)
❌ Incorreto
// Bad -- new object every render
const { text, promptId } =
useAppState(s => ({
text: s.promptSuggestion.text,
promptId: s.promptSuggestion.promptId
}))
NAO retorne novos objetos do selector. useSyncExternalStore compara snapshots com Object.is. Um inline s => ({ a: s.a, b: s.b }) cria um novo objeto a cada render, disparando um infinite re-render loop.
📁 Selectors & Transition Helpers
Selectors em state/selectors.ts aceitam Pick<AppState, ...> (nao o estado completo) para testabilidade:
export function getViewedTeammateTask(
appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>
): InProcessTeammateTaskState | undefined { ... }
export type ActiveAgentForInput =
| { type: 'leader' }
| { type: 'viewed'; task: InProcessTeammateTaskState }
| { type: 'named_agent'; task: LocalAgentTaskState }
export function getActiveAgentForInput(appState: AppState):
ActiveAgentForInput { ... }
Transition Helpers - teammateViewHelpers.ts
Transicoes de estado complexas recebem setAppState como argumento, mantendo-se framework-agnosticas e testaveis fora do React:
// Enter teammate transcript view
export function enterTeammateView(
taskId: string,
setAppState: (updater: (prev: AppState) => AppState) => void,
): void
// Exit back to leader's view
export function exitTeammateView(
setAppState: (updater: (prev: AppState) => AppState) => void,
): void
// Context-sensitive x button: abort if running, dismiss if terminal
export function stopOrDismissAgent(
taskId: string,
setAppState: (updater: (prev: AppState) => AppState) => void,
): void
📊 Retain/Evict Lifecycle
- Stub:
retain: false,messages: undefined. Row aparece no painel sem transcript. - Retained:
retain: true, messages carregadas. Acionado porenterTeammateView. - Eviction pending: task terminal,
evictAfter = Date.now() + 30_000. Row permanece 30s (PANEL_GRACE_MS). - Immediate dismiss:
evictAfter = 0. Filter esconde row imediatamente.
🔀 Full Data Flow
Diagrama: Do Componente ao Re-Render
flowchart TD
A["Component calls useSetAppState()"] -->|"returns store.setState"| B["store.setState(updater)"]
B --> C{"Object.is(next, prev)?"}
C -->|"same"| Z["Return - no-op"]
C -->|"changed"| D["state = next"]
D --> E["onChangeAppState({ newState, oldState })"]
E --> F1["permission mode?"]
E --> F2["mainLoopModel?"]
E --> F3["expandedView?"]
E --> F4["settings?"]
F1 -->|"yes"| G1["notifySessionMetadataChanged"]
F2 -->|"yes"| G2["updateSettingsForSource"]
F3 -->|"yes"| G3["saveGlobalConfig"]
F4 -->|"yes"| G4["clearAuthCaches"]
D --> H["for listener: listener()"]
H --> I["useSyncExternalStore re-render"]
I --> J["selector compares with Object.is"]
J -->|"changed"| K["Component re-renders"]
J -->|"same"| L["Render skipped"]
🗂️ Context vs State
Nem tudo no diretorio context/ e React Context. O diretorio contem um mix de padroes:
| Padrao | Exemplos | Descricao |
|---|---|---|
| React Context (thin) | modalContext, overlayContext | Valores compartilhados na arvore de componentes |
| Hooks over AppState | notifications.tsx | Le/escreve AppState via useAppState + useSetAppState |
| External Store | fpsMetrics, stats | Dados proprios fora do AppState (metricas de perf) |
| Side-Effect Manager | mailbox, voice | WebSocket/IPC connections |
⚠️ context.ts e Diferente
O arquivo top-level context.ts (nao context/) e inteiramente nao-relacionado a React Context. Ele constroi o system prompt injetado em cada API call: getSystemContext() (git status, cache breaker) e getUserContext() (CLAUDE.md files, current date).
📋 Resumo do Modulo
createStore<T> em 35 linhas implementa exatamente a interface que useSyncExternalStore precisa -- sem biblioteca
setState recebe updater function, nao partial. Object.is bail-out previne re-renders espurios
onChangeAppState centraliza todos os side effects em um unico diff observer
Pick<AppState, ...> para testabilidade; transition helpers recebem setAppState como argumento