From 58acd686df26cf15d7eef54b2c44862ec5f251e3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 16 May 2026 17:48:24 +0000 Subject: [PATCH] fix(app): optimistic UI updates for session archive, delete, rename, and question reply Session archive/delete: immediately remove from sidebar and navigate, rollback on API error by re-inserting at sorted position. Session rename: update title in onMutate, restore previous on error. Question reply/reject: fire-and-forget (onMutate already dismisses dock). These changes eliminate perceived latency on all session management operations by updating the UI optimistically and rolling back on failure. --- packages/app/src/context/sync.tsx | 22 ++- .../composer/session-question-dock.tsx | 8 +- .../src/pages/session/message-timeline.tsx | 138 ++++++++++-------- 3 files changed, 102 insertions(+), 66 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 34b597b6bb52..8eb950e05b98 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -597,17 +597,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }, more: createMemo(() => current()[0].session.length >= current()[0].limit), - archive: async (sessionID: string) => { + archive: (sessionID: string) => { const directory = sdk.directory const client = sdk.client - const [, setStore] = globalSync.child(directory) - await client.session.update({ sessionID, time: { archived: Date.now() } }) + const [store, setStore] = globalSync.child(directory) + const session = (() => { + const match = Binary.search(store.session, sessionID, (s) => s.id) + return match.found ? store.session[match.index] : undefined + })() + + // Optimistic: remove immediately setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) if (match.found) draft.session.splice(match.index, 1) }), ) + + client.session.update({ sessionID, time: { archived: Date.now() } }).catch(() => { + // Rollback: re-insert session + if (!session) return + setStore( + produce((draft) => { + const result = Binary.search(draft.session, sessionID, (s) => s.id) + if (!result.found) draft.session.splice(result.index, 0, session) + }), + ) + }) }, }, absolute, 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 35690030c913..3d3ad7b0d7cc 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -230,14 +230,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending) - const reply = async (answers: QuestionAnswer[]) => { + const reply = (answers: QuestionAnswer[]) => { if (sending()) return - await replyMutation.mutateAsync(answers) + replyMutation.mutate(answers) } - const reject = async () => { + const reject = () => { if (sending()) return - await rejectMutation.mutateAsync() + rejectMutation.mutate() } const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 8bbaafb4e433..da2eb633a48b 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -401,7 +401,8 @@ export function MessageTimeline(props: { const titleMutation = useMutation(() => ({ mutationFn: (input: { id: string; title: string }) => sdk.client.session.update({ sessionID: input.id, title: input.title }), - onSuccess: (_, input) => { + onMutate: (input) => { + const prev = sync.session.get(input.id)?.title sync.set( produce((draft) => { const index = draft.session.findIndex((s) => s.id === input.id) @@ -409,8 +410,17 @@ export function MessageTimeline(props: { }), ) setTitle("editing", false) + return { prev } }, - onError: (err) => { + onError: (err, input, context) => { + if (context?.prev !== undefined) { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === input.id) + if (index !== -1) draft.session[index].title = context.prev! + }), + ) + } showToast({ title: language.t("common.requestFailed"), description: errorMessage(err), @@ -500,7 +510,7 @@ export function MessageTimeline(props: { navigate(`/${params.dir}/session`) } - const archiveSession = async (sessionID: string) => { + const archiveSession = (sessionID: string) => { const session = sync.session.get(sessionID) if (!session) return @@ -508,26 +518,31 @@ export function MessageTimeline(props: { const index = sessions.findIndex((s) => s.id === sessionID) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - await sdk.client.session - .update({ sessionID, time: { archived: Date.now() } }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === sessionID) - if (index !== -1) draft.session.splice(index, 1) - }), - ) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) + // Optimistic: remove immediately and navigate + sync.set( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + + sdk.client.session.update({ sessionID, time: { archived: Date.now() } }).catch((err) => { + // Rollback: re-insert session + sync.set( + produce((draft) => { + const result = Binary.search(draft.session, sessionID, (s) => s.id) + if (!result.found) draft.session.splice(result.index, 0, session) + }), + ) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), }) + }) } - const deleteSession = async (sessionID: string) => { + const deleteSession = (sessionID: string) => { const session = sync.session.get(sessionID) if (!session) return false @@ -535,10 +550,53 @@ export function MessageTimeline(props: { const index = sessions.findIndex((s) => s.id === sessionID) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - const result = await sdk.client.session + // Collect sessions to remove (target + children) for rollback + const allSessions = sync.data.session ?? [] + const removedSessions: typeof allSessions = [] + const removed = new Set([sessionID]) + const byParent = new Map() + for (const item of allSessions) { + if (item.parentID) { + const existing = byParent.get(item.parentID) + if (existing) existing.push(item.id) + else byParent.set(item.parentID, [item.id]) + } + } + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + for (const child of byParent.get(parentID) ?? []) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + for (const s of allSessions) { + if (removed.has(s.id)) removedSessions.push(s) + } + + // Optimistic: remove immediately and navigate + sync.set( + produce((draft) => { + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + + sdk.client.session .delete({ sessionID }) .then((x) => x.data) .catch((err) => { + // Rollback: re-insert all removed sessions + sync.set( + produce((draft) => { + for (const s of removedSessions) { + const result = Binary.search(draft.session, s.id, (x) => x.id) + if (!result.found) draft.session.splice(result.index, 0, s) + } + }), + ) showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(err), @@ -546,44 +604,6 @@ export function MessageTimeline(props: { return false }) - if (!result) return false - - sync.set( - produce((draft) => { - const removed = new Set([sessionID]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) return true }