From 86e3e0e448a625ee1a71f12fc732a1cea07b8025 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 21 Sep 2026 17:00:48 +0800 Subject: [PATCH 1/5] feat(web): refine the Team header panel interactions and adaptive header The Team panel opens on hover with the subagent-catalog grace timings, pins on click, polls the one-shot view RPC every 20s while open (replacing the toolbar refresh/close controls), and eagerly loads on mount so the trigger keeps a stable two-person icon. The lead card navigates from teammate conversations, the current conversation carries a Current chat tag (selected ring only among multiple members), task descriptions clamp to two lines behind an expand toggle, an empty board shows a one-line description, and a lone member narrows the panel to one column. The header title row becomes an inline-size container: the preset label hides first, then the Team trigger collapses to its icon and switches to click-only opening. The subagent catalog moves to the front of the actions band so lineage stays beside the title crumbs, and its pinned-menu behavior matches the Team panel. Tooltip gains a gap prop for tight anchors. --- apps/web/tests/agent-team-panel.e2e.ts | 19 +- .../src/client/AgentPresetLabel.module.css | 11 +- .../skeleton/ConversationRoot.module.css | 6 + packages/client/ui-primitives/src/Tooltip.tsx | 14 +- .../client/ui-primitives/src/icons/index.tsx | 23 +- .../ui-primitives/tests/icons.client.spec.tsx | 4 +- .../client/SubagentHeaderLineage.module.css | 30 +- .../src/client/SubagentHeaderLineage.tsx | 67 ++-- .../client/ui-subagent/src/client/index.ts | 7 +- .../client/ui-subagent/src/client/locales.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 4 +- .../tests/conversation-ui.client.spec.tsx | 73 +++- .../src/client/tool/toolviews/details-row.tsx | 5 + .../ui-tool/tests/details-row.client.spec.tsx | 6 + .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 8 +- .../client-ui-agent-team/README.zh.md | 8 +- .../src/client/TeamAction.module.css | 254 ++++++++++--- .../src/client/TeamAction.tsx | 338 ++++++++++++------ .../src/client/locales.ts | 20 +- .../client-ui-agent-team/src/client/mount.ts | 6 +- .../tests/browser-plugin.client.spec.ts | 8 +- .../tests/team-action.client.spec.tsx | 186 ++++++++-- 23 files changed, 810 insertions(+), 295 deletions(-) diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index cf3fe29662..c846a9973a 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -86,7 +86,7 @@ describe('web e2e: Agent Teams panel', () => { onTestFinished(() => { projectionReads.mockRestore() }) await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) - await action.getByText('No shared tasks yet').waitFor() + await action.getByText('No shared tasks yet, you can create in the conversation').waitFor() await action.getByText('lead').waitFor() expect(projectionReads).not.toHaveBeenCalled() @@ -113,7 +113,7 @@ describe('web e2e: Agent Teams panel', () => { expect(projectionReads).not.toHaveBeenCalled() expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await action.getByRole('button', { name: 'Close' }).click() + await page.keyboard.press('Escape') }, 60_000) it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { @@ -135,31 +135,24 @@ describe('web e2e: Agent Teams panel', () => { .every(([x, y]) => element.contains(document.elementFromPoint(x!, y!))) })).toBe(true) } - await panel.getByText('Shared tasks', { exact: true }).click() + await panel.getByRole('heading', { name: /Shared tasks/u }).click() expect(await panel.count()).toBe(1) await page.mouse.click(2, 2) await panel.waitFor({ state: 'detached' }) await page.setViewportSize(viewport) await trigger.focus() await page.keyboard.press('Enter') - await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor() + await panel.getByRole('heading', { name: /Shared tasks/u }).waitFor() expect(await panel.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('Enter') - await panel.waitFor({ state: 'detached' }) - expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Enter') - await panel.waitFor() const outside = page.getByRole('button', { name: 'Settings', exact: true }) await outside.focus() + expect(await panel.count()).toBe(1) + await page.keyboard.press('Escape') await panel.waitFor({ state: 'detached' }) expect(await outside.evaluate(element => element === document.activeElement)).toBe(true) } finally { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index 6d2cdd814b..dfcc2acc90 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -11,7 +11,7 @@ background: var(--dsw-alias-fill-tsp-secondary); font-size: 12px; line-height: 22px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -21,3 +21,12 @@ flex: none; opacity: 0.7; } + +/* First sacrifice when the title row (the enclosing inline-size container) + runs out of width: the preset is passive chrome, so it yields before any + interactive header action loses its label. */ +@container (max-width: 540px) { + .label { + display: none; + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index cabe3711ae..4bd7aa80b2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -63,6 +63,12 @@ .titleRow { grid-column: 2; + /* Anonymous inline-size container (CSS modules hash container-names per + module, so occupants match the nearest container): header occupants such + as the preset label and slot actions shed their text at row-width + breakpoints. The minmax(0, 1fr) column sizes the row, so inline-size + containment cannot collapse it. */ + container-type: inline-size; display: flex; align-items: center; gap: 0; diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 6ebd60dbd4..0f7dd5bae3 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -49,6 +49,8 @@ if (typeof window !== 'undefined') { * @param props.portal - render the bubble under document.body, so an ancestor's clipping or its * stacking context (which confines the bubble's z-index to that context) cannot hide it. * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. + * @param props.gap - anchor-to-bubble distance in pixels for 'bottom'/'top' bubbles (default 8); + * ignored for side 'right'. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). * @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default @@ -59,7 +61,7 @@ if (typeof window !== 'undefined') { * anchor dismisses the bubble until the next trigger, and focus arriving after a pointer * interaction (a closing menu refocusing its trigger) never raises it. */ -export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { +export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -80,7 +82,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, ? 0 : side === 'right' ? pos.top + (pos.bottom - pos.top) / 2 - : side === 'top' ? pos.top - 8 : pos.bottom + 8 + : side === 'top' ? pos.top - gap : pos.bottom + gap const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). @@ -108,13 +110,13 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, const { inlineSize: width, blockSize: height } = size const offset = side === 'right' ? 0 : align === 'end' ? width : width / 2 const left = Math.max(edgeMargin, Math.min(pos.x - offset, window.innerWidth - edgeMargin - width)) - const fitsBelow = pos.bottom + 8 + height <= window.innerHeight - edgeMargin - const fitsAbove = pos.top - 8 - height >= edgeMargin + const fitsBelow = pos.bottom + gap + height <= window.innerHeight - edgeMargin + const fitsAbove = pos.top - gap - height >= edgeMargin if (placement === 'bottom' && !fitsBelow && fitsAbove) placement = 'top' else if (placement === 'top' && !fitsAbove && fitsBelow) placement = 'bottom' el.style.left = `${left + offset}px` el.style.top = `${placement === 'right' ? (pos.top + pos.bottom) / 2 - : placement === 'top' ? pos.top - 8 : pos.bottom + 8}px` + : placement === 'top' ? pos.top - gap : pos.bottom + gap}px` el.dataset.side = placement el.style.visibility = 'visible' } @@ -128,7 +130,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, observer.disconnect() window.removeEventListener('resize', fit) } - }, [align, pos, side, suppressed, visible]) + }, [align, gap, pos, side, suppressed, visible]) useEffect(() => { announce(visible) return () => { announce(false) } diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index fddac899db..1629d28a35 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -640,8 +640,8 @@ export const IconWarningTriangleOutlineMedium = (props: IconProps) => ( const IconUserOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) @@ -655,6 +655,25 @@ export const IconUserOutlineMedium = (props: IconProps) => ( ) +const IconUsersOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconUsersOutline artwork: a front person with a second person behind. */ +export const IconUsersOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconUsersOutline artwork with a 1.3px stroke. */ +export const IconUsersOutlineMedium = (props: IconProps) => ( + +) + const IconPaperPlaneOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => (
+ { changeOpen(false) }} + t={t} + /> +
), document.body)} diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index fc70c93abf..e24490966d 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -84,9 +84,10 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', - // After the task list: the preset label leads, running work follows, - // and delegation navigation closes the band. - order: 30, + // Leads the band, directly after the title crumbs: subagent lineage is + // the title's own continuation, ahead of Team navigation (-20) and the + // preset label (-10). + order: -30, locale: NS, inject: catalogActions, }, SubagentCatalogAction), diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index c17a2f3cb1..5913d680ae 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -37,7 +37,7 @@ export const zh = { 'count.running.other': '{count} 个子代理,正在运行', 'switcher.aria': '切换子代理:{title}', 'tree.aria': '子代理会话', - 'open.sidebar': '在侧边栏打开 {label}', + 'open.sidebar': '在侧边栏打开', 'sidebar.chat': '聊天', 'readonly.oneShot.title': '一次性子代理记录', 'readonly.title': '此子代理暂时只读', @@ -79,7 +79,7 @@ export const en: Record = { 'count.running.other': '{count} subagents running', 'switcher.aria': 'Switch subagent: {title}', 'tree.aria': 'Subagent sessions', - 'open.sidebar': 'Open {label} in sidebar', + 'open.sidebar': 'Open in sidebar', 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 161ba8ec62..23aa61b015 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -134,10 +134,10 @@ describe('apply', () => { ]) // The root-session catalog seat registers in the actions band with the - // same business face, ordered after the task list. + // same business face, leading the band directly after the title crumbs. const actionEntry = ctx.slots.entries('conversation.session.header.actions') .find(entry => entry.options.id === 'subagent-catalog')! - expect(actionEntry.options.order).toBe(30) + expect(actionEntry.options.order).toBe(-30) expect(actionEntry.inject).toBe(catalogEntry.inject) const composerEntry = ctx.slots.entries('conversation.composer') diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 381d7523fb..196229f58f 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -255,7 +255,8 @@ describe('SubagentHeaderLineage', () => { expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy() expect(screen.getByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeTruthy() - const sidebarButton = screen.getByRole('button', { name: '在侧边栏打开 worker' }) + const sidebarButton = within(screen.getByRole('treeitem', { name: /worker/ })) + .getByRole('button', { name: '在侧边栏打开' }) fireEvent.keyDown(sidebarButton, { key: 'Enter' }) fireEvent.click(sidebarButton) expect(input.openChildAside).toHaveBeenCalledWith({ @@ -331,7 +332,7 @@ describe('SubagentHeaderLineage', () => { expect(screen.queryByRole('tree')).toBeNull() }) - it('opens only on hover and preserves the portaled-menu crossing grace', async () => { + it('opens on hover and preserves the portaled-menu crossing grace', async () => { vi.useFakeTimers() const advance = async (duration: number): Promise => { await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) @@ -341,26 +342,23 @@ describe('SubagentHeaderLineage', () => { const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect') .mockReturnValue({ bottom: 40, left: 50 } as DOMRect) - fireEvent.click(trigger) - expect(screen.queryByRole('tree')).toBeNull() - fireEvent.mouseEnter(trigger.parentElement!) await advance(149) expect(screen.queryByRole('tree')).toBeNull() await advance(1) - const tree = screen.getByRole('tree') - expect(tree.style.top).toBe('45px') - expect(tree.style.left).toBe('50px') + const menu = screen.getByRole('tree').parentElement! + expect(menu.style.top).toBe('45px') + expect(menu.style.left).toBe('50px') triggerRect.mockReturnValue({ bottom: 60, left: 70 } as DOMRect) fireEvent.resize(window) - expect(tree.style.top).toBe('65px') - expect(tree.style.left).toBe('70px') + expect(menu.style.top).toBe('65px') + expect(menu.style.left).toBe('70px') fireEvent.mouseLeave(trigger.parentElement!) - fireEvent.mouseEnter(tree) + fireEvent.mouseEnter(menu) await advance(120) expect(screen.getByRole('tree')).toBeTruthy() - fireEvent.mouseLeave(tree) + fireEvent.mouseLeave(menu) await advance(119) expect(screen.getByRole('tree')).toBeTruthy() await advance(1) @@ -372,25 +370,62 @@ describe('SubagentHeaderLineage', () => { await advance(120) }) + it('pins a click-opened catalog through hover-out until outside pointer or Escape', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const root = trigger.parentElement! + + fireEvent.click(trigger) + const menu = screen.getByRole('tree') + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('tree')).toBe(menu) + fireEvent.mouseEnter(menu.parentElement!) + fireEvent.mouseLeave(menu.parentElement!) + await advance(300) + expect(screen.getByRole('tree')).toBe(menu) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('tree')).toBeNull() + + fireEvent.mouseEnter(root) + await advance(150) + const hovered = screen.getByRole('tree') + fireEvent.click(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('tree')).toBe(hovered) + fireEvent.keyDown(hovered, { key: 'Escape' }) + expect(screen.queryByRole('tree')).toBeNull() + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.queryByRole('tree')).toBeNull() + }) + it('repositions an open catalog after viewport resize and document scroll', () => { const view = render() const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) const bounds = vi.spyOn(trigger, 'getBoundingClientRect') bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect) hoverCatalog(trigger) - const tree = screen.getByRole('tree') - expect(tree.style.top).toBe('25px') - expect(tree.style.left).toBe('30px') + const menu = screen.getByRole('tree').parentElement! + expect(menu.style.top).toBe('25px') + expect(menu.style.left).toBe('30px') bounds.mockReturnValue({ bottom: 70, left: 80 } as DOMRect) act(() => { window.dispatchEvent(new Event('resize')) }) - expect(tree.style.top).toBe('75px') - expect(tree.style.left).toBe('80px') + expect(menu.style.top).toBe('75px') + expect(menu.style.left).toBe('80px') bounds.mockReturnValue({ bottom: 90, left: 100 } as DOMRect) act(() => { document.dispatchEvent(new Event('scroll')) }) - expect(tree.style.top).toBe('95px') - expect(tree.style.left).toBe('100px') + expect(menu.style.top).toBe('95px') + expect(menu.style.left).toBe('100px') view.unmount() }) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx index a2bfcb447d..ce5344f6bf 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx @@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import { IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconClockOutlineRegular, IconCodeOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconSearchOutlineRegular, + IconUsersOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -59,7 +60,11 @@ const LSP_TITLE_KEYS = { hover: 'tool.title.hoverSymbol', } as const +/** Teammate-coordination tools presented with the two-person team icon. */ +const TEAMMATE_TOOLS = new Set(['spawn_teammate', 'list_agents', 'send_message', 'interrupt_agent', 'wait_agent']) + function detailIcon(toolName: string) { + if (TEAMMATE_TOOLS.has(toolName)) return if (toolName.startsWith('schedule_')) return if (toolName.endsWith('_goal')) return if (toolName.startsWith('cordis_')) return diff --git a/packages/client/ui-tool/tests/details-row.client.spec.tsx b/packages/client/ui-tool/tests/details-row.client.spec.tsx index acca5c7617..c1b0b54cea 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -90,6 +90,12 @@ describe('DetailsRow', () => { expect(inspect).toHaveBeenCalledOnce() }) + it('presents teammate-coordination tools under the two-person team icon', () => { + const inspect = vi.fn() + render([0]} />) + expect(screen.getByText('Wait for teammates')).toBeTruthy() + }) + it('registers the supported tool names through the scoped keyed slot', () => { const register = vi.fn((_spec: unknown, _component: unknown) => () => undefined) const inject = vi.fn((_name: string, callback: () => Iterable<() => void>) => { diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index c819bea01e..ab4ea169b7 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: 364388796442ed71e128ee97b08a39b41982b41b -README.zh.md: 71ad88a6f8f8ecb203af733cd66b5f41ed863b03 +README.md: 85dedd82ef95bee134b8198afaf9820a5fb5ec71 +README.zh.md: ad91fcdcaeeb441e60340b0436ed76aa79f831f3 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 3643887964..85dedd82ef 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,15 +29,15 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the Lead Session's roster and task board from the shared Session store. Task and roster updates appear while the panel stays open. Opening the panel performs no projection requests. The panel shows a loading notice while the conversation or Session list is loading, and an unavailable notice when no Team value is present afterward. +The panel shows the Lead Session's roster and task board from the shared Session store. Task and roster updates appear while the panel stays open. Opening the panel performs no projection requests. The panel shows a loading notice while the conversation or Session list is loading, and an unavailable notice when no Team value is present afterward. -Roster rows show durable names and phases. Live Session status supplies running activity; the shared `modelSelection` projection supplies a model when available. Selecting an active teammate opens its ordinary continuable child address. The Host validates the parent, child, and mode when history opens; later human prompts use the same addressed-subagent conversation. +Roster rows show durable names and phases. Live Session status supplies running activity; the shared `modelSelection` projection supplies a model when available. The current conversation carries a Current chat tag and cannot be selected. Selecting the Lead from a teammate conversation opens the Lead Session directly. Selecting an active teammate opens its ordinary continuable child address. The Host validates the parent, child, and mode when history opens; later human prompts use the same addressed-subagent conversation. ### Inspect the task board Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. -The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. When the projection reports a rejected persisted Team record, the panel shows that failure above the last valid roster and tasks. +The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Descriptions longer than two lines have an expand toggle. Section headings show member and task counts; an empty board shows a short description, and a lone member with no tasks uses a single-column panel. Team agents create and update tasks through their tools; the panel provides no task mutation controls. When the projection reports a rejected persisted Team record, the panel shows that failure above the last valid roster and tasks. ----- @@ -49,7 +49,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -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. `useSession` supplies the Lead identity; `useSessions` selects its Team and each member's model from the shared store. Each roster row selects its own running state. The only injected callback opens a teammate using the current and child Session ids. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Hovering the trigger opens the panel after 150ms; leaving both trigger and panel closes it after a 120ms grace period. Clicking the trigger pins the panel and moves focus into it. Outside clicks and Escape dismiss the panel; Escape returns focus to the trigger only when focus was inside the panel. In a narrow header, the trigger becomes an icon and opens only on click. The component derives every row from the `useSessions`, `useSessionStatus`, and `useSession` seats: the Lead identity comes from the current Session's subagent address, the Team view from `projectionsBySession[lead].values.agentTeam`, member activity from Session status with the list summary as fallback, and the model from `projectionsBySession[member].values.modelSelection.next`. Each roster row selects its own running state. The only injected callback opens a roster Session using the current and target Session ids. Switching conversations closes the panel and clears a navigation failure. | 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 71ad88a6f8..ad91fcdcae 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,15 +29,15 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。 +面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。 -Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动;共享 `modelSelection` 投影在可用时提供模型。选择 active teammate 会打开其普通 continuable 子会话地址。Host 在打开历史时校验 parent、child 与 mode;后续人类提示词使用同一 addressed-subagent 会话。 +Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动;共享 `modelSelection` 投影在可用时提供模型。当前会话带有“当前会话”标签且不可选择。在 teammate 会话中选择 Lead 会直接打开 Lead Session。选择 active teammate 会打开其普通 continuable 子会话地址。Host 在打开历史时校验 parent、child 与 mode;后续人类提示词使用同一 addressed-subagent 会话。 ### 查看任务板 可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 -只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。当投影报告某条持久 Team 记录被拒绝时,面板在最后有效的 roster 与任务上方显示该失败。 +只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。超过两行的描述提供展开按钮。分区标题显示成员与任务数量;空任务板显示简短描述,只有一名成员且无任务时采用单列面板。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。当投影报告某条持久 Team 记录被拒绝时,面板在最后有效的 roster 与任务上方显示该失败。 ----- @@ -49,7 +49,7 @@ Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动 Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。`useSession` 提供 Lead 身份;`useSessions` 从共享 store 选择其 Team 及每个成员的模型。每个 roster 行只选择自己的运行状态。唯一的注入回调通过当前与子 Session 的 id 打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。悬停触发按钮 150ms 后打开面板;指针离开触发按钮和面板后,经过 120ms 宽限关闭。点击触发按钮会固定面板并将焦点移入其中。点击外部或按 Escape 可关闭面板;仅当焦点原本位于面板内时,Escape 才将焦点返回触发按钮。页头较窄时触发按钮折叠为图标,只响应点击打开。组件从 `useSessions`、`useSessionStatus` 与 `useSession` 座位派生每一行:Lead 身份来自当前 Session 的 subagent address,Team 视图来自 `projectionsBySession[lead].values.agentTeam`,成员活动来自 Session 状态并以列表摘要为后备,model 来自 `projectionsBySession[member].values.modelSelection.next`。每个 roster 行只选择自己的运行状态。唯一的注入回调通过当前与目标 Session 的 id 打开 roster Session。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css index 29d35ccaf0..0ce84652e7 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css @@ -2,90 +2,130 @@ position: relative; } -.trigger, -.iconButton { - border: 0; - border-radius: 6px; - background: transparent; - color: var(--dsw-alias-label-secondary); - cursor: pointer; -} - .trigger { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; padding: 3px 7px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); font-size: 12px; + cursor: pointer; } .trigger:hover, -.iconButton:hover { - background: var(--dsw-alias-interactive-bg-hover); +.trigger:focus-visible { + color: var(--dsw-alias-label-primary); +} + +/* Second sacrifice after the preset label: past this title-row width the + trigger keeps only its icon. TeamAction reads the collapsed state off this + rule to switch from hover-open to click-only. */ +@container (max-width: 480px) { + .triggerLabel { + display: none; + } } .count { - min-width: 16px; - border-radius: 8px; - background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-caption); + font-size: 12px; + font-weight: 400; font-variant-numeric: tabular-nums; - text-align: center; + line-height: 16px; } .panel { --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + display: flex; + flex-direction: column; position: fixed; - z-index: 110; + z-index: 100; box-sizing: border-box; - width: min(560px, calc(100vw - 32px)); + width: min(500px, 100vw - 32px); max-height: min(680px, calc(100vh - 32px)); - padding: 10px; - overflow: auto; + /* Horizontal insets live on the toolbar and scroll body, not the panel, so + member-card hover shadows stay inside the body's clip box; the thumb hugs + the panel edge (2px) while the body's stable gutter and trailing padding + rebuild the 16px content inset. */ + padding: 8px 2px 0; + overflow: hidden; border: 0; border-radius: 12px; - background: var(--dsw-specific-menu); - backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } -.toolbar, +/* The menu skin lives on a pseudo-element: backdrop-filter on the panel + itself would turn it into the containing block for fixed descendants, so + the member Tooltip bubbles would be re-anchored into the panel and clipped + by its hidden overflow. */ +.panel::before { + content: ''; + position: absolute; + z-index: -1; + inset: 0; + border-radius: 12px; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); +} + +/* A lone member with no shared tasks fits one card; the full panel width + would sit mostly empty, so the roster drops to one column. */ +.panelCompact { + width: min(320px, 100vw - 32px); +} + +.panelCompact .roster { + grid-template-columns: minmax(0, 1fr); +} + +.body { + --dsh-scrollbar-track-margin: 8px; + + flex: 1 1 auto; + min-height: 0; + padding: 0 9px 16px 14px; + overflow-y: auto; + scrollbar-gutter: stable; +} + .taskTitle { display: flex; align-items: center; gap: 8px; } -.toolbar { - min-height: 28px; -} - -.spacer { - flex: 1; -} - -.iconButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; +.taskTitle strong { + font-size: 13px; + font-weight: 500; } .panel h3 { - margin: 12px 0 6px; - color: var(--dsw-alias-label-secondary); - font-size: 12px; + display: flex; + align-items: center; + gap: 8px; + margin: 16px 0 8px 4px; + color: var(--dsw-alias-label-primary); + font-size: 13px; font-weight: 500; } +/* Without a toolbar the first heading sits 16px under the panel edge: 8px + panel padding plus this trimmed margin. */ +.body section:first-of-type h3 { + margin-top: 8px; +} + .roster { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; + gap: 8px; } .member { @@ -93,10 +133,10 @@ align-items: flex-start; gap: 8px; min-width: 0; - padding: 8px; - border: 0.5px solid var(--dsw-alias-border-l1); + padding: 10px 12px; + border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 8px; - background: transparent; + background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); text-align: left; cursor: pointer; @@ -106,12 +146,79 @@ cursor: default; } +/* The border echoes the Current tag's 'info' tint so the selected card and its + tag read as one state. The inset ring thickens the stroke without changing + the border width, so the card's box stays identical to its neighbors. */ +.memberCurrent { + border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); +} + +.member:not(:disabled):hover, +.member:not(:disabled):focus-visible { + border-color: var(--dsw-alias-border-l3); + box-shadow: var(--dsw-elevation-panel); +} + +/* Seat height 1lh tracks the member name's first text line, so any dot + diameter centers on that line instead of the row's top edge. */ +.memberDot { + display: inline-flex; + align-items: center; + flex: none; + height: 1lh; +} + +.inactiveIcon { + color: var(--dsw-alias-label-tertiary); +} + .memberText { display: flex; flex-direction: column; min-width: 0; } +.memberName { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; +} + +.memberNameText, +.memberText small { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Model details stay behind a hover/focus reveal to keep the resting card to + its name and activity. */ +.memberModel { + display: none; +} + +.member:hover .memberModel, +.member:focus-visible .memberModel { + display: inline; +} + +/* The member card is denser than the surfaces the standard capsule ships for, + so the current-conversation tag drops one step in size. The 999 shrink + factor collapses the tag (block display re-enables text-overflow) before + the member name gives up any width. */ +.currentTag { + display: inline-block; + flex: 0 999 auto; + min-width: 0; + padding: 0 4px; + overflow: hidden; + font-size: 10px; + line-height: 15px; + text-overflow: ellipsis; +} + .memberText small, .meta { color: var(--dsw-alias-label-tertiary); @@ -130,11 +237,19 @@ gap: 7px; } +/* Empty task board: a plain description in place of the section heading, + flush with the body's bottom padding. */ +.emptyNotice { + margin: 16px 0 0 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + .task { - padding: 9px; - border: 0.5px solid var(--dsw-alias-border-l1); + padding: 11px 13px; + border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 9px; - background: var(--dsw-alias-bg-base); + background: var(--dsw-alias-bg-layer-2); } .taskState { @@ -150,13 +265,54 @@ margin: 5px 0; color: var(--dsw-alias-label-secondary); font-size: 12px; + line-height: 18px; white-space: pre-wrap; } +.clampedDescription { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.expandToggle { + display: inline-flex; + align-items: center; + gap: 2px; + /* Floated ahead of the meta spans: it keeps the first line's top-right + corner, so wrapped meta lines continue below it instead of pushing the + toggle under content it expands. */ + float: right; + margin-left: 10px; + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 20px; + cursor: pointer; +} + +.expandToggle:hover, +.expandToggle:focus-visible { + color: var(--dsw-alias-label-primary); +} + +.expandToggle svg { + transition: transform 120ms ease; +} + +.expandToggleOpen { + transform: rotate(180deg); +} + .meta { - display: flex; - flex-wrap: wrap; - gap: 4px 10px; + line-height: 20px; +} + +.meta > span { + margin-right: 10px; } .notice, 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 5a4dd88b65..57a61548e9 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -7,33 +7,35 @@ import type { } from '@deepseek-ai/dsh-experimental-agent-team/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { - IconCloseOutlineRegular, IconUserOutlineRegular, StateDot, + IconChevronDownOutlineRegular, + IconUserOutlineRegular, IconUsersOutlineRegular, StateDot, Tag, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { NS, type TeamKey } from './locales.ts' import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Open a continuable child from the current conversation. */ + /** Open a roster Session from the current conversation. */ openTeammate: (sessionId: SessionId, childSessionId: SessionId) => void } +/** Durable lifecycle overlaid with the member Session's live turn activity. */ +type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed' + /** Full props of the Team conversation-header action. */ export type TeamActionProps = PropsRuntime<'conversation.session.header.actions'> & TeamActionInjected & PropsLocale -/** Durable lifecycle overlaid with the member Session's live turn activity. */ -type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed' - function statusKey(status: TeamTask['status']): TeamKey { switch (status) { case 'pending': return 'status.pending' case 'in_progress': return 'status.in_progress' case 'completed': return 'status.completed' - /* v8 ignore next -- the Team projection omits deleted task tombstones. */ + /* v8 ignore next -- Team views omit deleted task tombstones. */ case 'deleted': return 'status.completed' } } @@ -47,11 +49,10 @@ function memberStatusKey(status: MemberStatus): TeamKey { } } -function memberDotState(status: MemberStatus): StateDotState { +function memberDotState(status: Exclude): StateDotState { switch (status) { case 'running': case 'provisioning': return 'ongoing' - case 'inactive': return 'idle' case 'failed': return 'error' } } @@ -61,7 +62,7 @@ function taskDotState(task: TeamTask): StateDotState { case 'pending': return task.ready ? 'idle' : 'warning' case 'in_progress': return 'ongoing' case 'completed': return 'done' - /* v8 ignore next -- the Team projection omits deleted task tombstones. */ + /* v8 ignore next -- Team views omit deleted task tombstones. */ case 'deleted': return 'idle' } } @@ -70,11 +71,12 @@ type TeamMemberRowProps = Pick & { member: TeamMemberProjection + memberCount: number onError: (message: string) => void } function TeamMemberRow({ - member, sessionId, useSessions, useSessionStatus, openTeammate, onError, t, + member, memberCount, sessionId, useSessions, useSessionStatus, openTeammate, onError, t, }: TeamMemberRowProps) { const model = useSessions(state => state.projectionsBySession[member.id]?.values.modelSelection?.next?.model) const running = useSessionStatus(state => state.get(member.id)?.running) @@ -82,32 +84,93 @@ function TeamMemberRow({ const status: MemberStatus = member.phase === 'active' ? (running ?? summaryRunning) === true ? 'running' : 'inactive' : member.phase + const isCurrent = member.id === sessionId + const highlightCurrent = isCurrent && memberCount > 1 + const inert = isCurrent || status === 'failed' || status === 'provisioning' return ( - + + + ) } -/** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */ +/** Task card with a two-line description clamp expanded from a toggle in the meta row. */ +function TaskCard({ task, t }: { task: TeamTask; t: TranslateNS }) { + const [expanded, setExpanded] = useState(false) + const [clamped, setClamped] = useState(false) + const textRef = useRef(null) + useLayoutEffect(() => { + if (expanded) return + const paragraph = textRef.current + /* v8 ignore next -- the paragraph mounts in the same commit as the effect. */ + if (paragraph === null) return + setClamped(paragraph.scrollHeight > paragraph.clientHeight + 1) + }, [task.description, expanded]) + return ( +
+
+ {task.subject} + + + {t(statusKey(task.status))} + +
+

{task.description}

+
+ {(clamped || expanded) && ( + + )} + {task.id} + {t('owner')}: {task.ownerName ?? t('unowned')} + {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} + {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} + {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} + {task.writeScopeWarnings.map(warning => {warning})} +
+
+ ) +} + +/** Render the Team roster and read-only task board. */ export function TeamAction({ sessionId, useSession, useSessions, useSessionStatus, openTeammate, t, }: TeamActionProps) { @@ -115,133 +178,172 @@ export function TeamAction({ const [error, setError] = useState(null) const rootRef = useRef(null) const triggerRef = useRef(null) + const triggerLabelRef = useRef(null) const panelRef = useRef(null) const position = useAnchoredPosition({ open, anchorRef: triggerRef, panelRef, gap: 5, margin: 16, }) const positioned = position !== null - useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) - const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId const team = useSessions(state => state.projectionsBySession[leadSessionId]?.values.agentTeam) const opening = useSession(snapshot => snapshot.openState === 'loading') const listing = useSessions(state => state.phase === 'pending') + const hoverTimer = useRef | undefined>(undefined) + const pinnedRef = useRef(false) + + const cancelHoverChange = (): void => { + clearTimeout(hoverTimer.current) + hoverTimer.current = undefined + } useEffect(() => { + cancelHoverChange() + pinnedRef.current = false setOpen(false) setError(null) }, [sessionId]) + useEffect(() => cancelHoverChange, []) + useLayoutEffect(() => { - if (open && positioned) panelRef.current?.focus() + if (open && positioned && pinnedRef.current) panelRef.current?.focus() }, [open, positioned]) - const close = (): void => { - setOpen(false) - triggerRef.current?.focus() + const changeOpen = (next: boolean): void => { + cancelHoverChange() + if (!next) pinnedRef.current = false + setOpen(next) } - const teammates = team?.members.filter(member => member.role === 'teammate') ?? [] + const scheduleHoverOpen = (): void => { + cancelHoverChange() + if (open) return + const label = triggerLabelRef.current + /* v8 ignore next -- the label mounts with the trigger that received the hover. */ + if (label === null) return + // Icon-only trigger (label collapsed by the header container query): + // hover-open would surprise on such a small target, so only click opens. + if (getComputedStyle(label).display === 'none') return + hoverTimer.current = setTimeout(() => { + hoverTimer.current = undefined + changeOpen(true) + }, 150) + } + + const scheduleHoverClose = (): void => { + cancelHoverChange() + if (pinnedRef.current) return + hoverTimer.current = setTimeout(() => { + hoverTimer.current = undefined + changeOpen(false) + }, 120) + } + + useDismissOnOutsidePointer(rootRef, open, changeOpen, panelRef) + + useEffect(() => { + if (!open) return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + event.preventDefault() + cancelHoverChange() + pinnedRef.current = false + setOpen(false) + if (panelRef.current?.contains(document.activeElement)) triggerRef.current?.focus() + } + document.addEventListener('keydown', dismiss) + return () => { document.removeEventListener('keydown', dismiss) } + }, [open]) + + const compact = team !== undefined && team.members.length === 1 && team.tasks.length === 0 return ( -
{ - if (event.key !== 'Escape' || !open) return - event.preventDefault() - close() - }} onBlur={(event) => { - const target = event.relatedTarget - if (target instanceof Node && !event.currentTarget.contains(target) && !panelRef.current?.contains(target)) { - setOpen(false) - } - }}> +
{open && createPortal(
-
- {t('trigger')} - - +
+ {error !== null && ( +
{error}
+ )} + {team === undefined && ( +
+ + {t(opening || listing ? 'loading' : 'unavailable')} +
+ )} + {team !== undefined && ( + <> + {team.failure !== undefined && ( +
{t('failure', { message: team.failure })}
+ )} +
+

+ {t('roster')} + {team.members.length > 1 && {team.members.length}} +

+
+ {team.members.map(member => ( + + ))} +
+
+
+ {team.tasks.length === 0 + ?

{t('empty')}

+ : ( + <> +

{t('tasks')}{team.tasks.length}

+
+ {team.tasks.map(task => )} +
+ + )} +
+ + )}
- {error !== null && ( -
{error}
- )} - {team === undefined && ( -
- - {t(opening || listing ? 'loading' : 'unavailable')} -
- )} - {team !== undefined && ( - <> - {team.failure !== undefined && ( -
{t('failure', { message: team.failure })}
- )} -
-

{t('roster')}

-
- {team.members.map(member => ( - - ))} -
-
-
-

{t('tasks')}

- {team.tasks.length === 0 &&
{t('empty')}
} -
- {team.tasks.map(task => ( -
-
- {task.subject} - - - {t(statusKey(task.status))} - -
-

{task.description}

-
- {task.id} - {t('owner')}: {task.ownerName ?? t('unowned')} - {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} - {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} - {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} - {task.writeScopeWarnings.map(warning => {warning})} -
-
- ))} -
-
- - )}
, document.body, )} diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index 14d1eb0ed7..3ae022d753 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -5,22 +5,24 @@ export const NS = 'agent-team' /** Simplified Chinese dictionary and key source. */ export const zh = { - trigger: 'Agent Team', - close: '关闭', - loading: '正在加载 Team…', + trigger: '智能体团队', + loading: '正在加载团队…', unavailable: 'Team 暂不可用', - failure: 'Team 持久记录无效:{message}', - empty: '还没有共享任务', + failure: '团队持久记录无效:{message}', + empty: '暂无共享任务,可以通过对话创建', roster: '成员', tasks: '共享任务', model: '模型', - open: '打开 teammate 会话', + open: '打开成员会话', + current: '当前会话', owner: 'Owner', unowned: '未分配', blockedBy: '依赖', writeScopes: '写入范围', ready: '可开始', blocked: '被依赖阻塞', + 'task.expand': '展开', + 'task.collapse': '收起', 'memberStatus.running': '运行中', 'memberStatus.inactive': '未运行', 'memberStatus.provisioning': '准备中', @@ -36,21 +38,23 @@ export type TeamKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { trigger: 'Agent Team', - close: 'Close', loading: 'Loading Team…', unavailable: 'Team is unavailable', failure: 'Invalid persisted Team record: {message}', - empty: 'No shared tasks yet', + empty: 'No shared tasks yet, you can create in the conversation', roster: 'Members', tasks: 'Shared tasks', model: 'Model', open: 'Open teammate conversation', + current: 'Current chat', owner: 'Owner', unowned: 'Unowned', blockedBy: 'Blocked by', writeScopes: 'Write scopes', ready: 'Ready', blocked: 'Blocked by dependencies', + 'task.expand': 'Show more', + 'task.collapse': 'Show less', 'memberStatus.running': 'Running', 'memberStatus.inactive': 'Inactive', 'memberStatus.provisioning': 'Provisioning', diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 4ca1b5e04c..94a369df04 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -38,6 +38,10 @@ export function registerAgentTeamUi(ctx: ClientContext): void { openTeammate(sessionId: SessionId, childSessionId: SessionId): void { const parentSessionId = leadSessionId(sessionId) if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return + if (childSessionId === parentSessionId) { + ctx.uiWorkspace.openSession(parentSessionId) + return + } ctx.uiWorkspace.openSession({ parentSessionId, childSessionId, @@ -51,7 +55,7 @@ export function registerAgentTeamUi(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'agent-team', - order: 20, + order: -20, locale: NS, inject: () => actions, }, TeamAction), diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index abc786646f..23c5844747 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -78,7 +78,7 @@ describe('ui-team browser plugin', () => { const b = await bench() expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale']) expect(b.entry()).toMatchObject({ - options: { id: 'agent-team', order: 20 }, + options: { id: 'agent-team', order: -20 }, locale: 'agent-team', }) const t = b.ctx.locale.bind('agent-team') @@ -107,6 +107,12 @@ describe('ui-team browser plugin', () => { ]) }) + it('opens the Lead from an addressed teammate conversation', async () => { + const b = await bench({ addressed: true }) + b.actions().openTeammate(CHILD, SESSION) + expect(b.navigation).toEqual([['open', SESSION]]) + }) + it('does not open a teammate from a conversation outside the main view', async () => { const b = await bench() b.select('other-session' as SessionId) 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 3aca5c5cc8..0757f28362 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 @@ -15,7 +15,11 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { TeamAction, type TeamActionInjected, type TeamActionProps } from '../src/client/TeamAction.tsx' import { zh } from '../src/client/locales.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) const SESSION = 'lead' as SessionId const WORKER = 'worker-id' as SessionId @@ -99,7 +103,7 @@ function bench(options: { } function openPanel(): void { - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) + fireEvent.click(screen.getByRole('button', { name: /智能体团队/u })) } function setProjectionSnapshot( @@ -121,7 +125,7 @@ describe('TeamAction', () => { it('renders the Lead projection and applies later projection frames without any user action', async () => { const b = bench() render() - expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('1') + expect(screen.getByRole('button', { name: /智能体团队/u }).textContent).toBe(zh.trigger) openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() @@ -133,7 +137,7 @@ describe('TeamAction', () => { }) expect(screen.getByText('Pushed task')).toBeTruthy() expect(screen.getByRole('button', { name: /worker-b/u })).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('2') + expect(screen.getByRole('heading', { name: '成员3' })).toBeTruthy() }) it('overlays live Session status and the durable model selection on roster rows', () => { @@ -154,18 +158,18 @@ describe('TeamAction', () => { }) render() openPanel() - expect(screen.getByRole('button', { name: `lead${zh['memberStatus.running']} · ${zh.model}: lead-model` })).toBeTruthy() - const row = screen.getByRole('button', { name: `worker${zh['memberStatus.running']} · ${zh.model}: worker-model` }) + expect(screen.getByRole('button', { name: new RegExp(`lead.*${zh['memberStatus.running']}.*lead-model`, 'u') })).toBeTruthy() + const row = screen.getByRole('button', { name: new RegExp(`worker.*${zh['memberStatus.running']}.*worker-model`, 'u') }) expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() act(() => { b.statuses.set(new Map([[WORKER, { running: false, pendingInteraction: undefined, completionUnread: false }]])) }) - expect(screen.getByRole('button', { name: /^worker未运行/u })).toBeTruthy() + expect(screen.getByRole('button', { name: /^worker.*未运行/u })).toBeTruthy() act(() => { b.statuses.set(new Map()) b.sessions.update((draft) => { draft.byId[WORKER] = summary(WORKER, true) }) }) - expect(screen.getByRole('button', { name: /^worker运行中/u })).toBeTruthy() + expect(screen.getByRole('button', { name: /^worker.*运行中/u })).toBeTruthy() }) it('reads the Lead projection from an addressed teammate conversation', () => { @@ -173,8 +177,9 @@ describe('TeamAction', () => { render() openPanel() expect(screen.getByText('Implement runtime')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /^worker/u })) - expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, WORKER) + expect(screen.getByRole('button', { name: /^worker/u }).disabled).toBe(true) + fireEvent.click(screen.getByRole('button', { name: /^lead/u })) + expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, SESSION) }) it.each([false, true])('accepts shared baselines and late capability updates (teammate page: %s)', (addressed) => { @@ -256,7 +261,7 @@ describe('TeamAction', () => { }) render() openPanel() - expect(screen.getByRole('alert').textContent).toBe('Team 持久记录无效:revision is not contiguous') + expect(screen.getByRole('alert').textContent).toBe('团队持久记录无效:revision is not contiguous') expect(screen.getByText('Implement runtime')).toBeTruthy() }) @@ -306,9 +311,9 @@ describe('TeamAction', () => { fireEvent.click(screen.getByRole('button', { name: /^worker/u })) expect(screen.getByRole('alert').textContent).toBe('Error: navigation failed') - fireEvent.click(screen.getByRole('button', { name: zh.close })) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(screen.getByRole('button', { name: /Agent Team/u })) + expect(document.activeElement).toBe(screen.getByRole('button', { name: /智能体团队/u })) }) it('closes the panel and clears a navigation failure when the conversation switches sessions', () => { @@ -330,7 +335,7 @@ describe('TeamAction', () => { it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { const b = bench() const rendered = render() - const trigger = screen.getByRole('button', { name: /Agent Team/u }) + const trigger = screen.getByRole('button', { name: /智能体团队/u }) fireEvent.click(trigger) const panel = screen.getByRole('dialog') expect(rendered.container.contains(panel)).toBe(false) @@ -342,28 +347,161 @@ describe('TeamAction', () => { fireEvent.pointerDown(document.body) expect(screen.queryByRole('dialog')).toBeNull() fireEvent.click(trigger) - fireEvent.keyDown(screen.getByRole('button', { name: zh.close }), { key: 'Enter' }) + fireEvent.keyDown(screen.getByRole('button', { name: /^worker/u }), { key: 'Enter' }) expect(screen.queryByRole('dialog')).not.toBeNull() - fireEvent.keyDown(screen.getByRole('button', { name: zh.close }), { key: 'Escape' }) + fireEvent.keyDown(screen.getByRole('button', { name: /^worker/u }), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(trigger) fireEvent.keyDown(trigger, { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() }) - it('keeps focus within the trigger or panel and closes when focus moves elsewhere', () => { + it('clamps a long task description to two lines behind an expand toggle', async () => { + const scrollHeight = vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(120) + const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(60) + try { + render() + fireEvent.click(screen.getByRole('button', { name: /智能体团队/u })) + const expand = await screen.findByRole('button', { name: zh['task.expand'] }) + const description = screen.getByText('Build the Team runtime') + expect(expand.getAttribute('aria-expanded')).toBe('false') + expect(description.className).not.toBe('') + + fireEvent.click(expand) + const collapse = screen.getByRole('button', { name: zh['task.collapse'] }) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('Build the Team runtime').className).toBe('') + + fireEvent.click(collapse) + expect(screen.getByRole('button', { name: zh['task.expand'] })).toBeTruthy() + } finally { + scrollHeight.mockRestore() + clientHeight.mockRestore() + } + }) + + it('opens on hover and preserves the trigger-to-panel crossing grace', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } const b = bench() - render(<>) - const trigger = screen.getByRole('button', { name: /Agent Team/u }) + const rendered = render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + + fireEvent.mouseEnter(root) + await advance(149) + expect(screen.queryByRole('dialog')).toBeNull() + await advance(1) + const panel = screen.getByRole('dialog') + + fireEvent.mouseEnter(root) + expect(screen.getByRole('dialog')).toBe(panel) + + fireEvent.mouseLeave(root) + fireEvent.mouseEnter(panel) + await advance(120) + expect(screen.getByRole('dialog')).toBe(panel) + + fireEvent.mouseLeave(panel) + await advance(119) + expect(screen.getByRole('dialog')).toBe(panel) + await advance(1) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + rendered.unmount() + await advance(150) + }) + + it('pins a click-opened panel through hover-out until explicit dismissal', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + fireEvent.click(trigger) const panel = screen.getByRole('dialog') - fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.close }) }) + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + await advance(300) expect(screen.getByRole('dialog')).toBe(panel) - fireEvent.blur(panel, { relatedTarget: trigger }) + fireEvent.mouseEnter(panel) + fireEvent.mouseLeave(panel) + await advance(300) 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' }) }) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.mouseEnter(root) + await advance(150) + const hovered = screen.getByRole('dialog') + fireEvent.click(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('dialog')).toBe(hovered) + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + await advance(300) expect(screen.queryByRole('dialog')).toBeNull() }) + + it('opens by click only while the trigger is collapsed to its icon', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + const label = screen.getByText(zh.trigger) + const computedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation(element => + element === label ? { display: 'none' } as CSSStyleDeclaration : computedStyle(element)) + + fireEvent.mouseEnter(root) + await advance(300) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.click(trigger) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('keeps projection updates live without polling while pinned', async () => { + vi.useFakeTimers() + const b = bench() + render() + openPanel() + await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Pushed while pinned' }] }) + expect(screen.getByText('Pushed while pinned')).toBeTruthy() + }) + + it('keeps an accessible trigger name when its label is collapsed', () => { + render() + screen.getByText(zh.trigger).style.display = 'none' + expect(screen.getByRole('button', { name: zh.trigger })).toBeTruthy() + }) + + it('dismisses a hovered panel with Escape without stealing composer focus', async () => { + vi.useFakeTimers() + render(<>