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:
@@ -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