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.mddev/skills/kolu-client/SKILL.md
GitHubUI
- 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.tsmodules (singleton pattern — create store once, cache at module level). Keep App.tsx as a thin layout shell. - Commands stay declarative:
commands.tsregisters commands — it should not contain async RPC calls, error handling, or multi-step workflows. Those belong inuseXxx.tshandlers. Commands just calldeps.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.
createStoreovercreateSignal<Record>: For keyed state (e.g. per-terminal metadata), usecreateStorefromsolid-js/storefor fine-grained per-key reactivity.- Controlled components need selection PROVENANCE (echo loops): When you bridge an imperative/controlled library component (
@kolu/solid-pierre's PierreFileTree, 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-shotuserGesturetoken from a capture-phaseclick/keydownlistener on the component's container (click, notpointerdown— 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-testdispatchEvent), 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), neverqueueMicrotask— 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 — areconcile-backed store already isolates unrelated leaf ticks likeforeground.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
createMemowhen 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), andapp.events.X.use(inputFn, handler, { onError })fromwire.ts'ssurfaceClientfor 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), useclient.rawStream(name, proc, input, { onItem })— it enrols the stream intoclient.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 itspending/error(acreateSubscriptionover a surface procedure), join it withclient.enroll(name, sub). The bareunenrolledStreamCalllives at@kolu/surface/client(NOT the@kolu/surface/solidbarrel) and is for a stream DELIBERATELY carved out of the surfacehealth()fact — reached through a typed raw ref, either a SURFACE-scoped one (client.streams.X.unenrolled/client.collections.X.unenrolledKeys, the terminalattachasactivePadiStreams.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 inunenrolledStreamCallsilently loses reconnect handling. For one-shot RPC calls, use plainclient.*calls withcreateSignalorcreateResource. - A mirror's liveness rides in the fact:
client.health().liveis now the FULL conjunction — transport-live ∧ every readiness cell's predicate (sub errors stay insubs). A cell becomes a readiness gate viaCellSpec.liveWhen?: (v) => boolean(e.g. surface-remote'sconnectioncell declaresliveWhen: (v) => v.state === "connected");surfaceClientopens an eager standing sub for each, so amirroredSurface's connection-liveness leg folds intoliveBY CONSTRUCTION. Read the complete fact — NEVER hand-ANDconnection.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/solidbarrel). Its green is fact-only (defaultgateStatus(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>ongateStatus; a page indicator for a whole connection (green ⇔ what this page reads is arriving) isconn.readout()fromconnectSurface/connectSurfaces— five states (connecting/live/degraded/reconnecting/retired), pending does NOT degrade it,degradednames the stopped subs, andneedsReloadmarks the one state only a reload recovers. Never re-derive either fromclient.health()beside a transportstatusby 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, sohealth().livecatches 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 onewireClientchokepoint) — and unspellable for real, not by convention:surfaceClient/surfaceClientsTHROW unless handed a watchdog-backedLiveSignal, and the only minter iscreateLiveSignal(@kolu/surface/solid), which wires the watchdog first. The brand is membership in a module-private, un-reflectableWeakSeton the whole HANDLE (LIVE_SIGNAL_HANDLES) that onlycreateLiveSignaladds to — there is no exported stamper to forge with — and there is noheartbeat: falseon 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 inputsurfaceClientaccepts. The timing is fail-fast bounded, and — crucially —createLiveSignalbuilds the wire link over the socket it watches and reconnects, and carries it ON the handle, then probes a realsystem/liveround-trip over THAT link: there is no caller-supplied probe target to fabricate, so the watchdog provably probes the socket it guards. And becausesurfaceClient/surfaceClientstake the WHOLE handle and read.link/.liveoff 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-rolledwebsocketLink(deadWs2)" forge is simply unspellable (no external caller supplies a separate link), with nothing to re-check at runtime. (The probe gate reads the samestatusthe signal reads, not a secondreadyState, so the gate and the signal can't disagree.) Hand-building a client therefore meanssurfaceClient(surface, transport)— passingcreateLiveSignal's WHOLE returned handle (itslinkand watchdog-backedlive, paired on one object by construction; kolu's combinedwire.ts, the minimal examples) — never a hand-rolled signal or a self-builtwebsocketLink. There is no separate{ live }seam to thread a blind accessor through. AdirectLink(in-process, no transport) can't half-open, so it is passed bare; astdioLink/unixSocketLinkis a WIRE link that CAN half-open and is refused bare just like a websocket (its in-repo use is a raw.rpcclient — kaval-tui — orsurface-remote'shostSession.startLivenesshand-wiring its own watchdog, never the SolidsurfaceClient). When two layers watch one socket (an admin socket whoseconnectSurfacesowns the watchdog AND a<SurfaceAppProvider>lifecycle over it), the NON-brand layer (createServerLifecycle/ the provider) opts ITS watchdog out withheartbeat: false— that's watchdog-ownership coordination, never a blind brand. mapArrayfor dynamic per-entity subscriptions: When the set of subscriptions is driven by a reactive list (e.g., per-terminal metadata), usemapArrayto create subscriptions. SolidJS handles lifecycle — each item gets its own reactive owner, automatically disposed when removed from the list. (useCollectionalready does this internally; reach formapArraydirectly 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(...)).activePadiRpcispadiMap.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; reservedsystem.*procs and the link-root escape hatch live on.rpc). For a SPECIFIC (non-active) host, readpadiMap.entry(host).procedures.<ns>.<verb>(...)directly — there is nopadiRpcOfwrapper. The rootterminal.*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 baretoast()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
onErrorcallbacks, post-awaitsuccess lines). Do not extract into a separate toast helper module. richColorsis enabled: The<Toaster>hasrichColorsset, 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; }. Avoidtoast.promise()— it returns the toast ID (not the resolved value) and swallows rejections. - Action toasts: For persistent notifications requiring user action, use
duration: Infinitywith anactionprop (see server-update toast inrpc.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.