feat(web): done dot on sessions that finished while unviewed

A session that stops running while it is not the selected session arms a
green 'done' reminder dot on its sidebar row, so the operator notices a
finished background session and returns to it; opening the session clears
the dot, and a re-run re-arms it on completion.

SessionManager owns the reminder set (a sibling of the waiting-approval
bit): a running->idle edge of a non-selected session arms it, select()
consumes it, removal prunes it, and it survives connection generations.
The bit rides SessionListEntry/SessionSummary into the workspace browser
rows, which render the existing StateDot done state (running keeps the
spinner) and label the hover card '已完成/Completed'.
This commit is contained in:
GeeeekExplorer
2026-08-06 11:12:48 +08:00
parent 17ff1e0d4a
commit ffdcafb45f
10 changed files with 297 additions and 15 deletions
@@ -29,6 +29,8 @@ export interface SessionListEntry {
projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** User interaction currently blocking this session, derived from live mux frames. */
pendingInteraction?: PendingInteractionStatus
/** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
completed: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
@@ -39,11 +41,13 @@ export interface SessionListEntry {
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @param pendingInteractions - current manager-owned interaction status by session.
* @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(
summaries: readonly TitledSessionSummary[],
pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
completed?: ReadonlySet<SessionId>,
): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
@@ -72,6 +76,7 @@ export function flattenLineage(
out.push({
...s,
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
completed: completed?.has(s.sessionId) ?? false,
depth,
})
const kids = children.get(s.sessionId)
@@ -109,6 +109,14 @@ export class SessionManager {
* sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
* still-pending requests — and on session-removed. */
private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>()
/**
* Sessions that finished running while not selected — the sidebar's green
* "done" reminder (manager-owned, survives connection generations; cleared
* on select and session-removed, re-armed by the next completion).
*/
private readonly completedNotifications = new Set<SessionId>()
/** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */
private readonly prevRunning = new Map<SessionId, boolean>()
/** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -175,6 +183,8 @@ export class SessionManager {
: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
)
this.selected = sessionId
// Looking at the session consumes its completion reminder (dot clears).
this.completedNotifications.delete(sessionId)
void this.refreshSubagents(sessionId)
this.notifier.notifyNow()
}
@@ -192,6 +202,7 @@ export class SessionManager {
this.addresses.set(address.childSessionId, address)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
this.selected = address.childSessionId
this.completedNotifications.delete(address.childSessionId)
void this.refreshSubagents(address.childSessionId)
this.notifier.notifyNow()
}
@@ -414,13 +425,28 @@ export class SessionManager {
try {
const { result } = await this.api.sessions.list({})
if (result.ok) {
let summaries = this.listPhase === 'pending'
const baseline = this.listPhase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
// Seed first observations from the pull-time baseline BEFORE replaying
// in-flight mutations, then reconcile the reminders after EVERY
// replayed mutation: an edge that happens entirely between mutations
// (baseline idle → running → idle) must still arm, which a single
// sync on the folded result would collapse away.
for (const s of baseline) {
if (!this.prevRunning.has(s.sessionId)) this.prevRunning.set(s.sessionId, s.running)
}
let summaries = baseline
for (const mutation of mutations) {
summaries = applyMutation(summaries, mutation)
this.summaries = summaries
this.syncCompletedNotifications()
}
this.summaries = summaries
this.listState = 'idle'
this.listPhase = 'ready'
// Covers the empty-mutations pull (a plain baseline carries no edge).
this.syncCompletedNotifications()
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
@@ -566,6 +592,8 @@ export class SessionManager {
private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation)
this.summaries = applyMutation(this.summaries, mutation)
// Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames.
this.syncCompletedNotifications()
this.notifier.markDirty()
}
@@ -893,6 +921,38 @@ export class SessionManager {
})
}
/**
* Reconcile completion reminders against the latest summaries, eagerly after
* every mutation and pull (a snapshot-build-time pass would collapse
* consecutive status frames into one observation). A running→idle edge of a
* non-selected session arms its reminder; running disarms it; removal drops
* it. First observation only records the running bit — sessions already
* idle at load get no reminder.
*/
private syncCompletedNotifications(): void {
const seen = new Set<SessionId>()
for (const s of this.summaries) {
seen.add(s.sessionId)
const prev = this.prevRunning.get(s.sessionId)
if (prev === undefined) {
this.prevRunning.set(s.sessionId, s.running)
continue
}
if (prev && !s.running) {
if (s.sessionId !== this.selected) this.completedNotifications.add(s.sessionId)
} else if (s.running) {
this.completedNotifications.delete(s.sessionId)
}
this.prevRunning.set(s.sessionId, s.running)
}
for (const id of this.prevRunning.keys()) {
if (!seen.has(id)) this.prevRunning.delete(id)
}
for (const id of this.completedNotifications) {
if (!seen.has(id)) this.completedNotifications.delete(id)
}
}
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit
@@ -914,7 +974,7 @@ export class SessionManager {
const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0]
if (status !== undefined) pendingInteractions.set(sessionId, status)
}
const fresh = flattenLineage(merged, pendingInteractions)
const fresh = flattenLineage(merged, pendingInteractions, this.completedNotifications)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
@@ -924,6 +984,7 @@ export class SessionManager {
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.pendingInteraction === entry.pendingInteraction
&& prev.projectionValues === entry.projectionValues
&& prev.completed === entry.completed
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry
@@ -51,6 +51,8 @@ export interface SessionSummary {
running: boolean
/** User interaction currently blocking this session (sidebar amber-dot state). */
pendingInteraction?: PendingInteractionStatus
/** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
completed?: boolean
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
@@ -614,6 +616,7 @@ export class SessionsService implements ISessions {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
...(entry.completed ? { completed: true } : {}),
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.pendingInteraction === undefined
@@ -52,4 +52,11 @@ describe('flattenLineage', () => {
warnSpy.mockRestore()
}
})
it('projects the completion-reminder set into rows (absent = false)', () => {
const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId]))
expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
})
})
@@ -985,3 +985,128 @@ describe('pending-interaction list status', () => {
expect(session.getSnapshot().pending).toEqual([])
})
})
describe('completed reminder', () => {
const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({
rpcId: rpcId as never,
payload: { type: 'host/session-status' as const, sessionId, running },
})
const added = (rpcId: string, sessionId: SessionId) => ({
rpcId: rpcId as never,
payload: { type: 'host/session-added' as const, sessionId, blank: false },
})
const entry = (manager: SessionManager, sessionId: SessionId) =>
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
// Opening the session consumes the reminder.
manager.select(S2)
expect(entry(manager, S2)?.completed).toBe(false)
})
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S2)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
// Switch away; a fresh run completing again arms the reminder.
manager.select(S1)
manager.handleHostEnvelope(status('s3', S2, true))
manager.handleHostEnvelope(status('s4', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
})
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
// The user starts a new run without opening the session: running wins.
manager.handleHostEnvelope(status('s3', S2, true))
expect(entry(manager, S2)?.completed).toBe(false)
manager.handleHostEnvelope(status('s4', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
})
it('session-removed drops the reminder and a re-add starts clean', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
manager.handleHostEnvelope(added('h3', S2))
expect(entry(manager, S2)?.completed).toBe(false)
})
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(true)
})
it('never arms for sessions already idle at first observation', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(false)
})
it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const refresh = manager.refreshList()
// The session finishes while the first pull is still in flight; the pull
// response recorded it as running at pull time.
manager.handleHostEnvelope(status('s-mid', S2, false))
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const refresh = manager.refreshList()
// The unknown session starts and finishes while the first pull is in
// flight; the pull-time baseline recorded it idle, so the running→idle
// edge lives entirely inside the replayed mutations.
manager.handleHostEnvelope(status('s-start', S2, true))
manager.handleHostEnvelope(status('s-finish', S2, false))
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
})
@@ -49,6 +49,7 @@ export const zh = {
'status.waitingApproval': '等待审批',
'status.planReview': '计划待审',
'status.waitingAnswer': '等待回答',
'status.completed': '已完成',
'hover.created': '创建于 {time}',
'hover.copied': '已复制',
'date.ymd': '{y}年{m}月{d}日',
@@ -109,6 +110,7 @@ export const en = {
'status.waitingApproval': 'Waiting for approval',
'status.planReview': 'Plan awaiting review',
'status.waitingAnswer': 'Waiting for answer',
'status.completed': 'Completed',
'hover.created': 'Created {time}',
'hover.copied': 'Copied',
'date.ymd': '{y}-{m}-{d}',
@@ -173,7 +173,7 @@ function assertNever(value: never): never {
/** Session status presentation; pending user interaction outranks the running state. */
function sessionStatus(
node: Pick<SessionNode, 'pendingInteraction' | 'running'>,
node: Pick<SessionNode, 'pendingInteraction' | 'running' | 'completed'>,
t: RowTranslate,
): { state: StateDotState; label: string } {
switch (node.pendingInteraction) {
@@ -185,10 +185,11 @@ function sessionStatus(
default: return assertNever(node.pendingInteraction)
}
if (node.running) return { state: 'ongoing', label: t('status.running') }
if (node.completed) return { state: 'done', label: t('status.completed') }
return { state: 'done', label: t('status.idle') }
}
/** Hover-card body: full title, relative time, and interaction/running/idle status. */
/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const status = sessionStatus(node, t)
return (
@@ -251,7 +252,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
>
<span className={css.searchResultHeading}>
<span className={css.slot}>
{status.state !== 'done' && (
{(status.state !== 'done' || result.completed) && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
@@ -351,8 +352,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
drag.drop(rowHalf(e))
}}
>
{/* Pending interactions and running outrank the idle state; a
finished-but-unviewed session shows the green done reminder dot
(cleared by opening the session). */}
<span className={css.slot}>
{status.state !== 'done' && (
{(status.state !== 'done' || row.completed) && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
@@ -24,6 +24,8 @@ export interface SessionNode {
/** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
updatedAt: number
}
@@ -54,6 +56,8 @@ export interface SearchResultNode {
/** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
snippet?: string
}
@@ -175,6 +179,7 @@ function sessionNode(s: SessionSummary): SessionNode {
title: sessionTitle(s),
blank: s.blank,
running: s.running,
completed: s.completed === true,
updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
}
@@ -330,6 +335,7 @@ export function deriveSearchResults(
...(summary.pendingInteraction === undefined
? {}
: { pendingInteraction: summary.pendingInteraction }),
completed: summary.completed === true,
...match === undefined ? {} : { snippet: match.snippet },
}
}),
@@ -64,6 +64,7 @@ describe('workspace browser rows', () => {
title: 'Result title',
workspace: 'Workspace context',
running: true,
completed: false,
snippet: 'matching message excerpt',
}
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />)
@@ -85,7 +86,7 @@ describe('workspace browser rows', () => {
] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => {
const result: SearchResultNode = {
id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project',
pendingInteraction, running: true,
pendingInteraction, running: true, completed: false,
}
render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
const row = screen.getByRole('treeitem')
@@ -114,7 +115,7 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0,
id: sid('session'), title: 'Session', blank: false, running: true, completed: false, updatedAt: 0,
}
const onOpen = vi.fn()
render(
@@ -130,6 +131,38 @@ describe('workspace browser rows', () => {
expect(onOpen).toHaveBeenCalledWith(node.id)
})
it('shows the green done dot only on a finished, unviewed session (running wins the slot)', () => {
const renderRow = (over: Partial<SessionNode>) => render(
<SessionNodeItem
node={{ id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0, ...over }}
currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
/>,
)
const stateDot = (view: ReturnType<typeof renderRow>) =>
view.container.querySelector('[data-state]')
// No completion reminder, not running: no state dot at all.
const plain = renderRow({})
expect(stateDot(plain)).toBeNull()
plain.unmount()
// Completed while unviewed: the green done dot.
const done = renderRow({ completed: true })
expect(done.container.querySelector('[data-state="done"]')).not.toBeNull()
done.unmount()
// Running wins the slot: the animated ongoing dot, no done dot.
const running = renderRow({ completed: true, running: true })
expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="done"]')).toBeNull()
})
it('shows the green done dot on a finished search result row', () => {
render(<SearchResultItem
result={{ id: sid('result'), title: 'Done', workspace: 'Workspace', running: false, completed: true }}
currentId={undefined} onOpen={vi.fn()} t={t}
/>)
expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull()
})
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
const onRename = vi.fn()
const onDelete = vi.fn()
@@ -198,7 +231,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0,
id: sid('s-blank'), title: 'ignored', blank: true, running: false, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -224,7 +257,7 @@ describe('workspace browser rows', () => {
const onFork = vi.fn()
const onArchive = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -257,7 +290,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
id: sid('s1'), title: 'Hovered', blank: false, running: true, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -288,7 +321,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid(pendingInteraction), title: 'Needs input', blank: false,
pendingInteraction, running: true, updatedAt: 0,
pendingInteraction, running: true, completed: false, updatedAt: 0,
}
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -314,7 +347,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', blank: false, running: false, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -327,9 +360,26 @@ describe('workspace browser rows', () => {
}
})
it('completed hover card shows the Completed status line', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Done', blank: false, running: false, completed: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
// Row's visually-hidden reminder label plus the hover card's status line.
expect(screen.getAllByText('已完成')).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', blank: false, running: false, completed: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(
@@ -77,6 +77,22 @@ describe('deriveGroups', () => {
expect(strayGroups.map(group => group.key)).toEqual(['first'])
})
it('projects the completion reminder into session and search rows (absent = false)', () => {
const done = { ...summary('done', 3), completed: true }
const plain = summary('plain', 2)
const sessions = list(done, plain)
const groups = deriveGroups(
sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']),
)
const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
expect(doneNode.completed).toBe(true)
expect(plainNode.completed).toBe(false)
expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true)
const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10)
expect(search.items[0]?.completed).toBe(true)
})
it('hides subagent-origin sessions without hiding ordinary forks', () => {
const parent = summary('parent', 1)
const fork = { ...summary('fork', 2), parentId: parent.id }
@@ -259,6 +275,7 @@ describe('deriveSearchResults', () => {
workspace: 'Alpha',
running: false,
pendingInteraction: 'plan-review',
completed: false,
snippet: 'title session body excerpt',
},
{
@@ -266,12 +283,14 @@ describe('deriveSearchResults', () => {
title: 'Ordinary title',
workspace: 'Needle Workspace',
running: false,
completed: false,
},
{
id: contentHit.id,
title: 'content-hit',
workspace: 'c',
running: false,
completed: false,
snippet: 'body needle excerpt',
},
],