mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #1707 from deepseek-harness/feat/session-completed-dot
feat(web): done dot on sessions that finished while unviewed
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md
|
||||
2026-08-06-session-completed-done-dot.md: bd6911ce137f1272090c86c029710c9f4054ee6d
|
||||
2026-08-06-session-completed-done-dot.zh.md: 9ec2199a29d1307c3ebd0238e84d5f90d36fe21c
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Session completion dot in the sidebar
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-06-session-completed-done-dot.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A session the operator delegated work to and then left (switched to another conversation) gives no signal when it finishes. Its running indicator stops, but the row then looks identical to any idle session, so the operator must poll the list or discover the finished work late. The pending-interaction amber dot covers sessions that need input, not sessions whose work is simply done.
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionManager` owns a client-side completion-reminder set, a sibling of the pending-interaction bit: a running→idle edge of a session that is not the selected one arms its reminder; `select()`/`selectSubagent()` consume it; starting a new run disarms it and its completion re-arms it; removal prunes it. The bit rides `SessionListEntry` → `SessionSummary` (optional, absent = no reminder) into the workspace browser, whose session and search rows render the existing `StateDot` `done` state — running keeps the ongoing spinner, an idle session without a reminder shows nothing — and whose hover card labels the reminder 已完成 / Completed.
|
||||
|
||||
The reminder is in-memory and per browser. It survives connection generations — a transport blip does not invalidate "you have not looked yet" — but not a page reload.
|
||||
|
||||
## Consequences
|
||||
|
||||
The sidebar row states become three disjoint signals: green = finished and unviewed, amber = awaiting the operator's input, blue = running. No wire, on-disk, or configuration format changes: `SessionSummary.completed` is optional, so existing consumers and test fixtures stay valid, and only the workspace browser reads it. The completion edge is detected eagerly at every list mutation and pull (a snapshot-build-time-only pass would collapse two consecutive status frames into one observation and miss the completion).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Component-local UI state.** Rejected because the sidebar unmounts on collapse and multiple surfaces (grouped tree, flat list, search) need the same bit; the manager already owns the running transitions and the selection, so a manager-owned set is the one source all surfaces can project.
|
||||
- **Event-driven arming from status frames only.** Rejected because a list pull can also carry a running→idle transition (a session finished while the refresh was in flight); the reminder is reconciled against every mutation and pull.
|
||||
- **Persisting the reminder.** Rejected because the reminder means "you have not looked at this session yet" in this browser; reload restores the selection and the user is looking at the list again, so a durable bit would only go stale.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 侧边栏会话完成提醒点
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-06-session-completed-done-dot.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
操作者派发任务后切换到其他会话,原会话完成时没有任何信号。运行指示停止后,该行与普通空闲会话看起来完全一样,操作者只能反复查看列表或很晚才发现工作已完成。等待交互的琥珀点只覆盖需要操作者输入的会话,不覆盖"只是干完了活"的会话。
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionManager` 持有客户端侧的完成提醒集合,与待交互位并列:非当前会话发生 running→idle 边沿时点亮其提醒;`select()`/`selectSubagent()` 消费掉提醒;重新开始一轮运行会熄灭提醒并在再次完成时重新点亮;会话被移除时清理提醒。该位经 `SessionListEntry` → `SessionSummary`(可选字段,缺省 = 无提醒)进入工作区浏览区,其会话行与搜索结果行渲染现有的 `StateDot` `done` 状态——运行中仍显示转圈,无提醒的空闲会话不显示任何点——悬停卡片将该提醒标注为"已完成 / Completed"。
|
||||
|
||||
提醒仅存在于内存中且按浏览器实例隔离。它跨连接代存活——传输抖动不会使"你还没回来看"失效——但页面刷新后重置。
|
||||
|
||||
## Consequences
|
||||
|
||||
侧边栏行状态成为三个互斥信号:绿 = 已完成且未查看,琥珀 = 等待操作者输入,蓝 = 运行中。无 wire、磁盘或配置格式变更:`SessionSummary.completed` 为可选字段,现有消费者与测试 fixture 保持有效,只有工作区浏览区读取它。完成边沿在每次列表变更与拉取时即时检测(仅在建快照时检测会把连续两个状态帧折叠为一次观察,从而漏掉完成事件)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **组件本地 UI 状态。** 已拒绝:侧边栏折叠时会卸载,且多个界面(分组树、单列表、搜索)需要同一状态位;manager 本就持有运行状态迁移与选中状态,manager 持有的集合是所有界面都能投影的唯一事实源。
|
||||
- **仅从状态帧做事件驱动点亮。** 已拒绝:列表拉取本身也可能携带 running→idle 迁移(刷新在途时会话已完成);提醒需对每次变更与拉取做对账。
|
||||
- **持久化提醒。** 已拒绝:提醒的含义是"此浏览器里你还没查看该会话";刷新会恢复选中状态且用户正看着列表,持久化位只会过期。
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user