Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
16 changes: 13 additions & 3 deletions packages/app/src/components/session/session-status-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,22 @@ export function SessionStatusPanel(props: { shown: Accessor<boolean> }) {
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))
const canonical = createMemo(() => (params.id ? globalSync.data.session_todo[params.id] : undefined))
const isAuthoritativelyInvalidated = createMemo(() =>
params.id ? globalSync.todoHydrate.isAuthoritativelyInvalidated(params.id) : false,
)
const isPending = createMemo(() =>
params.id && sync.directory ? globalSync.todoHydrate.isPending(sync.directory, params.id) : false,
)

return (
<div class="h-full min-h-0 overflow-y-auto">
<SessionStatusSummary backend={backend} backendClearActivePartsAt={backendClearActivePartsAt} parts={parts} />
<SessionStatusSummary
canonical={canonical}
isAuthoritativelyInvalidated={isAuthoritativelyInvalidated}
isPending={isPending}
parts={parts}
/>
<SessionStatusConnections shown={props.shown} />
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@ describe("session-status-summary · row contract", () => {
expect(SOURCE).toMatch(/status === "completed"\s*\|\|\s*props\.todo\.status === "cancelled"/)
expect(SOURCE).toContain("line-through text-fg-weak")
})

test("does not render the empty progress fallback while todo hydrate is pending", () => {
expect(SOURCE).toContain("selectSessionTodoDataSnapshot")
expect(SOURCE).toContain('snapshot().phase !== "pending"')
})
})
39 changes: 23 additions & 16 deletions packages/app/src/components/session/session-status-summary.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import { TodoStatusMarker } from "@opencode-ai/ui/todo-status-marker"
import type { Part } from "@opencode-ai/sdk/v2"
import type { Todo } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { extractSources } from "@/pages/session/session-status-extractors"
import { selectSessionTodos } from "@/pages/session/session-todos"
import { selectSessionTodoDataSnapshot } from "@/pages/session/session-todos"
import type { SessionTodoItem } from "@/pages/session/todos/todo-model"
import type { CanonicalTodoSnapshot } from "@/pages/session/todos/todo-source"

function Section(props: { title: string; children: JSX.Element }) {
return (
Expand Down Expand Up @@ -51,29 +51,36 @@ function SourceRow(props: { url: string }) {
}

export function SessionStatusSummary(props: {
backend?: Accessor<Todo[] | undefined>
backendClearActivePartsAt?: Accessor<number | undefined>
canonical?: Accessor<CanonicalTodoSnapshot | undefined>
isAuthoritativelyInvalidated?: Accessor<boolean>
isPending?: Accessor<boolean>
parts: Accessor<Part[]>
}) {
const language = useLanguage()
const todos = createMemo(() =>
selectSessionTodos({
backend: props.backend?.(),
backendClearActivePartsAt: props.backendClearActivePartsAt?.(),
parts: props.parts(),
const snapshot = createMemo(() =>
selectSessionTodoDataSnapshot({
primary: {
canonical: props.canonical?.(),
isAuthoritativelyInvalidated: props.isAuthoritativelyInvalidated?.() ?? false,
isPending: props.isPending?.() ?? false,
parts: props.parts(),
},
}),
)
const todos = createMemo(() => snapshot().items)
const sources = createMemo(() => extractSources(props.parts()))

return (
<div class="flex flex-col">
<Section title={language.t("status.summary.progress")}>
<Show when={todos().length > 0} fallback={<Empty text={language.t("status.summary.progress.empty")} />}>
<div class="flex flex-col">
<For each={todos()}>{(todo) => <TodoRow todo={todo} />}</For>
</div>
</Show>
</Section>
<Show when={snapshot().phase !== "pending"}>
<Section title={language.t("status.summary.progress")}>
<Show when={todos().length > 0} fallback={<Empty text={language.t("status.summary.progress.empty")} />}>
<div class="flex flex-col">
<For each={todos()}>{(todo) => <TodoRow todo={todo} />}</For>
</div>
</Show>
</Section>
</Show>

<Section title={language.t("status.summary.sources")}>
<Show when={sources().length > 0} fallback={<Empty text={language.t("status.summary.sources.empty")} />}>
Expand Down
61 changes: 50 additions & 11 deletions packages/app/src/context/global-sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,66 @@
import { describe, expect, test } from "bun:test"
import type { Todo } from "@opencode-ai/sdk/v2/client"
import { nextSessionTodoClearFlag } from "./global-sync"
import { createStore } from "solid-js/store"
import { canAcceptSessionTodo, setSessionTodoSnapshot, type GlobalStore, type SessionTodoSnapshot } from "./global-sync"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"

describe("nextSessionTodoClearFlag", () => {
const globalStoreFixture = (): GlobalStore => ({
ready: true,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
reload: undefined,
})

describe("canAcceptSessionTodo", () => {
const todo = { id: "todo_1", content: "work", status: "in_progress", priority: "medium" } as Todo
const snapshot = (revision: number): SessionTodoSnapshot => ({ revision, todos: [todo] })

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

test("preserves existing live clear flag across ordinary empty backend refreshes", () => {
expect(nextSessionTodoClearFlag(10, [])).toBe(10)
test("rejects stale or equal revisions", () => {
expect(canAcceptSessionTodo(snapshot(2), snapshot(1))).toBe(false)
expect(canAcceptSessionTodo(snapshot(2), snapshot(2))).toBe(false)
})

test("does not create a clear flag for ordinary empty backend refreshes", () => {
expect(nextSessionTodoClearFlag(undefined, [])).toBeUndefined()
test("accepts newer revisions including authoritative empty snapshots", () => {
expect(canAcceptSessionTodo(snapshot(2), { revision: 3, todos: [] })).toBe(true)
})
})

describe("setSessionTodoSnapshot", () => {
test("creates the canonical todo entry on first write", () => {
const [store, setStore] = createStore<GlobalStore>(globalStoreFixture())
const todo = { id: "todo_1", content: "work", status: "in_progress", priority: "medium" } as Todo

setSessionTodoSnapshot(setStore, "ses_1", undefined, {
revision: 1,
todos: [todo],
})

expect(store.session_todo.ses_1).toEqual({ revision: 1, todos: [todo] })
})

test("clears the flag on non-empty backend updates and cleanup", () => {
expect(nextSessionTodoClearFlag(10, [todo])).toBeUndefined()
expect(nextSessionTodoClearFlag(10, undefined)).toBeUndefined()
test("updates todos through a keyed array path before writing revision", () => {
const calls: unknown[][] = []
const setStore = ((...input: unknown[]) => {
calls.push(input)
return input.at(-1)
}) as Parameters<typeof setSessionTodoSnapshot>[0]
const todo = { id: "todo_1", content: "work", status: "in_progress", priority: "medium" } as Todo
const current = { revision: 1, todos: [] }

setSessionTodoSnapshot(setStore, "ses_1", current, { revision: 2, todos: [todo] })

expect(calls).toHaveLength(2)
expect(calls[0].slice(0, 3)).toEqual(["session_todo", "ses_1", "todos"])
expect(calls[1]).toEqual(["session_todo", "ses_1", "revision", 2])
})
})

Expand Down
Loading
Loading