From 6a16db4b929422b6f5ef7072ac889cec41ae1eb2 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 21 Mar 2026 23:33:04 +0800 Subject: app: manage mutation loading states with tanstack query (#18501) --- packages/app/src/pages/session.tsx | 252 +++++++++++---------- .../session/composer/session-question-dock.tsx | 84 +++---- .../app/src/pages/session/message-timeline.tsx | 124 +++++----- 3 files changed, 234 insertions(+), 226 deletions(-) (limited to 'packages/app/src/pages') 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, - 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 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={ <> -
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())} >