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
132 changes: 123 additions & 9 deletions packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,77 @@ test("todo dock auto-hides after all todos complete", async ({ page, project })
)
})

test("todo dock appears from real todowrite tool parts", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock real todowrite",
async (session) => {
const dock = await todoDock(page, session.id)
await project.gotoSession(session.id)

await llm.tool("todowrite", {
todos: [
{ content: "count to 0", status: "completed", priority: "high" },
{ content: "count to 1", status: "in_progress", priority: "medium" },
{ content: "count to 2", status: "pending", priority: "medium" },
],
})
await llm.text("counting started")

await project.prompt("Create a todo list and start counting.")

await dock.expectCollapsed(["completed", "in_progress", "pending"])
},
{ trackSession: project.trackSession },
)
})

test("todo dock stays hidden when landing on an already completed session", async ({ page, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock todo completed landing source",
async (sessionA) => {
await withDockSession(
project.sdk,
"e2e composer dock todo completed landing target",
async (sessionB) => {
const dockA = await todoDock(page, sessionA.id)
const dockB = await todoDock(page, sessionB.id)
await project.gotoSession(sessionA.id)

try {
await dockB.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "completed", priority: "medium" },
{ content: "third task", status: "completed", priority: "medium" },
{ content: "fourth task", status: "completed", priority: "low" },
])
await project.gotoSession(sessionB.id)

await dockB.expectState(
{
dock: false,
completing: false,
count: 4,
states: ["completed", "completed", "completed", "completed"],
},
1_000,
)
await dockB.expectDockGone(1_000)
} finally {
await dockA.clear()
await dockB.clear()
}
},
{ trackSession: project.trackSession },
)
},
{ trackSession: project.trackSession },
)
})

test("todo dock treats cancelled todos as terminal and labels all-cancelled progress", async ({ page, project }) => {
await project.open()
await page.clock.install()
Expand Down Expand Up @@ -835,6 +906,33 @@ test("todo dock hides immediately when todos become empty", async ({ page, proje
)
})

test("todo dock does not treat completed-only todos as recent after clearing", async ({ page, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock todo empty clears active history",
async (session) => {
const dock = await todoDock(page, session.id)
await project.gotoSession(session.id)

try {
await dock.open([{ content: "active task", status: "in_progress", priority: "high" }])
await dock.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] })

await dock.finish([])
await dock.expectState({ dock: false, completing: false, count: 0, states: [] }, 1_000)

await dock.finish([{ content: "historical done task", status: "completed", priority: "high" }])
await dock.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }, 1_000)
await dock.expectDockGone(1_000)
} finally {
await dock.clear()
}
},
{ trackSession: project.trackSession },
)
})

test("todo dock cancels pending hide when a new active todo arrives", async ({ page, project }) => {
await project.open()
await page.clock.install()
Expand All @@ -846,7 +944,10 @@ test("todo dock cancels pending hide when a new active todo arrives", async ({ p
await project.gotoSession(session.id)

try {
await dock.open([{ content: "done task", status: "completed", priority: "high" }])
await dock.open([{ content: "done task", status: "in_progress", priority: "high" }])
await dock.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] })

await dock.finish([{ content: "done task", status: "completed", priority: "high" }])
await dock.expectState({ dock: true, completing: true, count: 1, states: ["completed"] })

await dock.finish([
Expand Down Expand Up @@ -875,7 +976,10 @@ test("todo dock restarts the hide timer when todos re-complete", async ({ page,
await project.gotoSession(session.id)

try {
await dock.open([{ content: "first task", status: "completed", priority: "high" }])
await dock.open([{ content: "first task", status: "in_progress", priority: "high" }])
await dock.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] })

await dock.finish([{ content: "first task", status: "completed", priority: "high" }])
await dock.expectState({ dock: true, completing: true, count: 1, states: ["completed"] })

await page.clock.fastForward(2_400)
Expand Down Expand Up @@ -918,15 +1022,18 @@ test("todo dock does not leak a pending hide timeout across sessions", async ({
await project.gotoSession(sessionA.id)

try {
await dockA.open([{ content: "done task", status: "completed", priority: "high" }])
await dockA.open([{ content: "done task", status: "in_progress", priority: "high" }])
await dockA.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] })

await dockA.finish([{ content: "done task", status: "completed", priority: "high" }])
await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] })

await project.gotoSession(sessionB.id)
await dockB.expectState({ dock: false, completing: false, count: 0, states: [] })

await page.clock.fastForward(3_500)
await project.gotoSession(sessionA.id)
await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] })
await dockA.expectState({ dock: false, completing: false, count: 1, states: ["completed"] })
} finally {
await dockA.clear()
await dockB.clear()
Expand All @@ -939,7 +1046,7 @@ test("todo dock does not leak a pending hide timeout across sessions", async ({
)
})

test("todo dock restarts completing delay after same-count terminal session switch", async ({ page, project }) => {
test("todo dock stays hidden after same-count terminal session switch", async ({ page, project }) => {
await project.open()
await page.clock.install()
await withDockSession(
Expand All @@ -955,17 +1062,20 @@ test("todo dock restarts completing delay after same-count terminal session swit
await project.gotoSession(sessionA.id)

try {
await dockA.open([{ content: "source done", status: "completed", priority: "high" }])
await dockA.open([{ content: "source done", status: "in_progress", priority: "high" }])
await dockA.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] })

await dockA.finish([{ content: "source done", status: "completed", priority: "high" }])
await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] })

await page.clock.fastForward(2_400)

await dockB.open([{ content: "target done", status: "completed", priority: "high" }])
await project.gotoSession(sessionB.id)
await dockB.expectState({ dock: true, completing: true, count: 1, states: ["completed"] })
await dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] })

await page.clock.fastForward(900)
await dockB.expectState({ dock: true, completing: true, count: 1, states: ["completed"] })
await dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] })
await page.clock.fastForward(2_100)
await dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] })
} finally {
Expand Down Expand Up @@ -1017,7 +1127,11 @@ test("e2e composer dock keeps latest turn visible when dock height changes", asy
const viewport = document.querySelector('[data-component="scroll-viewport"]')
const composer = document.querySelector('[data-component="session-prompt-dock"]')
const last = [...document.querySelectorAll("[data-message-id]")].at(-1)
if (!(viewport instanceof HTMLElement) || !(composer instanceof HTMLElement) || !(last instanceof HTMLElement)) {
if (
!(viewport instanceof HTMLElement) ||
!(composer instanceof HTMLElement) ||
!(last instanceof HTMLElement)
) {
return null
}
viewport.scrollTop = viewport.scrollHeight
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import type { Part } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
import { extractTodos, extractSources, type TodoItem } from "@/pages/session/session-status-extractors"
import { extractSources, type TodoItem } from "@/pages/session/session-status-extractors"
import { selectSessionTodos } from "@/pages/session/session-todos"

const TODO_STATUS_STYLES: Record<string, { dot: string; text: string }> = {
completed: { dot: "bg-icon-success-base", text: "" },
Expand Down Expand Up @@ -43,7 +44,7 @@ function SourceRow(props: { url: string }) {

export function SessionStatusSummary(props: { parts: Accessor<Part[]> }) {
const language = useLanguage()
const todos = createMemo(() => extractTodos(props.parts()))
const todos = createMemo(() => selectSessionTodos({ parts: props.parts() }))
const sources = createMemo(() => extractSources(props.parts()))

return (
Expand Down
12 changes: 2 additions & 10 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import {
createMemo,
createEffect,
createComputed,
createSignal,
on,
onCleanup,
untrack,
} from "solid-js"
import { createMemo, createEffect, createComputed, createSignal, on, onCleanup, untrack } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useLocal } from "@/context/local"
import { useFile } from "@/context/file"
Expand Down Expand Up @@ -118,7 +110,7 @@ export default function Page() {
const timelineSessionID = timeline.sessionID
const timelineSessionKey = timeline.sessionKey
const timelineIsChildSession = timeline.isChildSession
const composer = createSessionComposerState({ sessionID: timelineSessionID })
const composer = createSessionComposerState({ sessionID: timelineSessionID, fallbackSessionID: () => params.id })
const timelineMessages = timeline.messages
const timelineMessagesReady = timeline.messagesReady
const timelineDiffs = timeline.diffs
Expand Down
29 changes: 27 additions & 2 deletions packages/app/src/pages/session/composer/session-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { composerDriver, composerEnabled, composerEvent, composerStateProbe } from "@/testing/session-composer"
import { selectSessionTodos } from "@/pages/session/session-todos"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"

const TODO_DOCK_COMPLETING_DELAY_MS = 3000
Expand All @@ -17,7 +18,10 @@ const todoTerminal = (todo: Todo) => todo.status === "completed" || todo.status

const todoSignature = (todos: Todo[]) => todos.map((todo) => `${todo.status}:${todo.content}`).join("\u0000")

export function createSessionComposerState(input: { sessionID: () => string | undefined }) {
export function createSessionComposerState(input: {
sessionID: () => string | undefined
fallbackSessionID?: () => string | undefined
}) {
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
Expand Down Expand Up @@ -85,7 +89,16 @@ export function createSessionComposerState(input: { sessionID: () => string | un
const id = activeSessionID()
if (!id) return []
// Todo data follows the backend list. Dock visibility is derived below so terminal todos can remain stored after the dock hides.
return globalSync.data.session_todo[id] ?? []
const messages = sync.data.message[id] ?? []
const parts = messages.flatMap((message) => sync.data.part[message.id] ?? [])
const fallbackID = input.fallbackSessionID?.()
const fallbackMessages = fallbackID && fallbackID !== id ? (sync.data.message[fallbackID] ?? []) : []
const fallbackParts = fallbackMessages.flatMap((message) => sync.data.part[message.id] ?? [])
return selectSessionTodos({
backend: globalSync.data.session_todo[id],
parts,
fallback: { backend: fallbackID ? globalSync.data.session_todo[fallbackID] : undefined, parts: fallbackParts },
})
})

const allDone = createMemo(() => {
Expand Down Expand Up @@ -125,6 +138,8 @@ export function createSessionComposerState(input: { sessionID: () => string | un

let raf: number | undefined
let hideTimeout: number | undefined
let lastTodoSessionID: string | undefined
const sessionsWithActiveTodos = new Set<string>()

const clearHideTimeout = () => {
if (hideTimeout === undefined) return
Expand All @@ -143,14 +158,23 @@ export function createSessionComposerState(input: { sessionID: () => string | un
({ allDone: done, count, sessionID: expectedSessionID, signature }) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
const sessionChanged = expectedSessionID !== lastTodoSessionID
lastTodoSessionID = expectedSessionID

if (count === 0) {
if (expectedSessionID) sessionsWithActiveTodos.delete(expectedSessionID)
clearHideTimeout()
setStore({ dock: false, opening: false, completing: false })
return
}

if (done) {
if (sessionChanged || !expectedSessionID || !sessionsWithActiveTodos.has(expectedSessionID)) {
clearHideTimeout()
setStore({ dock: false, opening: false, completing: false })
return
}

setStore({ dock: true, opening: false, completing: true })
clearHideTimeout()
hideTimeout = window.setTimeout(() => {
Expand All @@ -162,6 +186,7 @@ export function createSessionComposerState(input: { sessionID: () => string | un
return
}

if (expectedSessionID) sessionsWithActiveTodos.add(expectedSessionID)
clearHideTimeout()
setStore("completing", false)

Expand Down
62 changes: 62 additions & 0 deletions packages/app/src/pages/session/session-todos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import type { Part, ToolState } from "@opencode-ai/sdk/v2"
import type { Todo } from "@opencode-ai/sdk/v2/client"
import { selectSessionTodos } from "./session-todos"

const completedState = (
overrides: Partial<Extract<ToolState, { status: "completed" }>> = {},
): Extract<ToolState, { status: "completed" }> => ({
status: "completed",
input: {},
output: "",
title: "",
metadata: {},
time: { start: 0, end: 0 },
...overrides,
})

const toolPart = (tool: string, state: ToolState = completedState()): Part =>
({
id: "p",
sessionID: "s",
messageID: "m",
type: "tool",
callID: "c",
tool,
state,
}) as Part

const todo = (content: string, status: Todo["status"] = "pending"): Todo => ({
content,
status,
priority: "medium",
})

describe("selectSessionTodos", () => {
test("prefers backend todos over message-derived todos", () => {
const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("from parts", "in_progress")] } }))]

expect(selectSessionTodos({ backend: [todo("from backend", "pending")], parts })).toEqual([
todo("from backend", "pending"),
])
})

test("falls back to latest todowrite parts when backend todos are empty", () => {
const parts = [
toolPart("todowrite", completedState({ input: { todos: [todo("old", "pending")] } })),
toolPart("todowrite", completedState({ input: { todos: [todo("new", "in_progress")] } })),
]

expect(selectSessionTodos({ backend: [], parts })).toEqual([todo("new", "in_progress")])
})

test("falls back to a secondary session source when the primary source is empty", () => {
const fallbackParts = [
toolPart("todowrite", completedState({ input: { todos: [todo("route todo", "in_progress")] } })),
]

expect(selectSessionTodos({ backend: [], parts: [], fallback: { parts: fallbackParts } })).toEqual([
todo("route todo", "in_progress"),
])
})
})
Loading
Loading