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-05 16:42:51 +08:00
parent 17ff1e0d4a
commit ffdcafb45f
10 changed files with 297 additions and 15 deletions

View File

@@ -29,6 +29,8 @@ export interface SessionListEntry {
projectionValues?: Readonly<Partial<SessionProjectionMap>> projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** User interaction currently blocking this session, derived from live mux frames. */ /** User interaction currently blocking this session, derived from live mux frames. */
pendingInteraction?: PendingInteractionStatus 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. */ /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number depth: number
} }
@@ -39,11 +41,13 @@ export interface SessionListEntry {
* hydrated list from mutable timestamps. * hydrated list from mutable timestamps.
* @param summaries - the host's session.list items. * @param summaries - the host's session.list items.
* @param pendingInteractions - current manager-owned interaction status by session. * @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. * @returns display rows in render order.
*/ */
export function flattenLineage( export function flattenLineage(
summaries: readonly TitledSessionSummary[], summaries: readonly TitledSessionSummary[],
pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>, pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
completed?: ReadonlySet<SessionId>,
): SessionListEntry[] { ): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>() const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s) for (const s of summaries) byId.set(s.sessionId, s)
@@ -72,6 +76,7 @@ export function flattenLineage(
out.push({ out.push({
...s, ...s,
...(pendingInteraction === undefined ? {} : { pendingInteraction }), ...(pendingInteraction === undefined ? {} : { pendingInteraction }),
completed: completed?.has(s.sessionId) ?? false,
depth, depth,
}) })
const kids = children.get(s.sessionId) const kids = children.get(s.sessionId)

View File

@@ -109,6 +109,14 @@ export class SessionManager {
* sessions never instantiated. Cleared per connection generation — the reopen replay re-adds * sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
* still-pending requests — and on session-removed. */ * still-pending requests — and on session-removed. */
private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>() 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 /** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session * 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 * 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.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
) )
this.selected = sessionId this.selected = sessionId
// Looking at the session consumes its completion reminder (dot clears).
this.completedNotifications.delete(sessionId)
void this.refreshSubagents(sessionId) void this.refreshSubagents(sessionId)
this.notifier.notifyNow() this.notifier.notifyNow()
} }
@@ -192,6 +202,7 @@ export class SessionManager {
this.addresses.set(address.childSessionId, address) this.addresses.set(address.childSessionId, address)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false) this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
this.selected = address.childSessionId this.selected = address.childSessionId
this.completedNotifications.delete(address.childSessionId)
void this.refreshSubagents(address.childSessionId) void this.refreshSubagents(address.childSessionId)
this.notifier.notifyNow() this.notifier.notifyNow()
} }
@@ -414,13 +425,28 @@ export class SessionManager {
try { try {
const { result } = await this.api.sessions.list({}) const { result } = await this.api.sessions.list({})
if (result.ok) { if (result.ok) {
let summaries = this.listPhase === 'pending' const baseline = this.listPhase === 'pending'
? result.value.items ? result.value.items
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) : 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.summaries = summaries
this.listState = 'idle' this.listState = 'idle'
this.listPhase = 'ready' 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). // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) { for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId) const session = this.sessions.get(s.sessionId)
@@ -566,6 +592,8 @@ export class SessionManager {
private recordMutation(mutation: SessionListMutation): void { private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation) this.listMutations?.push(mutation)
this.summaries = applyMutation(this.summaries, 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() 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 { private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => { const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit // 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] const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0]
if (status !== undefined) pendingInteractions.set(sessionId, status) 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 items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId) const prev = this.entryCache.get(entry.sessionId)
if ( if (
@@ -924,6 +984,7 @@ export class SessionManager {
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.pendingInteraction === entry.pendingInteraction && prev.pendingInteraction === entry.pendingInteraction
&& prev.projectionValues === entry.projectionValues && prev.projectionValues === entry.projectionValues
&& prev.completed === entry.completed
) return prev ) return prev
this.entryCache.set(entry.sessionId, entry) this.entryCache.set(entry.sessionId, entry)
return entry return entry

View File

@@ -51,6 +51,8 @@ export interface SessionSummary {
running: boolean running: boolean
/** User interaction currently blocking this session (sidebar amber-dot state). */ /** User interaction currently blocking this session (sidebar amber-dot state). */
pendingInteraction?: PendingInteractionStatus 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 * Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the * one targeting the same workspace. Filtering stays with the consumer: the
@@ -614,6 +616,7 @@ export class SessionsService implements ISessions {
id: entry.sessionId, id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running, running: entry.running,
...(entry.completed ? { completed: true } : {}),
blank: entry.blank, blank: entry.blank,
updatedAt: entry.updatedAt, updatedAt: entry.updatedAt,
...(entry.pendingInteraction === undefined ...(entry.pendingInteraction === undefined

View File

@@ -52,4 +52,11 @@ describe('flattenLineage', () => {
warnSpy.mockRestore() 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)
})
}) })

View File

@@ -985,3 +985,128 @@ describe('pending-interaction list status', () => {
expect(session.getSnapshot().pending).toEqual([]) 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)
})
})

View File

@@ -49,6 +49,7 @@ export const zh = {
'status.waitingApproval': '等待审批', 'status.waitingApproval': '等待审批',
'status.planReview': '计划待审', 'status.planReview': '计划待审',
'status.waitingAnswer': '等待回答', 'status.waitingAnswer': '等待回答',
'status.completed': '已完成',
'hover.created': '创建于 {time}', 'hover.created': '创建于 {time}',
'hover.copied': '已复制', 'hover.copied': '已复制',
'date.ymd': '{y}年{m}月{d}日', 'date.ymd': '{y}年{m}月{d}日',
@@ -109,6 +110,7 @@ export const en = {
'status.waitingApproval': 'Waiting for approval', 'status.waitingApproval': 'Waiting for approval',
'status.planReview': 'Plan awaiting review', 'status.planReview': 'Plan awaiting review',
'status.waitingAnswer': 'Waiting for answer', 'status.waitingAnswer': 'Waiting for answer',
'status.completed': 'Completed',
'hover.created': 'Created {time}', 'hover.created': 'Created {time}',
'hover.copied': 'Copied', 'hover.copied': 'Copied',
'date.ymd': '{y}-{m}-{d}', 'date.ymd': '{y}-{m}-{d}',

View File

@@ -173,7 +173,7 @@ function assertNever(value: never): never {
/** Session status presentation; pending user interaction outranks the running state. */ /** Session status presentation; pending user interaction outranks the running state. */
function sessionStatus( function sessionStatus(
node: Pick<SessionNode, 'pendingInteraction' | 'running'>, node: Pick<SessionNode, 'pendingInteraction' | 'running' | 'completed'>,
t: RowTranslate, t: RowTranslate,
): { state: StateDotState; label: string } { ): { state: StateDotState; label: string } {
switch (node.pendingInteraction) { switch (node.pendingInteraction) {
@@ -185,10 +185,11 @@ function sessionStatus(
default: return assertNever(node.pendingInteraction) default: return assertNever(node.pendingInteraction)
} }
if (node.running) return { state: 'ongoing', label: t('status.running') } 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') } 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 }) { function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const status = sessionStatus(node, t) const status = sessionStatus(node, t)
return ( return (
@@ -251,7 +252,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
> >
<span className={css.searchResultHeading}> <span className={css.searchResultHeading}>
<span className={css.slot}> <span className={css.slot}>
{status.state !== 'done' && ( {(status.state !== 'done' || result.completed) && (
<> <>
<StateDot state={status.state} /> <StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span> <span className={css.visuallyHidden}>{status.label}</span>
@@ -351,8 +352,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
drag.drop(rowHalf(e)) 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}> <span className={css.slot}>
{status.state !== 'done' && ( {(status.state !== 'done' || row.completed) && (
<> <>
<StateDot state={status.state} /> <StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span> <span className={css.visuallyHidden}>{status.label}</span>

View File

@@ -24,6 +24,8 @@ export interface SessionNode {
/** The runtime Session list reports an interaction awaiting this user. */ /** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus pendingInteraction?: PendingInteractionStatus
running: boolean running: boolean
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
updatedAt: number updatedAt: number
} }
@@ -54,6 +56,8 @@ export interface SearchResultNode {
/** The runtime Session list reports an interaction awaiting this user. */ /** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus pendingInteraction?: PendingInteractionStatus
running: boolean running: boolean
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
snippet?: string snippet?: string
} }
@@ -175,6 +179,7 @@ function sessionNode(s: SessionSummary): SessionNode {
title: sessionTitle(s), title: sessionTitle(s),
blank: s.blank, blank: s.blank,
running: s.running, running: s.running,
completed: s.completed === true,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }), ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
} }
@@ -330,6 +335,7 @@ export function deriveSearchResults(
...(summary.pendingInteraction === undefined ...(summary.pendingInteraction === undefined
? {} ? {}
: { pendingInteraction: summary.pendingInteraction }), : { pendingInteraction: summary.pendingInteraction }),
completed: summary.completed === true,
...match === undefined ? {} : { snippet: match.snippet }, ...match === undefined ? {} : { snippet: match.snippet },
} }
}), }),

View File

@@ -64,6 +64,7 @@ describe('workspace browser rows', () => {
title: 'Result title', title: 'Result title',
workspace: 'Workspace context', workspace: 'Workspace context',
running: true, running: true,
completed: false,
snippet: 'matching message excerpt', snippet: 'matching message excerpt',
} }
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />) 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) => { ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => {
const result: SearchResultNode = { const result: SearchResultNode = {
id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project', 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} />) render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
const row = screen.getByRole('treeitem') const row = screen.getByRole('treeitem')
@@ -114,7 +115,7 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => { it('renders and opens a selected running Session row', () => {
const node: SessionNode = { 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() const onOpen = vi.fn()
render( render(
@@ -130,6 +131,38 @@ describe('workspace browser rows', () => {
expect(onOpen).toHaveBeenCalledWith(node.id) 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', () => { it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
const onRename = vi.fn() const onRename = vi.fn()
const onDelete = vi.fn() const onDelete = vi.fn()
@@ -198,7 +231,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {
const node: SessionNode = { 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()} render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -224,7 +257,7 @@ describe('workspace browser rows', () => {
const onFork = vi.fn() const onFork = vi.fn()
const onArchive = vi.fn() const onArchive = vi.fn()
const node: SessionNode = { 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} render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />) onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -257,7 +290,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {
const node: SessionNode = { 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()} render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -288,7 +321,7 @@ describe('workspace browser rows', () => {
try { try {
const node: SessionNode = { const node: SessionNode = {
id: sid(pendingInteraction), title: 'Needs input', blank: false, 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()} const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -314,7 +347,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {
const node: SessionNode = { 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()} render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) 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', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = { 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 inactive = dragProps()
const { rerender } = render( const { rerender } = render(

View File

@@ -77,6 +77,22 @@ describe('deriveGroups', () => {
expect(strayGroups.map(group => group.key)).toEqual(['first']) 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', () => { it('hides subagent-origin sessions without hiding ordinary forks', () => {
const parent = summary('parent', 1) const parent = summary('parent', 1)
const fork = { ...summary('fork', 2), parentId: parent.id } const fork = { ...summary('fork', 2), parentId: parent.id }
@@ -259,6 +275,7 @@ describe('deriveSearchResults', () => {
workspace: 'Alpha', workspace: 'Alpha',
running: false, running: false,
pendingInteraction: 'plan-review', pendingInteraction: 'plan-review',
completed: false,
snippet: 'title session body excerpt', snippet: 'title session body excerpt',
}, },
{ {
@@ -266,12 +283,14 @@ describe('deriveSearchResults', () => {
title: 'Ordinary title', title: 'Ordinary title',
workspace: 'Needle Workspace', workspace: 'Needle Workspace',
running: false, running: false,
completed: false,
}, },
{ {
id: contentHit.id, id: contentHit.id,
title: 'content-hit', title: 'content-hit',
workspace: 'c', workspace: 'c',
running: false, running: false,
completed: false,
snippet: 'body needle excerpt', snippet: 'body needle excerpt',
}, },
], ],