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

@@ -2,5 +2,5 @@
# 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 packages/client/ui-workspace/README.md
README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469
README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab
README.md: 855bcdc7fa1850ee30a1887add10dc019d9887e2
README.zh.md: acb35ab169f10444c26ae6d68aefda9c5b8703df

View File

@@ -12,7 +12,7 @@ Workspace and Session hover cards copy the value their row clips: activating a W
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`.
Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator; ordinary rows repeat the localized status in their hover card, and both ordinary and search-result rows carry the same text as a visually hidden label for assistive technology. Running uses the blue indicator and its hidden label; an idle row leaves the reserved status slot empty.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -30,5 +30,5 @@ None; this package neither assembles nor sends a provider request.
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions.
- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded.
- **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -12,7 +12,7 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。
Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点优先级高于运行指示器;普通行的悬浮卡片重复显示本地化状态,普通行和搜索结果行则都以相同文本提供面向辅助技术的视觉隐藏标签。运行状态使用蓝色指示器及其隐藏标签;空闲行会保留空的状态槽位。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -30,5 +30,5 @@ Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token短语匹配选择结果会打开 Session而不是匹配的事件。
- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。
- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。
- **待处理的用户交互不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -306,6 +306,7 @@ function SearchResults({
result={result}
currentId={list.current}
onOpen={open}
t={t}
/>
))}
</div>

View File

@@ -47,6 +47,8 @@ export const zh = {
'status.running': '进行中',
'status.idle': '空闲',
'status.waitingApproval': '等待审批',
'status.planReview': '计划待审',
'status.waitingAnswer': '等待回答',
'hover.created': '创建于 {time}',
'hover.copied': '已复制',
'date.ymd': '{y}年{m}月{d}日',
@@ -105,6 +107,8 @@ export const en = {
'status.running': 'Running',
'status.idle': 'Idle',
'status.waitingApproval': 'Waiting for approval',
'status.planReview': 'Plan awaiting review',
'status.waitingAnswer': 'Waiting for answer',
'hover.created': 'Created {time}',
'hover.copied': 'Copied',
'date.ymd': '{y}-{m}-{d}',

View File

@@ -166,14 +166,29 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
)
}
/** Session status presentation; approval waiting outranks the underlying running state. */
function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } {
if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') }
/* v8 ignore next 3 -- closed-union backstop; only reached if the status is forged */
function assertNever(value: never): never {
throw new Error(`unknown pending interaction: ${String(value)}`)
}
/** Session status presentation; pending user interaction outranks the running state. */
function sessionStatus(
node: Pick<SessionNode, 'pendingInteraction' | 'running'>,
t: RowTranslate,
): { state: StateDotState; label: string } {
switch (node.pendingInteraction) {
case 'approval': return { state: 'warning', label: t('status.waitingApproval') }
case 'plan-review': return { state: 'warning', label: t('status.planReview') }
case 'question': return { state: 'warning', label: t('status.waitingAnswer') }
case undefined: break
/* v8 ignore next -- closed PendingInteractionStatus union */
default: return assertNever(node.pendingInteraction)
}
if (node.running) return { state: 'ongoing', label: t('status.running') }
return { state: 'done', label: t('status.idle') }
}
/** Hover-card body: full title, relative time, and approval/running/idle status. */
/** Hover-card body: full title, relative time, and interaction/running/idle status. */
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const status = sessionStatus(node, t)
return (
@@ -215,14 +230,17 @@ export interface RowDragProps {
* @param props.result - merged local/content search row.
* @param props.currentId - selected session id.
* @param props.onOpen - open the selected session.
* @param props.t - Workspace-browser translation seat.
* @returns the result button.
*/
export function SearchResultItem({ result, currentId, onOpen }: {
export function SearchResultItem({ result, currentId, onOpen, t }: {
result: SearchResultNode
currentId: string | undefined
onOpen: (id: SearchResultNode['id']) => void
t: RowTranslate
}) {
const selected = result.id === currentId
const status = sessionStatus(result, t)
return (
<button
type="button"
@@ -232,7 +250,14 @@ export function SearchResultItem({ result, currentId, onOpen }: {
onClick={() => { onOpen(result.id) }}
>
<span className={css.searchResultHeading}>
<span className={css.slot}>{result.running && <StateDot state="ongoing" />}</span>
<span className={css.slot}>
{status.state !== 'done' && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
</>
)}
</span>
<span className={css.searchResultTitle}>{result.title}</span>
</span>
<span className={css.searchResultWorkspace}>{result.workspace}</span>
@@ -250,7 +275,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
}
/**
* One top-level 34px session row: status dot (approval waiting outranks
* One top-level 34px session row: status dot (pending user interaction outranks
* running), title, relative time, and the row actions menu.
* @param props.node - derived session node.
* @param props.currentId - selected session id (row highlight).

View File

@@ -4,7 +4,8 @@
* remains visible.
*/
import type {
SessionId, SessionListState, SessionSearchResultItem, SessionSummary, WorkspaceId, WorkspaceView,
PendingInteractionStatus, SessionId, SessionListState, SessionSearchResultItem, SessionSummary,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for Sessions outside every Workspace. */
@@ -20,8 +21,8 @@ export interface SessionNode {
title: string
/** The provisional blank session (renderer shows the localized New Session title). */
blank: boolean
/** The runtime Session list reports a pending approval request for this Session. */
waitingApproval: boolean
/** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
updatedAt: number
}
@@ -50,6 +51,8 @@ export interface SearchResultNode {
id: SessionId
title: string
workspace: string
/** The runtime Session list reports an interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
snippet?: string
}
@@ -171,9 +174,9 @@ function sessionNode(s: SessionSummary): SessionNode {
id: s.id,
title: sessionTitle(s),
blank: s.blank,
waitingApproval: s.waitingApproval,
running: s.running,
updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
}
}
@@ -324,6 +327,9 @@ export function deriveSearchResults(
title: sessionTitle(summary),
workspace: labelOf(summary),
running: summary.running,
...(summary.pendingInteraction === undefined
? {}
: { pendingInteraction: summary.pendingInteraction }),
...match === undefined ? {} : { snippet: match.snippet },
}
}),

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),