Merge upstream/master into fix/subagent-zh-terminology
This commit is contained in:
commit
8833ef9f63
37 files changed
+1023
-335
No files matched your search
@@ -389,8 +389,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('Minimal mode')
|
||||
expect(snapshot).toContain('button "1 subagent"')
|
||||
// The preset label leads the header band; delegation navigation follows it.
|
||||
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
|
||||
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode'))
|
||||
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"'))
|
||||
// Static chrome, not a control: the header can only report a composition
|
||||
// the host would refuse to change.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url))
|
||||
const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md')
|
||||
const RESIZE_EXPECTED = join(SNAPSHOT_DIR, 'resize.expected.md')
|
||||
const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url))
|
||||
const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url))
|
||||
const INSTALL_ANCHORS = [
|
||||
@@ -86,7 +87,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. Create them through the conversation.').waitFor()
|
||||
await action.getByText('lead').waitFor()
|
||||
expect(projectionReads).not.toHaveBeenCalled()
|
||||
|
||||
@@ -113,7 +114,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 +136,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 {
|
||||
@@ -167,7 +161,50 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => {
|
||||
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
|
||||
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
|
||||
await trigger.hover()
|
||||
await panel.waitFor()
|
||||
await panel.hover()
|
||||
await trigger.hover()
|
||||
// The assertion spans the 120ms dismissal grace while the pointer stays on the trigger.
|
||||
await page.waitForTimeout(180)
|
||||
expect(await panel.count()).toBe(1)
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
it('offers expansion when resizing makes a task description exceed two lines', async () => {
|
||||
const agent = scaffold.ctx.agents.list()[0]!
|
||||
await scaffold.ctx.agentTeams.createTask(agent, {
|
||||
subject: 'Responsive task',
|
||||
description: 'Check the task description before you start work. '.repeat(3),
|
||||
writeScopes: [],
|
||||
})
|
||||
const viewport = page.viewportSize()!
|
||||
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
|
||||
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
|
||||
const card = panel.locator('article').filter({ hasText: 'Responsive task' })
|
||||
await trigger.click()
|
||||
await card.waitFor()
|
||||
expect(await card.getByRole('button', { name: 'Show more' }).count()).toBe(0)
|
||||
try {
|
||||
await page.setViewportSize({ width: 360, height: 700 })
|
||||
await card.getByRole('button', { name: 'Show more' }).waitFor()
|
||||
expect(await card.locator('p').evaluate(p => p.scrollHeight > p.clientHeight)).toBe(true)
|
||||
await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd), MODE)
|
||||
await card.getByRole('button', { name: 'Show more' }).click()
|
||||
expect(await card.locator('p').evaluate(p => p.scrollHeight === p.clientHeight)).toBe(true)
|
||||
await card.getByRole('button', { name: 'Show less' }).click()
|
||||
await page.setViewportSize(viewport)
|
||||
await expect.poll(() => card.getByRole('button', { name: 'Show more' }).count()).toBe(0)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md', 'resize.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
- navigation "Session hierarchy": Seeded turn
|
||||
- text: Minimal mode
|
||||
- button "1 subagent"
|
||||
- text: Minimal mode
|
||||
- button "More actions"
|
||||
- button "Open right sidebar"
|
||||
@@ -278,12 +278,12 @@ describe('web e2e: plugin manager', () => {
|
||||
await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3)
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
|
||||
await action.waitFor({ timeout: 20_000 })
|
||||
await action.getByRole('button', { name: /Agent Team/iu }).click()
|
||||
const teamPanel = teamPage.getByRole('dialog', { name: 'Agent Team', exact: true })
|
||||
await action.getByRole('button', { name: '智能体团队', exact: true }).click()
|
||||
const teamPanel = teamPage.getByRole('dialog', { name: '智能体团队', exact: true })
|
||||
await teamPanel.getByText('Team 暂不可用', { exact: true }).waitFor()
|
||||
await teamPage.reload({ waitUntil: 'load' })
|
||||
await action.getByRole('button', { name: /Agent Team/iu }).click()
|
||||
await teamPanel.getByText('还没有共享任务').waitFor()
|
||||
await action.getByRole('button', { name: '智能体团队', exact: true }).click()
|
||||
await teamPanel.getByText('暂无共享任务,可以通过对话创建').waitFor()
|
||||
await teamPanel.getByText('lead', { exact: true }).waitFor()
|
||||
const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
|
||||
dsh: { profile: { bundles: string[] } }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
- dialog "Agent Team":
|
||||
- heading "Members" [level=3]
|
||||
- button "lead Current chat Inactive" [disabled]
|
||||
- heading "Shared tasks 2" [level=3]
|
||||
- article:
|
||||
- strong: Agent task
|
||||
- text: In progress
|
||||
- paragraph: Created by the Team Lead
|
||||
- text: "task-1Owner: leadWrite scopes: src/web"
|
||||
- article:
|
||||
- strong: Responsive task
|
||||
- text: Pending
|
||||
- paragraph: Check the task description before you start work. Check the task description before you start work. Check the task description before you start work.
|
||||
- button "Show more"
|
||||
- text: "task-2Owner: UnownedReady"
|
||||
@@ -1,11 +1,9 @@
|
||||
- dialog "Agent Team":
|
||||
- strong: Agent Team
|
||||
- button "Close"
|
||||
- heading "Members" [level=3]
|
||||
- button "lead Inactive" [disabled]
|
||||
- heading "Shared tasks" [level=3]
|
||||
- button "lead Current chat Inactive" [disabled]
|
||||
- heading "Shared tasks 1" [level=3]
|
||||
- article:
|
||||
- strong: Agent task
|
||||
- text: In progress
|
||||
- paragraph: Created by the Team Lead
|
||||
- text: "task-1 Owner: lead Write scopes: src/web"
|
||||
- text: "task-1Owner: leadWrite scopes: src/web"
|
||||
@@ -404,7 +404,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
it('opens child history in the right Sidebar and releases it when closed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-sidebar-chat'))
|
||||
await page.getByRole('button', { name: '2 subagents' }).hover()
|
||||
await page.getByRole('button', { name: `Open ${LABEL} in sidebar` }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).click()
|
||||
const sidebarChat = page.locator('[data-sidebar-chat]')
|
||||
await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 })
|
||||
await compareOrRefreshGolden(
|
||||
@@ -416,7 +416,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
await sidebarChat.waitFor({ state: 'detached' })
|
||||
|
||||
await page.getByRole('button', { name: '2 subagents' }).hover()
|
||||
await page.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar` }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar`, exact: true }).click()
|
||||
await page.locator('[data-sidebar-chat]').getByText(
|
||||
'One-shot tasks do not accept follow-ups; review the full execution record here.',
|
||||
).waitFor({ timeout: 15_000 })
|
||||
@@ -532,16 +532,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
expect(await nestedDisclosure.count()).toBe(0)
|
||||
const clickArea = nestedRow.locator(':scope > div')
|
||||
expect(await clickArea.count()).toBe(1)
|
||||
const [treeBox, clickAreaBox] = await Promise.all([
|
||||
tree.boundingBox(),
|
||||
const [menuBox, clickAreaBox] = await Promise.all([
|
||||
tree.locator('..').boundingBox(),
|
||||
clickArea.boundingBox(),
|
||||
])
|
||||
expect(treeBox).not.toBeNull()
|
||||
expect(menuBox).not.toBeNull()
|
||||
expect(clickAreaBox).not.toBeNull()
|
||||
expect([
|
||||
Math.round(clickAreaBox!.x - treeBox!.x),
|
||||
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
|
||||
// Compact menu padding alone insets the rows now that the border is gone.
|
||||
Math.round(clickAreaBox!.x - menuBox!.x),
|
||||
Math.round(menuBox!.x + menuBox!.width - clickAreaBox!.x - clickAreaBox!.width),
|
||||
// The outer menu padding insets the scrollable tree rows.
|
||||
]).toEqual([3, 3])
|
||||
await compareOrRefreshGolden(
|
||||
BRANCHLESS_EXPECTED,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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-jobs/README.md
|
||||
README.md: 6f2b856a970cc869676bbda57d749cf4fb7ceba0
|
||||
README.zh.md: b99723e841cfb3d6c52ba0ead7e1ab4dd1952e51
|
||||
README.md: 0c530b802b502ea9db3135a711ed5b643584a79e
|
||||
README.zh.md: 86ac2890111c098d01d5a7bc2997f99e1aa4065f
|
||||
@@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
|
||||
One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
|
||||
@@ -45,7 +45,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现内幕——点击展开</summary>
|
||||
|
||||
头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
|
||||
头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
|
||||
|
||||
| 文件 | 角色 |
|
||||
|---|---|
|
||||
|
||||
@@ -37,8 +37,7 @@ export function apply(ctx: ClientContext): void {
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'job-list',
|
||||
// Between the preset label and the subagent catalog (order 30): running
|
||||
// work reads before the session lineage.
|
||||
// Background work follows the preset label in the header actions band.
|
||||
order: 20,
|
||||
locale: NS,
|
||||
inject: (): JobListInjected => ({
|
||||
|
||||
@@ -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<AnchorProps> }) {
|
||||
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<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(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<ReturnType<typeof setTimeout> | 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) }
|
||||
|
||||
@@ -640,8 +640,8 @@ export const IconWarningTriangleOutlineMedium = (props: IconProps) => (
|
||||
|
||||
const IconUserOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
|
||||
<path d="M8 8.5C9.65685 8.5 11 7.15685 11 5.5C11 3.84315 9.65685 2.5 8 2.5C6.34315 2.5 5 3.84315 5 5.5C5 7.15685 6.34315 8.5 8 8.5Z" stroke="currentColor" />
|
||||
<path d="M1.5 14.5C1.5 11.25 4.25 10 8 10C11.75 10 14.5 11.25 14.5 14.5" stroke="currentColor" />
|
||||
<path d="M8 8.25C9.51878 8.25 10.75 7.01878 10.75 5.5C10.75 3.98122 9.51878 2.75 8 2.75C6.48122 2.75 5.25 3.98122 5.25 5.5C5.25 7.01878 6.48122 8.25 8 8.25Z" stroke="currentColor" />
|
||||
<path d="M2.5 14.5C2.5 11.5 5.25 10.25 8 10.25C10.75 10.25 13.5 11.5 13.5 14.5" stroke="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -655,6 +655,25 @@ export const IconUserOutlineMedium = (props: IconProps) => (
|
||||
<IconUserOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
|
||||
)
|
||||
|
||||
const IconUsersOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
|
||||
<path d="M6 8.25C7.51878 8.25 8.75 7.01878 8.75 5.5C8.75 3.98122 7.51878 2.75 6 2.75C4.48122 2.75 3.25 3.98122 3.25 5.5C3.25 7.01878 4.48122 8.25 6 8.25Z" stroke="currentColor" />
|
||||
<path d="M1 14.5C1 11.5 3.5 10.25 6 10.25C8.5 10.25 11 11.5 11 14.5" stroke="currentColor" />
|
||||
<path d="M10.5 2.9C11.65 3.35 12.45 4.35 12.45 5.5C12.45 6.65 11.65 7.65 10.5 8.1" stroke="currentColor" />
|
||||
<path d="M12.4 10.6C13.9 11.3 15 12.6 15 14.5" stroke="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Regular one-pixel IconUsersOutline artwork: a front person with a second person behind. */
|
||||
export const IconUsersOutlineRegular = (props: IconProps) => (
|
||||
<IconUsersOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
|
||||
)
|
||||
|
||||
/** Medium IconUsersOutline artwork with a 1.3px stroke. */
|
||||
export const IconUsersOutlineMedium = (props: IconProps) => (
|
||||
<IconUsersOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
|
||||
)
|
||||
|
||||
const IconPaperPlaneOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
|
||||
<path d="M4.74024 9.11029L1.82882 7.79865C1.75022 7.76323 1.75026 7.65161 1.82889 7.61626L12.9665 2.60943C13.0354 2.57846 13.1125 2.63213 13.1073 2.70749L12.3914 13.1388C12.3864 13.2117 12.3073 13.2548 12.2433 13.2194L6.12677 9.83657" stroke="currentColor" />
|
||||
|
||||
@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('product icon set', () => {
|
||||
it('exports regular and medium variants for all 92 public glyphs', () => {
|
||||
expect(iconNames.length).toBe(184)
|
||||
it('exports regular and medium variants for all 93 public glyphs', () => {
|
||||
expect(iconNames.length).toBe(186)
|
||||
expect(iconNames.some(name => /\d+$/.test(name))).toBe(false)
|
||||
const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length))
|
||||
const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length))
|
||||
|
||||
@@ -309,6 +309,23 @@ describe('Tooltip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['bottom', 'top'] as const)('uses a custom gap to position and flip a %s tooltip', (side) => {
|
||||
const anchorBottom = side === 'bottom' ? window.innerHeight - 36 : 56
|
||||
const spy = placed(anchorBottom - 20, anchorBottom, 20)
|
||||
try {
|
||||
const view = render(<Tooltip label="Gap" side={side} gap={4}><button type="button">anchor</button></Tooltip>)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe(side)
|
||||
expect(bubble.style.top).toBe(`${side === 'bottom' ? anchorBottom + 4 : anchorBottom - 24}px`)
|
||||
view.rerender(<Tooltip label="Gap" side={side} gap={12}><button type="button">anchor</button></Tooltip>)
|
||||
expect(bubble.getAttribute('data-side')).toBe(side === 'bottom' ? 'top' : 'bottom')
|
||||
expect(bubble.style.top).toBe(`${side === 'bottom' ? anchorBottom - 32 : anchorBottom + 12}px`)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the requested side when neither side fits', () => {
|
||||
// A bubble taller than the viewport has no home; oscillating between the
|
||||
// two would be worse than honouring the request.
|
||||
|
||||
@@ -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-subagent/README.md
|
||||
README.md: b3dfb4a3aa56dbf240f4b76db410d45cc6ae5651
|
||||
README.zh.md: a6ebd6da3c3caba2a3bd015da450b5a33ab79d67
|
||||
README.md: 037c84c9c80d1d1d129f543b9f60d834ef2d4ce7
|
||||
README.zh.md: e7c3fb052c0b42dd3c102aa504a733c3938438c0
|
||||
@@ -25,12 +25,14 @@ Use this package to browse every subagent conversation beneath a parent session,
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders in the header actions band after the task list, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
|
||||
The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders at the start of the header actions band, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
|
||||
|
||||
This package registers the `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
|
||||
|
||||
### Browsing the tree
|
||||
|
||||
Hovering a trigger opens its catalog after 150ms; leaving both trigger and catalog closes it after 120ms. Clicking the descendant-count trigger pins its catalog until outside click or Escape from the trigger or tree. Breadcrumb-title clicks navigate to the corresponding conversation.
|
||||
|
||||
Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty.
|
||||
|
||||
### Continuing a conversation
|
||||
|
||||
@@ -25,12 +25,14 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区、任务列表之后,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
|
||||
会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区的最前方,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
|
||||
|
||||
本包注册 `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
|
||||
|
||||
### 浏览目录
|
||||
|
||||
悬停触发器 150ms 后打开目录;指针离开触发器和目录后,经过 120ms 关闭。点击后代数量触发器会固定目录,直到点击外部,或在触发器或树内按 Escape 关闭。点击面包屑标题会导航至对应会话。
|
||||
|
||||
行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。
|
||||
|
||||
### 续接对话
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.switcherTrigger:hover,
|
||||
@@ -98,21 +98,41 @@
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
max-height: min(560px, calc(100vh - 140px));
|
||||
padding: 3px;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-specific-menu);
|
||||
backdrop-filter: var(--dsw-menu-backdrop-filter);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
||||
box-shadow: var(--dsw-elevation-prominent);
|
||||
}
|
||||
|
||||
/* The menu skin lives on a pseudo-element: backdrop-filter on the menu itself
|
||||
would turn it into the containing block for fixed descendants, so the
|
||||
sidebar-button Tooltip bubbles would be re-anchored into the menu and
|
||||
clipped by its hidden overflow. */
|
||||
.menu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-specific-menu);
|
||||
backdrop-filter: var(--dsw-menu-backdrop-filter);
|
||||
}
|
||||
|
||||
.menuBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.menu > .node {
|
||||
.menuBody > .node {
|
||||
margin-left: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconRefreshOutlineRegular, StateDot,
|
||||
IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconRefreshOutlineRegular, StateDot, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS } from './locales.ts'
|
||||
@@ -351,16 +351,17 @@ function CatalogRows({
|
||||
</span>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.sidebarButton}
|
||||
aria-label={t('open.sidebar', { label })}
|
||||
title={t('open.sidebar', { label })}
|
||||
onClick={openAside}
|
||||
onKeyDown={(event) => { event.stopPropagation() }}
|
||||
>
|
||||
<IconChevronRightOutlineRegular />
|
||||
</button>
|
||||
<Tooltip label={t('open.sidebar')} side="bottom" align="end">
|
||||
<button
|
||||
type="button"
|
||||
className={css.sidebarButton}
|
||||
aria-label={t('open.sidebar.aria', { label })}
|
||||
onClick={openAside}
|
||||
onKeyDown={(event) => { event.stopPropagation() }}
|
||||
>
|
||||
<IconChevronRightOutlineRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,6 +469,8 @@ function CatalogDropdown({
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const hoverOpenTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
// A click-opened (pinned) menu ignores hover-out; only explicit dismissal closes it.
|
||||
const pinnedRef = useRef(false)
|
||||
const currentEntry = currentSessionId === undefined
|
||||
? undefined
|
||||
: catalog?.entries.find(entry => entry.id === currentSessionId)
|
||||
@@ -506,6 +509,7 @@ function CatalogDropdown({
|
||||
setMenuPosition(catalogMenuPosition(trigger))
|
||||
}
|
||||
else {
|
||||
pinnedRef.current = false
|
||||
setOpen(false)
|
||||
setMenuPosition(undefined)
|
||||
setExpanded(new Set())
|
||||
@@ -526,6 +530,7 @@ function CatalogDropdown({
|
||||
const scheduleHoverClose = (): void => {
|
||||
cancelHoverOpen()
|
||||
cancelHoverClose()
|
||||
if (pinnedRef.current) return
|
||||
hoverCloseTimer.current = setTimeout(() => {
|
||||
hoverCloseTimer.current = undefined
|
||||
changeOpen(false)
|
||||
@@ -602,6 +607,7 @@ function CatalogDropdown({
|
||||
cancelHoverOpen()
|
||||
cancelHoverClose()
|
||||
if (!open) return
|
||||
pinnedRef.current = false
|
||||
setOpen(false)
|
||||
setExpanded(new Set())
|
||||
}, [visible, open])
|
||||
@@ -640,11 +646,11 @@ function CatalogDropdown({
|
||||
className={`${css.root} ${variant === 'switcher' ? css.switcherRoot : ''}`}
|
||||
ref={rootRef}
|
||||
onKeyDown={navigate}
|
||||
onMouseEnter={scheduleHoverOpen}
|
||||
onMouseLeave={scheduleHoverClose}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onMouseEnter={scheduleHoverOpen}
|
||||
type="button"
|
||||
className={variant === 'switcher'
|
||||
? `${css.switcherTrigger} ${ancestorSwitcher ? css.ancestorSwitcherTrigger : ''}`
|
||||
@@ -658,7 +664,12 @@ function CatalogDropdown({
|
||||
{ count: runningCount > 0 ? runningCount : directCount },
|
||||
)}
|
||||
onClick={openTitle === undefined
|
||||
? undefined
|
||||
? () => {
|
||||
cancelHoverOpen()
|
||||
cancelHoverClose()
|
||||
pinnedRef.current = true
|
||||
if (!open) changeOpen(true)
|
||||
}
|
||||
: () => {
|
||||
cancelHoverOpen()
|
||||
if (open) changeOpen(false)
|
||||
@@ -692,26 +703,26 @@ function CatalogDropdown({
|
||||
ref={menuRef}
|
||||
className={css.menu}
|
||||
style={menuPosition}
|
||||
role="tree"
|
||||
aria-label={t('tree.aria')}
|
||||
onMouseEnter={cancelHoverClose}
|
||||
onMouseLeave={scheduleHoverClose}
|
||||
>
|
||||
<CatalogRows
|
||||
parentSessionId={rootSessionId}
|
||||
currentSessionId={currentSessionId}
|
||||
catalog={presentedCatalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={1}
|
||||
openChild={openChild}
|
||||
openChildAside={openChildAside}
|
||||
refreshProjection={refreshProjection}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={() => { changeOpen(false) }}
|
||||
t={t}
|
||||
/>
|
||||
<div className={css.menuBody} role="tree" aria-label={t('tree.aria')}>
|
||||
<CatalogRows
|
||||
parentSessionId={rootSessionId}
|
||||
currentSessionId={currentSessionId}
|
||||
catalog={presentedCatalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={1}
|
||||
openChild={openChild}
|
||||
openChildAside={openChildAside}
|
||||
refreshProjection={refreshProjection}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={() => { changeOpen(false) }}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
), document.body)}
|
||||
</div>
|
||||
@@ -724,7 +735,7 @@ export type SubagentCatalogActionProps =
|
||||
|
||||
/**
|
||||
* Session-header catalog action for root sessions: the descendant count and
|
||||
* its dropdown, ordered after the task list. Child sessions render nothing
|
||||
* its dropdown at the start of the header actions band. Child sessions render nothing
|
||||
* here — their breadcrumb switcher in the lineage slot owns the same
|
||||
* navigation.
|
||||
* @param props - Session standard props plus the catalog actions and translator.
|
||||
@@ -769,7 +780,7 @@ export function SubagentHeaderLineage({
|
||||
})
|
||||
const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t }
|
||||
// Root sessions carry no breadcrumb; their descendant count lives in the
|
||||
// header actions band (SubagentCatalogAction), after the task list.
|
||||
// header actions band (SubagentCatalogAction).
|
||||
if (parentId === undefined) return null
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -37,7 +37,8 @@ export const zh = {
|
||||
'count.running.other': '{count} 个子智能体,正在运行',
|
||||
'switcher.aria': '切换子智能体:{title}',
|
||||
'tree.aria': '子智能体会话',
|
||||
'open.sidebar': '在侧边栏打开 {label}',
|
||||
'open.sidebar': '在侧边栏打开',
|
||||
'open.sidebar.aria': '在侧边栏打开 {label}',
|
||||
'sidebar.chat': '聊天',
|
||||
'readonly.oneShot.title': '一次性子智能体记录',
|
||||
'readonly.title': '此子智能体暂时只读',
|
||||
@@ -79,7 +80,8 @@ export const en: Record<SubagentKey, string> = {
|
||||
'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',
|
||||
'open.sidebar.aria': 'Open {label} in sidebar',
|
||||
'sidebar.chat': 'Chat',
|
||||
'readonly.oneShot.title': 'One-shot subagent record',
|
||||
'readonly.title': 'This subagent is read-only for now',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -132,10 +132,8 @@ function summary(id: SessionId, updatedAt: number): SessionSummary {
|
||||
}
|
||||
|
||||
function hoverCatalog(trigger: HTMLElement): void {
|
||||
const root = trigger.parentElement
|
||||
if (root === null) throw new Error('catalog trigger has no root')
|
||||
vi.useFakeTimers()
|
||||
fireEvent.mouseEnter(root)
|
||||
fireEvent.mouseEnter(trigger)
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
}
|
||||
|
||||
@@ -255,7 +253,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: '在侧边栏打开 worker' })
|
||||
fireEvent.keyDown(sidebarButton, { key: 'Enter' })
|
||||
fireEvent.click(sidebarButton)
|
||||
expect(input.openChildAside).toHaveBeenCalledWith({
|
||||
@@ -331,7 +330,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<void> => {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
|
||||
@@ -341,26 +340,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!)
|
||||
fireEvent.mouseEnter(trigger)
|
||||
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 +368,89 @@ 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<void> => {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
|
||||
}
|
||||
render(<HeaderCatalog {...props(catalog())} />)
|
||||
const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ })
|
||||
const root = trigger.parentElement!
|
||||
|
||||
fireEvent.click(trigger)
|
||||
const menu = screen.getByRole('tree')
|
||||
fireEvent.mouseEnter(trigger)
|
||||
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(trigger)
|
||||
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(trigger)
|
||||
fireEvent.mouseLeave(root)
|
||||
await advance(300)
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the hover catalog open when the pointer returns directly to its trigger', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<HeaderCatalog {...props(catalog())} />)
|
||||
const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ })
|
||||
fireEvent.mouseOver(trigger, { relatedTarget: document.body })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
const menu = screen.getByRole('tree').parentElement!
|
||||
fireEvent.mouseOut(trigger, { relatedTarget: menu })
|
||||
fireEvent.mouseOver(menu, { relatedTarget: trigger })
|
||||
fireEvent.mouseOut(menu, { relatedTarget: trigger })
|
||||
fireEvent.mouseOver(trigger, { relatedTarget: menu })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
expect(screen.getByRole('tree').parentElement).toBe(menu)
|
||||
})
|
||||
|
||||
it('cancels pending hover dismissal when the catalog trigger is activated from the keyboard', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<HeaderCatalog {...props(catalog())} />)
|
||||
const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ })
|
||||
fireEvent.mouseOver(trigger, { relatedTarget: document.body })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
fireEvent.mouseOut(trigger, { relatedTarget: document.body })
|
||||
fireEvent.click(trigger, { detail: 0 })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(120) })
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('repositions an open catalog after viewport resize and document scroll', () => {
|
||||
const view = render(<HeaderCatalog {...props(catalog())} />)
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -399,7 +459,7 @@ describe('SubagentHeaderLineage', () => {
|
||||
const view = render(<HeaderCatalog {...props(catalog())} />)
|
||||
const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ })
|
||||
|
||||
fireEvent.mouseEnter(trigger.parentElement!)
|
||||
fireEvent.mouseEnter(trigger)
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
view.rerender(<HeaderCatalog {...props(catalog({ entries: [] }))} />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
|
||||
@@ -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 <IconUsersOutlineRegular size={14} />
|
||||
if (toolName.startsWith('schedule_')) return <IconClockOutlineRegular size={14} />
|
||||
if (toolName.endsWith('_goal')) return <IconGoalOutlineRegular size={14} />
|
||||
if (toolName.startsWith('cordis_')) return <IconCordisPluginOutlineRegular />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { IconUsersOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
@@ -90,6 +91,14 @@ describe('DetailsRow', () => {
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('presents teammate-coordination tools under the two-person team icon', () => {
|
||||
const inspect = vi.fn()
|
||||
const expectedIcon = render(<IconUsersOutlineRegular size={14} />).container.querySelector('svg')!.outerHTML
|
||||
const view = render(<DetailsRow {...{ useDisclosure, toolName: 'wait_agent', block: result('wait_agent', { timedOut: true }), inspect, t } as Parameters<typeof DetailsRow>[0]} />)
|
||||
expect(screen.getByText('Wait for teammates')).toBeTruthy()
|
||||
expect(view.container.querySelector('svg')?.outerHTML).toBe(expectedIcon)
|
||||
})
|
||||
|
||||
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>) => {
|
||||
|
||||
@@ -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: 17990a84ebe398b632e2e6224674efe09f401aa9
|
||||
README.zh.md: 90bed11f2886429f39d2e6b73d251df63256dab4
|
||||
@@ -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. Provisioning and running members use the shared ongoing loader, inactive members use a person icon, and failed members use error. 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. Member cards use the shared elevation stroke for their outlines in resting, selected, and hover states. 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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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 行展示持久名称与阶段。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用人物图标,failed 成员使用 error 红点。实时 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。切换会话会关闭面板并清除导航失败。
|
||||
面板渲染在会话容器外,并保持在视口范围内。成员卡片在静止、选中和悬停状态下均使用共享 elevation 描边绘制轮廓。悬停触发按钮 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。切换会话会关闭面板并清除导航失败。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
|
||||
@@ -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,12 @@
|
||||
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;
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
|
||||
box-shadow: var(--dsw-elevation-stroke);
|
||||
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 +148,78 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The Current tag and card share a tint; the inset ring adds emphasis
|
||||
without changing the card's dimensions. */
|
||||
.memberCurrent {
|
||||
--dsw-elevation-stroke-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
|
||||
box-shadow: var(--dsw-elevation-stroke), 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 {
|
||||
--dsw-elevation-stroke-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 +238,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 +266,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,
|
||||
|
||||
@@ -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<typeof NS>
|
||||
|
||||
/** 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<MemberStatus, 'inactive'>): 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<TeamActionProps,
|
||||
'sessionId' | 'useSessions' | 'useSessionStatus' | 'openTeammate' | 't'
|
||||
> & {
|
||||
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,98 @@ 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 (
|
||||
<button
|
||||
type="button"
|
||||
className={css.member}
|
||||
disabled={member.role === 'lead' || status === 'failed' || status === 'provisioning'}
|
||||
title={member.role === 'teammate' ? t('open') : undefined}
|
||||
onClick={() => {
|
||||
try {
|
||||
openTeammate(sessionId, member.id)
|
||||
} catch (reason) {
|
||||
onError(String(reason))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot state={memberDotState(status)} />
|
||||
<span className={css.memberText}>
|
||||
<span>{member.name}</span>
|
||||
<small>{t(memberStatusKey(status))}{model === undefined ? '' : ` · ${t('model')}: ${model}`}</small>
|
||||
{member.error !== undefined && <small className={css.diagnostic}>{member.error}</small>}
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip label={t('open')} side="bottom" gap={4} disabled={inert}>
|
||||
<button
|
||||
type="button"
|
||||
className={highlightCurrent ? `${css.member} ${css.memberCurrent}` : css.member}
|
||||
disabled={inert}
|
||||
onClick={() => {
|
||||
try {
|
||||
openTeammate(sessionId, member.id)
|
||||
} catch (reason) {
|
||||
onError(String(reason))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={css.memberDot}>
|
||||
{status === 'inactive'
|
||||
? <IconUserOutlineRegular size={14} className={css.inactiveIcon} />
|
||||
: <StateDot state={memberDotState(status)} />}
|
||||
</span>
|
||||
<span className={css.memberText}>
|
||||
<span className={css.memberName}>
|
||||
<span className={css.memberNameText}>{member.name}</span>
|
||||
{isCurrent && <Tag tone="info" className={css.currentTag}>{t('current')}</Tag>}
|
||||
</span>
|
||||
<small>
|
||||
{t(memberStatusKey(status))}
|
||||
{model !== undefined && (
|
||||
<span className={css.memberModel}>{` · ${t('model')}: ${model}`}</span>
|
||||
)}
|
||||
</small>
|
||||
{member.error !== undefined && <small className={css.diagnostic}>{member.error}</small>}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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<typeof NS> }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [clamped, setClamped] = useState(false)
|
||||
const textRef = useRef<HTMLParagraphElement>(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
|
||||
const measure = (): void => { setClamped(paragraph.scrollHeight > paragraph.clientHeight + 1) }
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(paragraph)
|
||||
return () => { observer.disconnect() }
|
||||
}, [task.description, expanded])
|
||||
return (
|
||||
<article className={css.task}>
|
||||
<div className={css.taskTitle}>
|
||||
<strong>{task.subject}</strong>
|
||||
<span className={css.taskState}>
|
||||
<StateDot state={taskDotState(task)} />
|
||||
<span>{t(statusKey(task.status))}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p ref={textRef} className={expanded ? undefined : css.clampedDescription}>{task.description}</p>
|
||||
<div className={css.meta}>
|
||||
{(clamped || expanded) && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expandToggle}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => { setExpanded(current => !current) }}
|
||||
>
|
||||
{t(expanded ? 'task.collapse' : 'task.expand')}
|
||||
<IconChevronDownOutlineRegular size={12} className={expanded ? css.expandToggleOpen : undefined} />
|
||||
</button>
|
||||
)}
|
||||
<span>{task.id}</span>
|
||||
<span>{t('owner')}: {task.ownerName ?? t('unowned')}</span>
|
||||
{task.status === 'pending' && <span>{task.ready ? t('ready') : t('blocked')}</span>}
|
||||
{task.blockedBy.length > 0 && <span>{t('blockedBy')}: {task.blockedBy.join(', ')}</span>}
|
||||
{task.writeScopes.length > 0 && <span>{t('writeScopes')}: {task.writeScopes.join(', ')}</span>}
|
||||
{task.writeScopeWarnings.map(warning => <span key={warning} className={css.warning}>{warning}</span>)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render the Team roster and read-only task board. */
|
||||
export function TeamAction({
|
||||
sessionId, useSession, useSessions, useSessionStatus, openTeammate, t,
|
||||
}: TeamActionProps) {
|
||||
@@ -115,133 +183,173 @@ export function TeamAction({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const triggerLabelRef = useRef<HTMLSpanElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div ref={rootRef} className={css.root} data-team-action onKeyDown={(event) => {
|
||||
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)
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={css.root}
|
||||
data-team-action
|
||||
onMouseLeave={scheduleHoverClose}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
ref={triggerRef}
|
||||
onMouseEnter={scheduleHoverOpen}
|
||||
className={css.trigger}
|
||||
aria-label={t('trigger')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
onClick={() => {
|
||||
cancelHoverChange()
|
||||
pinnedRef.current = true
|
||||
if (!open) changeOpen(true)
|
||||
else panelRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<IconUserOutlineRegular size={14} />
|
||||
<span>{t('trigger')}</span>
|
||||
{teammates.length > 0 && <span className={css.count}>{teammates.length}</span>}
|
||||
<IconUsersOutlineRegular size={14} />
|
||||
<span ref={triggerLabelRef} className={css.triggerLabel}>{t('trigger')}</span>
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={css.panel}
|
||||
className={compact ? `${css.panel} ${css.panelCompact}` : css.panel}
|
||||
style={position ?? { visibility: 'hidden', left: 0, top: 0 }}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-label={t('trigger')}
|
||||
data-team-panel
|
||||
onMouseEnter={cancelHoverChange}
|
||||
onMouseLeave={scheduleHoverClose}
|
||||
>
|
||||
<div className={css.toolbar}>
|
||||
<strong>{t('trigger')}</strong>
|
||||
<span className={css.spacer} />
|
||||
<button type="button" className={css.iconButton} aria-label={t('close')} onClick={close}>
|
||||
<IconCloseOutlineRegular size={14} />
|
||||
</button>
|
||||
<div className={css.body}>
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert"><StateDot state="error" />{error}</div>
|
||||
)}
|
||||
{team === undefined && (
|
||||
<div className={css.notice} role="status">
|
||||
<StateDot state={opening || listing ? 'ongoing' : 'warning'} />
|
||||
{t(opening || listing ? 'loading' : 'unavailable')}
|
||||
</div>
|
||||
)}
|
||||
{team !== undefined && (
|
||||
<>
|
||||
{team.failure !== undefined && (
|
||||
<div className={css.error} role="alert"><StateDot state="error" />{t('failure', { message: team.failure })}</div>
|
||||
)}
|
||||
<section>
|
||||
<h3>
|
||||
{t('roster')}
|
||||
{team.members.length > 1 && <span className={css.count}>{team.members.length}</span>}
|
||||
</h3>
|
||||
<div className={css.roster}>
|
||||
{team.members.map(member => (
|
||||
<TeamMemberRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
memberCount={team.members.length}
|
||||
sessionId={sessionId}
|
||||
useSessions={useSessions}
|
||||
useSessionStatus={useSessionStatus}
|
||||
openTeammate={openTeammate}
|
||||
onError={setError}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
{team.tasks.length === 0
|
||||
? <p className={css.emptyNotice}>{t('empty')}</p>
|
||||
: (
|
||||
<>
|
||||
<h3>{t('tasks')}<span className={css.count}>{team.tasks.length}</span></h3>
|
||||
<div className={css.tasks}>
|
||||
{team.tasks.map(task => <TaskCard key={task.id} task={task} t={t} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert"><StateDot state="error" />{error}</div>
|
||||
)}
|
||||
{team === undefined && (
|
||||
<div className={css.notice} role="status">
|
||||
<StateDot state={opening || listing ? 'ongoing' : 'warning'} />
|
||||
{t(opening || listing ? 'loading' : 'unavailable')}
|
||||
</div>
|
||||
)}
|
||||
{team !== undefined && (
|
||||
<>
|
||||
{team.failure !== undefined && (
|
||||
<div className={css.error} role="alert"><StateDot state="error" />{t('failure', { message: team.failure })}</div>
|
||||
)}
|
||||
<section>
|
||||
<h3>{t('roster')}</h3>
|
||||
<div className={css.roster}>
|
||||
{team.members.map(member => (
|
||||
<TeamMemberRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
sessionId={sessionId}
|
||||
useSessions={useSessions}
|
||||
useSessionStatus={useSessionStatus}
|
||||
openTeammate={openTeammate}
|
||||
onError={setError}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>{t('tasks')}</h3>
|
||||
{team.tasks.length === 0 && <div className={css.notice}>{t('empty')}</div>}
|
||||
<div className={css.tasks}>
|
||||
{team.tasks.map(task => (
|
||||
<article key={task.id} className={css.task}>
|
||||
<div className={css.taskTitle}>
|
||||
<strong>{task.subject}</strong>
|
||||
<span className={css.taskState}>
|
||||
<StateDot state={taskDotState(task)} />
|
||||
<span>{t(statusKey(task.status))}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p>{task.description}</p>
|
||||
<div className={css.meta}>
|
||||
<span>{task.id}</span>
|
||||
<span>{t('owner')}: {task.ownerName ?? t('unowned')}</span>
|
||||
{task.status === 'pending' && <span>{task.ready ? t('ready') : t('blocked')}</span>}
|
||||
{task.blockedBy.length > 0 && <span>{t('blockedBy')}: {task.blockedBy.join(', ')}</span>}
|
||||
{task.writeScopes.length > 0 && <span>{t('writeScopes')}: {task.writeScopes.join(', ')}</span>}
|
||||
{task.writeScopeWarnings.map(warning => <span key={warning} className={css.warning}>{warning}</span>)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -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. Create them through the conversation.',
|
||||
roster: 'Members',
|
||||
tasks: 'Shared tasks',
|
||||
model: 'Model',
|
||||
open: 'Open teammate conversation',
|
||||
open: 'Open member 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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,7 +15,12 @@ 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()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const SESSION = 'lead' as SessionId
|
||||
const WORKER = 'worker-id' as SessionId
|
||||
@@ -99,7 +104,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 +126,7 @@ describe('TeamAction', () => {
|
||||
it('renders the Lead projection and applies later projection frames without any user action', async () => {
|
||||
const b = bench()
|
||||
render(<TeamAction {...b.props} />)
|
||||
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 +138,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 +159,18 @@ describe('TeamAction', () => {
|
||||
})
|
||||
render(<TeamAction {...b.props} />)
|
||||
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 +178,9 @@ describe('TeamAction', () => {
|
||||
render(<TeamAction {...b.props} />)
|
||||
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<HTMLButtonElement>('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 +262,7 @@ describe('TeamAction', () => {
|
||||
})
|
||||
render(<TeamAction {...b.props} />)
|
||||
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 +312,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 +336,7 @@ describe('TeamAction', () => {
|
||||
it('keeps panel interactions open and dismisses on outside pointer or Escape', () => {
|
||||
const b = bench()
|
||||
const rendered = render(<TeamAction {...b.props} />)
|
||||
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 +348,218 @@ 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.each([true, false])('clamps a long task description behind an expand toggle (ResizeObserver: %s)', async (resizeObserver) => {
|
||||
if (!resizeObserver) vi.stubGlobal('ResizeObserver', undefined)
|
||||
const scrollHeight = vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(120)
|
||||
const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(60)
|
||||
try {
|
||||
render(<TeamAction {...bench().props} />)
|
||||
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<void> => {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
|
||||
}
|
||||
const b = bench()
|
||||
render(<><TeamAction {...b.props} /><button>Outside</button></>)
|
||||
const trigger = screen.getByRole('button', { name: /Agent Team/u })
|
||||
const rendered = render(<TeamAction {...b.props} />)
|
||||
const trigger = screen.getByRole('button', { name: /智能体团队/u })
|
||||
const root = trigger.parentElement!
|
||||
|
||||
fireEvent.mouseEnter(trigger)
|
||||
await advance(149)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
await advance(1)
|
||||
const panel = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.mouseEnter(trigger)
|
||||
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(trigger)
|
||||
fireEvent.mouseLeave(root, { relatedTarget: document.body })
|
||||
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<void> => {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
|
||||
}
|
||||
render(<TeamAction {...bench().props} />)
|
||||
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(trigger)
|
||||
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(trigger)
|
||||
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(trigger)
|
||||
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<void> => {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
|
||||
}
|
||||
render(<TeamAction {...bench().props} />)
|
||||
const trigger = screen.getByRole('button', { name: /智能体团队/u })
|
||||
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(trigger)
|
||||
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(<TeamAction {...b.props} />)
|
||||
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(<TeamAction {...bench().props} />)
|
||||
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(<><textarea aria-label="Composer" /><TeamAction {...bench().props} /></>)
|
||||
const composer = screen.getByRole('textbox')
|
||||
composer.focus()
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: zh.trigger }))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
fireEvent.keyDown(composer, { key: 'a' })
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
fireEvent.keyDown(composer, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(composer)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the hover panel open when the pointer returns directly to its trigger', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<TeamAction {...bench().props} />)
|
||||
const trigger = screen.getByRole('button', { name: zh.trigger })
|
||||
fireEvent.mouseOver(trigger, { relatedTarget: document.body })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
const panel = screen.getByRole('dialog')
|
||||
fireEvent.mouseOut(trigger, { relatedTarget: panel })
|
||||
fireEvent.mouseOver(panel, { relatedTarget: trigger })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
expect(screen.getByRole('dialog')).toBe(panel)
|
||||
fireEvent.mouseOut(panel, { relatedTarget: trigger })
|
||||
fireEvent.mouseOver(trigger, { relatedTarget: panel })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
expect(screen.getByRole('dialog')).toBe(panel)
|
||||
})
|
||||
|
||||
it('cancels pending hover dismissal when the trigger is activated from the keyboard', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<TeamAction {...bench().props} />)
|
||||
const trigger = screen.getByRole('button', { name: zh.trigger })
|
||||
trigger.focus()
|
||||
fireEvent.mouseOver(trigger, { relatedTarget: document.body })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
fireEvent.mouseOut(trigger, { relatedTarget: document.body })
|
||||
fireEvent.click(trigger, { detail: 0 })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(120) })
|
||||
expect(screen.getByRole('dialog')).toBe(document.activeElement)
|
||||
})
|
||||
|
||||
it('updates expansion availability on paragraph resize and disconnects its observer', () => {
|
||||
const observers: TestResizeObserver[] = []
|
||||
class TestResizeObserver implements ResizeObserver {
|
||||
observe = vi.fn<ResizeObserver['observe']>()
|
||||
unobserve = vi.fn<ResizeObserver['unobserve']>()
|
||||
disconnect = vi.fn()
|
||||
constructor(readonly callback: ResizeObserverCallback) { observers.push(this) }
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
const scrollHeight = vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(36)
|
||||
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(36)
|
||||
const view = render(<TeamAction {...bench().props} />)
|
||||
openPanel()
|
||||
const paragraph = screen.getByText('Build the Team runtime')
|
||||
const observer = observers.find(item => item.observe.mock.calls.some(([target]) => target === paragraph))!
|
||||
expect(observer).toBeDefined()
|
||||
expect(screen.queryByRole('button', { name: zh['task.expand'] })).toBeNull()
|
||||
scrollHeight.mockReturnValue(72)
|
||||
act(() => { observer.callback([], observer) })
|
||||
expect(screen.getByRole('button', { name: zh['task.expand'] })).toBeTruthy()
|
||||
scrollHeight.mockReturnValue(36)
|
||||
act(() => { observer.callback([], observer) })
|
||||
expect(screen.queryByRole('button', { name: zh['task.expand'] })).toBeNull()
|
||||
view.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
Reference in new issue
Block a user