Skip to content

Commit 6a16db4

Browse files
authored
app: manage mutation loading states with tanstack query (anomalyco#18501)
1 parent 9ad6588 commit 6a16db4

14 files changed

Lines changed: 454 additions & 433 deletions

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@solid-primitives/websocket": "1.3.1",
5555
"@solidjs/meta": "catalog:",
5656
"@solidjs/router": "catalog:",
57+
"@tanstack/solid-query": "5.91.4",
5758
"@thisbeyond/solid-dnd": "0.7.5",
5859
"diff": "catalog:",
5960
"effect": "catalog:",

packages/app/src/app.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Splash } from "@opencode-ai/ui/logo"
99
import { ThemeProvider } from "@opencode-ai/ui/theme"
1010
import { MetaProvider } from "@solidjs/meta"
1111
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
12+
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
1213
import { type Duration, Effect } from "effect"
1314
import {
1415
type Component,
@@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
8182
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
8283
}
8384

85+
function QueryProvider(props: ParentProps) {
86+
const client = new QueryClient()
87+
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
88+
}
89+
8490
function AppShellProviders(props: ParentProps) {
8591
return (
8692
<SettingsProvider>
@@ -136,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) {
136142
<LanguageProvider>
137143
<UiI18nBridge>
138144
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
139-
<DialogProvider>
140-
<MarkedProviderWithNativeParser>
141-
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
142-
</MarkedProviderWithNativeParser>
143-
</DialogProvider>
145+
<QueryProvider>
146+
<DialogProvider>
147+
<MarkedProviderWithNativeParser>
148+
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
149+
</MarkedProviderWithNativeParser>
150+
</DialogProvider>
151+
</QueryProvider>
144152
</ErrorBoundary>
145153
</UiI18nBridge>
146154
</LanguageProvider>

packages/app/src/components/dialog-connect-provider.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@ import { showToast } from "@opencode-ai/ui/toast"
1212
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
1313
import { createStore, produce } from "solid-js/store"
1414
import { Link } from "@/components/link"
15-
import { useLanguage } from "@/context/language"
1615
import { useGlobalSDK } from "@/context/global-sdk"
1716
import { useGlobalSync } from "@/context/global-sync"
18-
import { DialogSelectModel } from "./dialog-select-model"
17+
import { useLanguage } from "@/context/language"
1918
import { DialogSelectProvider } from "./dialog-select-provider"
2019

2120
export function DialogConnectProvider(props: { provider: string }) {

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export type FormState = {
3434
apiKey: string
3535
models: ModelRow[]
3636
headers: HeaderRow[]
37-
saving: boolean
3837
err: {
3938
providerID?: string
4039
name?: string

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
1616
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
1717
{ row: "h1", key: "", value: "", err: {} },
1818
],
19-
saving: false,
2019
err: {},
2120
},
2221
t,
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
6059
{ row: "h0", key: "Authorization", value: "one", err: {} },
6160
{ row: "h1", key: "authorization", value: "two", err: {} },
6261
],
63-
saving: false,
6462
err: {},
6563
},
6664
t,

packages/app/src/components/dialog-custom-provider.tsx

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
33
import { Dialog } from "@opencode-ai/ui/dialog"
44
import { IconButton } from "@opencode-ai/ui/icon-button"
55
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
6+
import { useMutation } from "@tanstack/solid-query"
67
import { TextField } from "@opencode-ai/ui/text-field"
78
import { showToast } from "@opencode-ai/ui/toast"
89
import { batch, For } from "solid-js"
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
3132
apiKey: "",
3233
models: [modelRow()],
3334
headers: [headerRow()],
34-
saving: false,
3535
err: {},
3636
})
3737

@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
116116
return output.result
117117
}
118118

119-
const save = async (e: SubmitEvent) => {
120-
e.preventDefault()
121-
if (form.saving) return
122-
123-
const result = validate()
124-
if (!result) return
119+
const saveMutation = useMutation(() => ({
120+
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
121+
const disabledProviders = globalSync.data.config.disabled_providers ?? []
122+
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
125123

126-
setForm("saving", true)
127-
128-
const disabledProviders = globalSync.data.config.disabled_providers ?? []
129-
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
130-
131-
const auth = result.key
132-
? globalSDK.client.auth.set({
124+
if (result.key) {
125+
await globalSDK.client.auth.set({
133126
providerID: result.providerID,
134127
auth: {
135128
type: "api",
136129
key: result.key,
137130
},
138131
})
139-
: Promise.resolve()
132+
}
140133

141-
auth
142-
.then(() =>
143-
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
144-
)
145-
.then(() => {
146-
dialog.close()
147-
showToast({
148-
variant: "success",
149-
icon: "circle-check",
150-
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
151-
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
152-
})
134+
await globalSync.updateConfig({
135+
provider: { [result.providerID]: result.config },
136+
disabled_providers: nextDisabled,
153137
})
154-
.catch((err: unknown) => {
155-
const message = err instanceof Error ? err.message : String(err)
156-
showToast({ title: language.t("common.requestFailed"), description: message })
157-
})
158-
.finally(() => {
159-
setForm("saving", false)
138+
return result
139+
},
140+
onSuccess: (result) => {
141+
dialog.close()
142+
showToast({
143+
variant: "success",
144+
icon: "circle-check",
145+
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
146+
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
160147
})
148+
},
149+
onError: (err) => {
150+
const message = err instanceof Error ? err.message : String(err)
151+
showToast({ title: language.t("common.requestFailed"), description: message })
152+
},
153+
}))
154+
155+
const save = (e: SubmitEvent) => {
156+
e.preventDefault()
157+
if (saveMutation.isPending) return
158+
159+
const result = validate()
160+
if (!result) return
161+
saveMutation.mutate(result)
161162
}
162163

163164
return (
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
312313
</Button>
313314
</div>
314315

315-
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
316-
{form.saving ? language.t("common.saving") : language.t("common.submit")}
316+
<Button
317+
class="w-auto self-start"
318+
type="submit"
319+
size="large"
320+
variant="primary"
321+
disabled={saveMutation.isPending}
322+
>
323+
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
317324
</Button>
318325
</form>
319326
</div>

packages/app/src/components/dialog-edit-project.tsx

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
22
import { useDialog } from "@opencode-ai/ui/context/dialog"
33
import { Dialog } from "@opencode-ai/ui/dialog"
44
import { TextField } from "@opencode-ai/ui/text-field"
5+
import { useMutation } from "@tanstack/solid-query"
56
import { Icon } from "@opencode-ai/ui/icon"
67
import { createMemo, For, Show } from "solid-js"
78
import { createStore } from "solid-js/store"
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
2829
color: props.project.icon?.color || "pink",
2930
iconUrl: props.project.icon?.override || "",
3031
startup: props.project.commands?.start ?? "",
31-
saving: false,
3232
dragOver: false,
3333
iconHover: false,
3434
})
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
7171
setStore("iconUrl", "")
7272
}
7373

74-
async function handleSubmit(e: SubmitEvent) {
75-
e.preventDefault()
76-
77-
await Promise.resolve()
78-
.then(async () => {
79-
setStore("saving", true)
80-
const name = store.name.trim() === folderName() ? "" : store.name.trim()
81-
const start = store.startup.trim()
74+
const saveMutation = useMutation(() => ({
75+
mutationFn: async () => {
76+
const name = store.name.trim() === folderName() ? "" : store.name.trim()
77+
const start = store.startup.trim()
8278

83-
if (props.project.id && props.project.id !== "global") {
84-
await globalSDK.client.project.update({
85-
projectID: props.project.id,
86-
directory: props.project.worktree,
87-
name,
88-
icon: { color: store.color, override: store.iconUrl },
89-
commands: { start },
90-
})
91-
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
92-
dialog.close()
93-
return
94-
}
95-
96-
globalSync.project.meta(props.project.worktree, {
79+
if (props.project.id && props.project.id !== "global") {
80+
await globalSDK.client.project.update({
81+
projectID: props.project.id,
82+
directory: props.project.worktree,
9783
name,
98-
icon: { color: store.color, override: store.iconUrl || undefined },
99-
commands: { start: start || undefined },
84+
icon: { color: store.color, override: store.iconUrl },
85+
commands: { start },
10086
})
87+
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
10188
dialog.close()
89+
return
90+
}
91+
92+
globalSync.project.meta(props.project.worktree, {
93+
name,
94+
icon: { color: store.color, override: store.iconUrl || undefined },
95+
commands: { start: start || undefined },
10296
})
103-
.finally(() => {
104-
setStore("saving", false)
105-
})
97+
dialog.close()
98+
},
99+
}))
100+
101+
function handleSubmit(e: SubmitEvent) {
102+
e.preventDefault()
103+
if (saveMutation.isPending) return
104+
saveMutation.mutate()
106105
}
107106

108107
return (
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
246245
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
247246
{language.t("common.cancel")}
248247
</Button>
249-
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
250-
{store.saving ? language.t("common.saving") : language.t("common.save")}
248+
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
249+
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
251250
</Button>
252251
</div>
253252
</form>

packages/app/src/components/dialog-select-mcp.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Component, createMemo, createSignal, Show } from "solid-js"
1+
import { useMutation } from "@tanstack/solid-query"
2+
import { Component, createMemo, Show } from "solid-js"
23
import { useSync } from "@/context/sync"
34
import { useSDK } from "@/context/sdk"
45
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -17,18 +18,15 @@ export const DialogSelectMcp: Component = () => {
1718
const sync = useSync()
1819
const sdk = useSDK()
1920
const language = useLanguage()
20-
const [loading, setLoading] = createSignal<string | null>(null)
2121

2222
const items = createMemo(() =>
2323
Object.entries(sync.data.mcp ?? {})
2424
.map(([name, status]) => ({ name, status: status.status }))
2525
.sort((a, b) => a.name.localeCompare(b.name)),
2626
)
2727

28-
const toggle = async (name: string) => {
29-
if (loading()) return
30-
setLoading(name)
31-
try {
28+
const toggle = useMutation(() => ({
29+
mutationFn: async (name: string) => {
3230
const status = sync.data.mcp[name]
3331
if (status?.status === "connected") {
3432
await sdk.client.mcp.disconnect({ name })
@@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
3836

3937
const result = await sdk.client.mcp.status()
4038
if (result.data) sync.set("mcp", result.data)
41-
} finally {
42-
setLoading(null)
43-
}
44-
}
39+
},
40+
}))
4541

4642
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
4743
const totalCount = createMemo(() => items().length)
@@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
5955
filterKeys={["name", "status"]}
6056
sortBy={(a, b) => a.name.localeCompare(b.name)}
6157
onSelect={(x) => {
62-
if (x) toggle(x.name)
58+
if (!x || toggle.isPending) return
59+
toggle.mutate(x.name)
6360
}}
6461
>
6562
{(i) => {
@@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
8380
<Show when={statusLabel()}>
8481
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
8582
</Show>
86-
<Show when={loading() === i.name}>
83+
<Show when={toggle.isPending && toggle.variables === i.name}>
8784
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
8885
</Show>
8986
</div>
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
9289
</Show>
9390
</div>
9491
<div onClick={(e) => e.stopPropagation()}>
95-
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
92+
<Switch
93+
checked={enabled()}
94+
disabled={toggle.isPending && toggle.variables === i.name}
95+
onChange={() => {
96+
if (toggle.isPending) return
97+
toggle.mutate(i.name)
98+
}}
99+
/>
96100
</div>
97101
</div>
98102
)

0 commit comments

Comments
 (0)