kolu-client

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.

SKILL.md

GitHub

UI

  • Extract reusable UI into SolidJS components (one component per file in packages/client/src/).
  • Follow the frontend-design skill 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<Record>: 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 <HostStatusPip health={…}> from @kolu/surface/solid/HostStatusPip (its own entry, like <SurfaceGate> — not the JSX-free @kolu/surface/solid barrel). Its green is fact-only (default gateStatus(h) === "ready", shared with <SurfaceGate>); 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 <HostStatusPip> 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 <SurfaceAppProvider> 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.<ns>.<verb>(...) 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 <Toaster> 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.