diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 05895d323..80159a22d 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -156,11 +156,14 @@ async function e2eAskPermission( always?: string[] }, ) { - const response = await fetch(`${project.url}/permission/__e2e/ask?directory=${encodeURIComponent(project.directory)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }) + const response = await fetch( + `${project.url}/permission/__e2e/ask?directory=${encodeURIComponent(project.directory)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ) expect(response.status).toBe(204) } @@ -382,6 +385,7 @@ async function todoDock(page: any, sessionID: string) { await expect.poll(readState, { timeout }).toMatchObject(expected) return api }, + readState, async expectUnmounted(timeout = 10_000) { await expect.poll(readUi, { timeout }).toMatchObject({ mounted: false, @@ -1133,6 +1137,8 @@ test("todo dock appears from real todowrite tool parts", async ({ page, llm, pro await project.prompt("Create a todo list and start counting.") + await dock.expectState({ dock: true, count: 3, states: ["completed", "in_progress", "pending"] }, 30_000) + expect((await dock.readState())?.openingSamples).toContain(true) await dock.expectCollapsed(["completed", "in_progress", "pending"]) }, { trackSession: project.trackSession }, @@ -1600,9 +1606,11 @@ test("todo dock does not flash on home after navigating from a session", async ( } }) observer.observe(document.body, { childList: true, subtree: true }) - ;(window as unknown as { - __todoDockObserver: { observer: MutationObserver; records: string[] } - }).__todoDockObserver = { observer, records } + ;( + window as unknown as { + __todoDockObserver: { observer: MutationObserver; records: string[] } + } + ).__todoDockObserver = { observer, records } }) await openSidebar(page) @@ -1689,7 +1697,12 @@ test("submit to question dock keeps latest turn visible", async ({ page, llm, pr ) }) -test("overflow question dock keeps keyboard focus visible without moving timeline", async ({ page, project, assistant, llm }) => { +test("overflow question dock keeps keyboard focus visible without moving timeline", async ({ + page, + project, + assistant, + llm, +}) => { const title = `e2e question overflow dock ${Date.now()}` const overflowQuestions = [ { @@ -1860,7 +1873,9 @@ test("cancelled question tool surfaces interrupted hint in message stream", asyn // Hint string lives in packages/ui/src/i18n/en.ts (not the app dict); // hardcode it here as the contract anchor for this fix. await expect( - page.getByText("This question was cancelled before it was answered. Ask again below if you want to continue."), + page.getByText( + "This question was cancelled before it was answered. Ask again below if you want to continue.", + ), ).toBeVisible({ timeout: 10_000 }) }) }, diff --git a/packages/app/e2e/session/session-todo-dock-restore.spec.ts b/packages/app/e2e/session/session-todo-dock-restore.spec.ts new file mode 100644 index 000000000..c9be675e9 --- /dev/null +++ b/packages/app/e2e/session/session-todo-dock-restore.spec.ts @@ -0,0 +1,71 @@ +import type { Todo } from "@opencode-ai/sdk/v2/client" +import type { createSdk } from "../utils" +import { test, expect } from "../fixtures" +import { openSidebar } from "../actions" +import { sessionItemSelector } from "../selectors" + +type Sdk = ReturnType + +type ProjectSeed = { + url: string + directory: string + sdk: Sdk +} + +async function seedSessionTurn(input: { sdk: ProjectSeed["sdk"]; sessionID: string }) { + await input.sdk.session.prompt({ + sessionID: input.sessionID, + noReply: true, + parts: [{ type: "text", text: "todo dock restored session seed" }], + }) +} + +async function updateTodos( + project: ProjectSeed, + input: { sessionID: string; todos: Array> }, +) { + const response = await fetch( + `${project.url}/session/__e2e/update-todos?directory=${encodeURIComponent(project.directory)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ) + expect(response.status, await response.text()).toBe(204) +} + +test("todo dock restores without entrance animation on session entry", async ({ page, project }) => { + let session: { id: string; title: string } | undefined + await project.open({ + beforeGoto: async ({ sdk }) => { + session = await sdk.session.create({ title: "e2e todo dock restored entry" }).then((res) => res.data) + if (session?.id) await seedSessionTurn({ sdk, sessionID: session.id }) + }, + }) + if (!session?.id) throw new Error("Session create did not return an id") + project.trackSession(session.id) + + await updateTodos( + { url: project.url, directory: project.directory, sdk: project.sdk }, + { + sessionID: session.id, + todos: [ + { content: "restored first task", status: "pending", priority: "high" }, + { content: "restored second task", status: "in_progress", priority: "medium" }, + ], + }, + ) + await page.clock.install() + + await openSidebar(page) + await page.locator(sessionItemSelector(session.id)).click() + const todoDock = page.locator('[data-component="session-todo-dock"]') + await expect(todoDock).toHaveCount(1, { timeout: 10_000 }) + + const restoredHeight = await todoDock.evaluate((el) => { + if (!(el instanceof HTMLElement)) return 0 + return Number.parseFloat(el.style.maxHeight || getComputedStyle(el).maxHeight) + }) + expect(restoredHeight).toBeGreaterThanOrEqual(35) +}) diff --git a/packages/app/e2e/snap/todo-dock-restored.snap.ts b/packages/app/e2e/snap/todo-dock-restored.snap.ts new file mode 100644 index 000000000..c7e567002 --- /dev/null +++ b/packages/app/e2e/snap/todo-dock-restored.snap.ts @@ -0,0 +1,67 @@ +import type { Todo } from "@opencode-ai/sdk/v2/client" +import type { createSdk } from "../utils" +import { test } from "../fixtures" +import { openSidebar } from "../actions" +import { sessionItemSelector } from "../selectors" +import { composeGrid, snapOutputPath } from "./_compose" + +type Sdk = ReturnType + +async function seedSessionTurn(input: { sdk: Sdk; sessionID: string }) { + await input.sdk.session.prompt({ + sessionID: input.sessionID, + noReply: true, + parts: [{ type: "text", text: "todo dock restored snap seed" }], + }) +} + +async function updateTodos(input: { + url: string + directory: string + sessionID: string + todos: Array> +}) { + const response = await fetch( + `${input.url}/session/__e2e/update-todos?directory=${encodeURIComponent(input.directory)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: input.sessionID, todos: input.todos }), + }, + ) + if (response.status !== 204) throw new Error(`updateTodos failed: ${response.status} ${await response.text()}`) +} + +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) + +test("todo-dock-restored", async ({ page, project }) => { + let sessionID: string | undefined + await project.open({ + beforeGoto: async ({ sdk }) => { + const session = await sdk.session.create({ title: "snap todo dock restored" }).then((res) => res.data) + sessionID = session?.id + if (sessionID) await seedSessionTurn({ sdk, sessionID }) + }, + }) + if (!sessionID) throw new Error("Session create did not return an id") + project.trackSession(sessionID) + + await updateTodos({ + url: project.url, + directory: project.directory, + sessionID, + todos: [ + { content: "Restored active task", status: "in_progress", priority: "high" }, + { content: "Queued follow-up task", status: "pending", priority: "medium" }, + ], + }) + + await openSidebar(page) + await page.locator(sessionItemSelector(sessionID)).click() + await page.locator('[data-component="session-todo-dock"]').waitFor({ state: "visible", timeout: 30_000 }) + + const composer = page.locator('[data-component="session-composer-column"]') + const out = snapOutputPath("todo-dock-restored") + await composeGrid([{ name: "restored todo dock", buf: await composer.screenshot() }], out) + process.stdout.write(`\n[snap] todo-dock-restored grid -> ${out}\n\n`) +}) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 6e1a3e72c..9bf663520 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo } from "solid-js" +import { Show, createEffect, createMemo, createSignal, on } from "solid-js" import { useNavigate } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" import { DockCard, DockSegment } from "@opencode-ai/ui/dock-card" @@ -88,13 +88,34 @@ export function SessionComposerRegion(props: { const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined)) - // Animate the Todo dock from 0 → visible when todos first appear (and back - // out when the dock closes). Multiplied into max-height inside the segment - // for slide-in + fed as dockProgress for content fade. Without this the - // dock pops in the moment props.state.dock() flips true. + // Animate the Todo dock from 0 → visible only for newly created live todos + // (and back out when the dock closes). Restored session todos jump straight + // to the settled height so opening an existing session does not replay the + // entrance animation from history. const dockOpen = createMemo(() => props.state.dock()) + const [dockOpeningMotion, setDockOpeningMotion] = createSignal(false) const dockSpring = useSpring(() => (dockOpen() ? 1 : 0), DOCK_MOTION) - const dockProgress = createMemo(() => Math.max(0, Math.min(1, dockSpring()))) + createEffect( + on( + () => ({ open: dockOpen(), opening: props.state.opening(), key: displaySessionKey() }), + (current, previous) => { + if (!current.open) { + setDockOpeningMotion(false) + return + } + if (current.opening) setDockOpeningMotion(true) + else if (previous?.key !== current.key) setDockOpeningMotion(false) + }, + ), + ) + createEffect(() => { + if (dockOpeningMotion() && dockSpring() >= 0.999) setDockOpeningMotion(false) + }) + const dockProgress = createMemo(() => { + const progress = Math.max(0, Math.min(1, dockSpring())) + if (dockOpen() && !dockOpeningMotion()) return 1 + return progress + }) const dockMounted = createMemo(() => dockOpen() || dockProgress() > 0.001) const dockKind = createMemo(() => { if (props.state.questionRequest()) return "question" diff --git a/packages/app/src/pages/session/todos/todo-dock-machine.test.ts b/packages/app/src/pages/session/todos/todo-dock-machine.test.ts index 2ae99ce97..fa8c3656c 100644 --- a/packages/app/src/pages/session/todos/todo-dock-machine.test.ts +++ b/packages/app/src/pages/session/todos/todo-dock-machine.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test" -import { reduceTodoDockState, todoDockHiddenState, type TodoDockMachineState } from "./todo-dock-machine" +import { + createTodoDockRestoreTracker, + reduceTodoDockState, + todoDockHiddenState, + type TodoDockMachineState, +} from "./todo-dock-machine" const active = (sessionID = "s") => ({ sessionID, count: 1, phase: "active" as const, lifecycleSignature: "pending" }) const terminal = (sessionID = "s", lifecycleSignature = "completed") => ({ @@ -19,6 +24,20 @@ describe("reduceTodoDockState", () => { }) }) + test("restored active todos show the dock without opening animation", () => { + expect( + reduceTodoDockState(todoDockHiddenState(), { + type: "snapshot", + input: { ...active(), restored: true }, + }), + ).toMatchObject({ + kind: "visible-active", + dock: true, + opening: false, + completing: false, + }) + }) + test("active to terminal enters completing", () => { const shown = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() }) @@ -138,3 +157,34 @@ describe("reduceTodoDockState", () => { }) }) }) + +describe("createTodoDockRestoreTracker", () => { + test("marks the first known active snapshot after an unknown session entry as restored", () => { + const restored = createTodoDockRestoreTracker() + + expect(restored({ sessionID: "s", known: false, count: 0, phase: "empty" })).toBe(false) + expect(restored({ sessionID: "s", known: true, count: 1, phase: "active" })).toBe(true) + expect(restored({ sessionID: "s", known: true, count: 1, phase: "active" })).toBe(false) + }) + + test("does not mark live todos as restored after a known empty snapshot primes the session", () => { + const restored = createTodoDockRestoreTracker() + + expect(restored({ sessionID: "s", known: true, count: 0, phase: "empty" })).toBe(false) + expect(restored({ sessionID: "s", known: true, count: 1, phase: "active" })).toBe(false) + }) + + test("does not mark the first live tool-parts todo snapshot as restored", () => { + const restored = createTodoDockRestoreTracker() + const liveToolPartsSnapshot = { + sessionID: "s", + known: true, + count: 1, + phase: "active" as const, + source: "primary-parts" as const, + } + + expect(restored({ sessionID: "s", known: false, count: 0, phase: "empty" })).toBe(false) + expect(restored(liveToolPartsSnapshot)).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/todos/todo-dock-machine.ts b/packages/app/src/pages/session/todos/todo-dock-machine.ts index 6eda151fb..7e1cff8e9 100644 --- a/packages/app/src/pages/session/todos/todo-dock-machine.ts +++ b/packages/app/src/pages/session/todos/todo-dock-machine.ts @@ -1,4 +1,4 @@ -import type { TodoPhase } from "./todo-model" +import type { TodoPhase, TodoSourceKind } from "./todo-model" export const TODO_DOCK_COMPLETING_DELAY_MS = 3000 @@ -43,6 +43,7 @@ export type TodoDockInput = { phase: TodoPhase lifecycleSignature: string dockEligible?: boolean + restored?: boolean // Semantic flag from the source selector. The reducer primarily uses active // session history to decide whether terminal snapshots complete a currently // active dock or remain hidden historical state. @@ -54,6 +55,42 @@ export type TodoDockTransition = | { type: "hideTimerElapsed"; sessionID?: string; lifecycleSignature: string } | { type: "animationFrameElapsed" } +export type TodoDockRestoreTrackerInput = { + sessionID?: string + known: boolean + source?: TodoSourceKind + count: number + phase: TodoPhase +} + +export function createTodoDockRestoreTracker() { + let sessionID: string | undefined + let primed = false + + return (input: TodoDockRestoreTrackerInput) => { + if (!input.sessionID) { + sessionID = undefined + primed = false + return false + } + + if (sessionID !== input.sessionID) { + sessionID = input.sessionID + primed = false + } + + const restored = + input.known && + !primed && + input.count > 0 && + input.phase === "active" && + input.source !== "primary-parts" && + input.source !== "fallback-parts" + if (input.known) primed = true + return restored + } +} + export function todoDockHiddenState(activeSessionIDs: ReadonlySet = new Set()): TodoDockMachineState { return { kind: "hidden", dock: false, opening: false, completing: false, activeSessionIDs } } @@ -115,7 +152,7 @@ export function reduceTodoDockState(state: TodoDockMachineState, transition: Tod kind: "visible-active", sessionID: input.sessionID, dock: true, - opening: hidden, + opening: hidden && input.restored !== true, completing: false, activeSessionIDs: rememberSession(activeSessionIDs, input.sessionID), } diff --git a/packages/app/src/pages/session/todos/use-session-todos.test.ts b/packages/app/src/pages/session/todos/use-session-todos.test.ts new file mode 100644 index 000000000..9357f2d3d --- /dev/null +++ b/packages/app/src/pages/session/todos/use-session-todos.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { isTodoSnapshotKnownForRestore } from "./use-session-todos" + +describe("isTodoSnapshotKnownForRestore", () => { + test("does not treat a non-none todo source as known without a sync cache", () => { + expect( + isTodoSnapshotKnownForRestore({ + sessionID: "s", + testTodosKnown: false, + source: "primary-backend", + syncTodoKnown: false, + globalTodoKnown: false, + }), + ).toBe(false) + + expect( + isTodoSnapshotKnownForRestore({ + sessionID: "s", + testTodosKnown: false, + source: "primary-parts", + syncTodoKnown: false, + globalTodoKnown: false, + }), + ).toBe(false) + }) + + test("uses explicit sync caches as restored-known sources", () => { + expect( + isTodoSnapshotKnownForRestore({ + sessionID: "s", + testTodosKnown: false, + source: "none", + syncTodoKnown: true, + globalTodoKnown: false, + }), + ).toBe(true) + + expect( + isTodoSnapshotKnownForRestore({ + sessionID: "s", + testTodosKnown: false, + source: "none", + syncTodoKnown: false, + globalTodoKnown: true, + }), + ).toBe(true) + }) +}) diff --git a/packages/app/src/pages/session/todos/use-session-todos.ts b/packages/app/src/pages/session/todos/use-session-todos.ts index d5ab60a7d..a17bad612 100644 --- a/packages/app/src/pages/session/todos/use-session-todos.ts +++ b/packages/app/src/pages/session/todos/use-session-todos.ts @@ -3,16 +3,34 @@ import { createStore } from "solid-js/store" import { useGlobalSync } from "@/context/global-sync" import { useSync } from "@/context/sync" import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer" -import { reduceTodoDockState, TODO_DOCK_COMPLETING_DELAY_MS, todoDockHiddenState } from "./todo-dock-machine" +import { + createTodoDockRestoreTracker, + reduceTodoDockState, + TODO_DOCK_COMPLETING_DELAY_MS, + todoDockHiddenState, +} from "./todo-dock-machine" import { todoSnapshot, type SessionTodoItem, type TodoSnapshot } from "./todo-model" import { selectSessionTodoDockSnapshot } from "./todo-source" -const dockInput = (snapshot: TodoSnapshot, sessionID?: string) => ({ +export const isTodoSnapshotKnownForRestore = (input: { + sessionID?: string + testTodosKnown: boolean + source: TodoSnapshot["source"] + syncTodoKnown: boolean + globalTodoKnown: boolean +}) => { + if (!input.sessionID) return true + if (input.testTodosKnown) return true + return input.syncTodoKnown || input.globalTodoKnown +} + +const dockInput = (snapshot: TodoSnapshot, sessionID?: string, restored?: boolean) => ({ sessionID: snapshot.sessionID ?? sessionID, count: snapshot.items.length, phase: snapshot.phase, lifecycleSignature: snapshot.lifecycleSignature, dockEligible: snapshot.dockEligible, + restored, historicalTerminal: snapshot.historicalTerminal, }) @@ -23,6 +41,7 @@ export function createSessionTodoModel(input: { const sync = useSync() const globalSync = useGlobalSync() const activeSessionID = input.sessionID + const restoredSnapshot = createTodoDockRestoreTracker() const [test, setTest] = createStore({ on: false, @@ -99,9 +118,34 @@ export function createSessionTodoModel(input: { }) }) + const snapshotKnown = (current: TodoSnapshot, sessionID?: string) => { + return isTodoSnapshotKnownForRestore({ + sessionID, + testTodosKnown: test.on && test.todos !== undefined, + source: current.source, + syncTodoKnown: sessionID ? sync.data.todo[sessionID] !== undefined : false, + globalTodoKnown: sessionID ? globalSync.data.session_todo[sessionID] !== undefined : false, + }) + } + + const currentDockInput = (current: TodoSnapshot) => { + const sessionID = current.sessionID ?? activeSessionID() + return dockInput( + current, + activeSessionID(), + restoredSnapshot({ + sessionID, + known: snapshotKnown(current, sessionID), + source: current.source, + count: current.items.length, + phase: current.phase, + }), + ) + } + let machine = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", - input: dockInput(snapshot(), activeSessionID()), + input: currentDockInput(snapshot()), }) const [dock, setDock] = createStore({ dock: machine.dock, @@ -160,7 +204,7 @@ export function createSessionTodoModel(input: { on( () => { const current = snapshot() - return dockInput(current, activeSessionID()) + return currentDockInput(current) }, (current) => dispatch({ type: "snapshot", input: current }), ), diff --git a/packages/app/src/testing/session-composer.ts b/packages/app/src/testing/session-composer.ts index a4de519b2..0898dd012 100644 --- a/packages/app/src/testing/session-composer.ts +++ b/packages/app/src/testing/session-composer.ts @@ -20,6 +20,7 @@ export type ComposerStateProbeState = { completing: boolean count: number states: Todo["status"][] + openingSamples?: boolean[] } type ComposerState = { @@ -101,6 +102,7 @@ export const composerStateProbe = (sessionID?: string) => { stateProbe: { ...next, states: [...next.states], + openingSamples: [...(prev.stateProbe?.openingSamples ?? []), next.opening], }, } }