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
101 changes: 100 additions & 1 deletion packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import {
composerEvent,
Expand All @@ -17,6 +17,7 @@ import {
sessionComposerDockSelector,
sessionTurnListSelector,
sessionTodoToggleButtonSelector,
titlebarRightSelector,
} from "../selectors"
import { modKey } from "../utils"
import { inputMatch } from "../prompt/mock"
Expand Down Expand Up @@ -192,6 +193,21 @@ async function e2ePublishQuestionBlocker(project: ProjectQuestionSeed, request:
expect(response.status).toBe(204)
}

async function e2eUpdateTodos(
project: ProjectQuestionSeed,
input: { sessionID: string; todos: Array<Pick<Todo, "content" | "status" | "priority"> & Partial<Pick<Todo, "id">>> },
) {
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)
}

async function waitForQuestionSeed(project: ProjectQuestionSeed, sessionID: string) {
let current: QuestionRequest | undefined
await expect
Expand Down Expand Up @@ -1237,6 +1253,89 @@ test("todo dock appears from real todowrite tool parts", async ({ page, llm, pro
)
})

test("todo dock and status summary use backend terminal update over stale todowrite parts", async ({
page,
llm,
project,
}) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock backend terminal todo",
async (session) => {
const content = "backend terminal todo"
await project.gotoSession(session.id)

await llm.tool("todowrite", {
todos: [{ content, status: "in_progress", priority: "medium" }],
})
await llm.text("todo started")
await project.prompt("Create a todo and start it.")

const dockItem = page.locator('[data-slot="session-todo-item"]').filter({ hasText: content }).first()
await expect(dockItem).toHaveAttribute("data-state", "in_progress", { timeout: 30_000 })

await e2eUpdateTodos(
{ url: project.url, directory: project.directory, sdk: project.sdk },
{
sessionID: session.id,
todos: [{ content, status: "completed", priority: "medium" }],
},
)

await expect(dockItem).toHaveAttribute("data-state", "completed", { timeout: 10_000 })

const rightPanel = page.locator("#right-panel")
if ((await rightPanel.getAttribute("aria-hidden")) !== "false") {
await page.locator(`${titlebarRightSelector} button`).first().click()
}
await expect(rightPanel).toHaveAttribute("aria-hidden", "false")
const summaryTodo = rightPanel.locator('[data-slot="status-summary-todo"]').filter({ hasText: content }).first()
await expect(summaryTodo).toHaveAttribute("data-state", "completed", { timeout: 10_000 })
},
{ trackSession: project.trackSession },
)
})

test("todo dock and status summary clear when backend todo update is empty", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock backend empty todo",
async (session) => {
const content = "backend cleared todo"
await project.gotoSession(session.id)

await llm.tool("todowrite", {
todos: [{ content, status: "in_progress", priority: "medium" }],
})
await llm.text("todo started")
await project.prompt("Create a todo and start it.")

const dockItem = page.locator('[data-slot="session-todo-item"]').filter({ hasText: content })
await expect(dockItem.first()).toHaveAttribute("data-state", "in_progress", { timeout: 30_000 })

await e2eUpdateTodos(
{ url: project.url, directory: project.directory, sdk: project.sdk },
{
sessionID: session.id,
todos: [],
},
)

await expect(dockItem).toHaveCount(0, { timeout: 10_000 })

const rightPanel = page.locator("#right-panel")
if ((await rightPanel.getAttribute("aria-hidden")) !== "false") {
await page.locator(`${titlebarRightSelector} button`).first().click()
}
await expect(rightPanel).toHaveAttribute("aria-hidden", "false")
await expect(rightPanel.locator('[data-slot="status-summary-todo"]').filter({ hasText: content })).toHaveCount(0)
},
{ trackSession: project.trackSession },
)
})

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

Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/components/session/session-status-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import { createMemo, type Accessor } from "solid-js"
import { useParams } from "@solidjs/router"
import type { Part } from "@opencode-ai/sdk/v2"
import { useGlobalSync } from "@/context/global-sync"
import { useSync } from "@/context/sync"
import { SessionStatusSummary } from "./session-status-summary"
import { SessionStatusConnections } from "./session-status-connections"

export function SessionStatusPanel(props: { shown: Accessor<boolean> }) {
const params = useParams()
const globalSync = useGlobalSync()
const sync = useSync()

const parts = createMemo<Part[]>(() => {
if (!params.id) return []
const messages = sync.data.message[params.id] ?? []
return messages.flatMap((message) => sync.data.part[message.id] ?? [])
})
const backend = createMemo(() => (params.id ? globalSync.data.session_todo[params.id] : undefined))
const backendClearActivePartsAt = createMemo(() => (params.id ? globalSync.data.session_todo_clear[params.id] : undefined))

return (
<div class="h-full min-h-0 overflow-y-auto">
<SessionStatusSummary parts={parts} />
<SessionStatusSummary backend={backend} backendClearActivePartsAt={backendClearActivePartsAt} parts={parts} />
<SessionStatusConnections shown={props.shown} />
</div>
)
Expand Down
17 changes: 14 additions & 3 deletions packages/app/src/components/session/session-status-summary.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import type { Part } from "@opencode-ai/sdk/v2"
import type { Todo } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { extractSources, type TodoItem } from "@/pages/session/session-status-extractors"
import { selectSessionTodos } from "@/pages/session/session-todos"
Expand Down Expand Up @@ -27,7 +28,7 @@ function Empty(props: { text: string }) {
function TodoRow(props: { todo: TodoItem }) {
const style = () => TODO_STATUS_STYLES[props.todo.status] ?? TODO_STATUS_STYLES.pending
return (
<div class="flex items-start gap-2.5 py-1">
<div data-slot="status-summary-todo" data-state={props.todo.status} class="flex items-start gap-2.5 py-1">
<div class={`size-2 rounded-full shrink-0 mt-1.5 ${style().dot}`} aria-hidden />
<div class={`text-13-regular text-fg-base min-w-0 ${style().text}`}>{props.todo.content}</div>
</div>
Expand All @@ -42,9 +43,19 @@ function SourceRow(props: { url: string }) {
)
}

export function SessionStatusSummary(props: { parts: Accessor<Part[]> }) {
export function SessionStatusSummary(props: {
backend?: Accessor<Todo[] | undefined>
backendClearActivePartsAt?: Accessor<number | undefined>
parts: Accessor<Part[]>
}) {
const language = useLanguage()
const todos = createMemo(() => selectSessionTodos({ parts: props.parts() }))
const todos = createMemo(() =>
selectSessionTodos({
backend: props.backend?.(),
backendClearActivePartsAt: props.backendClearActivePartsAt?.(),
parts: props.parts(),
}),
)
const sources = createMemo(() => extractSources(props.parts()))

return (
Expand Down
23 changes: 23 additions & 0 deletions packages/app/src/context/global-sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
import { describe, expect, test } from "bun:test"
import type { Todo } from "@opencode-ai/sdk/v2/client"
import { nextSessionTodoClearFlag } from "./global-sync"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"

describe("nextSessionTodoClearFlag", () => {
const todo = { id: "todo_1", content: "work", status: "in_progress", priority: "medium" } as Todo

test("marks live empty backend updates as active-parts clears", () => {
expect(nextSessionTodoClearFlag(undefined, [], { clearActiveParts: true }, 10)).toBe(10)
})

test("preserves existing live clear flag across ordinary empty backend refreshes", () => {
expect(nextSessionTodoClearFlag(10, [])).toBe(10)
})

test("does not create a clear flag for ordinary empty backend refreshes", () => {
expect(nextSessionTodoClearFlag(undefined, [])).toBeUndefined()
})

test("clears the flag on non-empty backend updates and cleanup", () => {
expect(nextSessionTodoClearFlag(10, [todo])).toBeUndefined()
expect(nextSessionTodoClearFlag(10, undefined)).toBeUndefined()
})
})

describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => {
const now = 5_000
Expand Down
39 changes: 38 additions & 1 deletion packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ type GlobalStore = {
session_todo: {
[sessionID: string]: Todo[]
}
session_todo_clear: {
[sessionID: string]: number
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
Expand All @@ -50,6 +53,18 @@ type GlobalStore = {

const inactiveQueryFn = async () => null

export function nextSessionTodoClearFlag(
previous: number | undefined,
todos: Todo[] | undefined,
options?: { clearActiveParts?: boolean },
now = Date.now(),
) {
if (!todos) return undefined
if (todos.length > 0) return undefined
if (options?.clearActiveParts === true) return now
return previous
}

export const loadSessionsQuery = (directory: string) =>
queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: inactiveQueryFn, enabled: false })

Expand All @@ -75,6 +90,7 @@ function createGlobalSync() {
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
session_todo: {},
session_todo_clear: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
Expand Down Expand Up @@ -141,7 +157,11 @@ function createGlobalSync() {
})
}

const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
const setSessionTodo = (
sessionID: string,
todos: Todo[] | undefined,
options?: { clearActiveParts?: boolean },
) => {
if (!sessionID) return
if (!todos) {
setGlobalStore(
Expand All @@ -150,9 +170,26 @@ function createGlobalSync() {
delete draft[sessionID]
}),
)
setGlobalStore(
"session_todo_clear",
produce((draft) => {
delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
const clearFlag = nextSessionTodoClearFlag(globalStore.session_todo_clear[sessionID], todos, options)
if (clearFlag !== undefined) {
setGlobalStore("session_todo_clear", sessionID, clearFlag)
return
}
setGlobalStore(
"session_todo_clear",
produce((draft) => {
delete draft[sessionID]
}),
)
}

const paused = () => untrack(() => globalStore.reload) !== undefined
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ type GlobalStore = {
session_todo: {
[sessionID: string]: Todo[]
}
session_todo_clear: {
[sessionID: string]: number
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
Expand Down
54 changes: 50 additions & 4 deletions packages/app/src/context/global-sync/event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,63 @@ 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 writes: Array<{
sessionID: string
todos: Todo[] | undefined
options?: { clearActiveParts?: boolean }
}> = []

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

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

test("marks detached empty todo updates as active-parts clears", () => {
const writes: Array<{
sessionID: string
todos: Todo[] | undefined
options?: { clearActiveParts?: boolean }
}> = []

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

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

test("marks directory empty todo updates as active-parts clears", () => {
const [store, setStore] = createStore(baseState())
const writes: Array<{
sessionID: string
todos: Todo[] | undefined
options?: { clearActiveParts?: boolean }
}> = []

applyDirectoryEvent({
event: { type: "todo.updated", properties: { sessionID: "ses_clear", todos: [] } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
setSessionTodo(sessionID, value, options) {
writes.push({ sessionID, todos: value, options })
},
})

expect(store.todo.ses_clear).toEqual([])
expect(writes).toEqual([{ sessionID: "ses_clear", todos: [], options: { clearActiveParts: true } }])
})

test("ignores detached events that need a directory child store", () => {
Expand Down
Loading
Loading