diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 6d0d39b991..2a01f64382 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -135,12 +135,30 @@ describe('web e2e: Agent Teams panel', () => { await page.mouse.click(2, 2) await panel.waitFor({ state: 'detached' }) await page.setViewportSize(viewport) - await trigger.click() + await trigger.focus() + await page.keyboard.press('Enter') await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor() - await panel.getByRole('button', { name: 'Refresh Team' }).focus() + expect(await panel.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Tab') + expect(await panel.getByRole('button', { name: 'Refresh Team' }) + .evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Tab') + expect(await panel.getByRole('button', { name: 'Close', exact: true }) + .evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Escape') await panel.waitFor({ state: 'detached' }) expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Enter') + await panel.waitFor() + await page.keyboard.press('Tab') + await page.keyboard.press('Tab') + await page.keyboard.press('Enter') + await panel.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Enter') + await panel.waitFor() + await page.keyboard.press('Shift+Tab') + await panel.waitFor({ state: 'detached' }) } finally { await page.setViewportSize(viewport) } diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index c5edd83d9c..5965f254f5 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -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/experimental/client-ui-agent-team/README.md -README.md: 24eaa13a248980fcf683403e54fd6b138fdfcf27 -README.zh.md: 51ed00b31791e33d8cdbc57c2aa5491c127113e7 +README.md: 253c243a680a989988ea65e553c14eff9a699155 +README.zh.md: 143c855b8b8a45a7d3777700aefa6a378401f20d diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 24eaa13a24..253c243a68 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export mounts the generated `ctx.remote.agentTeams` contribution from [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.md), then registers its locale dictionaries and one conversation-header slot through Cordis effects. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Clicking outside or pressing Escape closes it. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored. | File | Role | |---|---| diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 51ed00b317..143c855b8b 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -47,7 +47,7 @@ kind: "package-reference" Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.zh.md) 的生成的 `ctx.remote.agentTeams` contribution,然后通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。点击外部或按 Escape 会关闭面板。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。 | 文件 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index 0135f41a98..cec47b1b78 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -85,6 +85,7 @@ export function TeamAction({ const position = useAnchoredPosition({ open, anchorRef: triggerRef, panelRef, gap: 5, margin: 16, }) + const positioned = position !== null useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) const sessionRef = useRef(sessionId) const refreshGeneration = useRef(0) @@ -98,6 +99,15 @@ export function TeamAction({ setError(null) }, [sessionId]) + useEffect(() => { + if (open && positioned) panelRef.current?.focus() + }, [open, positioned]) + + const close = (): void => { + setOpen(false) + triggerRef.current?.focus() + } + const refresh = useCallback(async (): Promise => { const requestedSession = sessionId const generation = ++refreshGeneration.current @@ -119,8 +129,12 @@ export function TeamAction({
{ if (event.key !== 'Escape' || !open) return event.preventDefault() - setOpen(false) - triggerRef.current?.focus() + close() + }} onBlur={(event) => { + const target = event.relatedTarget + if (target instanceof Node && !event.currentTarget.contains(target) && !panelRef.current?.contains(target)) { + setOpen(false) + } }}> -
diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 4292a5ace1..82a90c7e02 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -186,6 +186,7 @@ describe('TeamAction', () => { await screen.findByRole('dialog') fireEvent.click(screen.getByRole('button', { name: zh.close })) expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('button', { name: /Agent Team/u })) }) it('shows load failures', async () => { @@ -237,6 +238,7 @@ describe('TeamAction', () => { fireEvent.click(trigger) const panel = await screen.findByRole('dialog') expect(rendered.container.contains(panel)).toBe(false) + expect(document.activeElement).toBe(panel) fireEvent.pointerDown(panel) expect(screen.getByRole('dialog')).toBe(panel) fireEvent.pointerDown(trigger) @@ -254,4 +256,19 @@ describe('TeamAction', () => { expect(screen.queryByRole('dialog')).toBeNull() }) + it('keeps focus within the trigger or panel and closes when focus moves elsewhere', async () => { + render(<>) + const trigger = screen.getByRole('button', { name: /Agent Team/u }) + fireEvent.click(trigger) + const panel = await screen.findByRole('dialog') + fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.refresh }) }) + expect(screen.getByRole('dialog')).toBe(panel) + fireEvent.blur(panel, { relatedTarget: trigger }) + expect(screen.getByRole('dialog')).toBe(panel) + fireEvent.blur(panel, { relatedTarget: null }) + expect(screen.getByRole('dialog')).toBe(panel) + fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: 'Outside' }) }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + })