From 16da91c9c8ad73a07c238ac35167549c0b12a8f5 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 25 Mar 2026 18:29:30 +0000 Subject: [PATCH] fix(app): resume sessions in desktop app --- packages/app/src/components/dialog-fork.tsx | 2 + .../app/src/components/prompt-input/submit.ts | 2 + packages/app/src/pages/session.tsx | 45 ++++++---- .../src/pages/session/session-resume.test.ts | 90 +++++++++++++++++++ .../app/src/pages/session/session-resume.ts | 46 ++++++++++ 5 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/pages/session/session-resume.test.ts create mode 100644 packages/app/src/pages/session/session-resume.ts diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 9e1b896fa8f5..7012ea9109d7 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -11,6 +11,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" import { useLanguage } from "@/context/language" +import { skipSessionResume } from "@/pages/session/session-resume" interface ForkableMessage { id: string @@ -76,6 +77,7 @@ export const DialogFork: Component = () => { return } dialog.close() + skipSessionResume(forked.data.id) prompt.set(restored, undefined, { dir, id: forked.data.id }) navigate(`/${dir}/session/${forked.data.id}`) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index ba299fe3650d..94728f0f1eab 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { skipSessionResume } from "@/pages/session/session-resume" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import { buildRequestParts } from "./build-request-parts" @@ -370,6 +371,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (created) { seed(sessionDirectory, created) session = created + skipSessionResume(created.id) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) local.session.promote(sessionDirectory, session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 19dcba58ee29..65e8aa8ac0dd 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -52,6 +52,7 @@ import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" import { syncSessionModel } from "@/pages/session/session-model-helpers" +import { syncSession, takeSessionResume } from "@/pages/session/session-resume" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" @@ -689,9 +690,12 @@ export default function Page() { } const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs + let resumed: string | undefined + let syncRun = 0 createEffect( on([() => sdk.directory, () => params.id] as const, ([, id]) => { + const run = ++syncRun if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) refreshFrame = undefined @@ -707,21 +711,32 @@ export default function Page() { return Date.now() - info.at > SESSION_PREFETCH_TTL })() const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined) - - untrack(() => { - void sync.session.sync(id) - }) - - refreshFrame = requestAnimationFrame(() => { - refreshFrame = undefined - refreshTimer = window.setTimeout(() => { - refreshTimer = undefined - if (params.id !== id) return - untrack(() => { - if (stale) void sync.session.sync(id, { force: true }) - void sync.session.todo(id, todos ? { force: true } : undefined) - }) - }, 0) + const resume = takeSessionResume(id) + + void untrack(async () => { + resumed = await syncSession({ + sessionID: id, + resume, + resumed, + sync: sync.session.sync, + resumeSession: sdk.client.session.resume, + onResumeError: console.error, + onSyncError: () => {}, + onMissing: () => {}, + }) + if (run !== syncRun) return + + refreshFrame = requestAnimationFrame(() => { + refreshFrame = undefined + refreshTimer = window.setTimeout(() => { + refreshTimer = undefined + if (params.id !== id) return + untrack(() => { + if (stale) void sync.session.sync(id, { force: true }) + void sync.session.todo(id, todos ? { force: true } : undefined) + }) + }, 0) + }) }) }), ) diff --git a/packages/app/src/pages/session/session-resume.test.ts b/packages/app/src/pages/session/session-resume.test.ts new file mode 100644 index 000000000000..0c62db3ab213 --- /dev/null +++ b/packages/app/src/pages/session/session-resume.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, mock, test } from "bun:test" +import { shouldResumeSession, skipSessionResume, syncSession, takeSessionResume } from "./session-resume" + +describe("session resume", () => { + test("skips resume when navigation opts out", () => { + expect(shouldResumeSession({ sessionID: "ses_1", resume: false })).toBe(false) + }) + + test("skips resume after the session was already resumed", () => { + expect(shouldResumeSession({ sessionID: "ses_1", resumed: "ses_1" })).toBe(false) + }) + + test("consumes one-shot resume skip markers", () => { + skipSessionResume("ses_1") + + expect(takeSessionResume("ses_1")).toBe(false) + expect(takeSessionResume("ses_1")).toBe(true) + }) + + test("resumes once after sync succeeds", async () => { + const calls: string[] = [] + + const result = await syncSession({ + sessionID: "ses_1", + sync: async (sessionID) => { + calls.push(`sync:${sessionID}`) + }, + resumeSession: async ({ sessionID }) => { + calls.push(`resume:${sessionID}`) + }, + onScroll: () => { + calls.push("scroll") + }, + onResumeError: () => { + calls.push("resume-error") + }, + onSyncError: () => { + calls.push("sync-error") + }, + onMissing: () => { + calls.push("missing") + }, + }) + + expect(result).toBe("ses_1") + expect(calls).toEqual(["sync:ses_1", "scroll", "resume:ses_1"]) + }) + + test("clears resumed state when resume fails so it can retry", async () => { + const err = new Error("boom") + const onResumeError = mock(() => {}) + + const result = await syncSession({ + sessionID: "ses_1", + resumed: "ses_old", + sync: async () => {}, + resumeSession: async () => { + throw err + }, + onResumeError, + onSyncError: mock(() => {}), + onMissing: mock(() => {}), + }) + + expect(result).toBeUndefined() + expect(onResumeError).toHaveBeenCalledWith(err) + }) + + test("shows missing-session flow when sync fails", async () => { + const err = new Error("missing") + const onSyncError = mock(() => {}) + const onMissing = mock(() => {}) + + const result = await syncSession({ + sessionID: "ses_1", + resumed: "ses_prev", + sync: async () => { + throw err + }, + resumeSession: async () => {}, + onResumeError: mock(() => {}), + onSyncError, + onMissing, + }) + + expect(result).toBe("ses_prev") + expect(onSyncError).toHaveBeenCalledWith(err) + expect(onMissing).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app/src/pages/session/session-resume.ts b/packages/app/src/pages/session/session-resume.ts new file mode 100644 index 000000000000..a36070e27928 --- /dev/null +++ b/packages/app/src/pages/session/session-resume.ts @@ -0,0 +1,46 @@ +const skipped = new Set() + +export function skipSessionResume(sessionID: string) { + skipped.add(sessionID) +} + +export function takeSessionResume(sessionID: string) { + if (!skipped.has(sessionID)) return true + skipped.delete(sessionID) + return false +} + +export function shouldResumeSession(input: { sessionID: string; resume?: boolean; resumed?: string }) { + return input.resume !== false && input.resumed !== input.sessionID +} + +export async function syncSession(input: { + sessionID: string + resume?: boolean + resumed?: string + sync: (sessionID: string) => Promise + resumeSession: (input: { sessionID: string }) => Promise + onScroll?: () => void + onResumeError: (err: unknown) => void + onSyncError: (err: unknown) => void + onMissing: () => void +}) { + try { + await input.sync(input.sessionID) + } catch (err) { + input.onSyncError(err) + input.onMissing() + return input.resumed + } + + input.onScroll?.() + if (!shouldResumeSession(input)) return input.resumed + + try { + await input.resumeSession({ sessionID: input.sessionID }) + return input.sessionID + } catch (err) { + input.onResumeError(err) + return undefined + } +}