Adam 1 месяц назад
Родитель
Сommit
270cb0b8b4

+ 10 - 8
packages/app/src/components/status-popover.tsx

@@ -86,15 +86,17 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
 const useDefaultServerKey = (
   get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
 ) => {
-  const [url, setUrl] = createSignal<string | undefined>()
-  const [tick, setTick] = createSignal(0)
+  const [state, setState] = createStore({
+    url: undefined as string | undefined,
+    tick: 0,
+  })
 
   createEffect(() => {
-    tick()
+    state.tick
     let dead = false
     const result = get?.()
     if (!result) {
-      setUrl(undefined)
+      setState("url", undefined)
       onCleanup(() => {
         dead = true
       })
@@ -104,7 +106,7 @@ const useDefaultServerKey = (
     if (result instanceof Promise) {
       void result.then((next) => {
         if (dead) return
-        setUrl(next ? normalizeServerUrl(next) : undefined)
+        setState("url", next ? normalizeServerUrl(next) : undefined)
       })
       onCleanup(() => {
         dead = true
@@ -112,7 +114,7 @@ const useDefaultServerKey = (
       return
     }
 
-    setUrl(normalizeServerUrl(result))
+    setState("url", normalizeServerUrl(result))
     onCleanup(() => {
       dead = true
     })
@@ -120,11 +122,11 @@ const useDefaultServerKey = (
 
   return {
     key: () => {
-      const u = url()
+      const u = state.url
       if (!u) return
       return ServerConnection.key({ type: "http", http: { url: u } })
     },
-    refresh: () => setTick((value) => value + 1),
+    refresh: () => setState("tick", (value) => value + 1),
   }
 }
 

+ 28 - 39
packages/app/src/pages/layout.tsx

@@ -1,16 +1,4 @@
-import {
-  batch,
-  createEffect,
-  createMemo,
-  createSignal,
-  For,
-  on,
-  onCleanup,
-  onMount,
-  ParentProps,
-  Show,
-  untrack,
-} from "solid-js"
+import { batch, createEffect, createMemo, For, on, onCleanup, onMount, ParentProps, Show, untrack } from "solid-js"
 import { useNavigate, useParams } from "@solidjs/router"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
@@ -145,6 +133,10 @@ export default function Layout(props: ParentProps) {
     hoverProject: undefined as string | undefined,
     scrollSessionKey: undefined as string | undefined,
     nav: undefined as HTMLElement | undefined,
+    sortNow: Date.now(),
+    sizing: false,
+    peek: undefined as LocalProject | undefined,
+    peeked: false,
   })
 
   const editor = createInlineEditorController()
@@ -163,14 +155,13 @@ export default function Layout(props: ParentProps) {
   }
   const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
   const navLeave = { current: undefined as number | undefined }
-  const [sortNow, setSortNow] = createSignal(Date.now())
-  const [sizing, setSizing] = createSignal(false)
+  const sortNow = () => state.sortNow
   let sizet: number | undefined
   let sortNowInterval: ReturnType<typeof setInterval> | undefined
   const sortNowTimeout = setTimeout(
     () => {
-      setSortNow(Date.now())
-      sortNowInterval = setInterval(() => setSortNow(Date.now()), 60_000)
+      setState("sortNow", Date.now())
+      sortNowInterval = setInterval(() => setState("sortNow", Date.now()), 60_000)
     },
     60_000 - (Date.now() % 60_000),
   )
@@ -196,7 +187,7 @@ export default function Layout(props: ParentProps) {
   })
 
   onMount(() => {
-    const stop = () => setSizing(false)
+    const stop = () => setState("sizing", false)
     window.addEventListener("pointerup", stop)
     window.addEventListener("pointercancel", stop)
     window.addEventListener("blur", stop)
@@ -234,8 +225,6 @@ export default function Layout(props: ParentProps) {
     }, 300)
   }
 
-  const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
-  const [peeked, setPeeked] = createSignal(false)
   let peekt: number | undefined
 
   const hoverProjectData = createMemo(() => {
@@ -251,17 +240,17 @@ export default function Layout(props: ParentProps) {
         clearTimeout(peekt)
         peekt = undefined
       }
-      setPeek(p)
-      setPeeked(true)
+      setState("peek", p)
+      setState("peeked", true)
       return
     }
 
-    setPeeked(false)
-    if (peek() === undefined) return
+    setState("peeked", false)
+    if (state.peek === undefined) return
     if (peekt !== undefined) clearTimeout(peekt)
     peekt = window.setTimeout(() => {
       peekt = undefined
-      setPeek(undefined)
+      setState("peek", undefined)
     }, 180)
   })
 
@@ -2245,7 +2234,7 @@ export default function Layout(props: ParentProps) {
             >
               <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
               <Show when={layout.sidebar.opened()}>
-                <div onPointerDown={() => setSizing(true)}>
+                <div onPointerDown={() => setState("sizing", true)}>
                   <ResizeHandle
                     direction="horizontal"
                     size={layout.sidebar.width()}
@@ -2253,9 +2242,9 @@ export default function Layout(props: ParentProps) {
                     max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
                     collapseThreshold={244}
                     onResize={(w) => {
-                      setSizing(true)
+                      setState("sizing", true)
                       if (sizet !== undefined) clearTimeout(sizet)
-                      sizet = window.setTimeout(() => setSizing(false), 120)
+                      sizet = window.setTimeout(() => setState("sizing", false), 120)
                       layout.sidebar.resize(w)
                     }}
                     onCollapse={layout.sidebar.close}
@@ -2300,7 +2289,7 @@ export default function Layout(props: ParentProps) {
                 "xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
                 "z-20": true,
                 "transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
-                  !sizing(),
+                  !state.sizing,
               }}
               style={{
                 "--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
@@ -2320,11 +2309,11 @@ export default function Layout(props: ParentProps) {
             <div
               classList={{
                 "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
-                "opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
-                "opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
+                "opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
+                "opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
                 "transition-[opacity,transform] motion-reduce:transition-none": true,
-                "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
-                "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
+                "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
+                "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
               }}
               onMouseMove={disarm}
               onMouseEnter={() => {
@@ -2336,19 +2325,19 @@ export default function Layout(props: ParentProps) {
                 arm()
               }}
             >
-              <Show when={peek()}>
-                <SidebarPanel project={peek()} merged={false} />
+              <Show when={state.peek}>
+                <SidebarPanel project={state.peek} merged={false} />
               </Show>
             </div>
 
             <div
               classList={{
                 "hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
-                "opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
-                "opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
+                "opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
+                "opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
                 "transition-[opacity,transform] motion-reduce:transition-none": true,
-                "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
-                "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
+                "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
+                "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
               }}
               style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
             >

+ 22 - 13
packages/ui/src/components/animated-number.tsx

@@ -1,4 +1,5 @@
-import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
+import { For, Index, createEffect, createMemo, on } from "solid-js"
+import { createStore } from "solid-js/store"
 
 const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
 const DURATION = 600
@@ -14,8 +15,12 @@ function spin(from: number, to: number, direction: 1 | -1) {
 }
 
 function Digit(props: { value: number; direction: 1 | -1 }) {
-  const [step, setStep] = createSignal(props.value + 10)
-  const [animating, setAnimating] = createSignal(false)
+  const [state, setState] = createStore({
+    step: props.value + 10,
+    animating: false,
+  })
+  const step = () => state.step
+  const animating = () => state.animating
   let last = props.value
 
   createEffect(
@@ -25,13 +30,13 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
         const delta = spin(last, next, props.direction)
         last = next
         if (!delta) {
-          setAnimating(false)
-          setStep(next + 10)
+          setState("animating", false)
+          setState("step", next + 10)
           return
         }
 
-        setAnimating(true)
-        setStep((value) => value + delta)
+        setState("animating", true)
+        setState("step", (value) => value + delta)
       },
       { defer: true },
     ),
@@ -43,8 +48,8 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
         data-slot="animated-number-strip"
         data-animating={animating() ? "true" : "false"}
         onTransitionEnd={() => {
-          setAnimating(false)
-          setStep((value) => normalize(value) + 10)
+          setState("animating", false)
+          setState("step", (value) => normalize(value) + 10)
         }}
         style={{
           "--animated-number-offset": `${step()}`,
@@ -63,8 +68,12 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
     return Math.max(0, Math.round(props.value))
   })
 
-  const [value, setValue] = createSignal(target())
-  const [direction, setDirection] = createSignal<1 | -1>(1)
+  const [state, setState] = createStore({
+    value: target(),
+    direction: 1 as 1 | -1,
+  })
+  const value = () => state.value
+  const direction = () => state.direction
 
   createEffect(
     on(
@@ -73,8 +82,8 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
         const current = value()
         if (next === current) return
 
-        setDirection(next > current ? 1 : -1)
-        setValue(next)
+        setState("direction", next > current ? 1 : -1)
+        setState("value", next)
       },
       { defer: true },
     ),

+ 12 - 7
packages/ui/src/components/basic-tool.tsx

@@ -1,5 +1,6 @@
-import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
+import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
 import { animate, type AnimationPlaybackControls } from "motion"
+import { createStore } from "solid-js/store"
 import { Collapsible } from "./collapsible"
 import type { IconProps } from "./icon"
 import { TextShimmer } from "./text-shimmer"
@@ -37,8 +38,12 @@ export interface BasicToolProps {
 const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
 
 export function BasicTool(props: BasicToolProps) {
-  const [open, setOpen] = createSignal(props.defaultOpen ?? false)
-  const [ready, setReady] = createSignal(open())
+  const [state, setState] = createStore({
+    open: props.defaultOpen ?? false,
+    ready: props.defaultOpen ?? false,
+  })
+  const open = () => state.open
+  const ready = () => state.ready
   const pending = () => props.status === "pending" || props.status === "running"
 
   let frame: number | undefined
@@ -52,7 +57,7 @@ export function BasicTool(props: BasicToolProps) {
   onCleanup(cancel)
 
   createEffect(() => {
-    if (props.forceOpen) setOpen(true)
+    if (props.forceOpen) setState("open", true)
   })
 
   createEffect(
@@ -62,7 +67,7 @@ export function BasicTool(props: BasicToolProps) {
         if (!props.defer) return
         if (!value) {
           cancel()
-          setReady(false)
+          setState("ready", false)
           return
         }
 
@@ -70,7 +75,7 @@ export function BasicTool(props: BasicToolProps) {
         frame = requestAnimationFrame(() => {
           frame = undefined
           if (!open()) return
-          setReady(true)
+          setState("ready", true)
         })
       },
       { defer: true },
@@ -112,7 +117,7 @@ export function BasicTool(props: BasicToolProps) {
   const handleOpenChange = (value: boolean) => {
     if (pending()) return
     if (props.locked && !value) return
-    setOpen(value)
+    setState("open", value)
   }
 
   return (

+ 10 - 3
packages/ui/src/components/line-comment-annotations.tsx

@@ -1,5 +1,6 @@
 import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
 import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
 import { render as renderSolid } from "solid-js/web"
 import { createHoverCommentUtility } from "../pierre/comment-hover"
 import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
@@ -200,8 +201,14 @@ export function createLineCommentAnnotationRenderer<T>(props: {
 }
 
 export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
-  const [draft, setDraft] = createSignal("")
-  const [editing, setEditing] = createSignal<T | null>(null)
+  const [state, setState] = createStore({
+    draft: "",
+    editing: null as T | null,
+  })
+  const draft = () => state.draft
+  const setDraft = (value: string) => setState("draft", value)
+  const editing = () => state.editing
+  const setEditing = (value: T | null) => setState("editing", typeof value === "function" ? () => value : value)
 
   const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
   const setSelected = (range: SelectedLineRange | null) => {
@@ -261,7 +268,7 @@ export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
     closeComment()
     setSelected(range)
     props.setCommenting(null)
-    setEditing(() => id)
+    setEditing(id)
     setDraft(value)
   }
 

+ 14 - 8
packages/ui/src/components/list.tsx

@@ -1,5 +1,5 @@
 import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "solid-js"
+import { createEffect, For, onCleanup, type JSX, on, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useI18n } from "../context/i18n"
 import { Icon, type IconProps } from "./icon"
@@ -56,12 +56,16 @@ export interface ListRef {
 
 export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
   const i18n = useI18n()
-  const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
-  const [internalFilter, setInternalFilter] = createSignal("")
   let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
   const [store, setStore] = createStore({
     mouseActive: false,
+    scrollRef: undefined as HTMLDivElement | undefined,
+    internalFilter: "",
   })
+  const scrollRef = () => store.scrollRef
+  const setScrollRef = (el: HTMLDivElement | undefined) => setStore("scrollRef", el)
+  const internalFilter = () => store.internalFilter
+  const setInternalFilter = (value: string) => setStore("internalFilter", value)
 
   const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
     const containerRect = container.getBoundingClientRect()
@@ -208,18 +212,20 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
   }
 
   function GroupHeader(groupProps: { group: { category: string; items: T[] } }): JSX.Element {
-    const [stuck, setStuck] = createSignal(false)
-    const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
+    const [state, setState] = createStore({
+      stuck: false,
+      header: undefined as HTMLDivElement | undefined,
+    })
 
     createEffect(() => {
       const scroll = scrollRef()
-      const node = header()
+      const node = state.header
       if (!scroll || !node) return
 
       const handler = () => {
         const rect = node.getBoundingClientRect()
         const scrollRect = scroll.getBoundingClientRect()
-        setStuck(rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
+        setState("stuck", rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
       }
 
       scroll.addEventListener("scroll", handler, { passive: true })
@@ -228,7 +234,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
     })
 
     return (
-      <div data-slot="list-header" data-stuck={stuck()} ref={setHeader}>
+      <div data-slot="list-header" data-stuck={state.stuck} ref={(el) => setState("header", el)}>
         {props.groupHeader?.(groupProps.group) ?? groupProps.group.category}
       </div>
     )

+ 11 - 6
packages/ui/src/components/message-part.tsx

@@ -12,6 +12,7 @@ import {
   Index,
   type JSX,
 } from "solid-js"
+import { createStore } from "solid-js/store"
 import stripAnsi from "strip-ansi"
 import { Dynamic } from "solid-js/web"
 import {
@@ -885,8 +886,12 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
   const data = useData()
   const dialog = useDialog()
   const i18n = useI18n()
-  const [copied, setCopied] = createSignal(false)
-  const [busy, setBusy] = createSignal<"fork" | "revert" | undefined>()
+  const [state, setState] = createStore({
+    copied: false,
+    busy: undefined as "fork" | "revert" | undefined,
+  })
+  const copied = () => state.copied
+  const busy = () => state.busy
 
   const textPart = createMemo(
     () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@@ -946,14 +951,14 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
     const content = text()
     if (!content) return
     await navigator.clipboard.writeText(content)
-    setCopied(true)
-    setTimeout(() => setCopied(false), 2000)
+    setState("copied", true)
+    setTimeout(() => setState("copied", false), 2000)
   }
 
   const run = (kind: "fork" | "revert") => {
     const act = kind === "fork" ? props.actions?.fork : props.actions?.revert
     if (!act || busy()) return
-    setBusy(kind)
+    setState("busy", kind)
     void Promise.resolve()
       .then(() =>
         act({
@@ -962,7 +967,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
         }),
       )
       .finally(() => {
-        if (busy() === kind) setBusy(undefined)
+        if (busy() === kind) setState("busy", undefined)
       })
   }
 

+ 17 - 16
packages/ui/src/components/popover.tsx

@@ -5,11 +5,11 @@ import {
   ParentProps,
   Show,
   createEffect,
-  createSignal,
   onCleanup,
   splitProps,
   ValidComponent,
 } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useI18n } from "../context/i18n"
 import { IconButton } from "./icon-button"
 
@@ -46,23 +46,24 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
     "modal",
   ])
 
-  const [contentRef, setContentRef] = createSignal<HTMLElement | undefined>(undefined)
-  const [triggerRef, setTriggerRef] = createSignal<HTMLElement | undefined>(undefined)
-  const [dismiss, setDismiss] = createSignal<"escape" | "outside" | null>(null)
-
-  const [uncontrolledOpen, setUncontrolledOpen] = createSignal<boolean>(local.defaultOpen ?? false)
+  const [state, setState] = createStore({
+    contentRef: undefined as HTMLElement | undefined,
+    triggerRef: undefined as HTMLElement | undefined,
+    dismiss: null as "escape" | "outside" | null,
+    uncontrolledOpen: local.defaultOpen ?? false,
+  })
 
   const controlled = () => local.open !== undefined
   const opened = () => {
     if (controlled()) return local.open ?? false
-    return uncontrolledOpen()
+    return state.uncontrolledOpen
   }
 
   const onOpenChange = (next: boolean) => {
-    if (next) setDismiss(null)
+    if (next) setState("dismiss", null)
     if (local.onOpenChange) local.onOpenChange(next)
     if (controlled()) return
-    setUncontrolledOpen(next)
+    setState("uncontrolledOpen", next)
   }
 
   createEffect(() => {
@@ -70,15 +71,15 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
 
     const inside = (node: Node | null | undefined) => {
       if (!node) return false
-      const content = contentRef()
+      const content = state.contentRef
       if (content && content.contains(node)) return true
-      const trigger = triggerRef()
+      const trigger = state.triggerRef
       if (trigger && trigger.contains(node)) return true
       return false
     }
 
     const close = (reason: "escape" | "outside") => {
-      setDismiss(reason)
+      setState("dismiss", reason)
       onOpenChange(false)
     }
 
@@ -116,7 +117,7 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
 
   const content = () => (
     <Kobalte.Content
-      ref={(el: HTMLElement | undefined) => setContentRef(el)}
+      ref={(el: HTMLElement | undefined) => setState("contentRef", el)}
       data-component="popover-content"
       classList={{
         ...(local.classList ?? {}),
@@ -124,8 +125,8 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
       }}
       style={local.style}
       onCloseAutoFocus={(event: Event) => {
-        if (dismiss() === "outside") event.preventDefault()
-        setDismiss(null)
+        if (state.dismiss === "outside") event.preventDefault()
+        setState("dismiss", null)
       }}
     >
       {/* <Kobalte.Arrow data-slot="popover-arrow" /> */}
@@ -151,7 +152,7 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
   return (
     <Kobalte gutter={4} {...rest} open={opened()} onOpenChange={onOpenChange} modal={local.modal ?? false}>
       <Kobalte.Trigger
-        ref={(el: HTMLElement) => setTriggerRef(el)}
+        ref={(el: HTMLElement) => setState("triggerRef", el)}
         as={local.triggerAs ?? "div"}
         data-slot="popover-trigger"
         {...(local.triggerProps as any)}

+ 10 - 5
packages/ui/src/components/resize-handle.stories.tsx

@@ -1,5 +1,6 @@
 // @ts-nocheck
 import { createSignal } from "solid-js"
+import { createStore } from "solid-js/store"
 import * as mod from "./resize-handle"
 
 const docs = `### Overview
@@ -94,8 +95,12 @@ export const Vertical = {
 
 export const Collapse = {
   render: () => {
-    const [size, setSize] = createSignal(200)
-    const [collapsed, setCollapsed] = createSignal(false)
+    const [state, setState] = createStore({
+      size: 200,
+      collapsed: false,
+    })
+    const size = () => state.size
+    const collapsed = () => state.collapsed
     return (
       <div style={{ display: "grid", gap: "8px" }}>
         <div style={{ color: "var(--text-weak)", "font-size": "12px" }}>
@@ -116,10 +121,10 @@ export const Collapse = {
           max={360}
           collapseThreshold={100}
           onResize={(next) => {
-            setCollapsed(false)
-            setSize(next)
+            setState("collapsed", false)
+            setState("size", next)
           }}
-          onCollapse={() => setCollapsed(true)}
+          onCollapse={() => setState("collapsed", true)}
           style="height:24px;border:1px dashed color-mix(in oklab, var(--text-base) 20%, transparent)"
         />
       </div>

+ 22 - 15
packages/ui/src/components/scroll-view.tsx

@@ -1,4 +1,5 @@
-import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useI18n } from "../context/i18n"
 
 export interface ScrollViewProps extends ComponentProps<"div"> {
@@ -48,23 +49,29 @@ export function ScrollView(props: ScrollViewProps) {
   let viewportRef!: HTMLDivElement
   let thumbRef!: HTMLDivElement
 
-  const [isHovered, setIsHovered] = createSignal(false)
-  const [isDragging, setIsDragging] = createSignal(false)
-
-  const [thumbHeight, setThumbHeight] = createSignal(0)
-  const [thumbTop, setThumbTop] = createSignal(0)
-  const [showThumb, setShowThumb] = createSignal(false)
+  const [state, setState] = createStore({
+    isHovered: false,
+    isDragging: false,
+    thumbHeight: 0,
+    thumbTop: 0,
+    showThumb: false,
+  })
+  const isHovered = () => state.isHovered
+  const isDragging = () => state.isDragging
+  const thumbHeight = () => state.thumbHeight
+  const thumbTop = () => state.thumbTop
+  const showThumb = () => state.showThumb
 
   const updateThumb = () => {
     if (!viewportRef) return
     const { scrollTop, scrollHeight, clientHeight } = viewportRef
 
     if (scrollHeight <= clientHeight || scrollHeight === 0) {
-      setShowThumb(false)
+      setState("showThumb", false)
       return
     }
 
-    setShowThumb(true)
+    setState("showThumb", true)
     const trackPadding = 8
     const trackHeight = clientHeight - trackPadding * 2
 
@@ -81,8 +88,8 @@ export function ScrollView(props: ScrollViewProps) {
     // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
     const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
 
-    setThumbHeight(height)
-    setThumbTop(boundedTop)
+    setState("thumbHeight", height)
+    setState("thumbTop", boundedTop)
   }
 
   onMount(() => {
@@ -113,7 +120,7 @@ export function ScrollView(props: ScrollViewProps) {
   const onThumbPointerDown = (e: PointerEvent) => {
     e.preventDefault()
     e.stopPropagation()
-    setIsDragging(true)
+    setState("isDragging", true)
     startY = e.clientY
     startScrollTop = viewportRef.scrollTop
 
@@ -132,7 +139,7 @@ export function ScrollView(props: ScrollViewProps) {
     }
 
     const onPointerUp = (e: PointerEvent) => {
-      setIsDragging(false)
+      setState("isDragging", false)
       thumbRef.releasePointerCapture(e.pointerId)
       thumbRef.removeEventListener("pointermove", onPointerMove)
       thumbRef.removeEventListener("pointerup", onPointerUp)
@@ -191,8 +198,8 @@ export function ScrollView(props: ScrollViewProps) {
       ref={rootRef}
       class={`scroll-view ${local.class || ""}`}
       style={local.style}
-      onPointerEnter={() => setIsHovered(true)}
-      onPointerLeave={() => setIsHovered(false)}
+      onPointerEnter={() => setState("isHovered", true)}
+      onPointerLeave={() => setState("isHovered", false)}
       {...rest}
     >
       {/* Viewport */}

+ 15 - 13
packages/ui/src/components/session-review.tsx

@@ -13,7 +13,7 @@ import { useFileComponent } from "../context/file"
 import { useI18n } from "../context/i18n"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
-import { createEffect, createMemo, createSignal, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
+import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
 import { onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
@@ -138,14 +138,16 @@ export const SessionReview = (props: SessionReviewProps) => {
   const i18n = useI18n()
   const fileComponent = useFileComponent()
   const anchors = new Map<string, HTMLElement>()
-  const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
-    open: [],
-    force: {},
+  const [store, setStore] = createStore({
+    open: [] as string[],
+    force: {} as Record<string, boolean>,
+    selection: null as SessionReviewSelection | null,
+    commenting: null as SessionReviewSelection | null,
+    opened: null as SessionReviewFocus | null,
   })
-
-  const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
-  const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
-  const [opened, setOpened] = createSignal<SessionReviewFocus | null>(null)
+  const selection = () => store.selection
+  const commenting = () => store.commenting
+  const opened = () => store.opened
 
   const open = () => props.open ?? store.open
   const files = createMemo(() => props.diffs.map((diff) => diff.file))
@@ -184,10 +186,10 @@ export const SessionReview = (props: SessionReviewProps) => {
       focusToken++
       const token = focusToken
 
-      setOpened(focus)
+      setStore("opened", focus)
 
       const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
-      if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
+      if (comment) setStore("selection", { file: comment.file, range: cloneSelectedLineRange(comment.selection) })
 
       const current = open()
       if (!current.includes(focus.file)) {
@@ -331,11 +333,11 @@ export const SessionReview = (props: SessionReviewProps) => {
                           if (!current || current.file !== file) return null
                           return current.id
                         },
-                        setOpened: (id) => setOpened(id ? { file, id } : null),
+                        setOpened: (id) => setStore("opened", id ? { file, id } : null),
                         selected: selectedLines,
-                        setSelected: (range) => setSelection(range ? { file, range } : null),
+                        setSelected: (range) => setStore("selection", range ? { file, range } : null),
                         commenting: draftRange,
-                        setCommenting: (range) => setCommenting(range ? { file, range } : null),
+                        setCommenting: (range) => setStore("commenting", range ? { file, range } : null),
                       },
                       getSide: selectionSide,
                       clearSelectionOnSelectionEndNull: false,

+ 12 - 5
packages/ui/src/components/session-turn.tsx

@@ -6,6 +6,7 @@ import { useFileComponent } from "../context/file"
 import { Binary } from "@opencode-ai/util/binary"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
+import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
 import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
 import { Card } from "./card"
@@ -240,14 +241,18 @@ export function SessionTurn(
       .reverse()
   })
   const edited = createMemo(() => diffs().length)
-  const [open, setOpen] = createSignal(false)
-  const [expanded, setExpanded] = createSignal<string[]>([])
+  const [state, setState] = createStore({
+    open: false,
+    expanded: [] as string[],
+  })
+  const open = () => state.open
+  const expanded = () => state.expanded
 
   createEffect(
     on(
       open,
       (value, prev) => {
-        if (!value && prev) setExpanded([])
+        if (!value && prev) setState("expanded", [])
       },
       { defer: true },
     ),
@@ -425,7 +430,7 @@ export function SessionTurn(
               <SessionRetry status={status()} show={active()} />
               <Show when={edited() > 0 && !working()}>
                 <div data-slot="session-turn-diffs">
-                  <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
+                  <Collapsible open={open()} onOpenChange={(value) => setState("open", value)} variant="ghost">
                     <Collapsible.Trigger>
                       <div data-component="session-turn-diffs-trigger">
                         <div data-slot="session-turn-diffs-title">
@@ -447,7 +452,9 @@ export function SessionTurn(
                             multiple
                             style={{ "--sticky-accordion-offset": "40px" }}
                             value={expanded()}
-                            onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
+                            onChange={(value) =>
+                              setState("expanded", Array.isArray(value) ? value : value ? [value] : [])
+                            }
                           >
                             <For each={diffs()}>
                               {(diff) => {

+ 38 - 21
packages/ui/src/components/shell-submessage-motion.stories.tsx

@@ -1,5 +1,6 @@
 // @ts-nocheck
-import { createEffect, createSignal, onCleanup } from "solid-js"
+import { createEffect, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 import { BasicTool } from "./basic-tool"
 import { animate } from "motion"
 
@@ -138,29 +139,39 @@ function SpringSubmessage(props: { text: string; visible: boolean; visualDuratio
 
 export const Playground = {
   render: () => {
-    const [text, setText] = createSignal("Prints five topic blocks between timed commands")
-    const [show, setShow] = createSignal(true)
-    const [visualDuration, setVisualDuration] = createSignal(0.35)
-    const [bounce, setBounce] = createSignal(0)
-    const [fadeMs, setFadeMs] = createSignal(320)
-    const [blur, setBlur] = createSignal(2)
-    const [fadeEase, setFadeEase] = createSignal<keyof typeof ease>("snappy")
-    const [auto, setAuto] = createSignal(false)
+    const [state, setState] = createStore({
+      text: "Prints five topic blocks between timed commands",
+      show: true,
+      visualDuration: 0.35,
+      bounce: 0,
+      fadeMs: 320,
+      blur: 2,
+      fadeEase: "snappy",
+      auto: false,
+    })
+    const text = () => state.text
+    const show = () => state.show
+    const visualDuration = () => state.visualDuration
+    const bounce = () => state.bounce
+    const fadeMs = () => state.fadeMs
+    const blur = () => state.blur
+    const fadeEase = () => state.fadeEase
+    const auto = () => state.auto
     let replayTimer
     let autoTimer
 
     const replay = () => {
-      setShow(false)
+      setState("show", false)
       if (replayTimer) clearTimeout(replayTimer)
       replayTimer = setTimeout(() => {
-        setShow(true)
+        setState("show", true)
       }, 50)
     }
 
     const stopAuto = () => {
       if (autoTimer) clearInterval(autoTimer)
       autoTimer = undefined
-      setAuto(false)
+      setState("auto", false)
     }
 
     const toggleAuto = () => {
@@ -168,7 +179,7 @@ export const Playground = {
         stopAuto()
         return
       }
-      setAuto(true)
+      setState("auto", true)
       autoTimer = setInterval(replay, 2200)
     }
 
@@ -224,7 +235,7 @@ export const Playground = {
           <button onClick={replay} style={btn()}>
             Replay entry
           </button>
-          <button onClick={() => setShow((v) => !v)} style={btn(show())}>
+          <button onClick={() => setState("show", (value) => !value)} style={btn(show())}>
             {show() ? "Hide subtitle" : "Show subtitle"}
           </button>
           <button onClick={toggleAuto} style={btn(auto())}>
@@ -244,7 +255,7 @@ export const Playground = {
             <span style={sliderLabel}>subtitle</span>
             <input
               value={text()}
-              onInput={(e) => setText(e.currentTarget.value)}
+              onInput={(e) => setState("text", e.currentTarget.value)}
               style={{
                 width: "420px",
                 "max-width": "100%",
@@ -265,7 +276,7 @@ export const Playground = {
               max={1.5}
               step={0.01}
               value={visualDuration()}
-              onInput={(e) => setVisualDuration(Number(e.currentTarget.value))}
+              onInput={(e) => setState("visualDuration", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{visualDuration().toFixed(2)}s</span>
           </div>
@@ -278,7 +289,7 @@ export const Playground = {
               max={0.5}
               step={0.01}
               value={bounce()}
-              onInput={(e) => setBounce(Number(e.currentTarget.value))}
+              onInput={(e) => setState("bounce", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{bounce().toFixed(2)}</span>
           </div>
@@ -287,8 +298,14 @@ export const Playground = {
             <span style={sliderLabel}>fade ease</span>
             <button
               onClick={() =>
-                setFadeEase((v) =>
-                  v === "snappy" ? "smooth" : v === "smooth" ? "standard" : v === "standard" ? "linear" : "snappy",
+                setState("fadeEase", (value) =>
+                  value === "snappy"
+                    ? "smooth"
+                    : value === "smooth"
+                      ? "standard"
+                      : value === "standard"
+                        ? "linear"
+                        : "snappy",
                 )
               }
               style={btn()}
@@ -305,7 +322,7 @@ export const Playground = {
               max={1400}
               step={10}
               value={fadeMs()}
-              onInput={(e) => setFadeMs(Number(e.currentTarget.value))}
+              onInput={(e) => setState("fadeMs", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{fadeMs()}ms</span>
           </div>
@@ -318,7 +335,7 @@ export const Playground = {
               max={14}
               step={0.5}
               value={blur()}
-              onInput={(e) => setBlur(Number(e.currentTarget.value))}
+              onInput={(e) => setState("blur", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{blur()}px</span>
           </div>

+ 37 - 27
packages/ui/src/components/text-reveal.stories.tsx

@@ -1,5 +1,6 @@
 // @ts-nocheck
-import { createSignal, onCleanup } from "solid-js"
+import { onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 import { TextReveal } from "./text-reveal"
 
 export default {
@@ -87,33 +88,42 @@ const headingSlot = {
 
 export const Playground = {
   render: () => {
-    const [index, setIndex] = createSignal(0)
-    const [cycling, setCycling] = createSignal(false)
-    const [growOnly, setGrowOnly] = createSignal(true)
-
-    const [duration, setDuration] = createSignal(600)
-    const [bounce, setBounce] = createSignal(1.0)
-    const [bounceSoft, setBounceSoft] = createSignal(1.0)
-
-    const [hybridTravel, setHybridTravel] = createSignal(25)
-    const [hybridEdge, setHybridEdge] = createSignal(17)
-
-    const [edge, setEdge] = createSignal(17)
-    const [revealTravel, setRevealTravel] = createSignal(0)
+    const [state, setState] = createStore({
+      index: 0,
+      cycling: false,
+      growOnly: true,
+      duration: 600,
+      bounce: 1.0,
+      bounceSoft: 1.0,
+      hybridTravel: 25,
+      hybridEdge: 17,
+      edge: 17,
+      revealTravel: 0,
+    })
+    const index = () => state.index
+    const cycling = () => state.cycling
+    const growOnly = () => state.growOnly
+    const duration = () => state.duration
+    const bounce = () => state.bounce
+    const bounceSoft = () => state.bounceSoft
+    const hybridTravel = () => state.hybridTravel
+    const hybridEdge = () => state.hybridEdge
+    const edge = () => state.edge
+    const revealTravel = () => state.revealTravel
 
     let timer: number | undefined
     const text = () => TEXTS[index()]
-    const next = () => setIndex((i) => (i + 1) % TEXTS.length)
-    const prev = () => setIndex((i) => (i - 1 + TEXTS.length) % TEXTS.length)
+    const next = () => setState("index", (value) => (value + 1) % TEXTS.length)
+    const prev = () => setState("index", (value) => (value - 1 + TEXTS.length) % TEXTS.length)
 
     const toggleCycle = () => {
       if (cycling()) {
         if (timer) clearTimeout(timer)
         timer = undefined
-        setCycling(false)
+        setState("cycling", false)
         return
       }
-      setCycling(true)
+      setState("cycling", true)
       const tick = () => {
         next()
         timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
@@ -172,7 +182,7 @@ export const Playground = {
 
         <div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
           {TEXTS.map((t, i) => (
-            <button onClick={() => setIndex(i)} style={btn(index() === i)}>
+            <button onClick={() => setState("index", i)} style={btn(index() === i)}>
               {t ?? "(none)"}
             </button>
           ))}
@@ -188,7 +198,7 @@ export const Playground = {
           <button onClick={toggleCycle} style={btn(cycling())}>
             {cycling() ? "Stop cycle" : "Auto cycle"}
           </button>
-          <button onClick={() => setGrowOnly((v) => !v)} style={btn(growOnly())}>
+          <button onClick={() => setState("growOnly", (value) => !value)} style={btn(growOnly())}>
             {growOnly() ? "growOnly: on" : "growOnly: off"}
           </button>
         </div>
@@ -204,7 +214,7 @@ export const Playground = {
               max="40"
               step="1"
               value={hybridEdge()}
-              onInput={(e) => setHybridEdge(e.currentTarget.valueAsNumber)}
+              onInput={(e) => setState("hybridEdge", e.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridEdge()}%</span>
@@ -218,7 +228,7 @@ export const Playground = {
               max="40"
               step="1"
               value={hybridTravel()}
-              onInput={(e) => setHybridTravel(e.currentTarget.valueAsNumber)}
+              onInput={(e) => setState("hybridTravel", e.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridTravel()}px</span>
@@ -234,7 +244,7 @@ export const Playground = {
               max="1400"
               step="10"
               value={duration()}
-              onInput={(e) => setDuration(e.currentTarget.valueAsNumber)}
+              onInput={(e) => setState("duration", e.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{duration()}ms</span>
@@ -248,7 +258,7 @@ export const Playground = {
               max="2"
               step="0.01"
               value={bounce()}
-              onInput={(e) => setBounce(e.currentTarget.valueAsNumber)}
+              onInput={(e) => setState("bounce", e.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounce().toFixed(2)}</span>
@@ -262,7 +272,7 @@ export const Playground = {
               max="1.5"
               step="0.01"
               value={bounceSoft()}
-              onInput={(e) => setBounceSoft(e.currentTarget.valueAsNumber)}
+              onInput={(e) => setState("bounceSoft", e.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounceSoft().toFixed(2)}</span>
@@ -280,7 +290,7 @@ export const Playground = {
               max="40"
               step="1"
               value={edge()}
-              onInput={(e) => setEdge(e.currentTarget.valueAsNumber)}
+              onInput={(e) => setState("edge", e.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{edge()}%</span>
@@ -294,7 +304,7 @@ export const Playground = {
               max="16"
               step="1"
               value={revealTravel()}
-              onInput={(e) => setRevealTravel(e.currentTarget.valueAsNumber)}
+              onInput={(e) => setState("revealTravel", e.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{revealTravel()}px</span>

+ 24 - 16
packages/ui/src/components/text-reveal.tsx

@@ -1,4 +1,5 @@
-import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
+import { createEffect, on, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
 
 const px = (value: number | string | undefined, fallback: number) => {
   if (typeof value === "number") return `${value}px`
@@ -30,11 +31,18 @@ export function TextReveal(props: {
   growOnly?: boolean
   truncate?: boolean
 }) {
-  const [cur, setCur] = createSignal(props.text)
-  const [old, setOld] = createSignal<string | undefined>()
-  const [width, setWidth] = createSignal("auto")
-  const [ready, setReady] = createSignal(false)
-  const [swapping, setSwapping] = createSignal(false)
+  const [state, setState] = createStore({
+    cur: props.text,
+    old: undefined as string | undefined,
+    width: "auto",
+    ready: false,
+    swapping: false,
+  })
+  const cur = () => state.cur
+  const old = () => state.old
+  const width = () => state.width
+  const ready = () => state.ready
+  const swapping = () => state.swapping
   let inRef: HTMLSpanElement | undefined
   let outRef: HTMLSpanElement | undefined
   let rootRef: HTMLSpanElement | undefined
@@ -49,7 +57,7 @@ export function TextReveal(props: {
       const prev = Number.parseFloat(width())
       if (Number.isFinite(prev) && next <= prev) return
     }
-    setWidth(`${next}px`)
+    setState("width", `${next}px`)
   }
 
   createEffect(
@@ -58,25 +66,25 @@ export function TextReveal(props: {
       (next, prev) => {
         if (next === prev) return
         if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) {
-          setCur(next)
+          setState("cur", next)
           widen(win())
           return
         }
-        setSwapping(true)
-        setOld(prev)
-        setCur(next)
+        setState("swapping", true)
+        setState("old", prev)
+        setState("cur", next)
 
         if (typeof requestAnimationFrame !== "function") {
           widen(Math.max(win(), wout()))
           rootRef?.offsetHeight
-          setSwapping(false)
+          setState("swapping", false)
           return
         }
         if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
         frame = requestAnimationFrame(() => {
           widen(Math.max(win(), wout()))
           rootRef?.offsetHeight
-          setSwapping(false)
+          setState("swapping", false)
           frame = undefined
         })
       },
@@ -87,16 +95,16 @@ export function TextReveal(props: {
     widen(win())
     const fonts = typeof document !== "undefined" ? document.fonts : undefined
     if (typeof requestAnimationFrame !== "function") {
-      setReady(true)
+      setState("ready", true)
       return
     }
     if (!fonts) {
-      requestAnimationFrame(() => setReady(true))
+      requestAnimationFrame(() => setState("ready", true))
       return
     }
     fonts.ready.finally(() => {
       widen(win())
-      requestAnimationFrame(() => setReady(true))
+      requestAnimationFrame(() => setState("ready", true))
     })
   })
 

+ 9 - 4
packages/ui/src/components/text-strikethrough.stories.tsx

@@ -1,5 +1,6 @@
 // @ts-nocheck
 import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useSpring } from "./motion-spring"
 import { TextStrikethrough } from "./text-strikethrough"
 
@@ -130,12 +131,16 @@ function VariantF(props: { active: boolean; text: string }) {
   )
   let baseRef: HTMLSpanElement | undefined
   let containerRef: HTMLSpanElement | undefined
-  const [textWidth, setTextWidth] = createSignal(0)
-  const [containerWidth, setContainerWidth] = createSignal(0)
+  const [state, setState] = createStore({
+    textWidth: 0,
+    containerWidth: 0,
+  })
+  const textWidth = () => state.textWidth
+  const containerWidth = () => state.containerWidth
 
   const measure = () => {
-    if (baseRef) setTextWidth(baseRef.scrollWidth)
-    if (containerRef) setContainerWidth(containerRef.offsetWidth)
+    if (baseRef) setState("textWidth", baseRef.scrollWidth)
+    if (containerRef) setState("containerWidth", containerRef.offsetWidth)
   }
 
   onMount(measure)

+ 10 - 5
packages/ui/src/components/text-strikethrough.tsx

@@ -1,5 +1,6 @@
 import type { JSX } from "solid-js"
-import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { createEffect, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useSpring } from "./motion-spring"
 
 export function TextStrikethrough(props: {
@@ -19,12 +20,16 @@ export function TextStrikethrough(props: {
 
   let baseRef: HTMLSpanElement | undefined
   let containerRef: HTMLSpanElement | undefined
-  const [textWidth, setTextWidth] = createSignal(0)
-  const [containerWidth, setContainerWidth] = createSignal(0)
+  const [state, setState] = createStore({
+    textWidth: 0,
+    containerWidth: 0,
+  })
+  const textWidth = () => state.textWidth
+  const containerWidth = () => state.containerWidth
 
   const measure = () => {
-    if (baseRef) setTextWidth(baseRef.scrollWidth)
-    if (containerRef) setContainerWidth(containerRef.offsetWidth)
+    if (baseRef) setState("textWidth", baseRef.scrollWidth)
+    if (containerRef) setState("containerWidth", containerRef.offsetWidth)
   }
 
   onMount(measure)

+ 73 - 56
packages/ui/src/components/thinking-heading.stories.tsx

@@ -1,5 +1,6 @@
 // @ts-nocheck
-import { createSignal, createEffect, on, onMount, onCleanup } from "solid-js"
+import { createEffect, on, onMount, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 import { TextShimmer } from "./text-shimmer"
 import { TextReveal } from "./text-reveal"
 
@@ -375,11 +376,18 @@ input[type="range"].heading-slider::-webkit-slider-thumb {
 // ---------------------------------------------------------------------------
 
 function AnimatedHeading(props) {
-  const [current, setCurrent] = createSignal(props.text)
-  const [leaving, setLeaving] = createSignal(undefined)
-  const [width, setWidth] = createSignal("auto")
-  const [ready, setReady] = createSignal(false)
-  const [swapping, setSwapping] = createSignal(false)
+  const [state, setState] = createStore({
+    current: props.text,
+    leaving: undefined,
+    width: "auto",
+    ready: false,
+    swapping: false,
+  })
+  const current = () => state.current
+  const leaving = () => state.leaving
+  const width = () => state.width
+  const ready = () => state.ready
+  const swapping = () => state.swapping
   let enterRef
   let leaveRef
   let containerRef
@@ -391,16 +399,16 @@ function AnimatedHeading(props) {
     if (px <= 0) return
     const w = Number.parseFloat(width())
     if (Number.isFinite(w) && px <= w) return
-    setWidth(`${px}px`)
+    setState("width", `${px}px`)
   }
 
   const measure = () => {
     if (!current()) {
-      setWidth("0px")
+      setState("width", "0px")
       return
     }
     const px = measureEnter()
-    if (px > 0) setWidth(`${px}px`)
+    if (px > 0) setState("width", `${px}px`)
   }
 
   createEffect(
@@ -408,9 +416,9 @@ function AnimatedHeading(props) {
       () => props.text,
       (next, prev) => {
         if (next === prev) return
-        setSwapping(true)
-        setLeaving(prev)
-        setCurrent(next)
+        setState("swapping", true)
+        setState("leaving", prev)
+        setState("current", next)
 
         if (frame) cancelAnimationFrame(frame)
         frame = requestAnimationFrame(() => {
@@ -420,10 +428,10 @@ function AnimatedHeading(props) {
             const leaveW = measureLeave()
             widen(Math.max(enterW, leaveW))
             containerRef?.offsetHeight // reflow with max width + swap positions
-            setSwapping(false)
+            setState("swapping", false)
           } else {
             containerRef?.offsetHeight
-            setSwapping(false)
+            setState("swapping", false)
             measure()
           }
           frame = undefined
@@ -436,7 +444,7 @@ function AnimatedHeading(props) {
     measure()
     document.fonts?.ready.finally(() => {
       measure()
-      requestAnimationFrame(() => setReady(true))
+      requestAnimationFrame(() => setState("ready", true))
     })
   })
 
@@ -552,47 +560,56 @@ const VARIANTS: { key: string; label: string }[] = []
 
 export const Playground = {
   render: () => {
-    const [heading, setHeading] = createSignal(HEADINGS[0])
-    const [headingIndex, setHeadingIndex] = createSignal(0)
-    const [active, setActive] = createSignal(true)
-    const [cycling, setCycling] = createSignal(false)
+    const [state, setState] = createStore({
+      heading: HEADINGS[0],
+      headingIndex: 0,
+      active: true,
+      cycling: false,
+      duration: 550,
+      blur: 2,
+      travel: 4,
+      bounce: 1.35,
+      maskSize: 12,
+      maskPad: 9,
+      maskHeight: 0,
+      debug: false,
+      odoBlur: false,
+    })
+    const heading = () => state.heading
+    const headingIndex = () => state.headingIndex
+    const active = () => state.active
+    const cycling = () => state.cycling
+    const duration = () => state.duration
+    const blur = () => state.blur
+    const travel = () => state.travel
+    const bounce = () => state.bounce
+    const maskSize = () => state.maskSize
+    const maskPad = () => state.maskPad
+    const maskHeight = () => state.maskHeight
+    const debug = () => state.debug
+    const odoBlur = () => state.odoBlur
     let cycleTimer
 
-    // tunable params
-    const [duration, setDuration] = createSignal(550)
-    const [blur, setBlur] = createSignal(2)
-    const [travel, setTravel] = createSignal(4)
-    const [bounce, setBounce] = createSignal(1.35)
-    const [maskSize, setMaskSize] = createSignal(12)
-    const [maskPad, setMaskPad] = createSignal(9)
-    const [maskHeight, setMaskHeight] = createSignal(0)
-    const [debug, setDebug] = createSignal(false)
-    const [odoBlur, setOdoBlur] = createSignal(false)
-
     const nextHeading = () => {
-      setHeadingIndex((i) => {
-        const next = (i + 1) % HEADINGS.length
-        setHeading(HEADINGS[next])
-        return next
-      })
+      const next = (headingIndex() + 1) % HEADINGS.length
+      setState("headingIndex", next)
+      setState("heading", HEADINGS[next])
     }
 
     const prevHeading = () => {
-      setHeadingIndex((i) => {
-        const prev = (i - 1 + HEADINGS.length) % HEADINGS.length
-        setHeading(HEADINGS[prev])
-        return prev
-      })
+      const prev = (headingIndex() - 1 + HEADINGS.length) % HEADINGS.length
+      setState("headingIndex", prev)
+      setState("heading", HEADINGS[prev])
     }
 
     const toggleCycling = () => {
       if (cycling()) {
         clearTimeout(cycleTimer)
         cycleTimer = undefined
-        setCycling(false)
+        setState("cycling", false)
         return
       }
-      setCycling(true)
+      setState("cycling", true)
       const tick = () => {
         if (!cycling()) return
         nextHeading()
@@ -602,11 +619,11 @@ export const Playground = {
     }
 
     const clearHeading = () => {
-      setHeading(undefined)
+      setState("heading", undefined)
       if (cycling()) {
         clearTimeout(cycleTimer)
         cycleTimer = undefined
-        setCycling(false)
+        setState("cycling", false)
       }
     }
 
@@ -686,7 +703,7 @@ export const Playground = {
               max={1400}
               step={50}
               value={duration()}
-              onInput={(e) => setDuration(Number(e.currentTarget.value))}
+              onInput={(e) => setState("duration", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{duration()}ms</span>
           </div>
@@ -700,7 +717,7 @@ export const Playground = {
               max={16}
               step={0.5}
               value={blur()}
-              onInput={(e) => setBlur(Number(e.currentTarget.value))}
+              onInput={(e) => setState("blur", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{blur()}px</span>
           </div>
@@ -714,7 +731,7 @@ export const Playground = {
               max={120}
               step={1}
               value={travel()}
-              onInput={(e) => setTravel(Number(e.currentTarget.value))}
+              onInput={(e) => setState("travel", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{travel()}px</span>
           </div>
@@ -728,7 +745,7 @@ export const Playground = {
               max={2.2}
               step={0.05}
               value={bounce()}
-              onInput={(e) => setBounce(Number(e.currentTarget.value))}
+              onInput={(e) => setState("bounce", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>
               {bounce().toFixed(2)} {bounce() <= 1.05 ? "(none)" : bounce() >= 1.9 ? "(heavy)" : ""}
@@ -744,7 +761,7 @@ export const Playground = {
               max={50}
               step={1}
               value={maskSize()}
-              onInput={(e) => setMaskSize(Number(e.currentTarget.value))}
+              onInput={(e) => setState("maskSize", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>
               {maskSize()}px {maskSize() === 0 ? "(hard)" : ""}
@@ -760,7 +777,7 @@ export const Playground = {
               max={60}
               step={1}
               value={maskPad()}
-              onInput={(e) => setMaskPad(Number(e.currentTarget.value))}
+              onInput={(e) => setState("maskPad", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{maskPad()}px</span>
           </div>
@@ -774,7 +791,7 @@ export const Playground = {
               max={80}
               step={1}
               value={maskHeight()}
-              onInput={(e) => setMaskHeight(Number(e.currentTarget.value))}
+              onInput={(e) => setState("maskHeight", Number(e.currentTarget.value))}
             />
             <span style={sliderValue}>{maskHeight()}px</span>
           </div>
@@ -795,13 +812,13 @@ export const Playground = {
             <button onClick={clearHeading} style={btn()}>
               Clear
             </button>
-            <button onClick={() => setActive((v) => !v)} style={smallBtn(active())}>
+            <button onClick={() => setState("active", (value) => !value)} style={smallBtn(active())}>
               {active() ? "Shimmer: on" : "Shimmer: off"}
             </button>
-            <button onClick={() => setDebug((v) => !v)} style={smallBtn(debug())}>
+            <button onClick={() => setState("debug", (value) => !value)} style={smallBtn(debug())}>
               {debug() ? "Debug mask: on" : "Debug mask"}
             </button>
-            <button onClick={() => setOdoBlur((v) => !v)} style={smallBtn(odoBlur())}>
+            <button onClick={() => setState("odoBlur", (value) => !value)} style={smallBtn(odoBlur())}>
               {odoBlur() ? "Odo blur: on" : "Odo blur"}
             </button>
           </div>
@@ -810,8 +827,8 @@ export const Playground = {
             {HEADINGS.map((h, i) => (
               <button
                 onClick={() => {
-                  setHeadingIndex(i)
-                  setHeading(h)
+                  setState("headingIndex", i)
+                  setState("heading", h)
                 }}
                 style={smallBtn(headingIndex() === i)}
               >

+ 60 - 39
packages/ui/src/components/todo-panel-motion.stories.tsx

@@ -1,5 +1,6 @@
 // @ts-nocheck
-import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { createEffect, createMemo, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 import type { Todo } from "@opencode-ai/sdk/v2"
 import { useGlobalSync } from "@/context/global-sync"
 import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
@@ -129,24 +130,44 @@ const css = `
 export const Playground = {
   render: () => {
     const global = useGlobalSync()
-    const [open, setOpen] = createSignal(true)
-    const [step, setStep] = createSignal(1)
-    const [dockOpenDuration, setDockOpenDuration] = createSignal(0.3)
-    const [dockOpenBounce, setDockOpenBounce] = createSignal(0)
-    const [dockCloseDuration, setDockCloseDuration] = createSignal(0.3)
-    const [dockCloseBounce, setDockCloseBounce] = createSignal(0)
-    const [drawerExpandDuration, setDrawerExpandDuration] = createSignal(0.3)
-    const [drawerExpandBounce, setDrawerExpandBounce] = createSignal(0)
-    const [drawerCollapseDuration, setDrawerCollapseDuration] = createSignal(0.3)
-    const [drawerCollapseBounce, setDrawerCollapseBounce] = createSignal(0)
-    const [subtitleDuration, setSubtitleDuration] = createSignal(600)
-    const [subtitleAuto, setSubtitleAuto] = createSignal(true)
-    const [subtitleTravel, setSubtitleTravel] = createSignal(25)
-    const [subtitleEdge, setSubtitleEdge] = createSignal(17)
-    const [countDuration, setCountDuration] = createSignal(600)
-    const [countMask, setCountMask] = createSignal(18)
-    const [countMaskHeight, setCountMaskHeight] = createSignal(0)
-    const [countWidthDuration, setCountWidthDuration] = createSignal(560)
+    const [cfg, setCfg] = createStore({
+      open: true,
+      step: 1,
+      dockOpenDuration: 0.3,
+      dockOpenBounce: 0,
+      dockCloseDuration: 0.3,
+      dockCloseBounce: 0,
+      drawerExpandDuration: 0.3,
+      drawerExpandBounce: 0,
+      drawerCollapseDuration: 0.3,
+      drawerCollapseBounce: 0,
+      subtitleDuration: 600,
+      subtitleAuto: true,
+      subtitleTravel: 25,
+      subtitleEdge: 17,
+      countDuration: 600,
+      countMask: 18,
+      countMaskHeight: 0,
+      countWidthDuration: 560,
+    })
+    const open = () => cfg.open
+    const step = () => cfg.step
+    const dockOpenDuration = () => cfg.dockOpenDuration
+    const dockOpenBounce = () => cfg.dockOpenBounce
+    const dockCloseDuration = () => cfg.dockCloseDuration
+    const dockCloseBounce = () => cfg.dockCloseBounce
+    const drawerExpandDuration = () => cfg.drawerExpandDuration
+    const drawerExpandBounce = () => cfg.drawerExpandBounce
+    const drawerCollapseDuration = () => cfg.drawerCollapseDuration
+    const drawerCollapseBounce = () => cfg.drawerCollapseBounce
+    const subtitleDuration = () => cfg.subtitleDuration
+    const subtitleAuto = () => cfg.subtitleAuto
+    const subtitleTravel = () => cfg.subtitleTravel
+    const subtitleEdge = () => cfg.subtitleEdge
+    const countDuration = () => cfg.countDuration
+    const countMask = () => cfg.countMask
+    const countMaskHeight = () => cfg.countMaskHeight
+    const countWidthDuration = () => cfg.countWidthDuration
     const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
     let frame
     let composerRef
@@ -187,7 +208,7 @@ export const Playground = {
 
     const openDock = () => {
       clear()
-      setOpen(true)
+      setCfg("open", true)
       frame = requestAnimationFrame(() => {
         pin()
         frame = undefined
@@ -196,7 +217,7 @@ export const Playground = {
 
     const closeDock = () => {
       clear()
-      setOpen(false)
+      setCfg("open", false)
     }
 
     const dockOpen = () => open()
@@ -223,7 +244,7 @@ export const Playground = {
     }
 
     const cycle = () => {
-      setStep((value) => (value + 1) % 4)
+      setCfg("step", (value) => (value + 1) % 4)
     }
 
     onCleanup(clear)
@@ -289,7 +310,7 @@ export const Playground = {
             Cycle progress ({step()}/3 done)
           </button>
           {[0, 1, 2, 3].map((value) => (
-            <button onClick={() => setStep(value)} style={btn(step() === value)}>
+            <button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
               {value} done
             </button>
           ))}
@@ -307,7 +328,7 @@ export const Playground = {
               max="1"
               step="0.01"
               value={dockOpenDuration()}
-              onInput={(event) => setDockOpenDuration(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -324,7 +345,7 @@ export const Playground = {
               max="1"
               step="0.01"
               value={dockOpenBounce()}
-              onInput={(event) => setDockOpenBounce(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -345,7 +366,7 @@ export const Playground = {
               max="1"
               step="0.01"
               value={dockCloseDuration()}
-              onInput={(event) => setDockCloseDuration(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -362,7 +383,7 @@ export const Playground = {
               max="1"
               step="0.01"
               value={dockCloseBounce()}
-              onInput={(event) => setDockCloseBounce(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -383,7 +404,7 @@ export const Playground = {
               max="1"
               step="0.01"
               value={drawerExpandDuration()}
-              onInput={(event) => setDrawerExpandDuration(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -400,7 +421,7 @@ export const Playground = {
               max="1"
               step="0.01"
               value={drawerExpandBounce()}
-              onInput={(event) => setDrawerExpandBounce(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -421,7 +442,7 @@ export const Playground = {
               max="1"
               step="0.01"
               value={drawerCollapseDuration()}
-              onInput={(event) => setDrawerCollapseDuration(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -438,7 +459,7 @@ export const Playground = {
               max="1"
               step="0.01"
               value={drawerCollapseBounce()}
-              onInput={(event) => setDrawerCollapseBounce(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -459,7 +480,7 @@ export const Playground = {
               max="1400"
               step="10"
               value={subtitleDuration()}
-              onInput={(event) => setSubtitleDuration(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -473,7 +494,7 @@ export const Playground = {
             <input
               type="checkbox"
               checked={subtitleAuto()}
-              onInput={(event) => setSubtitleAuto(event.currentTarget.checked)}
+              onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
               {subtitleAuto() ? "on" : "off"}
@@ -489,7 +510,7 @@ export const Playground = {
               max="40"
               step="1"
               value={subtitleTravel()}
-              onInput={(event) => setSubtitleTravel(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
@@ -504,7 +525,7 @@ export const Playground = {
               max="40"
               step="1"
               value={subtitleEdge()}
-              onInput={(event) => setSubtitleEdge(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
@@ -523,7 +544,7 @@ export const Playground = {
               max="1400"
               step="10"
               value={countDuration()}
-              onInput={(event) => setCountDuration(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
@@ -540,7 +561,7 @@ export const Playground = {
               max="40"
               step="1"
               value={countMask()}
-              onInput={(event) => setCountMask(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
@@ -555,7 +576,7 @@ export const Playground = {
               max="14"
               step="1"
               value={countMaskHeight()}
-              onInput={(event) => setCountMaskHeight(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
@@ -570,7 +591,7 @@ export const Playground = {
               max="1200"
               step="10"
               value={countWidthDuration()}
-              onInput={(event) => setCountWidthDuration(event.currentTarget.valueAsNumber)}
+              onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)}
               style={{ flex: 1 }}
             />
             <span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>

+ 30 - 22
packages/ui/src/components/tool-count-summary.stories.tsx

@@ -1,5 +1,6 @@
 // @ts-nocheck
-import { createSignal, onCleanup } from "solid-js"
+import { onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 import { AnimatedCountList, type CountItem } from "./tool-count-summary"
 import { ToolStatusTitle } from "./tool-status-title"
 
@@ -57,11 +58,18 @@ const smallBtn = (active?: boolean) =>
 
 export const Playground = {
   render: () => {
-    const [reads, setReads] = createSignal(0)
-    const [searches, setSearches] = createSignal(0)
-    const [lists, setLists] = createSignal(0)
-    const [active, setActive] = createSignal(false)
-    const [reducedMotion, setReducedMotion] = createSignal(false)
+    const [state, setState] = createStore({
+      reads: 0,
+      searches: 0,
+      lists: 0,
+      active: false,
+      reducedMotion: false,
+    })
+    const reads = () => state.reads
+    const searches = () => state.searches
+    const lists = () => state.lists
+    const active = () => state.active
+    const reducedMotion = () => state.reducedMotion
 
     let timeouts: ReturnType<typeof setTimeout>[] = []
 
@@ -74,10 +82,10 @@ export const Playground = {
 
     const startSim = () => {
       clearAll()
-      setReads(0)
-      setSearches(0)
-      setLists(0)
-      setActive(true)
+      setState("reads", 0)
+      setState("searches", 0)
+      setState("lists", 0)
+      setState("active", true)
       const steps = rand(3, 10)
       let elapsed = 0
 
@@ -86,27 +94,27 @@ export const Playground = {
         elapsed += delay
         const t = setTimeout(() => {
           const pick = rand(0, 2)
-          if (pick === 0) setReads((n) => n + 1)
-          else if (pick === 1) setSearches((n) => n + 1)
-          else setLists((n) => n + 1)
+          if (pick === 0) setState("reads", (value) => value + 1)
+          else if (pick === 1) setState("searches", (value) => value + 1)
+          else setState("lists", (value) => value + 1)
         }, elapsed)
         timeouts.push(t)
       }
 
-      const end = setTimeout(() => setActive(false), elapsed + 100)
+      const end = setTimeout(() => setState("active", false), elapsed + 100)
       timeouts.push(end)
     }
 
     const stopSim = () => {
       clearAll()
-      setActive(false)
+      setState("active", false)
     }
 
     const reset = () => {
       stopSim()
-      setReads(0)
-      setSearches(0)
-      setLists(0)
+      setState("reads", 0)
+      setState("searches", 0)
+      setState("lists", 0)
     }
 
     const items = (): CountItem[] => [
@@ -164,19 +172,19 @@ export const Playground = {
           <button onClick={reset} style={btn()}>
             Reset
           </button>
-          <button onClick={() => setReducedMotion((v) => !v)} style={smallBtn(reducedMotion())}>
+          <button onClick={() => setState("reducedMotion", (value) => !value)} style={smallBtn(reducedMotion())}>
             {reducedMotion() ? "Motion: reduced" : "Motion: normal"}
           </button>
         </div>
 
         <div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
-          <button onClick={() => setReads((n) => n + 1)} style={smallBtn()}>
+          <button onClick={() => setState("reads", (value) => value + 1)} style={smallBtn()}>
             + read
           </button>
-          <button onClick={() => setSearches((n) => n + 1)} style={smallBtn()}>
+          <button onClick={() => setState("searches", (value) => value + 1)} style={smallBtn()}>
             + search
           </button>
-          <button onClick={() => setLists((n) => n + 1)} style={smallBtn()}>
+          <button onClick={() => setState("lists", (value) => value + 1)} style={smallBtn()}>
             + list
           </button>
         </div>

+ 16 - 6
packages/ui/src/components/tool-error-card.tsx

@@ -1,4 +1,5 @@
-import { type ComponentProps, createMemo, createSignal, Show, splitProps } from "solid-js"
+import { type ComponentProps, createMemo, Show, splitProps } from "solid-js"
+import { createStore } from "solid-js/store"
 import { Card, CardDescription } from "./card"
 import { Collapsible } from "./collapsible"
 import { Icon } from "./icon"
@@ -16,8 +17,12 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
 
 export function ToolErrorCard(props: ToolErrorCardProps) {
   const i18n = useI18n()
-  const [open, setOpen] = createSignal(props.defaultOpen ?? false)
-  const [copied, setCopied] = createSignal(false)
+  const [state, setState] = createStore({
+    open: props.defaultOpen ?? false,
+    copied: false,
+  })
+  const open = () => state.open
+  const copied = () => state.copied
   const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen", "subtitle", "href"])
   const name = createMemo(() => {
     const map: Record<string, string> = {
@@ -65,13 +70,18 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
     const text = cleaned()
     if (!text) return
     await navigator.clipboard.writeText(text)
-    setCopied(true)
-    setTimeout(() => setCopied(false), 2000)
+    setState("copied", true)
+    setTimeout(() => setState("copied", false), 2000)
   }
 
   return (
     <Card {...rest} data-kind="tool-error-card" data-open={open() ? "true" : "false"} variant="error">
-      <Collapsible class="tool-collapsible" data-open={open() ? "true" : "false"} open={open()} onOpenChange={setOpen}>
+      <Collapsible
+        class="tool-collapsible"
+        data-open={open() ? "true" : "false"}
+        open={open()}
+        onOpenChange={(value) => setState("open", value)}
+      >
         <Collapsible.Trigger>
           <div data-component="tool-trigger">
             <div data-slot="basic-tool-tool-trigger-content">

+ 11 - 6
packages/ui/src/components/tool-status-title.tsx

@@ -1,4 +1,5 @@
-import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
+import { Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
 import { TextShimmer } from "./text-shimmer"
 
 function common(active: string, done: string) {
@@ -35,8 +36,12 @@ export function ToolStatusTitle(props: {
   const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
   const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
 
-  const [width, setWidth] = createSignal("auto")
-  const [ready, setReady] = createSignal(false)
+  const [state, setState] = createStore({
+    width: "auto",
+    ready: false,
+  })
+  const width = () => state.width
+  const ready = () => state.ready
   let activeRef: HTMLSpanElement | undefined
   let doneRef: HTMLSpanElement | undefined
   let frame: number | undefined
@@ -45,7 +50,7 @@ export function ToolStatusTitle(props: {
   const measure = () => {
     const target = props.active ? activeRef : doneRef
     const px = contentWidth(target)
-    if (px > 0) setWidth(`${px}px`)
+    if (px > 0) setState("width", `${px}px`)
   }
 
   const schedule = () => {
@@ -62,13 +67,13 @@ export function ToolStatusTitle(props: {
 
   const finish = () => {
     if (typeof requestAnimationFrame !== "function") {
-      setReady(true)
+      setState("ready", true)
       return
     }
     if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
     readyFrame = requestAnimationFrame(() => {
       readyFrame = undefined
-      setReady(true)
+      setState("ready", true)
     })
   }
 

+ 25 - 17
packages/ui/src/pierre/file-find.ts

@@ -1,4 +1,5 @@
-import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { createEffect, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
 
 export type FindHost = {
   element: () => HTMLElement | undefined
@@ -107,11 +108,18 @@ export function createFileFind(opts: CreateFileFindOptions) {
   let mode: "highlights" | "overlay" = "overlay"
   let hits: Range[] = []
 
-  const [open, setOpen] = createSignal(false)
-  const [query, setQuery] = createSignal("")
-  const [index, setIndex] = createSignal(0)
-  const [count, setCount] = createSignal(0)
-  const [pos, setPos] = createSignal({ top: 8, right: 8 })
+  const [state, setState] = createStore({
+    open: false,
+    query: "",
+    index: 0,
+    count: 0,
+    pos: { top: 8, right: 8 },
+  })
+  const open = () => state.open
+  const query = () => state.query
+  const index = () => state.index
+  const count = () => state.count
+  const pos = () => state.pos
 
   const clearOverlayScroll = () => {
     for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
@@ -200,8 +208,8 @@ export function createFileFind(opts: CreateFileFindOptions) {
     clearOverlay()
     clearOverlayScroll()
     hits = []
-    setCount(0)
-    setIndex(0)
+    setState("count", 0)
+    setState("index", 0)
   }
 
   const positionBar = () => {
@@ -214,7 +222,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
     const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
     const header = Number.isNaN(title) ? 0 : title
 
-    setPos({
+    setState("pos", {
       top: Math.round(rect.top) + header - 4,
       right: Math.round(window.innerWidth - rect.right) + 8,
     })
@@ -318,8 +326,8 @@ export function createFileFind(opts: CreateFileFindOptions) {
     const currentIndex = total ? Math.min(desired, total - 1) : 0
 
     hits = ranges
-    setCount(total)
-    setIndex(currentIndex)
+    setState("count", total)
+    setState("index", currentIndex)
 
     const active = ranges[currentIndex]
     if (mode === "highlights") {
@@ -342,8 +350,8 @@ export function createFileFind(opts: CreateFileFindOptions) {
   }
 
   const close = () => {
-    setOpen(false)
-    setQuery("")
+    setState("open", false)
+    setState("query", "")
     clearFind()
     if (current === host) current = undefined
   }
@@ -352,7 +360,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
     if (current && current !== host) current.close()
     current = host
     target = host
-    if (!open()) setOpen(true)
+    if (!open()) setState("open", true)
     requestAnimationFrame(() => {
       apply({ scroll: true })
       input?.focus()
@@ -366,7 +374,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
     if (total <= 0) return
 
     const currentIndex = (index() + dir + total) % total
-    setIndex(currentIndex)
+    setState("index", currentIndex)
 
     const active = hits[currentIndex]
     if (!active) return
@@ -449,8 +457,8 @@ export function createFileFind(opts: CreateFileFindOptions) {
       input = el
     },
     setQuery: (value: string) => {
-      setQuery(value)
-      setIndex(0)
+      setState("query", value)
+      setState("index", 0)
       apply({ reset: true, scroll: true })
     },
     focus,