Просмотр исходного кода

chore(app): createStore over signals

adamelmore 3 недель назад
Родитель
Сommit
d05ed5ca83

+ 17 - 14
packages/app/src/components/dialog-edit-project.tsx

@@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { Icon } from "@opencode-ai/ui/icon"
-import { createMemo, createSignal, For, Show } from "solid-js"
+import { createMemo, For, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
@@ -29,35 +29,34 @@ export function DialogEditProject(props: { project: LocalProject }) {
     iconUrl: props.project.icon?.override || "",
     startup: props.project.commands?.start ?? "",
     saving: false,
+    dragOver: false,
+    iconHover: false,
   })
 
-  const [dragOver, setDragOver] = createSignal(false)
-  const [iconHover, setIconHover] = createSignal(false)
-
   function handleFileSelect(file: File) {
     if (!file.type.startsWith("image/")) return
     const reader = new FileReader()
     reader.onload = (e) => {
       setStore("iconUrl", e.target?.result as string)
-      setIconHover(false)
+      setStore("iconHover", false)
     }
     reader.readAsDataURL(file)
   }
 
   function handleDrop(e: DragEvent) {
     e.preventDefault()
-    setDragOver(false)
+    setStore("dragOver", false)
     const file = e.dataTransfer?.files[0]
     if (file) handleFileSelect(file)
   }
 
   function handleDragOver(e: DragEvent) {
     e.preventDefault()
-    setDragOver(true)
+    setStore("dragOver", true)
   }
 
   function handleDragLeave() {
-    setDragOver(false)
+    setStore("dragOver", false)
   }
 
   function handleInputChange(e: Event) {
@@ -116,19 +115,23 @@ export function DialogEditProject(props: { project: LocalProject }) {
           <div class="flex flex-col gap-2">
             <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
             <div class="flex gap-3 items-start">
-              <div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
+              <div
+                class="relative"
+                onMouseEnter={() => setStore("iconHover", true)}
+                onMouseLeave={() => setStore("iconHover", false)}
+              >
                 <div
                   class="relative size-16 rounded-md transition-colors cursor-pointer"
                   classList={{
-                    "border-text-interactive-base bg-surface-info-base/20": dragOver(),
-                    "border-border-base hover:border-border-strong": !dragOver(),
+                    "border-text-interactive-base bg-surface-info-base/20": store.dragOver,
+                    "border-border-base hover:border-border-strong": !store.dragOver,
                     "overflow-hidden": !!store.iconUrl,
                   }}
                   onDrop={handleDrop}
                   onDragOver={handleDragOver}
                   onDragLeave={handleDragLeave}
                   onClick={() => {
-                    if (store.iconUrl && iconHover()) {
+                    if (store.iconUrl && store.iconHover) {
                       clearIcon()
                     } else {
                       document.getElementById("icon-upload")?.click()
@@ -166,7 +169,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
                     "border-radius": "6px",
                     "z-index": 10,
                     "pointer-events": "none",
-                    opacity: iconHover() && !store.iconUrl ? 1 : 0,
+                    opacity: store.iconHover && !store.iconUrl ? 1 : 0,
                     display: "flex",
                     "align-items": "center",
                     "justify-content": "center",
@@ -185,7 +188,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
                     "border-radius": "6px",
                     "z-index": 10,
                     "pointer-events": "none",
-                    opacity: iconHover() && store.iconUrl ? 1 : 0,
+                    opacity: store.iconHover && store.iconUrl ? 1 : 0,
                     display: "flex",
                     "align-items": "center",
                     "justify-content": "center",

+ 27 - 24
packages/app/src/components/session/session-sortable-terminal-tab.tsx

@@ -1,5 +1,6 @@
 import type { JSX } from "solid-js"
-import { createSignal, Show } from "solid-js"
+import { Show } from "solid-js"
+import { createStore } from "solid-js/store"
 import { createSortable } from "@thisbeyond/solid-dnd"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tabs } from "@opencode-ai/ui/tabs"
@@ -12,11 +13,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
   const terminal = useTerminal()
   const language = useLanguage()
   const sortable = createSortable(props.terminal.id)
-  const [editing, setEditing] = createSignal(false)
-  const [title, setTitle] = createSignal(props.terminal.title)
-  const [menuOpen, setMenuOpen] = createSignal(false)
-  const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 })
-  const [blurEnabled, setBlurEnabled] = createSignal(false)
+  const [store, setStore] = createStore({
+    editing: false,
+    title: props.terminal.title,
+    menuOpen: false,
+    menuPosition: { x: 0, y: 0 },
+    blurEnabled: false,
+  })
 
   const isDefaultTitle = () => {
     const number = props.terminal.titleNumber
@@ -47,7 +50,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
   }
 
   const focus = () => {
-    if (editing()) return
+    if (store.editing) return
 
     if (document.activeElement instanceof HTMLElement) {
       document.activeElement.blur()
@@ -71,26 +74,26 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
       e.preventDefault()
     }
 
-    setBlurEnabled(false)
-    setTitle(props.terminal.title)
-    setEditing(true)
+    setStore("blurEnabled", false)
+    setStore("title", props.terminal.title)
+    setStore("editing", true)
     setTimeout(() => {
       const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
       if (!input) return
       input.focus()
       input.select()
-      setTimeout(() => setBlurEnabled(true), 100)
+      setTimeout(() => setStore("blurEnabled", true), 100)
     }, 10)
   }
 
   const save = () => {
-    if (!blurEnabled()) return
+    if (!store.blurEnabled) return
 
-    const value = title().trim()
+    const value = store.title.trim()
     if (value && value !== props.terminal.title) {
       terminal.update({ id: props.terminal.id, title: value })
     }
-    setEditing(false)
+    setStore("editing", false)
   }
 
   const keydown = (e: KeyboardEvent) => {
@@ -101,14 +104,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
     }
     if (e.key === "Escape") {
       e.preventDefault()
-      setEditing(false)
+      setStore("editing", false)
     }
   }
 
   const menu = (e: MouseEvent) => {
     e.preventDefault()
-    setMenuPosition({ x: e.clientX, y: e.clientY })
-    setMenuOpen(true)
+    setStore("menuPosition", { x: e.clientX, y: e.clientY })
+    setStore("menuOpen", true)
   }
 
   return (
@@ -143,17 +146,17 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
             />
           }
         >
-          <span onDblClick={edit} style={{ visibility: editing() ? "hidden" : "visible" }}>
+          <span onDblClick={edit} style={{ visibility: store.editing ? "hidden" : "visible" }}>
             {label()}
           </span>
         </Tabs.Trigger>
-        <Show when={editing()}>
+        <Show when={store.editing}>
           <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
             <input
               id={`terminal-title-input-${props.terminal.id}`}
               type="text"
-              value={title()}
-              onInput={(e) => setTitle(e.currentTarget.value)}
+              value={store.title}
+              onInput={(e) => setStore("title", e.currentTarget.value)}
               onBlur={save}
               onKeyDown={keydown}
               onMouseDown={(e) => e.stopPropagation()}
@@ -161,13 +164,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
             />
           </div>
         </Show>
-        <DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
+        <DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
           <DropdownMenu.Portal>
             <DropdownMenu.Content
               style={{
                 position: "fixed",
-                left: `${menuPosition().x}px`,
-                top: `${menuPosition().y}px`,
+                left: `${store.menuPosition.x}px`,
+                top: `${store.menuPosition.y}px`,
               }}
             >
               <DropdownMenu.Item onSelect={edit}>

+ 24 - 21
packages/app/src/components/settings-keybinds.tsx

@@ -1,4 +1,5 @@
-import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
+import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -111,24 +112,26 @@ export const SettingsKeybinds: Component = () => {
   const language = useLanguage()
   const settings = useSettings()
 
-  const [active, setActive] = createSignal<string | null>(null)
-  const [filter, setFilter] = createSignal("")
+  const [store, setStore] = createStore({
+    active: null as string | null,
+    filter: "",
+  })
 
   const stop = () => {
-    if (!active()) return
-    setActive(null)
+    if (!store.active) return
+    setStore("active", null)
     command.keybinds(true)
   }
 
   const start = (id: string) => {
-    if (active() === id) {
+    if (store.active === id) {
       stop()
       return
     }
 
-    if (active()) stop()
+    if (store.active) stop()
 
-    setActive(id)
+    setStore("active", id)
     command.keybinds(false)
   }
 
@@ -203,7 +206,7 @@ export const SettingsKeybinds: Component = () => {
   })
 
   const filtered = createMemo(() => {
-    const query = filter().toLowerCase().trim()
+    const query = store.filter.toLowerCase().trim()
     if (!query) return grouped()
 
     const map = list()
@@ -285,7 +288,7 @@ export const SettingsKeybinds: Component = () => {
 
   onMount(() => {
     const handle = (event: KeyboardEvent) => {
-      const id = active()
+      const id = store.active
       if (!id) return
 
       event.preventDefault()
@@ -345,7 +348,7 @@ export const SettingsKeybinds: Component = () => {
   })
 
   onCleanup(() => {
-    if (active()) command.keybinds(true)
+    if (store.active) command.keybinds(true)
   })
 
   return (
@@ -370,8 +373,8 @@ export const SettingsKeybinds: Component = () => {
             <TextField
               variant="ghost"
               type="text"
-              value={filter()}
-              onChange={setFilter}
+              value={store.filter}
+              onChange={(v) => setStore("filter", v)}
               placeholder={language.t("settings.shortcuts.search.placeholder")}
               spellcheck={false}
               autocorrect="off"
@@ -379,8 +382,8 @@ export const SettingsKeybinds: Component = () => {
               autocapitalize="off"
               class="flex-1"
             />
-            <Show when={filter()}>
-              <IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
+            <Show when={store.filter}>
+              <IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
             </Show>
           </div>
         </div>
@@ -402,13 +405,13 @@ export const SettingsKeybinds: Component = () => {
                           classList={{
                             "h-8 px-3 rounded-md text-12-regular": true,
                             "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
-                              active() !== id,
-                            "border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id,
+                              store.active !== id,
+                            "border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
                           }}
                           onClick={() => start(id)}
                         >
                           <Show
-                            when={active() === id}
+                            when={store.active === id}
                             fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
                           >
                             {language.t("settings.shortcuts.pressKeys")}
@@ -423,11 +426,11 @@ export const SettingsKeybinds: Component = () => {
           )}
         </For>
 
-        <Show when={filter() && !hasResults()}>
+        <Show when={store.filter && !hasResults()}>
           <div class="flex flex-col items-center justify-center py-12 text-center">
             <span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
-            <Show when={filter()}>
-              <span class="text-14-regular text-text-strong mt-1">"{filter()}"</span>
+            <Show when={store.filter}>
+              <span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
             </Show>
           </div>
         </Show>

+ 11 - 12
packages/app/src/components/status-popover.tsx

@@ -39,9 +39,10 @@ export function StatusPopover() {
   const language = useLanguage()
   const navigate = useNavigate()
 
-  const [loading, setLoading] = createSignal<string | null>(null)
   const [store, setStore] = createStore({
     status: {} as Record<string, ServerStatus | undefined>,
+    loading: null as string | null,
+    defaultServerUrl: undefined as string | undefined,
   })
 
   const servers = createMemo(() => {
@@ -97,8 +98,8 @@ export function StatusPopover() {
   const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
 
   const toggleMcp = async (name: string) => {
-    if (loading()) return
-    setLoading(name)
+    if (store.loading) return
+    setStore("loading", name)
     const status = sync.data.mcp[name]
     if (status?.status === "connected") {
       await sdk.client.mcp.disconnect({ name })
@@ -107,7 +108,7 @@ export function StatusPopover() {
     }
     const result = await sdk.client.mcp.status()
     if (result.data) sync.set("mcp", result.data)
-    setLoading(null)
+    setStore("loading", null)
   }
 
   const lspItems = createMemo(() => sync.data.lsp ?? [])
@@ -123,19 +124,17 @@ export function StatusPopover() {
 
   const serverCount = createMemo(() => sortedServers().length)
 
-  const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
-
   const refreshDefaultServerUrl = () => {
     const result = platform.getDefaultServerUrl?.()
     if (!result) {
-      setDefaultServerUrl(undefined)
+      setStore("defaultServerUrl", undefined)
       return
     }
     if (result instanceof Promise) {
-      result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
+      result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
       return
     }
-    setDefaultServerUrl(normalizeServerUrl(result))
+    setStore("defaultServerUrl", normalizeServerUrl(result))
   }
 
   createEffect(() => {
@@ -220,7 +219,7 @@ export function StatusPopover() {
                 <For each={sortedServers()}>
                   {(url) => {
                     const isActive = () => url === server.url
-                    const isDefault = () => url === defaultServerUrl()
+                    const isDefault = () => url === store.defaultServerUrl
                     const status = () => store.status[url]
                     const isBlocked = () => status()?.healthy === false
                     const [truncated, setTruncated] = createSignal(false)
@@ -329,7 +328,7 @@ export function StatusPopover() {
                           type="button"
                           class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
                           onClick={() => toggleMcp(item.name)}
-                          disabled={loading() === item.name}
+                          disabled={store.loading === item.name}
                         >
                           <div
                             classList={{
@@ -345,7 +344,7 @@ export function StatusPopover() {
                           <div onClick={(event) => event.stopPropagation()}>
                             <Switch
                               checked={enabled()}
-                              disabled={loading() === item.name}
+                              disabled={store.loading === item.name}
                               onChange={() => toggleMcp(item.name)}
                             />
                           </div>

+ 10 - 8
packages/app/src/context/command.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
+import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -165,8 +165,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     const dialog = useDialog()
     const settings = useSettings()
     const language = useLanguage()
-    const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
-    const [suspendCount, setSuspendCount] = createSignal(0)
+    const [store, setStore] = createStore({
+      registrations: [] as Accessor<CommandOption[]>[],
+      suspendCount: 0,
+    })
 
     const [catalog, setCatalog, _, catalogReady] = persisted(
       Persist.global("command.catalog.v1"),
@@ -184,7 +186,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       const seen = new Set<string>()
       const all: CommandOption[] = []
 
-      for (const reg of registrations()) {
+      for (const reg of store.registrations) {
         for (const opt of reg()) {
           if (seen.has(opt.id)) continue
           seen.add(opt.id)
@@ -230,7 +232,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       ]
     })
 
-    const suspended = () => suspendCount() > 0
+    const suspended = () => store.suspendCount > 0
 
     const palette = createMemo(() => {
       const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
@@ -297,9 +299,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     return {
       register(cb: () => CommandOption[]) {
         const results = createMemo(cb)
-        setRegistrations((arr) => [results, ...arr])
+        setStore("registrations", (arr) => [results, ...arr])
         onCleanup(() => {
-          setRegistrations((arr) => arr.filter((x) => x !== results))
+          setStore("registrations", (arr) => arr.filter((x) => x !== results))
         })
       },
       trigger(id: string, source?: "palette" | "keybind" | "slash") {
@@ -321,7 +323,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       },
       show: showPalette,
       keybinds(enabled: boolean) {
-        setSuspendCount((count) => count + (enabled ? -1 : 1))
+        setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
       },
       suspended,
       get catalog() {

+ 13 - 5
packages/app/src/context/comments.tsx

@@ -1,4 +1,4 @@
-import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js"
+import { batch, createMemo, createRoot, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useParams } from "@solidjs/router"
@@ -37,8 +37,16 @@ function createCommentSession(dir: string, id: string | undefined) {
     }),
   )
 
-  const [focus, setFocus] = createSignal<CommentFocus | null>(null)
-  const [active, setActive] = createSignal<CommentFocus | null>(null)
+  const [state, setState] = createStore({
+    focus: null as CommentFocus | null,
+    active: null as CommentFocus | null,
+  })
+
+  const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
+    setState("focus", value)
+
+  const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
+    setState("active", value)
 
   const list = (file: string) => store.comments[file] ?? []
 
@@ -74,10 +82,10 @@ function createCommentSession(dir: string, id: string | undefined) {
     all,
     add,
     remove,
-    focus: createMemo(() => focus()),
+    focus: createMemo(() => state.focus),
     setFocus,
     clearFocus: () => setFocus(null),
-    active: createMemo(() => active()),
+    active: createMemo(() => state.active),
     setActive,
     clearActive: () => setActive(null),
   }

+ 21 - 18
packages/app/src/context/server.tsx

@@ -1,6 +1,6 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { usePlatform } from "@/context/platform"
 import { Persist, persisted } from "@/utils/persist"
@@ -40,12 +40,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       }),
     )
 
-    const [active, setActiveRaw] = createSignal("")
+    const [state, setState] = createStore({
+      active: "",
+      healthy: undefined as boolean | undefined,
+    })
+
+    const healthy = () => state.healthy
 
     function setActive(input: string) {
       const url = normalizeServerUrl(input)
       if (!url) return
-      setActiveRaw(url)
+      setState("active", url)
     }
 
     function add(input: string) {
@@ -54,7 +59,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
       const fallback = normalizeServerUrl(props.defaultUrl)
       if (fallback && url === fallback) {
-        setActiveRaw(url)
+        setState("active", url)
         return
       }
 
@@ -62,7 +67,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
         if (!store.list.includes(url)) {
           setStore("list", store.list.length, url)
         }
-        setActiveRaw(url)
+        setState("active", url)
       })
     }
 
@@ -71,25 +76,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       if (!url) return
 
       const list = store.list.filter((x) => x !== url)
-      const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
+      const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
 
       batch(() => {
         setStore("list", list)
-        setActiveRaw(next)
+        setState("active", next)
       })
     }
 
     createEffect(() => {
       if (!ready()) return
-      if (active()) return
+      if (state.active) return
       const url = normalizeServerUrl(props.defaultUrl)
       if (!url) return
-      setActiveRaw(url)
+      setState("active", url)
     })
 
-    const isReady = createMemo(() => ready() && !!active())
-
-    const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
+    const isReady = createMemo(() => ready() && !!state.active)
 
     const check = (url: string) => {
       const sdk = createOpencodeClient({
@@ -104,10 +107,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
     }
 
     createEffect(() => {
-      const url = active()
+      const url = state.active
       if (!url) return
 
-      setHealthy(undefined)
+      setState("healthy", undefined)
 
       let alive = true
       let busy = false
@@ -118,7 +121,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
         void check(url)
           .then((next) => {
             if (!alive) return
-            setHealthy(next)
+            setState("healthy", next)
           })
           .finally(() => {
             busy = false
@@ -134,7 +137,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       })
     })
 
-    const origin = createMemo(() => projectsKey(active()))
+    const origin = createMemo(() => projectsKey(state.active))
     const projectsList = createMemo(() => store.projects[origin()] ?? [])
     const isLocal = createMemo(() => origin() === "local")
 
@@ -143,10 +146,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       healthy,
       isLocal,
       get url() {
-        return active()
+        return state.active
       },
       get name() {
-        return serverDisplayName(active())
+        return serverDisplayName(state.active)
       },
       get list() {
         return store.list

+ 78 - 69
packages/app/src/pages/layout.tsx

@@ -91,7 +91,6 @@ export default function Layout(props: ParentProps) {
   let scrollContainerRef: HTMLDivElement | undefined
 
   const params = useParams()
-  const [autoselect, setAutoselect] = createSignal(!params.dir)
   const globalSDK = useGlobalSDK()
   const globalSync = useGlobalSync()
   const layout = useLayout()
@@ -117,27 +116,31 @@ export default function Layout(props: ParentProps) {
   }
   const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
 
+  const [state, setState] = createStore({
+    autoselect: !params.dir,
+    busyWorkspaces: new Set<string>(),
+    hoverSession: undefined as string | undefined,
+    hoverProject: undefined as string | undefined,
+    scrollSessionKey: undefined as string | undefined,
+    nav: undefined as HTMLElement | undefined,
+  })
+
   const [editor, setEditor] = createStore({
     active: "" as string,
     value: "",
   })
-  const [busyWorkspaces, setBusyWorkspaces] = createSignal<Set<string>>(new Set())
   const setBusy = (directory: string, value: boolean) => {
     const key = workspaceKey(directory)
-    setBusyWorkspaces((prev) => {
+    setState("busyWorkspaces", (prev) => {
       const next = new Set(prev)
       if (value) next.add(key)
       else next.delete(key)
       return next
     })
   }
-  const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory))
+  const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory))
   const editorRef = { current: undefined as HTMLInputElement | undefined }
 
-  const [hoverSession, setHoverSession] = createSignal<string | undefined>()
-  const [hoverProject, setHoverProject] = createSignal<string | undefined>()
-
-  const [nav, setNav] = createSignal<HTMLElement | undefined>(undefined)
   const navLeave = { current: undefined as number | undefined }
 
   onCleanup(() => {
@@ -145,18 +148,18 @@ export default function Layout(props: ParentProps) {
     clearTimeout(navLeave.current)
   })
 
-  const sidebarHovering = createMemo(() => !layout.sidebar.opened() && hoverProject() !== undefined)
+  const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
   const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
 
   const hoverProjectData = createMemo(() => {
-    const id = hoverProject()
+    const id = state.hoverProject
     if (!id) return
     return layout.projects.list().find((project) => project.worktree === id)
   })
 
   createEffect(() => {
     if (!layout.sidebar.opened()) return
-    setHoverProject(undefined)
+    setState("hoverProject", undefined)
   })
 
   createEffect(
@@ -164,9 +167,9 @@ export default function Layout(props: ParentProps) {
       () => ({ dir: params.dir, id: params.id }),
       () => {
         if (layout.sidebar.opened()) return
-        if (!hoverProject()) return
-        setHoverSession(undefined)
-        setHoverProject(undefined)
+        if (!state.hoverProject) return
+        setState("hoverSession", undefined)
+        setState("hoverProject", undefined)
       },
       { defer: true },
     ),
@@ -175,7 +178,7 @@ export default function Layout(props: ParentProps) {
   const autoselecting = createMemo(() => {
     if (params.dir) return false
     if (initialDir) return false
-    if (!autoselect()) return false
+    if (!state.autoselect) return false
     if (!pageReady()) return true
     if (!layoutReady()) return true
     const list = layout.projects.list()
@@ -483,20 +486,18 @@ export default function Layout(props: ParentProps) {
     }
   }
 
-  const [scrollSessionKey, setScrollSessionKey] = createSignal<string | undefined>(undefined)
-
   function scrollToSession(sessionId: string, sessionKey: string) {
     if (!scrollContainerRef) return
-    if (scrollSessionKey() === sessionKey) return
+    if (state.scrollSessionKey === sessionKey) return
     const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
     if (!element) return
     const containerRect = scrollContainerRef.getBoundingClientRect()
     const elementRect = element.getBoundingClientRect()
     if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) {
-      setScrollSessionKey(sessionKey)
+      setState("scrollSessionKey", sessionKey)
       return
     }
-    setScrollSessionKey(sessionKey)
+    setState("scrollSessionKey", sessionKey)
     element.scrollIntoView({ block: "nearest", behavior: "smooth" })
   }
 
@@ -544,7 +545,7 @@ export default function Layout(props: ParentProps) {
       (value) => {
         if (!value.ready) return
         if (!value.layoutReady) return
-        if (!autoselect()) return
+        if (!state.autoselect) return
         if (initialDir) return
         if (value.dir) return
         if (value.list.length === 0) return
@@ -552,7 +553,7 @@ export default function Layout(props: ParentProps) {
         const last = server.projects.last()
         const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
         if (!next) return
-        setAutoselect(false)
+        setState("autoselect", false)
         openProject(next.worktree, false)
         navigateToProject(next.worktree)
       },
@@ -1066,8 +1067,8 @@ export default function Layout(props: ParentProps) {
   function navigateToProject(directory: string | undefined) {
     if (!directory) return
     if (!layout.sidebar.opened()) {
-      setHoverSession(undefined)
-      setHoverProject(undefined)
+      setState("hoverSession", undefined)
+      setState("hoverProject", undefined)
     }
     server.projects.touch(directory)
     const lastSession = store.lastSession[directory]
@@ -1078,8 +1079,8 @@ export default function Layout(props: ParentProps) {
   function navigateToSession(session: Session | undefined) {
     if (!session) return
     if (!layout.sidebar.opened()) {
-      setHoverSession(undefined)
-      setHoverProject(undefined)
+      setState("hoverSession", undefined)
+      setState("hoverProject", undefined)
     }
     navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
     layout.mobileSidebar.hide()
@@ -1472,7 +1473,7 @@ export default function Layout(props: ParentProps) {
   function handleDragStart(event: unknown) {
     const id = getDraggableId(event)
     if (!id) return
-    setHoverProject(undefined)
+    setState("hoverProject", undefined)
     setStore("activeProject", id)
   }
 
@@ -1632,8 +1633,10 @@ export default function Layout(props: ParentProps) {
     const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
     const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
     const isActive = createMemo(() => props.session.id === params.id)
-    const [menuOpen, setMenuOpen] = createSignal(false)
-    const [pendingRename, setPendingRename] = createSignal(false)
+    const [menu, setMenu] = createStore({
+      open: false,
+      pendingRename: false,
+    })
 
     const messageLabel = (message: Message) => {
       const parts = sessionStore.part[message.id] ?? []
@@ -1644,13 +1647,13 @@ export default function Layout(props: ParentProps) {
     const item = (
       <A
         href={`${props.slug}/session/${props.session.id}`}
-        class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menuOpen() ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+        class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
         onMouseEnter={() => prefetchSession(props.session, "high")}
         onFocus={() => prefetchSession(props.session, "high")}
         onClick={() => {
-          setHoverSession(undefined)
+          setState("hoverSession", undefined)
           if (layout.sidebar.opened()) return
-          queueMicrotask(() => setHoverProject(undefined))
+          queueMicrotask(() => setState("hoverProject", undefined))
         }}
       >
         <div class="flex items-center gap-1 w-full">
@@ -1713,9 +1716,9 @@ export default function Layout(props: ParentProps) {
             gutter={16}
             shift={-2}
             trigger={item}
-            mount={!props.mobile ? nav() : undefined}
-            open={hoverSession() === props.session.id}
-            onOpenChange={(open) => setHoverSession(open ? props.session.id : undefined)}
+            mount={!props.mobile ? state.nav : undefined}
+            open={state.hoverSession === props.session.id}
+            onOpenChange={(open) => setState("hoverSession", open ? props.session.id : undefined)}
           >
             <Show
               when={hoverReady()}
@@ -1745,13 +1748,13 @@ export default function Layout(props: ParentProps) {
         <div
           class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
           classList={{
-            "opacity-100 pointer-events-auto": menuOpen(),
-            "opacity-0 pointer-events-none": !menuOpen(),
+            "opacity-100 pointer-events-auto": menu.open,
+            "opacity-0 pointer-events-none": !menu.open,
             "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
             "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
           }}
         >
-          <DropdownMenu modal={!sidebarHovering()} open={menuOpen()} onOpenChange={setMenuOpen}>
+          <DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
             <Tooltip value={language.t("common.moreOptions")} placement="top">
               <DropdownMenu.Trigger
                 as={IconButton}
@@ -1761,19 +1764,19 @@ export default function Layout(props: ParentProps) {
                 aria-label={language.t("common.moreOptions")}
               />
             </Tooltip>
-            <DropdownMenu.Portal mount={!props.mobile ? nav() : undefined}>
+            <DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
               <DropdownMenu.Content
                 onCloseAutoFocus={(event) => {
-                  if (!pendingRename()) return
+                  if (!menu.pendingRename) return
                   event.preventDefault()
-                  setPendingRename(false)
+                  setMenu("pendingRename", false)
                   openEditor(`session:${props.session.id}`, props.session.title)
                 }}
               >
                 <DropdownMenu.Item
                   onSelect={() => {
-                    setPendingRename(true)
-                    setMenuOpen(false)
+                    setMenu("pendingRename", true)
+                    setMenu("open", false)
                   }}
                 >
                   <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
@@ -1802,9 +1805,9 @@ export default function Layout(props: ParentProps) {
         end
         class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
         onClick={() => {
-          setHoverSession(undefined)
+          setState("hoverSession", undefined)
           if (layout.sidebar.opened()) return
-          queueMicrotask(() => setHoverProject(undefined))
+          queueMicrotask(() => setState("hoverProject", undefined))
         }}
       >
         <div class="flex items-center gap-1 w-full">
@@ -1884,8 +1887,10 @@ export default function Layout(props: ParentProps) {
   const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
     const sortable = createSortable(props.directory)
     const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
-    const [menuOpen, setMenuOpen] = createSignal(false)
-    const [pendingRename, setPendingRename] = createSignal(false)
+    const [menu, setMenu] = createStore({
+      open: false,
+      pendingRename: false,
+    })
     const slug = createMemo(() => base64Encode(props.directory))
     const sessions = createMemo(() =>
       workspaceStore.session
@@ -1995,13 +2000,17 @@ export default function Layout(props: ParentProps) {
                 <div
                   class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
                   classList={{
-                    "opacity-100 pointer-events-auto": menuOpen(),
-                    "opacity-0 pointer-events-none": !menuOpen(),
+                    "opacity-100 pointer-events-auto": menu.open,
+                    "opacity-0 pointer-events-none": !menu.open,
                     "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
                     "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
                   }}
                 >
-                  <DropdownMenu modal={!sidebarHovering()} open={menuOpen()} onOpenChange={setMenuOpen}>
+                  <DropdownMenu
+                    modal={!sidebarHovering()}
+                    open={menu.open}
+                    onOpenChange={(open) => setMenu("open", open)}
+                  >
                     <Tooltip value={language.t("common.moreOptions")} placement="top">
                       <DropdownMenu.Trigger
                         as={IconButton}
@@ -2011,20 +2020,20 @@ export default function Layout(props: ParentProps) {
                         aria-label={language.t("common.moreOptions")}
                       />
                     </Tooltip>
-                    <DropdownMenu.Portal mount={!props.mobile ? nav() : undefined}>
+                    <DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
                       <DropdownMenu.Content
                         onCloseAutoFocus={(event) => {
-                          if (!pendingRename()) return
+                          if (!menu.pendingRename) return
                           event.preventDefault()
-                          setPendingRename(false)
+                          setMenu("pendingRename", false)
                           openEditor(`workspace:${props.directory}`, workspaceValue())
                         }}
                       >
                         <DropdownMenu.Item
                           disabled={local()}
                           onSelect={() => {
-                            setPendingRename(true)
-                            setMenuOpen(false)
+                            setMenu("pendingRename", true)
+                            setMenu("open", false)
                           }}
                         >
                           <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
@@ -2103,7 +2112,7 @@ export default function Layout(props: ParentProps) {
 
     const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
     const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
-    const active = createMemo(() => (preview() ? open() : overlay() && hoverProject() === props.project.worktree))
+    const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree))
 
     createEffect(() => {
       if (preview()) return
@@ -2155,14 +2164,14 @@ export default function Layout(props: ParentProps) {
         onMouseEnter={() => {
           if (!overlay()) return
           globalSync.child(props.project.worktree)
-          setHoverProject(props.project.worktree)
-          setHoverSession(undefined)
+          setState("hoverProject", props.project.worktree)
+          setState("hoverSession", undefined)
         }}
         onFocus={() => {
           if (!overlay()) return
           globalSync.child(props.project.worktree)
-          setHoverProject(props.project.worktree)
-          setHoverSession(undefined)
+          setState("hoverProject", props.project.worktree)
+          setState("hoverSession", undefined)
         }}
         onClick={() => navigateToProject(props.project.worktree)}
         onBlur={() => setOpen(false)}
@@ -2184,7 +2193,7 @@ export default function Layout(props: ParentProps) {
             trigger={trigger}
             onOpenChange={(value) => {
               setOpen(value)
-              if (value) setHoverSession(undefined)
+              if (value) setState("hoverSession", undefined)
             }}
           >
             <div class="-m-3 p-2 flex flex-col w-72">
@@ -2323,8 +2332,8 @@ export default function Layout(props: ParentProps) {
 
   const createWorkspace = async (project: LocalProject) => {
     if (!layout.sidebar.opened()) {
-      setHoverSession(undefined)
-      setHoverProject(undefined)
+      setState("hoverSession", undefined)
+      setState("hoverProject", undefined)
     }
     const created = await globalSDK.client.worktree
       .create({ directory: project.worktree })
@@ -2427,7 +2436,7 @@ export default function Layout(props: ParentProps) {
                       class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
                       aria-label={language.t("common.moreOptions")}
                     />
-                    <DropdownMenu.Portal mount={!panelProps.mobile ? nav() : undefined}>
+                    <DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
                       <DropdownMenu.Content class="mt-1">
                         <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
                           <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
@@ -2476,8 +2485,8 @@ export default function Layout(props: ParentProps) {
                           class="w-full"
                           onClick={() => {
                             if (!layout.sidebar.opened()) {
-                              setHoverSession(undefined)
-                              setHoverProject(undefined)
+                              setState("hoverSession", undefined)
+                              setState("hoverProject", undefined)
                             }
                             navigate(`/${base64Encode(p.worktree)}/session`)
                             layout.mobileSidebar.hide()
@@ -2668,7 +2677,7 @@ export default function Layout(props: ParentProps) {
           }}
           style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
           ref={(el) => {
-            setNav(el)
+            setState("nav", el)
           }}
           onMouseEnter={() => {
             if (navLeave.current === undefined) return
@@ -2681,8 +2690,8 @@ export default function Layout(props: ParentProps) {
             if (navLeave.current !== undefined) clearTimeout(navLeave.current)
             navLeave.current = window.setTimeout(() => {
               navLeave.current = undefined
-              setHoverProject(undefined)
-              setHoverSession(undefined)
+              setState("hoverProject", undefined)
+              setState("hoverSession", undefined)
             }, 300)
           }}
         >

+ 69 - 30
packages/app/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
 import { createMediaQuery } from "@solid-primitives/media"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { Dynamic } from "solid-js/web"
@@ -198,12 +198,17 @@ export default function Page() {
     return next
   })
 
-  const [responding, setResponding] = createSignal(false)
+  const [ui, setUi] = createStore({
+    responding: false,
+    pendingMessage: undefined as string | undefined,
+    scrollGesture: 0,
+    autoCreated: false,
+  })
 
   createEffect(
     on(
       () => request()?.id,
-      () => setResponding(false),
+      () => setUi("responding", false),
       { defer: true },
     ),
   )
@@ -211,18 +216,17 @@ export default function Page() {
   const decide = (response: "once" | "always" | "reject") => {
     const perm = request()
     if (!perm) return
-    if (responding()) return
+    if (ui.responding) return
 
-    setResponding(true)
+    setUi("responding", true)
     sdk.client.permission
       .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
       .catch((err: unknown) => {
         const message = err instanceof Error ? err.message : String(err)
         showToast({ title: language.t("common.requestFailed"), description: message })
       })
-      .finally(() => setResponding(false))
+      .finally(() => setUi("responding", false))
   }
-  const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey))
   const view = createMemo(() => layout.view(sessionKey))
@@ -439,7 +443,6 @@ export default function Page() {
   let promptDock: HTMLDivElement | undefined
   let scroller: HTMLDivElement | undefined
 
-  const [scrollGesture, setScrollGesture] = createSignal(0)
   const scrollGestureWindowMs = 250
 
   const markScrollGesture = (target?: EventTarget | null) => {
@@ -450,26 +453,24 @@ export default function Page() {
     const nested = el?.closest("[data-scrollable]")
     if (nested && nested !== root) return
 
-    setScrollGesture(Date.now())
+    setUi("scrollGesture", Date.now())
   }
 
-  const hasScrollGesture = () => Date.now() - scrollGesture() < scrollGestureWindowMs
+  const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
 
   createEffect(() => {
     if (!params.id) return
     sync.session.sync(params.id)
   })
 
-  const [autoCreated, setAutoCreated] = createSignal(false)
-
   createEffect(() => {
     if (!view().terminal.opened()) {
-      setAutoCreated(false)
+      setUi("autoCreated", false)
       return
     }
-    if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return
+    if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
     terminal.new()
-    setAutoCreated(true)
+    setUi("autoCreated", true)
   })
 
   createEffect(
@@ -1019,9 +1020,18 @@ export default function Page() {
 
   const showTabs = createMemo(() => view().reviewPanel.opened())
 
-  const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes")
-  const [reviewScroll, setReviewScroll] = createSignal<HTMLDivElement | undefined>(undefined)
-  const [pendingDiff, setPendingDiff] = createSignal<string | undefined>(undefined)
+  const [tree, setTree] = createStore({
+    fileTreeTab: "changes" as "changes" | "all",
+    reviewScroll: undefined as HTMLDivElement | undefined,
+    pendingDiff: undefined as string | undefined,
+  })
+
+  const fileTreeTab = () => tree.fileTreeTab
+  const setFileTreeTab = (value: "changes" | "all") => setTree("fileTreeTab", value)
+  const reviewScroll = () => tree.reviewScroll
+  const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
+  const pendingDiff = () => tree.pendingDiff
+  const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
 
   createEffect(() => {
     if (!layout.fileTree.opened()) return
@@ -1316,7 +1326,7 @@ export default function Page() {
     if (pendingSessionID !== sessionID) return
 
     sessionStorage.removeItem("opencode.pendingMessage")
-    setPendingMessage(messageID)
+    setUi("pendingMessage", messageID)
   })
 
   const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -1484,7 +1494,7 @@ export default function Page() {
     store.turnStart
 
     const targetId =
-      pendingMessage() ??
+      ui.pendingMessage ??
       (() => {
         const hash = window.location.hash.slice(1)
         const match = hash.match(/^message-(.+)$/)
@@ -1496,7 +1506,7 @@ export default function Page() {
 
     const msg = visibleUserMessages().find((m) => m.id === targetId)
     if (!msg) return
-    if (pendingMessage() === targetId) setPendingMessage(undefined)
+    if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined)
     requestAnimationFrame(() => scrollToMessage(msg, "auto"))
   })
 
@@ -1877,18 +1887,18 @@ export default function Page() {
                     </BasicTool>
                     <div data-component="permission-prompt">
                       <div data-slot="permission-actions">
-                        <Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={responding()}>
+                        <Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={ui.responding}>
                           {language.t("ui.permission.deny")}
                         </Button>
                         <Button
                           variant="secondary"
                           size="small"
                           onClick={() => decide("always")}
-                          disabled={responding()}
+                          disabled={ui.responding}
                         >
                           {language.t("ui.permission.allowAlways")}
                         </Button>
-                        <Button variant="primary" size="small" onClick={() => decide("once")} disabled={responding()}>
+                        <Button variant="primary" size="small" onClick={() => decide("once")} disabled={ui.responding}>
                           {language.t("ui.permission.allowOnce")}
                         </Button>
                       </div>
@@ -2144,11 +2154,40 @@ export default function Page() {
 
                           const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
 
-                          const [openedComment, setOpenedComment] = createSignal<string | null>(null)
-                          const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
-                          const [draft, setDraft] = createSignal("")
-                          const [positions, setPositions] = createSignal<Record<string, number>>({})
-                          const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
+                          const [note, setNote] = createStore({
+                            openedComment: null as string | null,
+                            commenting: null as SelectedLineRange | null,
+                            draft: "",
+                            positions: {} as Record<string, number>,
+                            draftTop: undefined as number | undefined,
+                          })
+
+                          const openedComment = () => note.openedComment
+                          const setOpenedComment = (
+                            value:
+                              | typeof note.openedComment
+                              | ((value: typeof note.openedComment) => typeof note.openedComment),
+                          ) => setNote("openedComment", value)
+
+                          const commenting = () => note.commenting
+                          const setCommenting = (
+                            value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting),
+                          ) => setNote("commenting", value)
+
+                          const draft = () => note.draft
+                          const setDraft = (
+                            value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft),
+                          ) => setNote("draft", value)
+
+                          const positions = () => note.positions
+                          const setPositions = (
+                            value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions),
+                          ) => setNote("positions", value)
+
+                          const draftTop = () => note.draftTop
+                          const setDraftTop = (
+                            value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop),
+                          ) => setNote("draftTop", value)
 
                           const commentLabel = (range: SelectedLineRange) => {
                             const start = Math.min(range.start, range.end)
@@ -2695,7 +2734,7 @@ export default function Page() {
                             terminal={pty}
                             onClose={() => {
                               view().terminal.close()
-                              setAutoCreated(false)
+                              setUi("autoCreated", false)
                             }}
                           />
                         )}

+ 24 - 17
packages/app/src/utils/speech.ts

@@ -1,4 +1,5 @@
-import { createSignal, onCleanup } from "solid-js"
+import { onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 
 // Minimal types to avoid relying on non-standard DOM typings
 type RecognitionResult = {
@@ -59,9 +60,15 @@ export function createSpeechRecognition(opts?: {
     typeof window !== "undefined" &&
     Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
 
-  const [isRecording, setIsRecording] = createSignal(false)
-  const [committed, setCommitted] = createSignal("")
-  const [interim, setInterim] = createSignal("")
+  const [store, setStore] = createStore({
+    isRecording: false,
+    committed: "",
+    interim: "",
+  })
+
+  const isRecording = () => store.isRecording
+  const committed = () => store.committed
+  const interim = () => store.interim
 
   let recognition: Recognition | undefined
   let shouldContinue = false
@@ -82,7 +89,7 @@ export function createSpeechRecognition(opts?: {
     const nextCommitted = appendSegment(committedText, segment)
     if (nextCommitted === committedText) return
     committedText = nextCommitted
-    setCommitted(committedText)
+    setStore("committed", committedText)
     if (opts?.onFinal) opts.onFinal(segment.trim())
   }
 
@@ -98,7 +105,7 @@ export function createSpeechRecognition(opts?: {
     pendingHypothesis = ""
     lastInterimSuffix = ""
     shrinkCandidate = undefined
-    setInterim("")
+    setStore("interim", "")
     if (opts?.onInterim) opts.onInterim("")
   }
 
@@ -107,7 +114,7 @@ export function createSpeechRecognition(opts?: {
     pendingHypothesis = hypothesis
     lastInterimSuffix = suffix
     shrinkCandidate = undefined
-    setInterim(suffix)
+    setStore("interim", suffix)
     if (opts?.onInterim) {
       opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
     }
@@ -122,7 +129,7 @@ export function createSpeechRecognition(opts?: {
       pendingHypothesis = ""
       lastInterimSuffix = ""
       shrinkCandidate = undefined
-      setInterim("")
+      setStore("interim", "")
       if (opts?.onInterim) opts.onInterim("")
     }, COMMIT_DELAY)
   }
@@ -162,7 +169,7 @@ export function createSpeechRecognition(opts?: {
         pendingHypothesis = ""
         lastInterimSuffix = ""
         shrinkCandidate = undefined
-        setInterim("")
+        setStore("interim", "")
         if (opts?.onInterim) opts.onInterim("")
         return
       }
@@ -211,7 +218,7 @@ export function createSpeechRecognition(opts?: {
       lastInterimSuffix = ""
       shrinkCandidate = undefined
       if (e.error === "no-speech" && shouldContinue) {
-        setInterim("")
+        setStore("interim", "")
         if (opts?.onInterim) opts.onInterim("")
         setTimeout(() => {
           try {
@@ -221,7 +228,7 @@ export function createSpeechRecognition(opts?: {
         return
       }
       shouldContinue = false
-      setIsRecording(false)
+      setStore("isRecording", false)
     }
 
     recognition.onstart = () => {
@@ -230,16 +237,16 @@ export function createSpeechRecognition(opts?: {
       cancelPendingCommit()
       lastInterimSuffix = ""
       shrinkCandidate = undefined
-      setInterim("")
+      setStore("interim", "")
       if (opts?.onInterim) opts.onInterim("")
-      setIsRecording(true)
+      setStore("isRecording", true)
     }
 
     recognition.onend = () => {
       cancelPendingCommit()
       lastInterimSuffix = ""
       shrinkCandidate = undefined
-      setIsRecording(false)
+      setStore("isRecording", false)
       if (shouldContinue) {
         setTimeout(() => {
           try {
@@ -258,7 +265,7 @@ export function createSpeechRecognition(opts?: {
     cancelPendingCommit()
     lastInterimSuffix = ""
     shrinkCandidate = undefined
-    setInterim("")
+    setStore("interim", "")
     try {
       recognition.start()
     } catch {}
@@ -271,7 +278,7 @@ export function createSpeechRecognition(opts?: {
     cancelPendingCommit()
     lastInterimSuffix = ""
     shrinkCandidate = undefined
-    setInterim("")
+    setStore("interim", "")
     if (opts?.onInterim) opts.onInterim("")
     try {
       recognition.stop()
@@ -284,7 +291,7 @@ export function createSpeechRecognition(opts?: {
     cancelPendingCommit()
     lastInterimSuffix = ""
     shrinkCandidate = undefined
-    setInterim("")
+    setStore("interim", "")
     if (opts?.onInterim) opts.onInterim("")
     try {
       recognition?.stop()