diff --git a/bun.lock b/bun.lock index 58cfe892f39..7e07e61ddf8 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/directive-functions-plugin@1.134.5", "", { "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/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="], + "@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "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/server-functions-plugin@1.134.5", "", { "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/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], + "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "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 545d3130980..3f4e2472f2a 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 9a282bbb708..5247c951d32 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 {props.children} } +function QueryProvider(props: ParentProps) { + const client = new QueryClient() + return {props.children} +} + function AppShellProviders(props: ParentProps) { return ( @@ -136,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) { }> - - - {props.children} - - + + + + {props.children} + + + diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index e4fe9e7c4ed..734958dd589 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 92d235c3bcc..e26dcb09710 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 8cfd78ebeb3..07dd26ecd67 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 4d220a0b191..53b66fb451d 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>) => { + 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) { - diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ec0793c540e..eb962f47eb0 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 }) { - diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index f8913eee4fb..fafba6168cc 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(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 = () => { {statusLabel()} - + {language.t("common.loading.ellipsis")} @@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
e.stopPropagation()}> - toggle(i.name)} /> + { + if (toggle.isPending) return + toggle.mutate(i.name) + }} + />
) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index f8d14cbb943..ca4c42a3769 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 063205f0c30..464522443f1 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 - sdk: ReturnType - language: ReturnType -}) => { - const [loading, setLoading] = createSignal(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() { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081a..428826f6ad9 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, - sending: {} as Record, failed: {} as Record, paused: {} as Record, 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")} - @@ -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 b66c27579a9..7ba07b15d0c 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={ <> -
0}> - -
@@ -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())} >