Skip to content
Merged
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
35 changes: 25 additions & 10 deletions packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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 })
})
},
Expand Down
71 changes: 71 additions & 0 deletions packages/app/e2e/session/session-todo-dock-restore.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createSdk>

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<Pick<Todo, "content" | "status" | "priority">> },
) {
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)
})
67 changes: 67 additions & 0 deletions packages/app/e2e/snap/todo-dock-restored.snap.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createSdk>

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<Pick<Todo, "content" | "status" | "priority">>
}) {
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`)
})
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
52 changes: 51 additions & 1 deletion packages/app/src/pages/session/todos/todo-dock-machine.test.ts
Original file line number Diff line number Diff line change
@@ -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") => ({
Expand All @@ -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() })

Expand Down Expand Up @@ -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)
})
})
41 changes: 39 additions & 2 deletions packages/app/src/pages/session/todos/todo-dock-machine.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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<string> = new Set()): TodoDockMachineState {
return { kind: "hidden", dock: false, opening: false, completing: false, activeSessionIDs }
}
Expand Down Expand Up @@ -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),
}
Expand Down
Loading