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
37 changes: 37 additions & 0 deletions packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,17 @@ async function submitVisiblePrompt(page: Page, text: string) {
await page.keyboard.press("Enter")
}

async function readPromptSend(page: Page) {
return page.evaluate(() => {
const win = window as ComposerWindow
const sent = win.__opencode_e2e?.prompt?.sent
return {
count: sent?.count ?? 0,
sessionID: sent?.sessionID,
}
})
}

async function scrollTimelineToBottom(page: Page) {
await page.evaluate(() => {
const viewport = document.querySelector('[data-component="scroll-viewport"]')
Expand Down Expand Up @@ -1070,6 +1081,32 @@ test("todo dock appears from real todowrite tool parts", async ({ page, llm, pro
)
})

test("todo dock appears for the first todowrite in a fresh session", async ({ page, llm, project }) => {
await project.open()

await llm.tool("todowrite", {
todos: [
{ content: "fresh first todo", status: "in_progress", priority: "high" },
{ content: "fresh follow-up todo", status: "pending", priority: "medium" },
],
})
await llm.text("fresh todo started")

const before = await readPromptSend(page)
await submitVisiblePrompt(page, "Create todos in a fresh session.")
const sent = await expect
.poll(async () => readPromptSend(page), { timeout: 30_000 })
.toMatchObject({ count: before.count + 1 })
.then(() => readPromptSend(page))
if (!sent.sessionID) throw new Error("Prompt submission did not expose a session id")

const dock = page.locator('[data-component="session-todo-dock"]')

await expect(dock).toHaveCount(1, { timeout: 30_000 })
await expect(dock).toContainText("fresh first todo", { timeout: 30_000 })
project.trackSession(sent.sessionID)
})

test("todo dock recovers after missed todowrite via SSE replay", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
Expand Down
12 changes: 10 additions & 2 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
import { createBlockerTerminalCache } from "./global-sync/blocker-terminal-cache"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import {
applyDetachedDirectoryEvent,
applyDirectoryEvent,
applyGlobalEvent,
cleanupDroppedSessionCaches,
} from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
Expand Down Expand Up @@ -323,7 +328,10 @@ function createGlobalSync() {
}

const existing = children.children[directory]
if (!existing) return
if (!existing) {
applyDetachedDirectoryEvent({ event, setSessionTodo })
return
}
children.mark(directory)
const [store, setStore] = existing
applyDirectoryEvent({
Expand Down
69 changes: 67 additions & 2 deletions packages/app/src/context/global-sync/event-reducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session, Todo } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { createBlockerTerminalCache } from "./blocker-terminal-cache"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
import { applyDetachedDirectoryEvent, applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"

const rootSession = (input: { id: string; parentID?: string; archived?: number; created?: number; updated?: number }) =>
({
Expand Down Expand Up @@ -137,6 +137,71 @@ describe("applyGlobalEvent", () => {
})

describe("applyDirectoryEvent", () => {
test("caches detached todo updates before a directory child store exists", () => {
const todos: Todo[] = [{ id: "todo_1", content: "fresh todo", status: "in_progress", priority: "high" } as Todo]
const writes: Array<{ sessionID: string; todos: Todo[] | undefined }> = []

const handled = applyDetachedDirectoryEvent({
event: { type: "todo.updated", properties: { sessionID: "ses_fresh", todos } },
setSessionTodo(sessionID, value) {
writes.push({ sessionID, todos: value })
},
})

expect(handled).toBe(true)
expect(writes).toEqual([{ sessionID: "ses_fresh", todos }])
})

test("ignores detached events that need a directory child store", () => {
const handled = applyDetachedDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_1", "ses_1") } },
setSessionTodo() {
throw new Error("should not write detached todo cache")
},
})

expect(handled).toBe(false)
})

test("ignores malformed detached todo updates", () => {
const handled = applyDetachedDirectoryEvent({
event: { type: "todo.updated" },
setSessionTodo() {
throw new Error("should not write detached todo cache")
},
})

expect(handled).toBe(false)
})

test("clears detached todo cache for deleted and archived sessions", () => {
const writes: Array<{ sessionID: string; todos: Todo[] | undefined }> = []
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
writes.push({ sessionID, todos })
}

const deleted = applyDetachedDirectoryEvent({
event: { type: "session.deleted", properties: { info: rootSession({ id: "ses_deleted" }) } },
setSessionTodo,
})
const archived = applyDetachedDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "ses_archived", archived: 2 }) } },
setSessionTodo,
})
const activeUpdate = applyDetachedDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "ses_active" }) } },
setSessionTodo,
})

expect(deleted).toBe(true)
expect(archived).toBe(true)
expect(activeUpdate).toBe(false)
expect(writes).toEqual([
{ sessionID: "ses_deleted", todos: undefined },
{ sessionID: "ses_archived", todos: undefined },
])
})

test("inserts root sessions in sorted order and updates sessionTotal", () => {
const [store, setStore] = createStore(
baseState({
Expand Down
29 changes: 29 additions & 0 deletions packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,35 @@ export function cleanupDroppedSessionCaches(
)
}

export function applyDetachedDirectoryEvent(input: {
event: { type: string; properties?: unknown }
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) {
if (!input.event.properties || typeof input.event.properties !== "object") return false
switch (input.event.type) {
case "todo.updated": {
const props = input.event.properties as { sessionID?: string; todos?: Todo[] }
if (!props.sessionID || !Array.isArray(props.todos)) return false
input.setSessionTodo?.(props.sessionID, props.todos)
return true
}
case "session.deleted": {
const info = (input.event.properties as { info?: Session }).info
if (!info?.id) return false
input.setSessionTodo?.(info.id, undefined)
return true
}
case "session.updated": {
const info = (input.event.properties as { info?: Session }).info
if (!info?.id || !info.time?.archived) return false
input.setSessionTodo?.(info.id, undefined)
return true
}
default:
return false
}
}

export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown }
store: Store<State>
Expand Down
Loading