summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-03-21 23:33:04 +0800
committerGitHub <[email protected]>2026-03-21 23:33:04 +0800
commit6a16db4b929422b6f5ef7072ac889cec41ae1eb2 (patch)
tree2663160c18632706372f83432d44f1a741a613bf
parent9ad6588f3e0066125033810a5e0e4dc08f0c6961 (diff)
downloadopencode-6a16db4b929422b6f5ef7072ac889cec41ae1eb2.tar.gz
opencode-6a16db4b929422b6f5ef7072ac889cec41ae1eb2.zip
app: manage mutation loading states with tanstack query (#18501)
-rw-r--r--bun.lock5
-rw-r--r--packages/app/package.json1
-rw-r--r--packages/app/src/app.tsx18
-rw-r--r--packages/app/src/components/dialog-connect-provider.tsx3
-rw-r--r--packages/app/src/components/dialog-custom-provider-form.ts1
-rw-r--r--packages/app/src/components/dialog-custom-provider.test.ts2
-rw-r--r--packages/app/src/components/dialog-custom-provider.tsx77
-rw-r--r--packages/app/src/components/dialog-edit-project.tsx59
-rw-r--r--packages/app/src/components/dialog-select-mcp.tsx30
-rw-r--r--packages/app/src/components/dialog-select-server.tsx173
-rw-r--r--packages/app/src/components/status-popover.tsx58
-rw-r--r--packages/app/src/pages/session.tsx252
-rw-r--r--packages/app/src/pages/session/composer/session-question-dock.tsx84
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx124
14 files changed, 454 insertions, 433 deletions
diff --git a/bun.lock b/bun.lock
index 58cfe892f..7e07e61dd 100644
--- a/bun.lock
+++ b/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=="],
diff --git a/packages/app/package.json b/packages/app/package.json
index 545d31309..3f4e2472f 100644
--- a/packages/app/package.json
+++ b/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:",
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 9a282bbb7..5247c951d 100644
--- a/packages/app/src/app.tsx
+++ b/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>
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx
index e4fe9e7c4..734958dd5 100644
--- a/packages/app/src/components/dialog-connect-provider.tsx
+++ b/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 }) {
diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts
index 92d235c3b..e26dcb097 100644
--- a/packages/app/src/components/dialog-custom-provider-form.ts
+++ b/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
diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts
index 8cfd78ebe..07dd26ecd 100644
--- a/packages/app/src/components/dialog-custom-provider.test.ts
+++ b/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,
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx
index 4d220a0b1..53b66fb45 100644
--- a/packages/app/src/components/dialog-custom-provider.tsx
+++ b/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>
diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx
index ec0793c54..eb962f47e 100644
--- a/packages/app/src/components/dialog-edit-project.tsx
+++ b/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>
diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx
index f8913eee4..fafba6168 100644
--- a/packages/app/src/components/dialog-select-mcp.tsx
+++ b/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>
)
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx
index f8d14cbb9..ca4c42a37 100644
--- a/packages/app/src/components/dialog-select-server.tsx
+++ b/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")
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 063205f0c..464522443 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/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>
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 6d2917008..428826f6a 100644
--- a/packages/app/src/pages/session.tsx
+++ b/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
diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx
index b66c27579..7ba07b15d 100644
--- a/packages/app/src/pages/session/composer/session-question-dock.tsx
+++ b/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()
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 74f2e8c2c..2ec5352aa 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/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>