diff options
| author | Brendan Allan <[email protected]> | 2026-03-21 23:33:04 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-21 23:33:04 +0800 |
| commit | 6a16db4b929422b6f5ef7072ac889cec41ae1eb2 (patch) | |
| tree | 2663160c18632706372f83432d44f1a741a613bf /packages/app/src/pages | |
| parent | 9ad6588f3e0066125033810a5e0e4dc08f0c6961 (diff) | |
| download | opencode-6a16db4b929422b6f5ef7072ac889cec41ae1eb2.tar.gz opencode-6a16db4b929422b6f5ef7072ac889cec41ae1eb2.zip | |
app: manage mutation loading states with tanstack query (#18501)
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/session.tsx | 252 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-question-dock.tsx | 84 | ||||
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 124 |
3 files changed, 234 insertions, 226 deletions
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> |
