--- name: kolu-client description: >- kolu's client component conventions: SolidJS patterns, UI component rules, and toast notifications. Use when building or changing a client component, a store, a subscription, or a toast. --- ## UI - Extract reusable UI into SolidJS components (one component per file in `packages/client/src/`). - Follow the [frontend-design skill](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md) for UI design decisions — bold intentionality over generic aesthetics. ## SolidJS Patterns - **State per domain**: Extract shared state into `useXxx.ts` modules (singleton pattern — create store once, cache at module level). Keep App.tsx as a thin layout shell. - **Commands stay declarative**: `commands.ts` registers commands — it should not contain async RPC calls, error handling, or multi-step workflows. Those belong in `useXxx.ts` handlers. Commands just call `deps.handleFoo()`. - **Components own their behavior**: If a component has a keyboard shortcut or toggle state, it should manage that internally (always mounted if needed), not leak it to the parent. - **`createStore` over `createSignal`**: For keyed state (e.g. per-terminal metadata), use `createStore` from `solid-js/store` for fine-grained per-key reactivity. - **Controlled components need selection PROVENANCE (echo loops)**: When you bridge an imperative/controlled library component (`@kolu/solid-pierre`'s Pierre `FileTree`, an editor, a map) into Solid — driving its state from a store AND subscribing to its change callback — that callback fires for BOTH real user actions AND your own programmatic writes (echoes). Forwarding an echo back into the store that drives the component closes a frame-rate feedback loop: store write → the effect re-applies it into the component → the component re-emits → store writes again, ping-ponging at ~60-120Hz (the Code-tab file-tree "shimmer", #1841). Cut it with provenance, not by de-duping values (the echo drifts by a row, so value-equality won't catch it): arm a one-shot `userGesture` token from a **capture-phase** `click`/`keydown` listener on the component's container (`click`, not `pointerdown` — it matches the event the component selects on and covers touch taps; composed events cross a shadow root, so the token is set before the component's own row handler runs) and forward ONLY when it is set, consuming it on the first emit. Mind the timing — a controlled component's change emit is **environment-dependent**: synchronous inside the gesture dispatch when driven directly (a unit-test `dispatchEvent`), but **deferred past a microtask** in the real app (framework render + real browser event). Disarm the token on the next **animation frame** (which survives BOTH), never `queueMicrotask` — in production the emit lands after the microtask, so a microtask disarm races ahead of it and drops every real interaction (this shipped as #1846 and dropped every click). Pin the wrapper CONTRACT with a scripted mock emit, not the real library's timing (a real-component integration test measures the synchronous test-env timing and can't guard the deferred production case). (This is NOT a coarse-signal over-subscription — a `reconcile`-backed store already isolates unrelated leaf ticks like `foreground.title`; the loop is the controlled-component echo itself.) - **`@solid-primitives`**: Prefer community primitives (`makePersisted`, `createResizeObserver`, `makeEventListener`) over hand-rolled equivalents. - **Props stay reactive**: Never destructure props. Access via `props.xxx`. Pass accessors when needed. - **Memos for multi-consumer derivations**: Use `createMemo` when 2+ reactive contexts read the same derived value. Use plain functions for single-consumer or trivial derivations. - **Surface bound primitives for server streams**: Use `app.cells.X.use(...)`, `app.collections.X.use(...)`, `app.streams.X.use(inputFn)`, and `app.events.X.use(inputFn, handler, { onError })` from `wire.ts`'s `surfaceClient` for streaming server state. The framework owns snapshot+deltas, retry, and reconcile-vs-assign. For a SURFACE-scoped raw streaming RPC that doesn't fit a primitive (a bulk snapshot feed), use `client.rawStream(name, proc, input, { onItem })` — it enrols the stream into `client.health()` structurally (and throws if driven outside a reactive owner), so a raw stream can't silently escape the health fact. For a stream that ALREADY owns its `pending`/`error` (a `createSubscription` over a surface procedure), join it with `client.enroll(name, sub)`. The bare `unenrolledStreamCall` lives at `@kolu/surface/client` (NOT the `@kolu/surface/solid` barrel) and is for a stream DELIBERATELY carved out of the surface `health()` fact — reached through a typed raw ref, either a SURFACE-scoped one (`client.streams.X.unenrolled` / `client.collections.X.unenrolledKeys`, the terminal `attach` as `activePadiStreams.terminalAttach.unenrolled`, whose transient re-attach must not flicker the connection-health gate — #1591) or a genuine root RPC outside any surface: `unenrolledStreamCall(proc, input, { signal, onRetry })`. Reaching such a ref WITHOUT wrapping it in `unenrolledStreamCall` silently loses reconnect handling. For one-shot RPC calls, use plain `client.*` calls with `createSignal` or `createResource`. - **A mirror's liveness rides in the fact**: `client.health().live` is now the FULL conjunction — transport-live ∧ every readiness cell's predicate (sub errors stay in `subs`). A cell becomes a readiness gate via `CellSpec.liveWhen?: (v) => boolean` (e.g. surface-remote's `connection` cell declares `liveWhen: (v) => v.state === "connected"`); `surfaceClient` opens an eager standing sub for each, so a `mirroredSurface`'s connection-liveness leg folds into `live` BY CONSTRUCTION. Read the complete fact — NEVER hand-AND `connection.state === "connected"`. - **Paint the connection dot from the fact**: Render the status dot with `` from `@kolu/surface/solid/HostStatusPip` (its own entry, like `` — not the JSX-free `@kolu/surface/solid` barrel). Its green is fact-only (default `gateStatus(h) === "ready"`, shared with ``); NEVER color a status dot from a raw cell `.state`. There are TWO honest lights and they answer different questions — pick by which one the dot is making: a **gate-aligned** dot (green ⇔ the body is drawable, so a pending first frame withholds green) is `` on `gateStatus`; a **page** indicator for a whole connection (green ⇔ what this page reads is arriving) is `conn.readout()` from `connectSurface` / `connectSurfaces` — five states (`connecting`/`live`/`degraded`/`reconnecting`/`retired`), pending does NOT degrade it, `degraded` names the stopped subs, and `needsReload` marks the one state only a reload recovers. Never re-derive either from `client.health()` beside a transport `status` by hand; and never paint an indicator from a transport status alone (that is the green-over-a-dead-subscription lie the readout exists to close). - **Socket-backed clients use `connectSurface` / `connectSurfaces`**: They wire the always-on heartbeat / half-open watchdog in, so `health().live` catches a silent half-open (laptop sleep / wifi roam). The half-open-blind path is **unspellable** over every wire link (`websocketLink`, `stdioLink`, `unixSocketLink` — all branded half-openable at the one `wireClient` chokepoint) — and unspellable for real, not by convention: `surfaceClient`/`surfaceClients` THROW unless handed a watchdog-backed `LiveSignal`, and the only minter is `createLiveSignal` (`@kolu/surface/solid`), which wires the watchdog first. The brand is membership in a module-private, **un-reflectable** `WeakSet` on the whole HANDLE (`LIVE_SIGNAL_HANDLES`) that only `createLiveSignal` adds to — there is no exported stamper to forge with — and there is **no `heartbeat: false`** on the minting path to mint a handle without a watchdog. So a bare `{ live: () => true }` (or an open/close-only `() => socketStatus() === "live"`) can never become the input `surfaceClient` accepts. The timing is fail-fast bounded, and — crucially — `createLiveSignal` **builds the wire link over the socket it watches and reconnects, and carries it ON the handle**, then probes a real `system/live` round-trip over THAT link: there is no caller-supplied probe target to fabricate, so the watchdog provably probes the socket it guards. And because `surfaceClient`/`surfaceClients` take the WHOLE handle and read `.link`/`.live` off it themselves, the link the client is built over and the link the watchdog probes are the SAME object **by construction** — the old "mint a brand over a healthy socket, then build the client over a self-rolled `websocketLink(deadWs2)`" forge is simply **unspellable** (no external caller supplies a separate link), with nothing to re-check at runtime. (The probe gate reads the same `status` the signal reads, not a second `readyState`, so the gate and the signal can't disagree.) Hand-building a client therefore means `surfaceClient(surface, transport)` — passing `createLiveSignal`'s WHOLE returned handle (its `link` and watchdog-backed `live`, paired on one object by construction; kolu's combined `wire.ts`, the minimal examples) — never a hand-rolled signal or a self-built `websocketLink`. There is no separate `{ live }` seam to thread a blind accessor through. A `directLink` (in-process, no transport) can't half-open, so it is passed bare; a `stdioLink`/`unixSocketLink` is a WIRE link that CAN half-open and is refused bare just like a websocket (its in-repo use is a raw `.rpc` client — kaval-tui — or `surface-remote`'s `hostSession.startLiveness` hand-wiring its own watchdog, never the Solid `surfaceClient`). When two layers watch one socket (an admin socket whose `connectSurfaces` owns the watchdog AND a `` lifecycle over it), the NON-brand layer (`createServerLifecycle` / the provider) opts ITS watchdog out with `heartbeat: false` — that's watchdog-ownership coordination, never a blind brand. - **`mapArray` for dynamic per-entity subscriptions**: When the set of subscriptions is driven by a reactive list (e.g., per-terminal metadata), use `mapArray` to create subscriptions. SolidJS handles lifecycle — each item gets its own reactive owner, automatically disposed when removed from the list. (`useCollection` already does this internally; reach for `mapArray` directly only when composing outside the framework.) - **Plain calls for mutations**: No mutation wrappers needed. Terminal mutations go through the ACTIVE host's bound procedures face — `activePadiRpc.lifecycle.create(...)` / `.kill(...)`, `activePadiRpc.chrome.*` — and handle errors with `.catch((err: Error) => toast.error(...))`. `activePadiRpc` is `padiMap.useEntry(activeHost).procedures` (the active-host bound procedures face, typed from the declaration — the keyed map's link folds the host key into every call; reserved `system.*` procs and the link-root escape hatch live on `.rpc`). For a SPECIFIC (non-active) host, read `padiMap.entry(host).procedures..(...)` directly — there is no `padiRpcOf` wrapper. The root `terminal.*` namespace was deleted at W1.R7; `client.server.*` / `client.daemon.*` / `client.hosts.*` (runtime pool membership) remain at the link root. Server pushes update subscriptions automatically. ## Toast Conventions - **Semantic variants**: Use `toast.success()` for success outcomes, `toast.error()` for failures, `toast.warning()` for degraded states (e.g. non-zero exit codes), `toast.info()` for informational notices with actions. Never use bare `toast()` for outcomes — reserve it for neutral notifications (tips, exit-code-0). - **Colocated, not centralized**: Keep toast calls next to the logic that triggers them (mutation `onError` callbacks, post-`await` success lines). Do not extract into a separate toast helper module. - **`richColors` is enabled**: The `` has `richColors` set, so semantic variants automatically get colored backgrounds. Choosing the right variant matters for UX. - **Loading toasts for slow operations**: Use `toast.loading()` + update via `{ id }` for operations with perceptible delay (worktree create/remove, session restore). Pattern: `const id = toast.loading("…"); try { await op(); toast.success("…", { id }); } catch (err) { toast.error("…", { id }); throw err; }`. Avoid `toast.promise()` — it returns the toast ID (not the resolved value) and swallows rejections. - **Action toasts**: For persistent notifications requiring user action, use `duration: Infinity` with an `action` prop (see server-update toast in `rpc.ts`). - **Always surface `err.message`**: When catching errors for toast display, include the server's error message: `.catch((err: Error) => toast.error(\`Failed to X: ${err.message}\`))`. Never swallow the message with a generic string.