فهرست منبع

app: manage mutation loading states with tanstack query (#18501)

Brendan Allan 4 هفته پیش
والد
کامیت
6a16db4b92

+ 5 - 0
bun.lock

@@ -44,6 +44,7 @@
         "@solid-primitives/websocket": "1.3.1",
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "catalog:",
+        "@tanstack/solid-query": "5.91.4",
         "@thisbeyond/solid-dnd": "0.7.5",
         "diff": "catalog:",
         "effect": "catalog:",
@@ -1966,10 +1967,14 @@
 
     "@tanstack/directive-functions-plugin": ["@tanstack/[email protected]", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
 
+    "@tanstack/query-core": ["@tanstack/[email protected]", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="],
+
     "@tanstack/router-utils": ["@tanstack/[email protected]", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
 
     "@tanstack/server-functions-plugin": ["@tanstack/[email protected]", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
 
+    "@tanstack/solid-query": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
+
     "@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
 
     "@tauri-apps/cli": ["@tauri-apps/[email protected]", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],

+ 1 - 0
packages/app/package.json

@@ -54,6 +54,7 @@
     "@solid-primitives/websocket": "1.3.1",
     "@solidjs/meta": "catalog:",
     "@solidjs/router": "catalog:",
+    "@tanstack/solid-query": "5.91.4",
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "catalog:",
     "effect": "catalog:",

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

@@ -9,6 +9,7 @@ import { Splash } from "@opencode-ai/ui/logo"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { MetaProvider } from "@solidjs/meta"
 import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
+import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
 import { type Duration, Effect } from "effect"
 import {
   type Component,
@@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
   return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
 }
 
+function QueryProvider(props: ParentProps) {
+  const client = new QueryClient()
+  return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
+}
+
 function AppShellProviders(props: ParentProps) {
   return (
     <SettingsProvider>
@@ -136,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) {
         <LanguageProvider>
           <UiI18nBridge>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
-              <DialogProvider>
-                <MarkedProviderWithNativeParser>
-                  <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
-                </MarkedProviderWithNativeParser>
-              </DialogProvider>
+              <QueryProvider>
+                <DialogProvider>
+                  <MarkedProviderWithNativeParser>
+                    <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
+                  </MarkedProviderWithNativeParser>
+                </DialogProvider>
+              </QueryProvider>
             </ErrorBoundary>
           </UiI18nBridge>
         </LanguageProvider>

+ 1 - 2
packages/app/src/components/dialog-connect-provider.tsx

@@ -12,10 +12,9 @@ import { showToast } from "@opencode-ai/ui/toast"
 import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
-import { useLanguage } from "@/context/language"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
-import { DialogSelectModel } from "./dialog-select-model"
+import { useLanguage } from "@/context/language"
 import { DialogSelectProvider } from "./dialog-select-provider"
 
 export function DialogConnectProvider(props: { provider: string }) {

+ 0 - 1
packages/app/src/components/dialog-custom-provider-form.ts

@@ -34,7 +34,6 @@ export type FormState = {
   apiKey: string
   models: ModelRow[]
   headers: HeaderRow[]
-  saving: boolean
   err: {
     providerID?: string
     name?: string

+ 0 - 2
packages/app/src/components/dialog-custom-provider.test.ts

@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
           { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
           { row: "h1", key: "", value: "", err: {} },
         ],
-        saving: false,
         err: {},
       },
       t,
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
           { row: "h0", key: "Authorization", value: "one", err: {} },
           { row: "h1", key: "authorization", value: "two", err: {} },
         ],
-        saving: false,
         err: {},
       },
       t,

+ 42 - 35
packages/app/src/components/dialog-custom-provider.tsx

@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { useMutation } from "@tanstack/solid-query"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
 import { batch, For } from "solid-js"
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
     apiKey: "",
     models: [modelRow()],
     headers: [headerRow()],
-    saving: false,
     err: {},
   })
 
@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
     return output.result
   }
 
-  const save = async (e: SubmitEvent) => {
-    e.preventDefault()
-    if (form.saving) return
-
-    const result = validate()
-    if (!result) return
+  const saveMutation = useMutation(() => ({
+    mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
+      const disabledProviders = globalSync.data.config.disabled_providers ?? []
+      const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
 
-    setForm("saving", true)
-
-    const disabledProviders = globalSync.data.config.disabled_providers ?? []
-    const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
-
-    const auth = result.key
-      ? globalSDK.client.auth.set({
+      if (result.key) {
+        await globalSDK.client.auth.set({
           providerID: result.providerID,
           auth: {
             type: "api",
             key: result.key,
           },
         })
-      : Promise.resolve()
+      }
 
-    auth
-      .then(() =>
-        globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
-      )
-      .then(() => {
-        dialog.close()
-        showToast({
-          variant: "success",
-          icon: "circle-check",
-          title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
-          description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
-        })
+      await globalSync.updateConfig({
+        provider: { [result.providerID]: result.config },
+        disabled_providers: nextDisabled,
       })
-      .catch((err: unknown) => {
-        const message = err instanceof Error ? err.message : String(err)
-        showToast({ title: language.t("common.requestFailed"), description: message })
-      })
-      .finally(() => {
-        setForm("saving", false)
+      return result
+    },
+    onSuccess: (result) => {
+      dialog.close()
+      showToast({
+        variant: "success",
+        icon: "circle-check",
+        title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
+        description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
       })
+    },
+    onError: (err) => {
+      const message = err instanceof Error ? err.message : String(err)
+      showToast({ title: language.t("common.requestFailed"), description: message })
+    },
+  }))
+
+  const save = (e: SubmitEvent) => {
+    e.preventDefault()
+    if (saveMutation.isPending) return
+
+    const result = validate()
+    if (!result) return
+    saveMutation.mutate(result)
   }
 
   return (
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
             </Button>
           </div>
 
-          <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
-            {form.saving ? language.t("common.saving") : language.t("common.submit")}
+          <Button
+            class="w-auto self-start"
+            type="submit"
+            size="large"
+            variant="primary"
+            disabled={saveMutation.isPending}
+          >
+            {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
           </Button>
         </form>
       </div>

+ 29 - 30
packages/app/src/components/dialog-edit-project.tsx

@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { TextField } from "@opencode-ai/ui/text-field"
+import { useMutation } from "@tanstack/solid-query"
 import { Icon } from "@opencode-ai/ui/icon"
 import { createMemo, For, Show } from "solid-js"
 import { createStore } from "solid-js/store"
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
     color: props.project.icon?.color || "pink",
     iconUrl: props.project.icon?.override || "",
     startup: props.project.commands?.start ?? "",
-    saving: false,
     dragOver: false,
     iconHover: false,
   })
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
     setStore("iconUrl", "")
   }
 
-  async function handleSubmit(e: SubmitEvent) {
-    e.preventDefault()
-
-    await Promise.resolve()
-      .then(async () => {
-        setStore("saving", true)
-        const name = store.name.trim() === folderName() ? "" : store.name.trim()
-        const start = store.startup.trim()
+  const saveMutation = useMutation(() => ({
+    mutationFn: async () => {
+      const name = store.name.trim() === folderName() ? "" : store.name.trim()
+      const start = store.startup.trim()
 
-        if (props.project.id && props.project.id !== "global") {
-          await globalSDK.client.project.update({
-            projectID: props.project.id,
-            directory: props.project.worktree,
-            name,
-            icon: { color: store.color, override: store.iconUrl },
-            commands: { start },
-          })
-          globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
-          dialog.close()
-          return
-        }
-
-        globalSync.project.meta(props.project.worktree, {
+      if (props.project.id && props.project.id !== "global") {
+        await globalSDK.client.project.update({
+          projectID: props.project.id,
+          directory: props.project.worktree,
           name,
-          icon: { color: store.color, override: store.iconUrl || undefined },
-          commands: { start: start || undefined },
+          icon: { color: store.color, override: store.iconUrl },
+          commands: { start },
         })
+        globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
         dialog.close()
+        return
+      }
+
+      globalSync.project.meta(props.project.worktree, {
+        name,
+        icon: { color: store.color, override: store.iconUrl || undefined },
+        commands: { start: start || undefined },
       })
-      .finally(() => {
-        setStore("saving", false)
-      })
+      dialog.close()
+    },
+  }))
+
+  function handleSubmit(e: SubmitEvent) {
+    e.preventDefault()
+    if (saveMutation.isPending) return
+    saveMutation.mutate()
   }
 
   return (
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
           <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
             {language.t("common.cancel")}
           </Button>
-          <Button type="submit" variant="primary" size="large" disabled={store.saving}>
-            {store.saving ? language.t("common.saving") : language.t("common.save")}
+          <Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
+            {saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
           </Button>
         </div>
       </form>

+ 17 - 13
packages/app/src/components/dialog-select-mcp.tsx

@@ -1,4 +1,5 @@
-import { Component, createMemo, createSignal, Show } from "solid-js"
+import { useMutation } from "@tanstack/solid-query"
+import { Component, createMemo, Show } from "solid-js"
 import { useSync } from "@/context/sync"
 import { useSDK } from "@/context/sdk"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
   const sync = useSync()
   const sdk = useSDK()
   const language = useLanguage()
-  const [loading, setLoading] = createSignal<string | null>(null)
 
   const items = createMemo(() =>
     Object.entries(sync.data.mcp ?? {})
@@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
       .sort((a, b) => a.name.localeCompare(b.name)),
   )
 
-  const toggle = async (name: string) => {
-    if (loading()) return
-    setLoading(name)
-    try {
+  const toggle = useMutation(() => ({
+    mutationFn: async (name: string) => {
       const status = sync.data.mcp[name]
       if (status?.status === "connected") {
         await sdk.client.mcp.disconnect({ name })
@@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
 
       const result = await sdk.client.mcp.status()
       if (result.data) sync.set("mcp", result.data)
-    } finally {
-      setLoading(null)
-    }
-  }
+    },
+  }))
 
   const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
   const totalCount = createMemo(() => items().length)
@@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
         filterKeys={["name", "status"]}
         sortBy={(a, b) => a.name.localeCompare(b.name)}
         onSelect={(x) => {
-          if (x) toggle(x.name)
+          if (!x || toggle.isPending) return
+          toggle.mutate(x.name)
         }}
       >
         {(i) => {
@@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
                   <Show when={statusLabel()}>
                     <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
                   </Show>
-                  <Show when={loading() === i.name}>
+                  <Show when={toggle.isPending && toggle.variables === i.name}>
                     <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
                   </Show>
                 </div>
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
                 </Show>
               </div>
               <div onClick={(e) => e.stopPropagation()}>
-                <Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
+                <Switch
+                  checked={enabled()}
+                  disabled={toggle.isPending && toggle.variables === i.name}
+                  onChange={() => {
+                    if (toggle.isPending) return
+                    toggle.mutate(i.name)
+                  }}
+                />
               </div>
             </div>
           )

+ 85 - 88
packages/app/src/components/dialog-select-server.tsx

@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { List } from "@opencode-ai/ui/list"
 import { TextField } from "@opencode-ai/ui/text-field"
+import { useMutation } from "@tanstack/solid-query"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useNavigate } from "@solidjs/router"
 import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@@ -186,7 +187,6 @@ export function DialogSelectServer() {
       name: "",
       username: DEFAULT_USERNAME,
       password: "",
-      adding: false,
       error: "",
       showForm: false,
       status: undefined as boolean | undefined,
@@ -198,7 +198,6 @@ export function DialogSelectServer() {
       username: "",
       password: "",
       error: "",
-      busy: false,
       status: undefined as boolean | undefined,
     },
   })
@@ -209,7 +208,6 @@ export function DialogSelectServer() {
       name: "",
       username: DEFAULT_USERNAME,
       password: "",
-      adding: false,
       error: "",
       showForm: false,
       status: undefined,
@@ -224,10 +222,78 @@ export function DialogSelectServer() {
       password: "",
       error: "",
       status: undefined,
-      busy: false,
     })
   }
 
+  const addMutation = useMutation(() => ({
+    mutationFn: async (value: string) => {
+      const normalized = normalizeServerUrl(value)
+      if (!normalized) {
+        resetAdd()
+        return
+      }
+
+      const conn: ServerConnection.Http = {
+        type: "http",
+        http: { url: normalized },
+      }
+      if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
+      if (store.addServer.password) conn.http.password = store.addServer.password
+      if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
+      const result = await checkServerHealth(conn.http)
+      if (!result.healthy) {
+        setStore("addServer", { error: language.t("dialog.server.add.error") })
+        return
+      }
+
+      resetAdd()
+      await select(conn, true)
+    },
+  }))
+
+  const editMutation = useMutation(() => ({
+    mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
+      if (input.original.type !== "http") return
+      const normalized = normalizeServerUrl(input.value)
+      if (!normalized) {
+        resetEdit()
+        return
+      }
+
+      const name = store.editServer.name.trim() || undefined
+      const username = store.editServer.username || undefined
+      const password = store.editServer.password || undefined
+      const existingName = input.original.displayName
+      if (
+        normalized === input.original.http.url &&
+        name === existingName &&
+        username === input.original.http.username &&
+        password === input.original.http.password
+      ) {
+        resetEdit()
+        return
+      }
+
+      const conn: ServerConnection.Http = {
+        type: "http",
+        displayName: name,
+        http: { url: normalized, username, password },
+      }
+      const result = await checkServerHealth(conn.http)
+      if (!result.healthy) {
+        setStore("editServer", { error: language.t("dialog.server.add.error") })
+        return
+      }
+      if (normalized === input.original.http.url) {
+        server.add(conn)
+      } else {
+        replaceServer(input.original, conn)
+      }
+
+      resetEdit()
+    },
+  }))
+
   const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
     const active = server.key
     const newConn = server.add(next)
@@ -296,7 +362,7 @@ export function DialogSelectServer() {
   }
 
   const handleAddChange = (value: string) => {
-    if (store.addServer.adding) return
+    if (addMutation.isPending) return
     setStore("addServer", { url: value, error: "" })
     void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
       setStore("addServer", { status: next }),
@@ -304,12 +370,12 @@ export function DialogSelectServer() {
   }
 
   const handleAddNameChange = (value: string) => {
-    if (store.addServer.adding) return
+    if (addMutation.isPending) return
     setStore("addServer", { name: value, error: "" })
   }
 
   const handleAddUsernameChange = (value: string) => {
-    if (store.addServer.adding) return
+    if (addMutation.isPending) return
     setStore("addServer", { username: value, error: "" })
     void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
       setStore("addServer", { status: next }),
@@ -317,7 +383,7 @@ export function DialogSelectServer() {
   }
 
   const handleAddPasswordChange = (value: string) => {
-    if (store.addServer.adding) return
+    if (addMutation.isPending) return
     setStore("addServer", { password: value, error: "" })
     void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
       setStore("addServer", { status: next }),
@@ -325,7 +391,7 @@ export function DialogSelectServer() {
   }
 
   const handleEditChange = (value: string) => {
-    if (store.editServer.busy) return
+    if (editMutation.isPending) return
     setStore("editServer", { value, error: "" })
     void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
       setStore("editServer", { status: next }),
@@ -333,12 +399,12 @@ export function DialogSelectServer() {
   }
 
   const handleEditNameChange = (value: string) => {
-    if (store.editServer.busy) return
+    if (editMutation.isPending) return
     setStore("editServer", { name: value, error: "" })
   }
 
   const handleEditUsernameChange = (value: string) => {
-    if (store.editServer.busy) return
+    if (editMutation.isPending) return
     setStore("editServer", { username: value, error: "" })
     void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
       setStore("editServer", { status: next }),
@@ -346,85 +412,13 @@ export function DialogSelectServer() {
   }
 
   const handleEditPasswordChange = (value: string) => {
-    if (store.editServer.busy) return
+    if (editMutation.isPending) return
     setStore("editServer", { password: value, error: "" })
     void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
       setStore("editServer", { status: next }),
     )
   }
 
-  async function handleAdd(value: string) {
-    if (store.addServer.adding) return
-    const normalized = normalizeServerUrl(value)
-    if (!normalized) {
-      resetAdd()
-      return
-    }
-
-    setStore("addServer", { adding: true, error: "" })
-
-    const conn: ServerConnection.Http = {
-      type: "http",
-      http: { url: normalized },
-    }
-    if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
-    if (store.addServer.password) conn.http.password = store.addServer.password
-    if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
-    const result = await checkServerHealth(conn.http)
-    setStore("addServer", { adding: false })
-    if (!result.healthy) {
-      setStore("addServer", { error: language.t("dialog.server.add.error") })
-      return
-    }
-
-    resetAdd()
-    await select(conn, true)
-  }
-
-  async function handleEdit(original: ServerConnection.Any, value: string) {
-    if (store.editServer.busy || original.type !== "http") return
-    const normalized = normalizeServerUrl(value)
-    if (!normalized) {
-      resetEdit()
-      return
-    }
-
-    const name = store.editServer.name.trim() || undefined
-    const username = store.editServer.username || undefined
-    const password = store.editServer.password || undefined
-    const existingName = original.displayName
-    if (
-      normalized === original.http.url &&
-      name === existingName &&
-      username === original.http.username &&
-      password === original.http.password
-    ) {
-      resetEdit()
-      return
-    }
-
-    setStore("editServer", { busy: true, error: "" })
-
-    const conn: ServerConnection.Http = {
-      type: "http",
-      displayName: name,
-      http: { url: normalized, username, password },
-    }
-    const result = await checkServerHealth(conn.http)
-    setStore("editServer", { busy: false })
-    if (!result.healthy) {
-      setStore("editServer", { error: language.t("dialog.server.add.error") })
-      return
-    }
-    if (normalized === original.http.url) {
-      server.add(conn)
-    } else {
-      replaceServer(original, conn)
-    }
-
-    resetEdit()
-  }
-
   const mode = createMemo<"list" | "add" | "edit">(() => {
     if (store.editServer.id) return "edit"
     if (store.addServer.showForm) return "add"
@@ -464,23 +458,26 @@ export function DialogSelectServer() {
       password: conn.http.password ?? "",
       error: "",
       status: store.status[ServerConnection.key(conn)]?.healthy,
-      busy: false,
     })
   }
 
   const submitForm = () => {
     if (mode() === "add") {
-      void handleAdd(store.addServer.url)
+      if (addMutation.isPending) return
+      setStore("addServer", { error: "" })
+      addMutation.mutate(store.addServer.url)
       return
     }
     const original = editing()
     if (!original) return
-    void handleEdit(original, store.editServer.value)
+    if (editMutation.isPending) return
+    setStore("editServer", { error: "" })
+    editMutation.mutate({ original, value: store.editServer.value })
   }
 
   const isFormMode = createMemo(() => mode() !== "list")
   const isAddMode = createMemo(() => mode() === "add")
-  const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
+  const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
 
   const formTitle = createMemo(() => {
     if (!isFormMode()) return language.t("dialog.server.title")

+ 27 - 31
packages/app/src/components/status-popover.tsx

@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { Popover } from "@opencode-ai/ui/popover"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Tabs } from "@opencode-ai/ui/tabs"
+import { useMutation } from "@tanstack/solid-query"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useNavigate } from "@solidjs/router"
 import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -130,41 +131,30 @@ const useDefaultServerKey = (
   }
 }
 
-const useMcpToggle = (input: {
-  sync: ReturnType<typeof useSync>
-  sdk: ReturnType<typeof useSDK>
-  language: ReturnType<typeof useLanguage>
-}) => {
-  const [loading, setLoading] = createSignal<string | null>(null)
-
-  const toggle = async (name: string) => {
-    if (loading()) return
-    setLoading(name)
+const useMcpToggleMutation = () => {
+  const sync = useSync()
+  const sdk = useSDK()
+  const language = useLanguage()
 
-    try {
-      const status = input.sync.data.mcp[name]
-      await (status?.status === "connected"
-        ? input.sdk.client.mcp.disconnect({ name })
-        : input.sdk.client.mcp.connect({ name }))
-      const result = await input.sdk.client.mcp.status()
-      if (result.data) input.sync.set("mcp", result.data)
-    } catch (err) {
+  return useMutation(() => ({
+    mutationFn: async (name: string) => {
+      const status = sync.data.mcp[name]
+      await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
+      const result = await sdk.client.mcp.status()
+      if (result.data) sync.set("mcp", result.data)
+    },
+    onError: (err) => {
       showToast({
         variant: "error",
-        title: input.language.t("common.requestFailed"),
+        title: language.t("common.requestFailed"),
         description: err instanceof Error ? err.message : String(err),
       })
-    } finally {
-      setLoading(null)
-    }
-  }
-
-  return { loading, toggle }
+    },
+  }))
 }
 
 export function StatusPopover() {
   const sync = useSync()
-  const sdk = useSDK()
   const server = useServer()
   const platform = usePlatform()
   const dialog = useDialog()
@@ -181,7 +171,7 @@ export function StatusPopover() {
   })
   const health = useServerHealth(servers)
   const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
-  const mcp = useMcpToggle({ sync, sdk, language })
+  const toggleMcp = useMcpToggleMutation()
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
   const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
   const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -337,8 +327,11 @@ export function StatusPopover() {
                         <button
                           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={() => mcp.toggle(name)}
-                          disabled={mcp.loading() === name}
+                          onClick={() => {
+                            if (toggleMcp.isPending) return
+                            toggleMcp.mutate(name)
+                          }}
+                          disabled={toggleMcp.isPending && toggleMcp.variables === name}
                         >
                           <div
                             classList={{
@@ -354,8 +347,11 @@ export function StatusPopover() {
                           <div onClick={(event) => event.stopPropagation()}>
                             <Switch
                               checked={enabled()}
-                              disabled={mcp.loading() === name}
-                              onChange={() => mcp.toggle(name)}
+                              disabled={toggleMcp.isPending && toggleMcp.variables === name}
+                              onChange={() => {
+                                if (toggleMcp.isPending) return
+                                toggleMcp.mutate(name)
+                              }}
                             />
                           </div>
                         </button>

+ 130 - 122
packages/app/src/pages/session.tsx

@@ -1,5 +1,6 @@
 import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useMutation } from "@tanstack/solid-query"
 import {
   batch,
   onCleanup,
@@ -327,10 +328,7 @@ export default function Page() {
   })
 
   const [ui, setUi] = createStore({
-    git: false,
     pendingMessage: undefined as string | undefined,
-    restoring: undefined as string | undefined,
-    reverting: false,
     reviewSnap: false,
     scrollGesture: 0,
     scroll: {
@@ -506,7 +504,6 @@ export default function Page() {
 
   const [followup, setFollowup] = createStore({
     items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
-    sending: {} as Record<string, string | undefined>,
     failed: {} as Record<string, string | undefined>,
     paused: {} as Record<string, boolean | undefined>,
     edit: {} as Record<
@@ -644,25 +641,24 @@ export default function Page() {
     globalSync.set("project", [...list, next])
   }
 
-  function initGit() {
-    if (ui.git) return
-    setUi("git", true)
-    void sdk.client.project
-      .initGit()
-      .then((x) => {
-        if (!x.data) return
-        upsert(x.data)
-      })
-      .catch((err) => {
-        showToast({
-          variant: "error",
-          title: language.t("common.requestFailed"),
-          description: formatServerError(err, language.t),
-        })
-      })
-      .finally(() => {
-        setUi("git", false)
+  const gitMutation = useMutation(() => ({
+    mutationFn: () => sdk.client.project.initGit(),
+    onSuccess: (x) => {
+      if (!x.data) return
+      upsert(x.data)
+    },
+    onError: (err) => {
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: formatServerError(err, language.t),
       })
+    },
+  }))
+
+  function initGit() {
+    if (gitMutation.isPending) return
+    gitMutation.mutate()
   }
 
   let inputRef!: HTMLDivElement
@@ -961,8 +957,8 @@ export default function Page() {
               {language.t("session.review.noVcs.createGit.description")}
             </div>
           </div>
-          <Button size="large" disabled={ui.git} onClick={initGit}>
-            {ui.git
+          <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
+            {gitMutation.isPending
               ? language.t("session.review.noVcs.createGit.actionLoading")
               : language.t("session.review.noVcs.createGit.action")}
           </Button>
@@ -1379,10 +1375,40 @@ export default function Page() {
     return followup.edit[id]
   })
 
+  const followupMutation = useMutation(() => ({
+    mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
+      const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
+      if (!item) return
+
+      if (input.manual) setFollowup("paused", input.sessionID, undefined)
+      setFollowup("failed", input.sessionID, undefined)
+
+      const ok = await sendFollowupDraft({
+        client: sdk.client,
+        sync,
+        globalSync,
+        draft: item,
+        optimisticBusy: item.sessionDirectory === sdk.directory,
+      }).catch((err) => {
+        setFollowup("failed", input.sessionID, input.id)
+        fail(err)
+        return false
+      })
+      if (!ok) return
+
+      setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
+      if (input.manual) resumeScroll()
+    },
+  }))
+
+  const followupBusy = (sessionID: string) =>
+    followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
+
   const sendingFollowup = createMemo(() => {
     const id = params.id
     if (!id) return
-    return followup.sending[id]
+    if (!followupBusy(id)) return
+    return followupMutation.variables?.id
   })
 
   const queueEnabled = createMemo(() => {
@@ -1422,37 +1448,15 @@ export default function Page() {
   const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
     const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
     if (!item) return Promise.resolve()
-    if (followup.sending[sessionID]) return Promise.resolve()
-
-    if (opts?.manual) setFollowup("paused", sessionID, undefined)
-    setFollowup("sending", sessionID, id)
-    setFollowup("failed", sessionID, undefined)
-
-    return sendFollowupDraft({
-      client: sdk.client,
-      sync,
-      globalSync,
-      draft: item,
-      optimisticBusy: item.sessionDirectory === sdk.directory,
-    })
-      .then((ok) => {
-        if (ok === false) return
-        setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
-        if (opts?.manual) resumeScroll()
-      })
-      .catch((err) => {
-        setFollowup("failed", sessionID, id)
-        fail(err)
-      })
-      .finally(() => {
-        setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
-      })
+    if (followupBusy(sessionID)) return Promise.resolve()
+
+    return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
   }
 
   const editFollowup = (id: string) => {
     const sessionID = params.id
     if (!sessionID) return
-    if (followup.sending[sessionID]) return
+    if (followupBusy(sessionID)) return
 
     const item = queuedFollowups().find((entry) => entry.id === id)
     if (!item) return
@@ -1475,6 +1479,74 @@ export default function Page() {
   const halt = (sessionID: string) =>
     busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
 
+  const revertMutation = useMutation(() => ({
+    mutationFn: async (input: { sessionID: string; messageID: string }) => {
+      const prev = prompt.current().slice()
+      const last = info()?.revert
+      const value = draft(input.messageID)
+      batch(() => {
+        roll(input.sessionID, { messageID: input.messageID })
+        prompt.set(value)
+      })
+      await halt(input.sessionID)
+        .then(() => sdk.client.session.revert(input))
+        .then((result) => {
+          if (result.data) merge(result.data)
+        })
+        .catch((err) => {
+          batch(() => {
+            roll(input.sessionID, last)
+            prompt.set(prev)
+          })
+          fail(err)
+        })
+    },
+  }))
+
+  const restoreMutation = useMutation(() => ({
+    mutationFn: async (id: string) => {
+      const sessionID = params.id
+      if (!sessionID) return
+
+      const next = userMessages().find((item) => item.id > id)
+      const prev = prompt.current().slice()
+      const last = info()?.revert
+
+      batch(() => {
+        roll(sessionID, next ? { messageID: next.id } : undefined)
+        if (next) {
+          prompt.set(draft(next.id))
+          return
+        }
+        prompt.reset()
+      })
+
+      const task = !next
+        ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
+        : halt(sessionID).then(() =>
+            sdk.client.session.revert({
+              sessionID,
+              messageID: next.id,
+            }),
+          )
+
+      await task
+        .then((result) => {
+          if (result.data) merge(result.data)
+        })
+        .catch((err) => {
+          batch(() => {
+            roll(sessionID, last)
+            prompt.set(prev)
+          })
+          fail(err)
+        })
+    },
+  }))
+
+  const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
+  const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
+
   const fork = (input: { sessionID: string; messageID: string }) => {
     const value = draft(input.messageID)
     const dir = base64Encode(sdk.directory)
@@ -1496,77 +1568,13 @@ export default function Page() {
   }
 
   const revert = (input: { sessionID: string; messageID: string }) => {
-    if (ui.reverting || ui.restoring) return
-    const prev = prompt.current().slice()
-    const last = info()?.revert
-    const value = draft(input.messageID)
-    batch(() => {
-      setUi("reverting", true)
-      roll(input.sessionID, { messageID: input.messageID })
-      prompt.set(value)
-    })
-    return halt(input.sessionID)
-      .then(() => sdk.client.session.revert(input))
-      .then((result) => {
-        if (result.data) merge(result.data)
-      })
-      .catch((err) => {
-        batch(() => {
-          roll(input.sessionID, last)
-          prompt.set(prev)
-        })
-        fail(err)
-      })
-      .finally(() => {
-        setUi("reverting", false)
-      })
+    if (reverting()) return
+    return revertMutation.mutateAsync(input)
   }
 
   const restore = (id: string) => {
-    const sessionID = params.id
-    if (!sessionID || ui.restoring || ui.reverting) return
-
-    const next = userMessages().find((item) => item.id > id)
-    const prev = prompt.current().slice()
-    const last = info()?.revert
-
-    batch(() => {
-      setUi("restoring", id)
-      setUi("reverting", true)
-      roll(sessionID, next ? { messageID: next.id } : undefined)
-      if (next) {
-        prompt.set(draft(next.id))
-        return
-      }
-      prompt.reset()
-    })
-
-    const task = !next
-      ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
-      : halt(sessionID).then(() =>
-          sdk.client.session.revert({
-            sessionID,
-            messageID: next.id,
-          }),
-        )
-
-    return task
-      .then((result) => {
-        if (result.data) merge(result.data)
-      })
-      .catch((err) => {
-        batch(() => {
-          roll(sessionID, last)
-          prompt.set(prev)
-        })
-        fail(err)
-      })
-      .finally(() => {
-        batch(() => {
-          setUi("restoring", (value) => (value === id ? undefined : value))
-          setUi("reverting", false)
-        })
-      })
+    if (!params.id || reverting()) return
+    return restoreMutation.mutateAsync(id)
   }
 
   const rolled = createMemo(() => {
@@ -1585,7 +1593,7 @@ export default function Page() {
 
     const item = queuedFollowups()[0]
     if (!item) return
-    if (followup.sending[sessionID]) return
+    if (followupBusy(sessionID)) return
     if (followup.failed[sessionID] === item.id) return
     if (followup.paused[sessionID]) return
     if (composer.blocked()) return
@@ -1780,8 +1788,8 @@ export default function Page() {
               rolled().length > 0
                 ? {
                     items: rolled(),
-                    restoring: ui.restoring,
-                    disabled: ui.reverting,
+                    restoring: restoring(),
+                    disabled: reverting(),
                     onRestore: restore,
                   }
                 : undefined

+ 44 - 40
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -1,5 +1,6 @@
 import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
 import { createStore } from "solid-js/store"
+import { useMutation } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
 import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     custom: cached?.custom ?? ([] as string[]),
     customOn: cached?.customOn ?? ([] as boolean[]),
     editing: false,
-    sending: false,
   })
 
   let root: HTMLDivElement | undefined
@@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     showToast({ title: language.t("common.requestFailed"), description: message })
   }
 
-  const reply = async (answers: QuestionAnswer[]) => {
-    if (store.sending) return
-
-    props.onSubmit()
-    setStore("sending", true)
-    try {
-      await sdk.client.question.reply({ requestID: props.request.id, answers })
+  const replyMutation = useMutation(() => ({
+    mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
+    onMutate: () => {
+      props.onSubmit()
+    },
+    onSuccess: () => {
       replied = true
       cache.delete(props.request.id)
-    } catch (err) {
-      fail(err)
-    } finally {
-      setStore("sending", false)
-    }
+    },
+    onError: fail,
+  }))
+
+  const rejectMutation = useMutation(() => ({
+    mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
+    onMutate: () => {
+      props.onSubmit()
+    },
+    onSuccess: () => {
+      replied = true
+      cache.delete(props.request.id)
+    },
+    onError: fail,
+  }))
+
+  const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
+
+  const reply = async (answers: QuestionAnswer[]) => {
+    if (sending()) return
+    await replyMutation.mutateAsync(answers)
   }
 
   const reject = async () => {
-    if (store.sending) return
-
-    props.onSubmit()
-    setStore("sending", true)
-    try {
-      await sdk.client.question.reject({ requestID: props.request.id })
-      replied = true
-      cache.delete(props.request.id)
-    } catch (err) {
-      fail(err)
-    } finally {
-      setStore("sending", false)
-    }
+    if (sending()) return
+    await rejectMutation.mutateAsync()
   }
 
   const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
@@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const customToggle = () => {
-    if (store.sending) return
+    if (sending()) return
 
     if (!multi()) {
       setStore("customOn", store.tab, true)
@@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const customOpen = () => {
-    if (store.sending) return
+    if (sending()) return
     if (!on()) setStore("customOn", store.tab, true)
     setStore("editing", true)
     customUpdate(input(), true)
   }
 
   const selectOption = (optIndex: number) => {
-    if (store.sending) return
+    if (sending()) return
 
     if (optIndex === options().length) {
       customOpen()
@@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const next = () => {
-    if (store.sending) return
+    if (sending()) return
     if (store.editing) commitCustom()
 
     if (store.tab >= total() - 1) {
@@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const back = () => {
-    if (store.sending) return
+    if (sending()) return
     if (store.tab <= 0) return
     setStore("tab", store.tab - 1)
     setStore("editing", false)
   }
 
   const jump = (tab: number) => {
-    if (store.sending) return
+    if (sending()) return
     setStore("tab", tab)
     setStore("editing", false)
   }
@@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                     (store.answers[i()]?.length ?? 0) > 0 ||
                     (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
                   }
-                  disabled={store.sending}
+                  disabled={sending()}
                   onClick={() => jump(i())}
                   aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
                 />
@@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       }
       footer={
         <>
-          <Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
+          <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
             {language.t("ui.common.dismiss")}
           </Button>
           <div data-slot="question-footer-actions">
             <Show when={store.tab > 0}>
-              <Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
+              <Button variant="secondary" size="large" disabled={sending()} onClick={back}>
                 {language.t("ui.common.back")}
               </Button>
             </Show>
-            <Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
+            <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
               {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
             </Button>
           </div>
@@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                 data-picked={picked()}
                 role={multi() ? "checkbox" : "radio"}
                 aria-checked={picked()}
-                disabled={store.sending}
+                disabled={sending()}
                 onClick={() => selectOption(i())}
               >
                 <span data-slot="question-option-check" aria-hidden="true">
@@ -345,7 +349,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
               data-picked={on()}
               role={multi() ? "checkbox" : "radio"}
               aria-checked={on()}
-              disabled={store.sending}
+              disabled={sending()}
               onClick={customOpen}
             >
               <span
@@ -377,7 +381,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
             role={multi() ? "checkbox" : "radio"}
             aria-checked={on()}
             onMouseDown={(e) => {
-              if (store.sending) {
+              if (sending()) {
                 e.preventDefault()
                 return
               }
@@ -419,7 +423,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                 placeholder={language.t("ui.question.custom.placeholder")}
                 value={input()}
                 rows={1}
-                disabled={store.sending}
+                disabled={sending()}
                 onKeyDown={(e) => {
                   if (e.key === "Escape") {
                     e.preventDefault()

+ 60 - 64
packages/app/src/pages/session/message-timeline.tsx

@@ -1,6 +1,7 @@
 import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { useNavigate } from "@solidjs/router"
+import { useMutation } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -321,7 +322,6 @@ export function MessageTimeline(props: {
   const [title, setTitle] = createStore({
     draft: "",
     editing: false,
-    saving: false,
     menuOpen: false,
     pendingRename: false,
     pendingShare: false,
@@ -335,38 +335,6 @@ export function MessageTimeline(props: {
 
   let more: HTMLButtonElement | undefined
 
-  const [req, setReq] = createStore({ share: false, unshare: false })
-
-  const shareSession = () => {
-    const id = sessionID()
-    if (!id || req.share) return
-    if (!shareEnabled()) return
-    setReq("share", true)
-    globalSDK.client.session
-      .share({ sessionID: id, directory: sdk.directory })
-      .catch((err: unknown) => {
-        console.error("Failed to share session", err)
-      })
-      .finally(() => {
-        setReq("share", false)
-      })
-  }
-
-  const unshareSession = () => {
-    const id = sessionID()
-    if (!id || req.unshare) return
-    if (!shareEnabled()) return
-    setReq("unshare", true)
-    globalSDK.client.session
-      .unshare({ sessionID: id, directory: sdk.directory })
-      .catch((err: unknown) => {
-        console.error("Failed to unshare session", err)
-      })
-      .finally(() => {
-        setReq("unshare", false)
-      })
-  }
-
   const viewShare = () => {
     const url = shareUrl()
     if (!url) return
@@ -382,6 +350,53 @@ export function MessageTimeline(props: {
     return language.t("common.requestFailed")
   }
 
+  const shareMutation = useMutation(() => ({
+    mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
+    onError: (err) => {
+      console.error("Failed to share session", err)
+    },
+  }))
+
+  const unshareMutation = useMutation(() => ({
+    mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
+    onError: (err) => {
+      console.error("Failed to unshare session", err)
+    },
+  }))
+
+  const titleMutation = useMutation(() => ({
+    mutationFn: (input: { id: string; title: string }) => sdk.client.session.update({ sessionID: input.id, title: input.title }),
+    onSuccess: (_, input) => {
+      sync.set(
+        produce((draft) => {
+          const index = draft.session.findIndex((s) => s.id === input.id)
+          if (index !== -1) draft.session[index].title = input.title
+        }),
+      )
+      setTitle("editing", false)
+    },
+    onError: (err) => {
+      showToast({
+        title: language.t("common.requestFailed"),
+        description: errorMessage(err),
+      })
+    },
+  }))
+
+  const shareSession = () => {
+    const id = sessionID()
+    if (!id || shareMutation.isPending) return
+    if (!shareEnabled()) return
+    shareMutation.mutate(id)
+  }
+
+  const unshareSession = () => {
+    const id = sessionID()
+    if (!id || unshareMutation.isPending) return
+    if (!shareEnabled()) return
+    unshareMutation.mutate(id)
+  }
+
   createEffect(
     on(
       sessionKey,
@@ -389,7 +404,6 @@ export function MessageTimeline(props: {
         setTitle({
           draft: "",
           editing: false,
-          saving: false,
           menuOpen: false,
           pendingRename: false,
           pendingShare: false,
@@ -408,40 +422,22 @@ export function MessageTimeline(props: {
   }
 
   const closeTitleEditor = () => {
-    if (title.saving) return
-    setTitle({ editing: false, saving: false })
+    if (titleMutation.isPending) return
+    setTitle("editing", false)
   }
 
-  const saveTitleEditor = async () => {
+  const saveTitleEditor = () => {
     const id = sessionID()
     if (!id) return
-    if (title.saving) return
+    if (titleMutation.isPending) return
 
     const next = title.draft.trim()
     if (!next || next === (titleValue() ?? "")) {
-      setTitle({ editing: false, saving: false })
+      setTitle("editing", false)
       return
     }
 
-    setTitle("saving", true)
-    await sdk.client.session
-      .update({ sessionID: id, title: next })
-      .then(() => {
-        sync.set(
-          produce((draft) => {
-            const index = draft.session.findIndex((s) => s.id === id)
-            if (index !== -1) draft.session[index].title = next
-          }),
-        )
-        setTitle({ editing: false, saving: false })
-      })
-      .catch((err) => {
-        setTitle("saving", false)
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: errorMessage(err),
-        })
-      })
+    titleMutation.mutate({ id, title: next })
   }
 
   const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
@@ -712,7 +708,7 @@ export function MessageTimeline(props: {
                               titleRef = el
                             }}
                             value={title.draft}
-                            disabled={title.saving}
+                            disabled={titleMutation.isPending}
                             class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
                             style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
                             onInput={(event) => setTitle("draft", event.currentTarget.value)}
@@ -863,9 +859,9 @@ export function MessageTimeline(props: {
                                         variant="primary"
                                         class="w-full"
                                         onClick={shareSession}
-                                        disabled={req.share}
+                                        disabled={shareMutation.isPending}
                                       >
-                                        {req.share
+                                        {shareMutation.isPending
                                           ? language.t("session.share.action.publishing")
                                           : language.t("session.share.action.publish")}
                                       </Button>
@@ -886,9 +882,9 @@ export function MessageTimeline(props: {
                                           variant="secondary"
                                           class="w-full shadow-none border border-border-weak-base"
                                           onClick={unshareSession}
-                                          disabled={req.unshare}
+                                          disabled={unshareMutation.isPending}
                                         >
-                                          {req.unshare
+                                          {unshareMutation.isPending
                                             ? language.t("session.share.action.unpublishing")
                                             : language.t("session.share.action.unpublish")}
                                         </Button>
@@ -897,7 +893,7 @@ export function MessageTimeline(props: {
                                           variant="primary"
                                           class="w-full"
                                           onClick={viewShare}
-                                          disabled={req.unshare}
+                                          disabled={unshareMutation.isPending}
                                         >
                                           {language.t("session.share.action.view")}
                                         </Button>