feat(client): surface pending session interactions

This commit is contained in:
Turtle
2026-08-04 17:14:58 +08:00
parent 11bad56fc3
commit 00d4349eb4
40 changed files with 334 additions and 147 deletions

View File

@@ -66,16 +66,34 @@ describe('workspace browser rows', () => {
running: true,
snippet: 'matching message excerpt',
}
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} />)
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />)
const row = screen.getByRole('treeitem')
expect(row.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('Workspace context')).toBeTruthy()
expect(screen.getByText('matching message excerpt')).toBeTruthy()
expect(row.querySelector('[data-state="ongoing"]')).toBeTruthy()
expect(screen.getByText('进行中')).toBeTruthy()
expect(row.hasAttribute('draggable')).toBe(false)
fireEvent.click(row)
expect(onOpen).toHaveBeenCalledWith(result.id)
})
it.each([
['approval', '等待审批'],
['plan-review', '计划待审'],
['question', '等待回答'],
] 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,
}
render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
const row = screen.getByRole('treeitem')
expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
expect(screen.getByText(label)).toBeTruthy()
})
it('renders an active Workspace and keeps its create action separate from toggling', () => {
const onToggle = vi.fn()
const onCreate = vi.fn()
@@ -96,7 +114,7 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', blank: false, waitingApproval: false, running: true, updatedAt: 0,
id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0,
}
const onOpen = vi.fn()
render(
@@ -180,7 +198,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s-blank'), title: 'ignored', blank: true, waitingApproval: false, running: false, updatedAt: 0,
id: sid('s-blank'), title: 'ignored', blank: true, running: 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} />)
@@ -206,7 +224,7 @@ describe('workspace browser rows', () => {
const onFork = vi.fn()
const onArchive = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, waitingApproval: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -239,7 +257,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', blank: false, waitingApproval: false, running: true, updatedAt: 0,
id: sid('s1'), title: 'Hovered', blank: false, running: true, 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} />)
@@ -261,19 +279,23 @@ describe('workspace browser rows', () => {
}
})
it('shows approval waiting as warning ahead of the running state', () => {
it.each([
['approval', '等待审批'],
['plan-review', '计划待审'],
['question', '等待回答'],
] as const)('shows %s as warning ahead of the running state', (pendingInteraction, label) => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('approval'), title: 'Needs approval', blank: false,
waitingApproval: true, running: true, updatedAt: 0,
id: sid(pendingInteraction), title: 'Needs input', blank: false,
pendingInteraction, running: true, 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} />)
const row = screen.getByRole('treeitem')
expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
expect(screen.getByText('等待审批')).toBeTruthy()
expect(screen.getByText(label)).toBeTruthy()
view.rerender(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -281,7 +303,7 @@ describe('workspace browser rows', () => {
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getAllByText('等待审批')).toHaveLength(2)
expect(screen.getAllByText(label)).toHaveLength(2)
expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
} finally {
vi.useRealTimers()
@@ -292,7 +314,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', blank: false, waitingApproval: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', blank: false, running: 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} />)
@@ -307,7 +329,7 @@ describe('workspace browser rows', () => {
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, waitingApproval: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(

View File

@@ -11,7 +11,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts'
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
})
const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),
@@ -38,12 +38,12 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
it('projects approval-waiting state into grouped and flat rows', () => {
const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true }
it('projects pending-interaction state into grouped and flat rows', () => {
const awaiting = { ...summary('awaiting', 10), pendingInteraction: 'plan-review' as const, running: true }
const sessions = list(awaiting)
const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project']))
expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true })
expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ waitingApproval: true, running: true })
expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
})
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
@@ -225,6 +225,7 @@ describe('deriveSearchResults', () => {
it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
const titleHit = summary('title-hit', 30, '/projects/a')
titleHit.displayTitle = 'Needle title'
titleHit.pendingInteraction = 'plan-review'
const workspaceHit = summary('workspace-hit', 20, '/projects/b')
workspaceHit.displayTitle = 'Ordinary title'
const contentHit = summary('content-hit', 10, '/projects/c')
@@ -257,6 +258,7 @@ describe('deriveSearchResults', () => {
title: 'Needle title',
workspace: 'Alpha',
running: false,
pendingInteraction: 'plan-review',
snippet: 'title session body excerpt',
},
{

View File

@@ -22,7 +22,7 @@ const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh)
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...overrides,
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
})
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
ids: items.map(item => item.id),