Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app/src/components/dialog-fork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
})
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 30 additions & 15 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
})
})
}),
)
Expand Down
90 changes: 90 additions & 0 deletions packages/app/src/pages/session/session-resume.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
46 changes: 46 additions & 0 deletions packages/app/src/pages/session/session-resume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const skipped = new Set<string>()

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<unknown>
resumeSession: (input: { sessionID: string }) => Promise<unknown>
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
}
}
Loading