feat(tasks-ui): tasks on their owner nodes, host-primitive controls, icon glyphs, draggable output
Round-two review feedback, all three points: - TASKS ON NODES: the model maps each shared task (ownerName -> member name -> member session id) onto its owner's node; graph cards and tree rows render a fourth line (icon + subject + status + '+N'), and the node detail popover lists every owned task. - TEAM BOARD: member Pills double as the owner filter; each task row is a status dot + subject + owner + status Tag + ONE overflow Menu (complete / reopen / edit / reassign submenu / delete with a two-step arm). Creating and editing moved into a real Modal with labeled Inputs and owner Pills — the narrow strip is no longer where you type. - HOST PRIMITIVES ONLY: the board and the jobs drawer now use Menu / Modal / Input / Button / Pill / Tag / Switch / StateDot; no native select or input remains (guarded by a spec). - ICONS, NOT HAND-DRAWN GLYPHS: every text glyph (circle/diamond/triangle/ check/chevrons/i/house) is replaced by a host icon — agent preset, user (teammate), branch (workflow run), checklist (fold + tasks), tree corner, fullscreen (fit), ellipsis (node details), copy/stop (jobs). The live line now shows the tool's own icon + name + args without the drawn box. - JOBS UX: the output popover is DRAGGABLE (offset clamped to the viewport, double click re-anchors, controls do not start a drag), defaults to 380px, and gains copy-output, a follow-latest switch, kind/status Tags and a drag hint; the drawer highlights the open row. - 19 new copy keys across zh/en/ja and the 18 third-language dictionaries. Tests: 4 new specs (task rendered on its node, no native select/input + menu-driven board, create-through-dialog posts the CAS-free create, job popover drag moves the card). Full suite 1404 passed / 133 files.
This commit is contained in:
@@ -102,6 +102,19 @@ client 半(src/client/)
|
||||
|
||||
真实浏览器里的组件级回归无法靠 jsdom 覆盖(点击捕获、fit/居中、主题令牌解析都是浏览器行为)。harness 用 esbuild 把**真实组件** + mockup 形状的 fixture(走 `buildTasksModel`,因此折叠/重挂/富化都真实生效)打包进一个页面,注入从 `dsh-client-ui-theme` 抽出的令牌 CSS,`@deepseek-ai/dsh-client-ui-primitives` 别名到轻量桩(避免把 katex/shiki 资源拖进截图包),再由 Playwright 在 360px / 720px 两档宽度截图并断言:节点点击回调、ⓘ 回调、背景拖拽平移量。本次返工的四条反馈里有三条正是它先复现、修完再确认的。
|
||||
|
||||
## 第二轮返工(2026-09-14 深夜,13 条反馈)
|
||||
|
||||
用户第二轮反馈三条,逐条落地:
|
||||
|
||||
| 反馈 | 做法 |
|
||||
|---|---|
|
||||
| 任务板「太窄太小」,且任务应显示到**对应节点**上 | 任务按 `ownerName → 成员名 → 成员会话 id` 映射进模型(`TasksNodeTask`),节点第 4 行渲染「☑ 主题 · 状态 +N」;任务板改为:成员 Pill 行**兼当负责人筛选**、任务行 = 状态点 + 主题 + 负责人 + 状态 Tag + **一个**溢出菜单;新建/编辑改为 `Modal` + `Input`(真正的宽表单),负责人用 Pill 选择 |
|
||||
| 所有组件用自建、不用原生 HTML | 任务板全量换成宿主 primitives:`Menu`(含子菜单的改派)/`Modal`/`Input`/`Button`/`Pill`/`Tag`/`Switch`/`StateDot`;后台任务行的 kind/状态用 `Tag`、终止用 `Button`、输出浮窗的复制/跟随/终止同理。页面仅剩结构性 div(卡片、树行)不是表单控件 |
|
||||
| Agent 用图标而非自绘 SVG;优化工具展示 | `◉/◇/▶/✓/⇪/⇩/i/☰/⌗/⌂` 等字形全部删除,改用宿主图标:`IconAgentPresetOutline16`(代理)、`IconUserOutline16`(teammate)、`IconBranchOutline16`(workflow run)、`IconChecklistOutline14`(折叠聚合 / 任务行 / 折叠按钮)、`IconTreeCorner8x10`(树视图)、`IconFullscreenOutline16`(适应)、`IconEllipsisOutline16`(节点详情)、`IconCopyOutline16`/`IconStopFill16`(作业)。live 行改为工具自身图标 + 工具名 + 参数(去掉手绘方框) |
|
||||
| 后台任务弹窗要能拖动 + 体验 | `AnchoredPopover` 增加 `draggable`(拖动整体、按钮/输入/`pre` 不拦截、双击复位、视口内钳制)与 `width` 参数;输出浮窗宽 380 且可拖,新增「复制输出」「跟随最新」开关(关闭后不再尾钉)、kind/状态 Tag、拖动提示;抽屉行高亮当前打开的作业 |
|
||||
|
||||
i18n:本轮新增 19 条文案,zh/en/ja + 18 份第三语言词典同步(`tests/locales.spec.ts` 16/16)。
|
||||
|
||||
## 验证结果(2026-09-14)
|
||||
|
||||
PR:[#680](https://github.com/omdsh-dev/DSH-better-sidebar/pull/680)(分支 `feat/tasks-graph-workflow-teams`)。
|
||||
|
||||
@@ -1,38 +1,62 @@
|
||||
/**
|
||||
* The anchored floating popover of the Tasks page (node details, job output,
|
||||
* workflow run detail, team task editor): a viewport-anchored card portaled
|
||||
* to `document.body`, positioned below its anchor and flipped above when the
|
||||
* viewport bottom would clip it. Dismissal follows the proven
|
||||
* selection-popup contract (fixes upstream issue #425): outside mousedown,
|
||||
* Escape, document hidden, window blur, and the ANCHOR leaving the viewport
|
||||
* (tab switches flip the pane to display:none, which has no DOM event — the
|
||||
* IntersectionObserver geometry signal is the only reliable one).
|
||||
* workflow run detail): a viewport-anchored card portaled to `document.body`,
|
||||
* positioned below its anchor and flipped above when the viewport bottom
|
||||
* would clip it. Dismissal follows the proven selection-popup contract
|
||||
* (fixes upstream issue #425): outside mousedown, Escape, document hidden,
|
||||
* window blur, and the ANCHOR leaving the viewport (tab switches flip the
|
||||
* pane to display:none, which has no DOM event — the IntersectionObserver
|
||||
* geometry signal is the only reliable one).
|
||||
*
|
||||
* DRAGGABLE mode: the job-output popover must be movable (a long log needs to
|
||||
* sit wherever the reader puts it). While `draggable`, dragging the card (or
|
||||
* its `data-popover-handle` area) moves it; the offset is clamped to the
|
||||
* viewport and a double click re-anchors it. The card stays portaled and
|
||||
* viewport-positioned — DSH 0.1.5 has no host free-window API, so the drag
|
||||
* is implemented here rather than reaching for a platform window.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
useCallback, useEffect, useLayoutEffect, useRef, useState,
|
||||
type PointerEvent as ReactPointerEvent, type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/** The popover's visual width (viewport-clamped). */
|
||||
/** The popover's default visual width (viewport-clamped). */
|
||||
const POP_WIDTH = 264
|
||||
/** Free space kept at the viewport edges. */
|
||||
const VIEWPORT_MARGIN = 8
|
||||
|
||||
export interface AnchoredPopoverProps {
|
||||
/** The anchor element (null = closed). */
|
||||
anchor: HTMLElement | null
|
||||
/** Dismiss (outside click / Escape / anchor off-screen). */
|
||||
onClose(): void
|
||||
/** Allow dragging the card (default false = pinned to the anchor). */
|
||||
draggable?: boolean
|
||||
/** Card width in px before viewport clamping (default 264). */
|
||||
width?: number
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a viewport-anchored popover. The caller owns WHAT is shown inside;
|
||||
* this component owns geometry + dismissal. Re-anchoring while open (a new
|
||||
* anchor) re-measures and keeps the card on screen.
|
||||
* this component owns geometry, dragging and dismissal. Changing the anchor
|
||||
* re-measures and resets any drag offset.
|
||||
*/
|
||||
export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
|
||||
const { anchor, onClose, children } = props
|
||||
const { anchor, onClose, draggable = false, width = POP_WIDTH, children } = props
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
|
||||
/** Reader-applied drag offset (added to the anchored position). */
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const offsetRef = useRef(offset)
|
||||
offsetRef.current = offset
|
||||
/** The anchored baseline: a drag always starts from the measured position. */
|
||||
const baseRef = useRef({ left: 0, top: 0 })
|
||||
|
||||
const cardWidth = Math.min(width, window.innerWidth - VIEWPORT_MARGIN * 2)
|
||||
|
||||
// Measure + position once the card is in the DOM (and whenever the anchor
|
||||
// or content changes size). layout effect: no visible jump.
|
||||
@@ -41,15 +65,54 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
|
||||
const card = cardRef.current
|
||||
if (card === null) return
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const width = Math.min(POP_WIDTH, window.innerWidth - 16)
|
||||
const height = card.offsetHeight
|
||||
let top = rect.bottom + 6
|
||||
if (top + height > window.innerHeight - 8) {
|
||||
top = Math.max(8, rect.top - height - 6)
|
||||
if (top + height > window.innerHeight - VIEWPORT_MARGIN) {
|
||||
top = Math.max(VIEWPORT_MARGIN, rect.top - height - 6)
|
||||
}
|
||||
const left = Math.min(Math.max(8, rect.left), window.innerWidth - width - 8)
|
||||
setPos({ left, top })
|
||||
}, [anchor, children])
|
||||
const left = Math.min(
|
||||
Math.max(VIEWPORT_MARGIN, rect.left),
|
||||
Math.max(VIEWPORT_MARGIN, window.innerWidth - cardWidth - VIEWPORT_MARGIN),
|
||||
)
|
||||
baseRef.current = { left, top }
|
||||
setPos({ left: left + offsetRef.current.x, top: top + offsetRef.current.y })
|
||||
}, [anchor, children, cardWidth])
|
||||
|
||||
// A new anchor starts a fresh placement (never inherit another node's drag).
|
||||
useEffect(() => { setOffset({ x: 0, y: 0 }) }, [anchor])
|
||||
|
||||
/** Drag the card (buttons inside it keep working). */
|
||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (!draggable || event.button !== 0) return
|
||||
const target = event.target as HTMLElement
|
||||
// A control inside the card (copy, kill, follow…) must not start a drag.
|
||||
if (target.closest('button, input, select, a, [data-popover-no-drag]') !== null) return
|
||||
event.preventDefault()
|
||||
const startX = event.clientX
|
||||
const startY = event.clientY
|
||||
const start = offsetRef.current
|
||||
const base = baseRef.current
|
||||
const onMove = (move: PointerEvent): void => {
|
||||
const nextX = Math.min(
|
||||
Math.max(start.x + (move.clientX - startX), VIEWPORT_MARGIN - base.left),
|
||||
Math.max(VIEWPORT_MARGIN - base.left, window.innerWidth - cardWidth - VIEWPORT_MARGIN - base.left),
|
||||
)
|
||||
const nextY = Math.min(
|
||||
Math.max(start.y + (move.clientY - startY), VIEWPORT_MARGIN - base.top),
|
||||
Math.max(VIEWPORT_MARGIN - base.top, window.innerHeight - 48 - base.top),
|
||||
)
|
||||
setOffset({ x: nextX, y: nextY })
|
||||
setPos({ left: base.left + nextX, top: base.top + nextY })
|
||||
}
|
||||
const onUp = (): void => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
}
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}, [draggable, cardWidth])
|
||||
|
||||
// Global dismissal (see the file header for the contract).
|
||||
useEffect(() => {
|
||||
@@ -95,10 +158,12 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
|
||||
position: 'fixed',
|
||||
left: pos?.left ?? -9999,
|
||||
top: pos?.top ?? -9999,
|
||||
width: Math.min(POP_WIDTH, window.innerWidth - 16),
|
||||
width: cardWidth,
|
||||
zIndex: 90,
|
||||
visibility: pos === null ? 'hidden' : 'visible',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onDoubleClick={draggable ? () => { setOffset({ x: 0, y: 0 }) } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
|
||||
+150
-53
@@ -1,15 +1,23 @@
|
||||
/**
|
||||
* The background-jobs bottom drawer of the Tasks page: the tree's jobs
|
||||
* (owner-labeled, fed by the `session/jobs` push mirror) collapse into a
|
||||
* bottom bar that AUTO-COLLAPSES when the agent count crosses the page's
|
||||
* threshold — the manual toggle always wins afterwards. Job output no
|
||||
* longer docks inside the page: clicking a row opens an anchored popover
|
||||
* (event replay — never the model's job_output cursor). Live rows keep the
|
||||
* two-click-confirm kill.
|
||||
* The background-jobs bottom drawer of the Tasks page and its output popover.
|
||||
*
|
||||
* Drawer: the tree's jobs (owner-labeled, fed by the `session/jobs` push
|
||||
* mirror) collapse into a bottom bar that AUTO-COLLAPSES once the tree has
|
||||
* many agents — the manual toggle always wins afterwards.
|
||||
*
|
||||
* Popover: clicking a row opens a DRAGGABLE, portalled output card (see
|
||||
* AnchoredPopover) instead of docking a pane inside the page. It shows the
|
||||
* output the MODEL has read (event replay — never the model's job_output
|
||||
* cursor), with a copy action, a follow-latest switch, the two-click kill,
|
||||
* and a terminal-style tail while the job runs.
|
||||
*
|
||||
* Every control is a host primitive (Button / Tag / Switch / StateDot).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
Button, IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch, Tag,
|
||||
type TagTone,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarJobView } from '../context-types.ts'
|
||||
import {
|
||||
formatJobDuration,
|
||||
@@ -19,7 +27,6 @@ import {
|
||||
type TreeJob,
|
||||
} from './subagent-jobs.ts'
|
||||
import { api, type JobOutputResult } from './api.ts'
|
||||
import { IconStopOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
|
||||
@@ -27,6 +34,16 @@ import css from './tasks-graph.module.css'
|
||||
const JOB_POLL_MS = 2000
|
||||
/** How long the kill button stays armed before it needs re-confirming. */
|
||||
const JOB_KILL_ARM_MS = 3000
|
||||
/** The agent count at which the drawer starts collapsed. */
|
||||
export const JOBS_DRAWER_COLLAPSE_AT = 8
|
||||
|
||||
/** The Tag tone of one job status. */
|
||||
function jobTone(job: SidebarJobView): TagTone {
|
||||
if (job.status === 'running') return 'info'
|
||||
if (job.status === 'completed') return 'success'
|
||||
if (job.status === 'failed') return 'danger'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
export interface JobsDrawerProps {
|
||||
rows: readonly TreeJob[]
|
||||
@@ -34,13 +51,12 @@ export interface JobsDrawerProps {
|
||||
agentCount: number
|
||||
/** Open the output popover of one row (anchor = the row's main button). */
|
||||
onOpenOutput(row: TreeJob, anchor: HTMLElement): void
|
||||
/** The job whose output popover is currently open (row highlight). */
|
||||
openJobId?: string
|
||||
}
|
||||
|
||||
/** The agent count at which the drawer starts collapsed. */
|
||||
export const JOBS_DRAWER_COLLAPSE_AT = 8
|
||||
|
||||
export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
const { rows, agentCount, onOpenOutput } = props
|
||||
const { rows, agentCount, onOpenOutput, openJobId } = props
|
||||
const autoOpen = agentCount < JOBS_DRAWER_COLLAPSE_AT
|
||||
/** Manual override; undefined = follow the auto rule. */
|
||||
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
|
||||
@@ -103,15 +119,11 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
<span>{t('jobs')}</span>
|
||||
<span className={css.jobsBigNum}>{liveCount > 0 ? liveCount : rows.length}</span>
|
||||
<span className={css.jobsDrawerCount}>{countLabel}</span>
|
||||
<span className={clsx(css.jobsDrawerChev, open && css.jobsDrawerChevOpen)} aria-hidden="true">
|
||||
<span className={css.jobsDrawerChev} data-open={open ? 'true' : 'false'} aria-hidden="true">
|
||||
<IconChevronUpOutline14 size={12} />
|
||||
</span>
|
||||
</button>
|
||||
{!autoOpen && (
|
||||
<div className={css.jobsAutoNote}>
|
||||
{t('jobsAutoCollapsed')}
|
||||
</div>
|
||||
)}
|
||||
{!autoOpen && <div className={css.jobsAutoNote}>{t('jobsAutoCollapsed')}</div>}
|
||||
{open && (
|
||||
<div className={css.jobsDrawerBody}>
|
||||
{rows.map((row) => {
|
||||
@@ -125,39 +137,47 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
: (job.finishedAt ?? job.startedAt) - job.startedAt
|
||||
const secondary = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
jobStatusLabel(job.status, t),
|
||||
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
|
||||
formatJobDuration(elapsed, t),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div key={job.id} className={clsx(css.jobsRow, !live && css.jobsRowSettled)}>
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
key={job.id}
|
||||
className={css.jobsRow}
|
||||
data-settled={live ? 'false' : 'true'}
|
||||
data-open={openJobId === job.id ? 'true' : 'false'}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.jobsRowMain}
|
||||
aria-label={`${job.label} ${secondary}`}
|
||||
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
|
||||
title={t('jobViewOutput')}
|
||||
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
|
||||
>
|
||||
<StateDot state={jobDotState(job.status)} size={6} />
|
||||
<span className={css.jobsKind}>{job.kind}</span>
|
||||
<Tag tone="quiet">{job.kind}</Tag>
|
||||
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
|
||||
<Tag tone={jobTone(job)}>{jobStatusLabel(job.status, t)}</Tag>
|
||||
<span className={css.jobsMeta}>{secondary}</span>
|
||||
</button>
|
||||
</Button>
|
||||
{job.status === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.jobsKill, armed && css.jobsKillArmed)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={css.jobsKill}
|
||||
data-armed={armed ? 'true' : 'false'}
|
||||
icon={<IconStopFill16 size={11} />}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick={() => {
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
{armed ? '!' : <IconStopOutline16 size={12} />}
|
||||
</button>
|
||||
{armed ? t('jobKillConfirm') : undefined}
|
||||
</Button>
|
||||
)}
|
||||
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
|
||||
</div>
|
||||
@@ -172,8 +192,9 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
/**
|
||||
* The output popover content of one job: the text the MODEL has read so far
|
||||
* (replayed from the owner session's event log), refreshed every
|
||||
* {@link JOB_POLL_MS} while the job runs and the page is visible, pinned to
|
||||
* the newest output like a terminal tail while live.
|
||||
* {@link JOB_POLL_MS} while the job runs and the page is visible. The reader
|
||||
* can copy the output and turn the terminal-style tail off; the kill action
|
||||
* keeps its two-click confirm.
|
||||
*/
|
||||
export function JobOutputPopoverContent(props: {
|
||||
ownerSessionId: string
|
||||
@@ -182,8 +203,14 @@ export function JobOutputPopoverContent(props: {
|
||||
}): ReactNode {
|
||||
const { ownerSessionId, job, active } = props
|
||||
const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
|
||||
const [follow, setFollow] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [armed, setArmed] = useState(false)
|
||||
const [killing, setKilling] = useState(false)
|
||||
const [killFailed, setKillFailed] = useState(false)
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
const live = isJobLive(job)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
@@ -201,47 +228,117 @@ export function JobOutputPopoverContent(props: {
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (!active || !isJobLive(job)) return
|
||||
if (!active || !live) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
// isJobLive reads only job.status; whole-job identity churns on every
|
||||
// catalog refresh and must not restart the poll interval.
|
||||
// isJobLive reads only job.status; the job object churns on every catalog
|
||||
// refresh and must not restart the poll interval.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
// Terminal-tail behavior: while the job runs, each refresh pins the view
|
||||
// to the newest output; a settled popover leaves scrolling to the reader.
|
||||
// The armed kill disarms itself, like the drawer's button.
|
||||
useEffect(() => {
|
||||
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
|
||||
if (!armed) return
|
||||
const timer = window.setTimeout(() => { setArmed(false) }, JOB_KILL_ARM_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [armed])
|
||||
|
||||
// Terminal-tail behavior: each refresh pins the view to the newest output
|
||||
// while the reader keeps the follow switch on.
|
||||
useEffect(() => {
|
||||
if (!live || !follow || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
// Same as the poll effect above: only the status transition matters.
|
||||
// Same as the poll effect: only the status transition matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, job.status])
|
||||
}, [state, job.status, follow])
|
||||
|
||||
const text = typeof state === 'object' ? state.text : ''
|
||||
|
||||
/** Two-click kill from the popover (then re-read so the row settles). */
|
||||
const kill = async (): Promise<void> => {
|
||||
setKilling(true)
|
||||
setKillFailed(false)
|
||||
try {
|
||||
await api.jobKill({ sessionId: ownerSessionId }, job.id)
|
||||
setArmed(false)
|
||||
await load()
|
||||
} catch {
|
||||
setKillFailed(true)
|
||||
} finally {
|
||||
setKilling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.popCard} style={{ width: 340 }}>
|
||||
<span className={css.popTitle} title={job.label}>
|
||||
<StateDot state={jobDotState(job.status)} size={8} /> {job.label}
|
||||
</span>
|
||||
<span className={css.popHint}>
|
||||
{jobStatusLabel(job.status, t)}
|
||||
{job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
|
||||
</span>
|
||||
<div className={css.popCard} data-popover-handle>
|
||||
<div className={css.popHead}>
|
||||
<span>{t('jobs')}</span>
|
||||
<span className={css.popHeadActions} data-popover-no-drag>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<IconCopyOutline16 size={12} />}
|
||||
aria-label={copied ? t('jobCopied') : t('jobCopyOutput')}
|
||||
title={copied ? t('jobCopied') : t('jobCopyOutput')}
|
||||
disabled={text === ''}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(text).then(() => {
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, 1500)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.jobPopTitle}>
|
||||
<StateDot state={jobDotState(job.status)} size={6} />
|
||||
<span className={css.popTitle} title={job.label}>{job.label}</span>
|
||||
<Tag tone={jobTone(job)}>{jobStatusLabel(job.status, t)}</Tag>
|
||||
</div>
|
||||
<div className={css.jobPopMeta}>
|
||||
<Tag tone="quiet">{job.kind}</Tag>
|
||||
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
|
||||
<span className={css.popHint}>{t('jobDragHint')}</span>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.popHint}>{t('loading')}</div>}
|
||||
{state === 'error' && <div className={css.popError}>{t('jobOutputError')}</div>}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.popPre}>{state.text}</pre>
|
||||
? <pre ref={preRef} className={css.popPre} data-popover-no-drag>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.popHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.popHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.popHint}>{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
<div className={css.jobPopActions} data-popover-no-drag>
|
||||
<Switch
|
||||
checked={follow}
|
||||
onChange={setFollow}
|
||||
label={t('jobFollowTail')}
|
||||
disabled={!live}
|
||||
/>
|
||||
{live && (
|
||||
<Button
|
||||
variant={armed ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
className={armed ? css.jobPopKillArmed : undefined}
|
||||
icon={<IconStopFill16 size={11} />}
|
||||
disabled={killing}
|
||||
onClick={() => {
|
||||
if (armed) void kill()
|
||||
else setArmed(true)
|
||||
}}
|
||||
>
|
||||
{armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{killFailed && <div className={css.popError}>{t('jobKillError')}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -198,9 +198,10 @@ export function SubagentView(props: {
|
||||
live,
|
||||
runs,
|
||||
teamMembers,
|
||||
teamTasks: teamView?.available === true ? teamView.team?.tasks ?? [] : [],
|
||||
folded,
|
||||
})),
|
||||
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, folded],
|
||||
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, teamView, folded],
|
||||
)
|
||||
|
||||
/** Catalog owners currently consuming live membership updates. */
|
||||
@@ -465,13 +466,19 @@ export function SubagentView(props: {
|
||||
<JobsDrawer
|
||||
rows={jobRows}
|
||||
agentCount={agentCount}
|
||||
openJobId={popover?.kind === 'job' ? popover.jobId : undefined}
|
||||
onOpenOutput={(row, anchor) => {
|
||||
setPopover(popover?.kind === 'job' && popover.jobId === row.job.id
|
||||
? null
|
||||
: { kind: 'job', jobId: row.job.id, anchor })
|
||||
}}
|
||||
/>
|
||||
<AnchoredPopover anchor={popover?.anchor ?? null} onClose={closePopover}>
|
||||
<AnchoredPopover
|
||||
anchor={popover?.anchor ?? null}
|
||||
onClose={closePopover}
|
||||
draggable={popover?.kind === 'job'}
|
||||
width={popover?.kind === 'job' ? 380 : 280}
|
||||
>
|
||||
{popoverContent}
|
||||
</AnchoredPopover>
|
||||
</div>
|
||||
|
||||
+49
-39
@@ -23,11 +23,17 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
Button, IconChecklistOutline14, IconEllipsisOutline16, IconFullscreenOutline16, IconTreeCorner8x10,
|
||||
StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import { tasksEdges } from './tasks-model.ts'
|
||||
import { GRAPH_NODE_W, layoutTasksGraphForWidth, type GraphBox } from './tasks-graph-layout.ts'
|
||||
import { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx'
|
||||
import {
|
||||
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
|
||||
WorkflowGlyph, workflowMeta,
|
||||
} from './tasks-shared.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
|
||||
@@ -72,17 +78,19 @@ export function ViewModeToggle(props: {
|
||||
}): ReactNode {
|
||||
const { mode, onModeChange } = props
|
||||
const toTree = mode === 'graph'
|
||||
const label = toTree ? `▤ ${t('tasksViewTree')}` : `⌗ ${t('tasksViewGraph')}`
|
||||
const label = toTree ? t('tasksViewTree') : t('tasksViewGraph')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.controlBtn, css.controlMode)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.controlMode}
|
||||
icon={toTree ? <IconTreeCorner8x10 /> : <WorkflowGlyph size={13} />}
|
||||
aria-label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
|
||||
title={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
|
||||
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,16 +98,16 @@ export function ViewModeToggle(props: {
|
||||
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
|
||||
const { folded, onToggleFold } = props
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.controlBtn}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={clsx(css.controlBtn, folded && css.controlBtnActive)}
|
||||
icon={<IconChecklistOutline14 size={13} />}
|
||||
aria-pressed={folded}
|
||||
aria-label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
|
||||
title={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
|
||||
onClick={onToggleFold}
|
||||
>
|
||||
{folded ? '⇪' : '⇩'}
|
||||
</button>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -333,34 +341,36 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
<div className={css.controls} data-graph-controls>
|
||||
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
|
||||
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.controlBtn}
|
||||
aria-label={t('tasksZoomOut')}
|
||||
title={t('tasksZoomOut')}
|
||||
onClick={() => { zoomBy(1 / 1.2) }}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</Button>
|
||||
<span className={css.controlZoomLevel} aria-hidden="true">{Math.round(tf.k * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.controlBtn}
|
||||
aria-label={t('tasksZoomIn')}
|
||||
title={t('tasksZoomIn')}
|
||||
onClick={() => { zoomBy(1.2) }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.controlBtn}
|
||||
icon={<IconFullscreenOutline16 size={13} />}
|
||||
aria-label={t('tasksZoomFit')}
|
||||
title={t('tasksZoomFit')}
|
||||
onClick={refit}
|
||||
>
|
||||
⌂
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,25 +409,24 @@ function renderAgentNode(
|
||||
>
|
||||
<span className={css.nodeHeader}>
|
||||
<StateDot state={nodeDotState(node.state)} size={6} className={css.nodeDot} />
|
||||
<span className={css.nodeTitle} title={node.label}>
|
||||
{node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label}
|
||||
</span>
|
||||
<span className={css.nodeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span>
|
||||
<span className={css.nodeTitle} title={node.label}>{node.label}</span>
|
||||
</span>
|
||||
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span>
|
||||
{node.state === 'running' && <LiveLine live={node.live} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.treeInfo}
|
||||
style={{ position: 'absolute', top: 3, right: 3 }}
|
||||
aria-label={t('tasksNodeState')}
|
||||
title={t('tasksNodeState')}
|
||||
<TaskLine tasks={node.tasks} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.nodeInfo}
|
||||
icon={<IconEllipsisOutline16 size={12} />}
|
||||
aria-label={t('tasksNodeDetail')}
|
||||
title={t('tasksNodeDetail')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onNodeInfo(node, event.currentTarget)
|
||||
}}
|
||||
>
|
||||
i
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -445,7 +454,8 @@ function renderWorkflowNode(
|
||||
>
|
||||
<span className={css.nodeHeader}>
|
||||
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} className={css.nodeDot} />
|
||||
<span className={css.nodeTitle} title={node.run.name}>{`▶ ${node.run.name}`}</span>
|
||||
<span className={css.nodeGlyph} aria-hidden="true"><WorkflowGlyph /></span>
|
||||
<span className={css.nodeTitle} title={node.run.name}>{node.run.name}</span>
|
||||
</span>
|
||||
<span className={css.nodeMeta}>{workflowMeta(node)}</span>
|
||||
</div>
|
||||
@@ -474,13 +484,13 @@ function renderFoldNode(
|
||||
}}
|
||||
>
|
||||
<span className={css.nodeHeader}>
|
||||
<StateDot state="done" size={6} className={css.nodeDot} />
|
||||
<span className={css.nodeGlyph} aria-hidden="true"><FoldGlyph /></span>
|
||||
<span className={clsx(css.nodeTitle, css.nodeTitlePlain)}>
|
||||
{t('tasksFoldCompleted', { count: node.count })}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.nodeMeta} title={foldPreviews(node.previews)}>
|
||||
{`⇪ ${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
|
||||
{`${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, IconRightUpOutline14, StateDot, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarSubagentAddress } from '../context-types.ts'
|
||||
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import { flatten, nodeDotState, workflowStatusKey } from './tasks-shared.tsx'
|
||||
import {
|
||||
flatten, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
|
||||
} from './tasks-shared.tsx'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
|
||||
@@ -80,10 +82,33 @@ export function AgentNodePopover(props: {
|
||||
{liveText !== undefined && liveTool !== undefined && (
|
||||
<div className={css.popHint}>{liveText}</div>
|
||||
)}
|
||||
{(node.tasks?.length ?? 0) > 0 && (
|
||||
<div className={css.popSection}>
|
||||
<div className={css.popGroup}>{t('tasksNodeTasks')}</div>
|
||||
<div className={css.popList}>
|
||||
{(node.tasks ?? []).map(task => (
|
||||
<div key={task.id} className={css.popListRow} title={task.subject}>
|
||||
<StateDot state={taskDotState(task)} size={6} />
|
||||
<span className={css.popListLabel}>{task.subject}</span>
|
||||
<Tag tone={task.status === 'completed' ? 'success' : task.ready ? 'info' : 'warning'}>
|
||||
{t(taskStatusKey(task.status))}
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(node.childAddress !== undefined || node.parentId === undefined) && (
|
||||
<button type="button" className={css.popPrimary} onClick={() => { onJump(node) }}>
|
||||
{t('tasksNodeJump')}
|
||||
</button>
|
||||
<div className={css.popFoot}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<IconRightUpOutline14 size={12} />}
|
||||
onClick={() => { onJump(node) }}
|
||||
>
|
||||
{t('tasksNodeJump')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+23
-16
@@ -7,9 +7,12 @@
|
||||
*/
|
||||
import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, IconChevronUpOutline14, IconEllipsisOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx'
|
||||
import {
|
||||
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
|
||||
WorkflowGlyph, workflowMeta,
|
||||
} from './tasks-shared.tsx'
|
||||
import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
@@ -94,12 +97,12 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
onClick={onToggleFold}
|
||||
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
|
||||
>
|
||||
<StateDot state="done" size={6} />
|
||||
<span className={css.treeGlyph} aria-hidden="true"><FoldGlyph /></span>
|
||||
<span className={css.treeContent}>
|
||||
<span className={css.treeTitle}>
|
||||
{t('tasksFoldCompleted', { count: node.count })}
|
||||
<span className={css.treeMeta}>
|
||||
{`${folded ? '⇪' : '⇩'} ${t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}`}
|
||||
{t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.treeMeta}>{foldPreviews(node.previews)}</span>
|
||||
@@ -123,8 +126,9 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
}}
|
||||
>
|
||||
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} />
|
||||
<span className={css.treeGlyph} aria-hidden="true"><WorkflowGlyph /></span>
|
||||
<span className={css.treeContent}>
|
||||
<span className={css.treeTitle}>{`▶ ${node.run.name}`}</span>
|
||||
<span className={css.treeTitle}>{node.run.name}</span>
|
||||
<span className={css.treeMeta}>{workflowMeta(node)}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -147,25 +151,25 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
|
||||
>
|
||||
<StateDot state={nodeDotState(node.state)} size={6} />
|
||||
<span className={css.treeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span>
|
||||
<span className={css.treeContent}>
|
||||
<span className={css.treeTitle}>
|
||||
{node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label}
|
||||
</span>
|
||||
<span className={css.treeTitle}>{node.label}</span>
|
||||
<span className={css.treeMeta}>{agentMeta(node)}</span>
|
||||
{node.state === 'running' && <LiveLine live={node.live} />}
|
||||
<TaskLine tasks={node.tasks} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.treeInfo}
|
||||
aria-label={t('tasksNodeState')}
|
||||
title={t('tasksNodeState')}
|
||||
icon={<IconEllipsisOutline16 size={12} />}
|
||||
aria-label={t('tasksNodeDetail')}
|
||||
title={t('tasksNodeDetail')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onNodeInfo(node, event.currentTarget)
|
||||
}}
|
||||
>
|
||||
i
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
if (children.length === 0) return <div key={node.id}>{row}</div>
|
||||
@@ -191,7 +195,10 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
onKeyDown={onTreeKeyDown}
|
||||
>
|
||||
{loading === true && roots.length === 0 && (
|
||||
<div className={css.viewEmptyHint}>{t('loading')}</div>
|
||||
<div className={css.viewEmptyHint}>
|
||||
<span className={css.treeGlyph} aria-hidden="true"><IconChevronUpOutline14 size={12} /></span>
|
||||
{t('loading')}
|
||||
</div>
|
||||
)}
|
||||
{roots.map(root => renderNode(root, 0))}
|
||||
</div>
|
||||
|
||||
+296
-228
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* The Agent Teams board — ALWAYS VISIBLE under the page header whenever the
|
||||
* tree's root leads a team (the explicit review ask: the board must not hide
|
||||
* behind a chip). It is a full-width strip, not a side rail, because the
|
||||
* native sidebar is narrow (~360px): members wrap as chips and the task list
|
||||
* scrolls inside a bounded height, so the canvas keeps its room.
|
||||
* tree's root leads a team, plus the task editor dialog. Every control is a
|
||||
* host primitive (Menu / Modal / Input / Button / Pill / Tag / StateDot); the
|
||||
* page ships no native form control of its own.
|
||||
*
|
||||
* Mutations are CAS: every action sends the task's CURRENT revision; a
|
||||
* Layout: the strip is full width because the native sidebar is narrow — a
|
||||
* member row of Pills (also the owner filter), then task rows (subject +
|
||||
* owner + status Tag + ONE overflow Menu). Editing opens a Modal with real
|
||||
* Inputs — that is where the "too narrow to use" complaint is answered.
|
||||
*
|
||||
* Mutations are CAS: each action sends the task's CURRENT revision; a
|
||||
* conflict surfaces as a note and re-syncs through the parent's poller.
|
||||
*/
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconChecklistOutline14, IconCheckOutline14, IconChevronUpOutline14, IconEditOutline16,
|
||||
IconEllipsisOutline16, IconPlusOutline16, IconRefreshOutline14, IconTrashOutline16,
|
||||
IconUserOutline16, Input, Menu, Modal, Pill, StateDot, Tag,
|
||||
type MenuEntry, type TagTone,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
|
||||
import { api } from './api.ts'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
@@ -26,9 +34,25 @@ function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
|
||||
}
|
||||
}
|
||||
|
||||
/** The status Tag tone of one task. */
|
||||
function taskTone(task: SidebarTeamTaskView): TagTone {
|
||||
if (task.status === 'completed') return 'success'
|
||||
if (!task.ready) return 'warning'
|
||||
return task.status === 'in_progress' ? 'info' : 'neutral'
|
||||
}
|
||||
|
||||
/** One mutation outcome (the host route's own union). */
|
||||
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** The task being created or edited (undefined = dialog closed). */
|
||||
interface TaskDraft {
|
||||
/** The task under edit; undefined = a new task. */
|
||||
task: SidebarTeamTaskView | undefined
|
||||
subject: string
|
||||
description: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export interface TeamBoardProps {
|
||||
rootId: string
|
||||
members: readonly SidebarTeamMemberView[]
|
||||
@@ -44,11 +68,17 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
const { rootId, members, tasks, onChanged, collapsed, onToggleCollapsed } = props
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [note, setNote] = useState<string | undefined>(undefined)
|
||||
const [editingId, setEditingId] = useState<string | undefined>(undefined)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [draft, setDraft] = useState<TaskDraft | undefined>(undefined)
|
||||
const [menuTaskId, setMenuTaskId] = useState<string | undefined>(undefined)
|
||||
const [armedDeleteId, setArmedDeleteId] = useState<string | undefined>(undefined)
|
||||
const [ownerFilter, setOwnerFilter] = useState<string | undefined>(undefined)
|
||||
|
||||
const open = tasks.filter(task => task.status !== 'deleted')
|
||||
const teammates = useMemo(() => members.filter(member => member.role === 'teammate'), [members])
|
||||
const live = useMemo(() => tasks.filter(task => task.status !== 'deleted'), [tasks])
|
||||
const shown = useMemo(
|
||||
() => live.filter(task => ownerFilter === undefined || task.ownerName === ownerFilter),
|
||||
[live, ownerFilter],
|
||||
)
|
||||
|
||||
/** Run one mutation with the shared busy/conflict handling. */
|
||||
const mutate = async (action: () => Promise<MutationResult>): Promise<void> => {
|
||||
@@ -70,7 +100,117 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
const teammates = members.filter(member => member.role === 'teammate')
|
||||
/** The overflow menu of one task (actions per status). */
|
||||
const menuItems = (task: SidebarTeamTaskView): MenuEntry[] => {
|
||||
const entries: MenuEntry[] = []
|
||||
if (task.status === 'completed') {
|
||||
entries.push({ id: 'reopen', label: t('teamTaskReopen'), icon: <IconRefreshOutline14 size={14} /> })
|
||||
} else {
|
||||
entries.push({ id: 'complete', label: t('teamTaskComplete'), icon: <IconCheckOutline14 size={14} /> })
|
||||
}
|
||||
entries.push({ id: 'edit', label: t('teamTaskEdit'), icon: <IconEditOutline16 size={14} /> })
|
||||
if (teammates.length > 0) {
|
||||
entries.push({
|
||||
id: 'reassign',
|
||||
label: t('teamTaskOwner'),
|
||||
icon: <IconUserOutline16 size={14} />,
|
||||
submenu: [
|
||||
{ id: 'reassign:', label: t('teamTaskUnowned') },
|
||||
...teammates.map(member => ({
|
||||
id: `reassign:${member.name}`,
|
||||
label: member.name,
|
||||
...(task.ownerName === member.name ? { disabled: true } : {}),
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
entries.push({ type: 'separator', id: 'team-task-sep' })
|
||||
entries.push({
|
||||
id: 'delete',
|
||||
label: armedDeleteId === task.id ? t('teamTaskDeleteConfirm') : t('teamTaskDelete'),
|
||||
icon: <IconTrashOutline16 size={14} />,
|
||||
danger: true,
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Dispatch one overflow-menu pick. */
|
||||
const onMenuSelect = (task: SidebarTeamTaskView, id: string): void => {
|
||||
if (id === 'complete') {
|
||||
setMenuTaskId(undefined)
|
||||
void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'complete',
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (id === 'reopen') {
|
||||
setMenuTaskId(undefined)
|
||||
void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (id === 'edit') {
|
||||
setMenuTaskId(undefined)
|
||||
setDraft({ task, subject: task.subject, description: task.description, owner: task.ownerName ?? '' })
|
||||
return
|
||||
}
|
||||
if (id === 'delete') {
|
||||
// Two-step: the first pick arms the label, the second deletes.
|
||||
if (armedDeleteId !== task.id) {
|
||||
setArmedDeleteId(task.id)
|
||||
return
|
||||
}
|
||||
setArmedDeleteId(undefined)
|
||||
setMenuTaskId(undefined)
|
||||
void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'delete',
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (id.startsWith('reassign:')) {
|
||||
const owner = id.slice('reassign:'.length)
|
||||
setMenuTaskId(undefined)
|
||||
setArmedDeleteId(undefined)
|
||||
void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id,
|
||||
expectedRevision: task.revision,
|
||||
action: 'reassign',
|
||||
...(owner === '' ? {} : { owner }),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit the create/edit dialog (subject/description, then the owner). */
|
||||
const saveDraft = async (): Promise<void> => {
|
||||
const current = draft
|
||||
if (current === undefined) return
|
||||
const subject = current.subject.trim()
|
||||
if (subject === '') return
|
||||
await mutate(async () => {
|
||||
const result = current.task === undefined
|
||||
? await api.teamsTaskCreate(rootId, { subject, description: current.description.trim() })
|
||||
: await api.teamsTaskUpdate(rootId, {
|
||||
taskId: current.task.id,
|
||||
expectedRevision: current.task.revision,
|
||||
action: 'edit',
|
||||
subject,
|
||||
description: current.description.trim(),
|
||||
})
|
||||
if (!result.ok) return result
|
||||
if (current.task !== undefined && current.owner !== (current.task.ownerName ?? '')) {
|
||||
const moved = await api.teamsTaskUpdate(rootId, {
|
||||
taskId: current.task.id,
|
||||
expectedRevision: result.value.revision,
|
||||
action: 'reassign',
|
||||
...(current.owner === '' ? {} : { owner: current.owner }),
|
||||
})
|
||||
if (!moved.ok) return moved
|
||||
}
|
||||
setDraft(undefined)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.teamBoard} aria-label={t('teamBoard')}>
|
||||
@@ -80,9 +220,10 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
aria-expanded={!collapsed}
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<span className={css.teamBoardIcon} aria-hidden="true"><IconChecklistOutline14 size={11} /></span>
|
||||
<span>{t('teamBoard')}</span>
|
||||
<span className={css.teamBoardCount}>
|
||||
{t('teamChip', { members: members.length, tasks: open.length })}
|
||||
{t('teamChip', { members: members.length, tasks: live.length })}
|
||||
</span>
|
||||
<span
|
||||
className={css.teamBoardChev}
|
||||
@@ -95,11 +236,22 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className={css.teamMembers}>
|
||||
<Pill
|
||||
active={ownerFilter === undefined}
|
||||
className={css.teamMemberPill}
|
||||
onClick={() => { setOwnerFilter(undefined) }}
|
||||
>
|
||||
{t('teamFilterAll')}
|
||||
</Pill>
|
||||
{members.map(member => (
|
||||
<span
|
||||
<Pill
|
||||
key={member.id}
|
||||
className={clsx(css.teamMember, member.role === 'lead' && css.teamMemberLead)}
|
||||
active={ownerFilter === member.name}
|
||||
className={css.teamMemberPill}
|
||||
title={`${member.name} · ${member.role}`}
|
||||
onClick={() => {
|
||||
setOwnerFilter(current => (current === member.name ? undefined : member.name))
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
size={6}
|
||||
@@ -108,226 +260,142 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
: member.status === 'failed' ? 'error' : 'idle'}
|
||||
/>
|
||||
<span className={css.teamMemberName}>{member.name}</span>
|
||||
</span>
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
{open.length === 0 && !creating && <div className={css.teamEmpty}>{t('teamTasksEmpty')}</div>}
|
||||
{open.length > 0 && (
|
||||
<div className={css.teamTasks}>
|
||||
{open.map(task => (
|
||||
<TeamTaskRow
|
||||
key={task.id}
|
||||
rootId={rootId}
|
||||
task={task}
|
||||
teammates={teammates}
|
||||
busy={busy}
|
||||
editing={editingId === task.id}
|
||||
armedDelete={armedDeleteId === task.id}
|
||||
onEdit={(edit) => { setEditingId(edit ? task.id : undefined) }}
|
||||
onArmDelete={(armed) => { setArmedDeleteId(armed ? task.id : undefined) }}
|
||||
mutate={mutate}
|
||||
<div className={css.teamTasks}>
|
||||
{shown.length === 0 && <div className={css.teamEmpty}>{t('teamTasksEmpty')}</div>}
|
||||
{shown.map(task => (
|
||||
<div key={task.id} className={css.teamTask}>
|
||||
<StateDot
|
||||
size={6}
|
||||
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{note !== undefined && <div className={css.teamEmpty}>{note}</div>}
|
||||
{creating
|
||||
? (
|
||||
<TeamTaskForm
|
||||
busy={busy}
|
||||
submitLabel={t('teamTaskCreate')}
|
||||
onCancel={() => { setCreating(false) }}
|
||||
onSubmit={(subject, description) => void mutate(async () => {
|
||||
const result = await api.teamsTaskCreate(rootId, { subject, description })
|
||||
if (result.ok) setCreating(false)
|
||||
return result
|
||||
})}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={css.teamFormActions} style={{ padding: '0 12px 8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.teamBtn}
|
||||
disabled={busy}
|
||||
onClick={() => { setCreating(true) }}
|
||||
<span
|
||||
className={css.teamTaskSubject}
|
||||
title={`${task.subject}${task.description === '' ? '' : `\n${task.description}`}`}
|
||||
>
|
||||
{`+ ${t('teamTaskCreate')}`}
|
||||
</button>
|
||||
{task.subject}
|
||||
</span>
|
||||
{task.ownerName !== undefined && (
|
||||
<span className={css.teamTaskOwner}>{task.ownerName}</span>
|
||||
)}
|
||||
<Tag tone={taskTone(task)}>
|
||||
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
|
||||
</Tag>
|
||||
<Menu
|
||||
open={menuTaskId === task.id}
|
||||
onClose={() => {
|
||||
setMenuTaskId(undefined)
|
||||
setArmedDeleteId(undefined)
|
||||
}}
|
||||
items={menuItems(task)}
|
||||
onSelect={(id) => { onMenuSelect(task, id) }}
|
||||
align="end"
|
||||
portal
|
||||
compact
|
||||
anchor={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<IconEllipsisOutline16 size={13} />}
|
||||
aria-label={`${t('teamTaskActions')} ${task.subject}`}
|
||||
title={t('teamTaskActions')}
|
||||
onClick={() => {
|
||||
setArmedDeleteId(undefined)
|
||||
setMenuTaskId(current => (current === task.id ? undefined : task.id))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{note !== undefined && <div className={css.teamNote}>{note}</div>}
|
||||
<div className={css.teamActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<IconPlusOutline16 size={13} />}
|
||||
disabled={busy}
|
||||
onClick={() => { setDraft({ task: undefined, subject: '', description: '', owner: '' }) }}
|
||||
>
|
||||
{t('teamTaskCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Modal
|
||||
open={draft !== undefined}
|
||||
onClose={() => { setDraft(undefined) }}
|
||||
title={draft?.task === undefined ? t('teamTaskCreate') : t('teamTaskEdit')}
|
||||
closeLabel={t('teamTaskCancel')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => { setDraft(undefined) }}>
|
||||
{t('teamTaskCancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || (draft?.subject.trim() ?? '') === ''}
|
||||
onClick={() => { void saveDraft() }}
|
||||
>
|
||||
{draft?.task === undefined ? t('teamTaskCreate') : t('teamTaskSave')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.taskForm}>
|
||||
<label className={css.taskField}>
|
||||
<span className={css.taskLabel}>{t('teamTaskSubject')}</span>
|
||||
<Input
|
||||
value={draft?.subject ?? ''}
|
||||
placeholder={t('teamTaskSubjectPlaceholder')}
|
||||
aria-label={t('teamTaskSubject')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setDraft(current => (current === undefined ? current : { ...current, subject: value }))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.taskField}>
|
||||
<span className={css.taskLabel}>{t('teamTaskDescription')}</span>
|
||||
<Input
|
||||
value={draft?.description ?? ''}
|
||||
placeholder={t('teamTaskDescriptionPlaceholder')}
|
||||
aria-label={t('teamTaskDescription')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setDraft(current => (current === undefined ? current : { ...current, description: value }))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.taskField}>
|
||||
<span className={css.taskLabel}>{t('teamTaskOwner')}</span>
|
||||
<div className={css.taskOwnerRow}>
|
||||
<Pill
|
||||
active={(draft?.owner ?? '') === ''}
|
||||
onClick={() => {
|
||||
setDraft(current => (current === undefined ? current : { ...current, owner: '' }))
|
||||
}}
|
||||
>
|
||||
{t('teamTaskUnowned')}
|
||||
</Pill>
|
||||
{teammates.map(member => (
|
||||
<Pill
|
||||
key={member.id}
|
||||
active={draft?.owner === member.name}
|
||||
onClick={() => {
|
||||
setDraft(current => (current === undefined ? current : { ...current, owner: member.name }))
|
||||
}}
|
||||
>
|
||||
{member.name}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** One task row: status dot, subject, owner select, status chip, actions. */
|
||||
function TeamTaskRow(props: {
|
||||
rootId: string
|
||||
task: SidebarTeamTaskView
|
||||
teammates: readonly SidebarTeamMemberView[]
|
||||
busy: boolean
|
||||
editing: boolean
|
||||
armedDelete: boolean
|
||||
onEdit(edit: boolean): void
|
||||
onArmDelete(armed: boolean): void
|
||||
mutate(action: () => Promise<MutationResult>): Promise<void>
|
||||
}): ReactNode {
|
||||
const { rootId, task, teammates, busy, editing, armedDelete, onEdit, onArmDelete, mutate } = props
|
||||
if (editing) {
|
||||
return (
|
||||
<TeamTaskForm
|
||||
busy={busy}
|
||||
initialSubject={task.subject}
|
||||
initialDescription={task.description}
|
||||
submitLabel={t('teamTaskSave')}
|
||||
onCancel={() => { onEdit(false) }}
|
||||
onSubmit={(subject, description) => void mutate(async () => {
|
||||
const result = await api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id,
|
||||
expectedRevision: task.revision,
|
||||
action: 'edit',
|
||||
subject,
|
||||
description,
|
||||
})
|
||||
if (result.ok) onEdit(false)
|
||||
return result
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={css.teamTask}>
|
||||
<StateDot size={6} state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'} />
|
||||
<span className={css.teamTaskSubject} title={`${task.subject}${task.description === '' ? '' : `\n${task.description}`}`}>
|
||||
{task.subject}
|
||||
</span>
|
||||
<select
|
||||
className={css.teamOwnerSelect}
|
||||
aria-label={t('teamTaskOwner')}
|
||||
title={t('teamTaskOwner')}
|
||||
disabled={busy || task.status === 'completed'}
|
||||
value={task.ownerName ?? ''}
|
||||
onChange={(event) => {
|
||||
const owner = event.target.value
|
||||
void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id,
|
||||
expectedRevision: task.revision,
|
||||
action: 'reassign',
|
||||
...(owner === '' ? {} : { owner }),
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<option value="">{t('teamTaskUnowned')}</option>
|
||||
{teammates.map(member => (
|
||||
<option key={member.id} value={member.name}>{member.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className={clsx(css.teamTaskStatus, !task.ready && css.teamTaskBlocked)}>
|
||||
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
|
||||
</span>
|
||||
<span className={css.teamActions}>
|
||||
{task.status !== 'completed'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.teamBtn}
|
||||
disabled={busy}
|
||||
title={t('teamTaskComplete')}
|
||||
aria-label={`${t('teamTaskComplete')} ${task.subject}`}
|
||||
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'complete',
|
||||
}))}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.teamBtn}
|
||||
disabled={busy}
|
||||
title={t('teamTaskReopen')}
|
||||
aria-label={`${t('teamTaskReopen')} ${task.subject}`}
|
||||
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
|
||||
}))}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.teamBtn}
|
||||
disabled={busy}
|
||||
title={t('teamTaskEdit')}
|
||||
aria-label={`${t('teamTaskEdit')} ${task.subject}`}
|
||||
onClick={() => { onEdit(true) }}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.teamBtn, armedDelete && css.teamBtnDanger)}
|
||||
disabled={busy}
|
||||
title={armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')}
|
||||
aria-label={`${armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')} ${task.subject}`}
|
||||
onClick={() => {
|
||||
if (!armedDelete) { onArmDelete(true); return }
|
||||
onArmDelete(false)
|
||||
void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'delete',
|
||||
}))
|
||||
}}
|
||||
>
|
||||
{armedDelete ? '!' : '✕'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The create/edit task form (subject + description). */
|
||||
function TeamTaskForm(props: {
|
||||
busy: boolean
|
||||
initialSubject?: string
|
||||
initialDescription?: string
|
||||
submitLabel: string
|
||||
onCancel(): void
|
||||
onSubmit(subject: string, description: string): void
|
||||
}): ReactNode {
|
||||
const [subject, setSubject] = useState(props.initialSubject ?? '')
|
||||
const [description, setDescription] = useState(props.initialDescription ?? '')
|
||||
return (
|
||||
<div className={css.teamForm}>
|
||||
<input
|
||||
className={css.teamInput}
|
||||
placeholder={t('teamTaskSubject')}
|
||||
aria-label={t('teamTaskSubject')}
|
||||
value={subject}
|
||||
onChange={(event) => { setSubject(event.target.value) }}
|
||||
/>
|
||||
<input
|
||||
className={css.teamInput}
|
||||
placeholder={t('teamTaskDescription')}
|
||||
aria-label={t('teamTaskDescription')}
|
||||
value={description}
|
||||
onChange={(event) => { setDescription(event.target.value) }}
|
||||
/>
|
||||
<div className={css.teamFormActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.teamBtn}
|
||||
disabled={props.busy || subject.trim() === ''}
|
||||
onClick={() => { props.onSubmit(subject.trim(), description.trim()) }}
|
||||
>
|
||||
{props.submitLabel}
|
||||
</button>
|
||||
<button type="button" className={css.teamBtn} disabled={props.busy} onClick={props.onCancel}>
|
||||
{t('teamTaskCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -443,6 +443,15 @@ export const ar: Record<string, string> = {
|
||||
teamTaskConflict: 'تم تعديل المهمة من جهة أخرى، تم التحديث',
|
||||
teamTaskError: 'فشلت العملية: {message}',
|
||||
teamTaskDeleted: 'محذوفة',
|
||||
teamTaskActions: 'إجراءات المهمة',
|
||||
teamTaskSubjectPlaceholder: 'سطر واحد يصف العمل',
|
||||
teamTaskDescriptionPlaceholder: 'تفاصيل إضافية (اختياري)',
|
||||
teamFilterAll: 'الكل',
|
||||
tasksNodeTasks: 'المهام المشتركة',
|
||||
jobCopyOutput: 'نسخ الإخراج',
|
||||
jobCopied: 'تم النسخ',
|
||||
jobFollowTail: 'متابعة الأحدث',
|
||||
jobDragHint: 'اسحب للتحريك',
|
||||
workflowRunning: 'قيد التشغيل',
|
||||
workflowCompleted: 'مكتمل',
|
||||
workflowCancelled: 'أُلغي',
|
||||
|
||||
@@ -428,6 +428,15 @@ export const de: Record<string, string> = {
|
||||
teamTaskConflict: 'Die Aufgabe wurde anderweitig geändert; aktualisiert',
|
||||
teamTaskError: 'Vorgang fehlgeschlagen: {message}',
|
||||
teamTaskDeleted: 'Gelöscht',
|
||||
teamTaskActions: 'Aufgabenaktionen',
|
||||
teamTaskSubjectPlaceholder: 'Eine Zeile, die die Arbeit beschreibt',
|
||||
teamTaskDescriptionPlaceholder: 'Zusätzliche Details (optional)',
|
||||
teamFilterAll: 'Alle',
|
||||
tasksNodeTasks: 'Geteilte Aufgaben',
|
||||
jobCopyOutput: 'Ausgabe kopieren',
|
||||
jobCopied: 'Kopiert',
|
||||
jobFollowTail: 'Neuestes verfolgen',
|
||||
jobDragHint: 'Zum Verschieben ziehen',
|
||||
workflowRunning: 'Läuft',
|
||||
workflowCompleted: 'Abgeschlossen',
|
||||
workflowCancelled: 'Abgebrochen',
|
||||
|
||||
@@ -435,6 +435,15 @@ export const fr: Record<string, string> = {
|
||||
teamTaskConflict: 'La tâche a été modifiée ailleurs ; actualisée',
|
||||
teamTaskError: 'Échec de l’opération : {message}',
|
||||
teamTaskDeleted: 'Supprimée',
|
||||
teamTaskActions: 'Actions sur la tâche',
|
||||
teamTaskSubjectPlaceholder: 'Décrire le travail en une ligne',
|
||||
teamTaskDescriptionPlaceholder: 'Précisions (facultatif)',
|
||||
teamFilterAll: 'Tout',
|
||||
tasksNodeTasks: 'Tâches partagées',
|
||||
jobCopyOutput: 'Copier la sortie',
|
||||
jobCopied: 'Copié',
|
||||
jobFollowTail: 'Suivre la fin',
|
||||
jobDragHint: 'Glisser pour déplacer',
|
||||
workflowRunning: 'En cours',
|
||||
workflowCompleted: 'Terminé',
|
||||
workflowCancelled: 'Annulé',
|
||||
|
||||
@@ -442,6 +442,15 @@ export const hi: Record<string, string> = {
|
||||
teamTaskConflict: 'कार्य दूसरी जगह बदल गया; ताज़ा कर दिया गया',
|
||||
teamTaskError: 'ऑपरेशन विफल: {message}',
|
||||
teamTaskDeleted: 'हटाया गया',
|
||||
teamTaskActions: 'कार्य क्रियाएँ',
|
||||
teamTaskSubjectPlaceholder: 'काम को एक पंक्ति में बताएँ',
|
||||
teamTaskDescriptionPlaceholder: 'अतिरिक्त जानकारी (वैकल्पिक)',
|
||||
teamFilterAll: 'सभी',
|
||||
tasksNodeTasks: 'साझा कार्य',
|
||||
jobCopyOutput: 'आउटपुट कॉपी',
|
||||
jobCopied: 'कॉपी हो गया',
|
||||
jobFollowTail: 'नवीनतम पर बने रहें',
|
||||
jobDragHint: 'ड्रैग करके खिसकाएँ',
|
||||
workflowRunning: 'चल रहा है',
|
||||
workflowCompleted: 'पूर्ण',
|
||||
workflowCancelled: 'रद्द किया गया',
|
||||
|
||||
@@ -440,6 +440,15 @@ export const id: Record<string, string> = {
|
||||
teamTaskConflict: 'Tugas berubah di tempat lain; sudah disegarkan',
|
||||
teamTaskError: 'Operasi gagal: {message}',
|
||||
teamTaskDeleted: 'Terhapus',
|
||||
teamTaskActions: 'Aksi tugas',
|
||||
teamTaskSubjectPlaceholder: 'Jelaskan pekerjaan dalam satu baris',
|
||||
teamTaskDescriptionPlaceholder: 'Keterangan tambahan (opsional)',
|
||||
teamFilterAll: 'Semua',
|
||||
tasksNodeTasks: 'Tugas bersama',
|
||||
jobCopyOutput: 'Salin keluaran',
|
||||
jobCopied: 'Tersalin',
|
||||
jobFollowTail: 'Ikuti baris terbaru',
|
||||
jobDragHint: 'Seret untuk memindahkan',
|
||||
workflowRunning: 'Berjalan',
|
||||
workflowCompleted: 'Selesai',
|
||||
workflowCancelled: 'Dibatalkan',
|
||||
|
||||
@@ -433,6 +433,15 @@ export const it: Record<string, string> = {
|
||||
teamTaskConflict: 'L’attività è stata modificata da altri; aggiornata',
|
||||
teamTaskError: 'Operazione non riuscita: {message}',
|
||||
teamTaskDeleted: 'Eliminata',
|
||||
teamTaskActions: 'Azioni attività',
|
||||
teamTaskSubjectPlaceholder: 'Descrivi il lavoro in una riga',
|
||||
teamTaskDescriptionPlaceholder: 'Dettagli aggiuntivi (opzionale)',
|
||||
teamFilterAll: 'Tutti',
|
||||
tasksNodeTasks: 'Attività condivise',
|
||||
jobCopyOutput: 'Copia output',
|
||||
jobCopied: 'Copiato',
|
||||
jobFollowTail: 'Segui le ultime righe',
|
||||
jobDragHint: 'Trascina per spostare',
|
||||
workflowRunning: 'In corso',
|
||||
workflowCompleted: 'Completato',
|
||||
workflowCancelled: 'Annullato',
|
||||
|
||||
@@ -442,6 +442,15 @@ export const ja: Record<string, string> = {
|
||||
teamTaskConflict: '他の場所で変更されました。最新に更新しました',
|
||||
teamTaskError: '操作に失敗: {message}',
|
||||
teamTaskDeleted: '削除済み',
|
||||
teamTaskActions: 'タスク操作',
|
||||
teamTaskSubjectPlaceholder: 'やることを一行で',
|
||||
teamTaskDescriptionPlaceholder: '補足(任意)',
|
||||
teamFilterAll: 'すべて',
|
||||
tasksNodeTasks: '共有タスク',
|
||||
jobCopyOutput: '出力をコピー',
|
||||
jobCopied: 'コピーしました',
|
||||
jobFollowTail: '最新に追従',
|
||||
jobDragHint: 'ドラッグで移動',
|
||||
workflowRunning: '進行中',
|
||||
workflowCompleted: '完了',
|
||||
workflowCancelled: 'キャンセル',
|
||||
|
||||
@@ -434,6 +434,15 @@ export const ko: Record<string, string> = {
|
||||
teamTaskConflict: '다른 사람이 작업을 수정해 새로고침했습니다',
|
||||
teamTaskError: '작업 실패: {message}',
|
||||
teamTaskDeleted: '삭제됨',
|
||||
teamTaskActions: '작업 동작',
|
||||
teamTaskSubjectPlaceholder: '할 일을 한 줄로 설명',
|
||||
teamTaskDescriptionPlaceholder: '추가 설명 (선택)',
|
||||
teamFilterAll: '모두',
|
||||
tasksNodeTasks: '공유 작업',
|
||||
jobCopyOutput: '출력 복사',
|
||||
jobCopied: '복사됨',
|
||||
jobFollowTail: '최신 따라가기',
|
||||
jobDragHint: '드래그하여 이동',
|
||||
workflowRunning: '진행 중',
|
||||
workflowCompleted: '완료됨',
|
||||
workflowCancelled: '취소됨',
|
||||
|
||||
@@ -440,6 +440,15 @@ export const nl: Record<string, string> = {
|
||||
teamTaskConflict: 'De taak is elders gewijzigd; vernieuwd',
|
||||
teamTaskError: 'Bewerking mislukt: {message}',
|
||||
teamTaskDeleted: 'Verwijderd',
|
||||
teamTaskActions: 'Taakacties',
|
||||
teamTaskSubjectPlaceholder: 'Beschrijf het werk in één regel',
|
||||
teamTaskDescriptionPlaceholder: 'Aanvullende details (optioneel)',
|
||||
teamFilterAll: 'Alles',
|
||||
tasksNodeTasks: 'Gedeelde taken',
|
||||
jobCopyOutput: 'Uitvoer kopiëren',
|
||||
jobCopied: 'Gekopieerd',
|
||||
jobFollowTail: 'Nieuwste volgen',
|
||||
jobDragHint: 'Slepen om te verplaatsen',
|
||||
workflowRunning: 'Bezig',
|
||||
workflowCompleted: 'Voltooid',
|
||||
workflowCancelled: 'Geannuleerd',
|
||||
|
||||
@@ -444,6 +444,15 @@ export const pl: Record<string, string> = {
|
||||
teamTaskConflict: 'Zadanie zostało zmienione przez kogoś innego; odświeżono',
|
||||
teamTaskError: 'Operacja nie powiodła się: {message}',
|
||||
teamTaskDeleted: 'Usunięte',
|
||||
teamTaskActions: 'Akcje zadania',
|
||||
teamTaskSubjectPlaceholder: 'Opisz pracę w jednym zdaniu',
|
||||
teamTaskDescriptionPlaceholder: 'Dodatkowe informacje (opcjonalnie)',
|
||||
teamFilterAll: 'Wszystko',
|
||||
tasksNodeTasks: 'Wspólne zadania',
|
||||
jobCopyOutput: 'Kopiuj wyjście',
|
||||
jobCopied: 'Skopiowano',
|
||||
jobFollowTail: 'Śledź najnowsze',
|
||||
jobDragHint: 'Przeciągnij, aby przenieść',
|
||||
workflowRunning: 'W toku',
|
||||
workflowCompleted: 'Ukończony',
|
||||
workflowCancelled: 'Anulowany',
|
||||
|
||||
@@ -425,6 +425,15 @@ export const pt: Record<string, string> = {
|
||||
teamTaskConflict: 'A tarefa foi alterada por outra pessoa; atualizada',
|
||||
teamTaskError: 'Operação falhou: {message}',
|
||||
teamTaskDeleted: 'Excluída',
|
||||
teamTaskActions: 'Ações da tarefa',
|
||||
teamTaskSubjectPlaceholder: 'Descreva o trabalho em uma linha',
|
||||
teamTaskDescriptionPlaceholder: 'Detalhes adicionais (opcional)',
|
||||
teamFilterAll: 'Tudo',
|
||||
tasksNodeTasks: 'Tarefas compartilhadas',
|
||||
jobCopyOutput: 'Copiar saída',
|
||||
jobCopied: 'Copiado',
|
||||
jobFollowTail: 'Acompanhar as últimas linhas',
|
||||
jobDragHint: 'Arraste para mover',
|
||||
workflowRunning: 'Em execução',
|
||||
workflowCompleted: 'Concluído',
|
||||
workflowCancelled: 'Cancelado',
|
||||
|
||||
@@ -440,6 +440,15 @@ export const ru: Record<string, string> = {
|
||||
teamTaskConflict: 'Задача изменена другим участником, обновлено',
|
||||
teamTaskError: 'Не удалось выполнить: {message}',
|
||||
teamTaskDeleted: 'Удалена',
|
||||
teamTaskActions: 'Действия с задачей',
|
||||
teamTaskSubjectPlaceholder: 'Опишите работу одной строкой',
|
||||
teamTaskDescriptionPlaceholder: 'Дополнительные сведения (необязательно)',
|
||||
teamFilterAll: 'Все',
|
||||
tasksNodeTasks: 'Общие задачи',
|
||||
jobCopyOutput: 'Копировать вывод',
|
||||
jobCopied: 'Скопировано',
|
||||
jobFollowTail: 'Следить за новыми строками',
|
||||
jobDragHint: 'Перетащите, чтобы переместить',
|
||||
workflowRunning: 'Выполняется',
|
||||
workflowCompleted: 'Завершён',
|
||||
workflowCancelled: 'Отменён',
|
||||
|
||||
@@ -425,6 +425,15 @@ export const sv: Record<string, string> = {
|
||||
teamTaskConflict: 'Uppgiften ändrades av någon annan, har uppdaterats',
|
||||
teamTaskError: 'Åtgärden misslyckades: {message}',
|
||||
teamTaskDeleted: 'Borttagen',
|
||||
teamTaskActions: 'Uppgiftsåtgärder',
|
||||
teamTaskSubjectPlaceholder: 'Beskriv arbetet på en rad',
|
||||
teamTaskDescriptionPlaceholder: 'Ytterligare information (valfritt)',
|
||||
teamFilterAll: 'Alla',
|
||||
tasksNodeTasks: 'Delade uppgifter',
|
||||
jobCopyOutput: 'Kopiera utdata',
|
||||
jobCopied: 'Kopierades',
|
||||
jobFollowTail: 'Följ senaste',
|
||||
jobDragHint: 'Dra för att flytta',
|
||||
workflowRunning: 'Körs',
|
||||
workflowCompleted: 'Slutförd',
|
||||
workflowCancelled: 'Avbruten',
|
||||
|
||||
@@ -442,6 +442,15 @@ export const th: Record<string, string> = {
|
||||
teamTaskConflict: 'งานถูกแก้ไขโดยผู้อื่น รีเฟรชแล้ว',
|
||||
teamTaskError: 'ดำเนินการไม่สำเร็จ: {message}',
|
||||
teamTaskDeleted: 'ลบแล้ว',
|
||||
teamTaskActions: 'การดำเนินการของงาน',
|
||||
teamTaskSubjectPlaceholder: 'อธิบายงานที่ต้องทำในบรรทัดเดียว',
|
||||
teamTaskDescriptionPlaceholder: 'รายละเอียดเพิ่มเติม (ไม่บังคับ)',
|
||||
teamFilterAll: 'ทั้งหมด',
|
||||
tasksNodeTasks: 'งานที่แชร์',
|
||||
jobCopyOutput: 'คัดลอกเอาต์พุต',
|
||||
jobCopied: 'คัดลอกแล้ว',
|
||||
jobFollowTail: 'ติดตามบรรทัดล่าสุด',
|
||||
jobDragHint: 'ลากเพื่อย้าย',
|
||||
workflowRunning: 'กำลังทำงาน',
|
||||
workflowCompleted: 'เสร็จสิ้น',
|
||||
workflowCancelled: 'ยกเลิกแล้ว',
|
||||
|
||||
@@ -442,6 +442,15 @@ export const tr: Record<string, string> = {
|
||||
teamTaskConflict: 'Görev başkası tarafından değiştirildi, güncellendi',
|
||||
teamTaskError: 'İşlem başarısız: {message}',
|
||||
teamTaskDeleted: 'Silindi',
|
||||
teamTaskActions: 'Görev eylemleri',
|
||||
teamTaskSubjectPlaceholder: 'İşi tek satırda açıkla',
|
||||
teamTaskDescriptionPlaceholder: 'Ek ayrıntılar (isteğe bağlı)',
|
||||
teamFilterAll: 'Tümü',
|
||||
tasksNodeTasks: 'Paylaşılan görevler',
|
||||
jobCopyOutput: 'Çıkışı kopyala',
|
||||
jobCopied: 'Kopyalandı',
|
||||
jobFollowTail: 'En sona kaydır',
|
||||
jobDragHint: 'Taşımak için sürükle',
|
||||
workflowRunning: 'Çalışıyor',
|
||||
workflowCompleted: 'Tamamlandı',
|
||||
workflowCancelled: 'İptal edildi',
|
||||
|
||||
@@ -442,6 +442,15 @@ export const vi: Record<string, string> = {
|
||||
teamTaskConflict: 'Tác vụ đã bị người khác thay đổi, đã làm mới',
|
||||
teamTaskError: 'Thao tác thất bại: {message}',
|
||||
teamTaskDeleted: 'Đã xóa',
|
||||
teamTaskActions: 'Thao tác với tác vụ',
|
||||
teamTaskSubjectPlaceholder: 'Mô tả việc cần làm trong một dòng',
|
||||
teamTaskDescriptionPlaceholder: 'Thông tin bổ sung (không bắt buộc)',
|
||||
teamFilterAll: 'Tất cả',
|
||||
tasksNodeTasks: 'Tác vụ dùng chung',
|
||||
jobCopyOutput: 'Sao chép đầu ra',
|
||||
jobCopied: 'Đã sao chép',
|
||||
jobFollowTail: 'Theo dòng mới nhất',
|
||||
jobDragHint: 'Kéo để di chuyển',
|
||||
workflowRunning: 'Đang chạy',
|
||||
workflowCompleted: 'Hoàn thành',
|
||||
workflowCancelled: 'Đã hủy',
|
||||
|
||||
@@ -457,6 +457,15 @@ export const zhHK: Record<string, string> = {
|
||||
teamTaskConflict: '任務已被他人修改,已重新整理',
|
||||
teamTaskError: '操作失敗:{message}',
|
||||
teamTaskDeleted: '已刪除',
|
||||
teamTaskActions: '任務操作',
|
||||
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
|
||||
teamTaskDescriptionPlaceholder: '補充說明(可選)',
|
||||
teamFilterAll: '全部',
|
||||
tasksNodeTasks: '共用任務',
|
||||
jobCopyOutput: '複製輸出',
|
||||
jobCopied: '已複製',
|
||||
jobFollowTail: '跟隨最新',
|
||||
jobDragHint: '拖曳浮窗',
|
||||
workflowRunning: '進行中',
|
||||
workflowCompleted: '已完成',
|
||||
workflowCancelled: '已取消',
|
||||
|
||||
@@ -457,6 +457,15 @@ export const zhMO: Record<string, string> = {
|
||||
teamTaskConflict: '任務已被他人修改,已重新整理',
|
||||
teamTaskError: '操作失敗:{message}',
|
||||
teamTaskDeleted: '已刪除',
|
||||
teamTaskActions: '任務操作',
|
||||
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
|
||||
teamTaskDescriptionPlaceholder: '補充說明(可選)',
|
||||
teamFilterAll: '全部',
|
||||
tasksNodeTasks: '共用任務',
|
||||
jobCopyOutput: '複製輸出',
|
||||
jobCopied: '已複製',
|
||||
jobFollowTail: '跟隨最新',
|
||||
jobDragHint: '拖曳浮窗',
|
||||
workflowRunning: '進行中',
|
||||
workflowCompleted: '已完成',
|
||||
workflowCancelled: '已取消',
|
||||
|
||||
@@ -457,6 +457,15 @@ export const zhTW: Record<string, string> = {
|
||||
teamTaskConflict: '任務已被他人修改,已重新整理',
|
||||
teamTaskError: '操作失敗:{message}',
|
||||
teamTaskDeleted: '已刪除',
|
||||
teamTaskActions: '任務操作',
|
||||
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
|
||||
teamTaskDescriptionPlaceholder: '補充說明(可選)',
|
||||
teamFilterAll: '全部',
|
||||
tasksNodeTasks: '共用任務',
|
||||
jobCopyOutput: '複製輸出',
|
||||
jobCopied: '已複製',
|
||||
jobFollowTail: '跟隨最新',
|
||||
jobDragHint: '拖曳浮窗',
|
||||
workflowRunning: '進行中',
|
||||
workflowCompleted: '已完成',
|
||||
workflowCancelled: '已取消',
|
||||
|
||||
@@ -454,6 +454,15 @@ export const zh = {
|
||||
teamTaskConflict: '任务已被他人修改,已刷新',
|
||||
teamTaskError: '操作失败:{message}',
|
||||
teamTaskDeleted: '已删除',
|
||||
teamTaskActions: '任务操作',
|
||||
teamTaskSubjectPlaceholder: '一句话说明要做什么',
|
||||
teamTaskDescriptionPlaceholder: '补充说明(可选)',
|
||||
teamFilterAll: '全部',
|
||||
tasksNodeTasks: '共享任务',
|
||||
jobCopyOutput: '复制输出',
|
||||
jobCopied: '已复制',
|
||||
jobFollowTail: '跟随最新',
|
||||
jobDragHint: '拖动浮窗',
|
||||
workflowRunning: '进行中',
|
||||
workflowCompleted: '已完成',
|
||||
workflowCancelled: '已取消',
|
||||
@@ -949,6 +958,15 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
teamTaskConflict: 'The task changed elsewhere; refreshed',
|
||||
teamTaskError: 'Operation failed: {message}',
|
||||
teamTaskDeleted: 'Deleted',
|
||||
teamTaskActions: 'Task actions',
|
||||
teamTaskSubjectPlaceholder: 'One line describing the work',
|
||||
teamTaskDescriptionPlaceholder: 'Extra context (optional)',
|
||||
teamFilterAll: 'All',
|
||||
tasksNodeTasks: 'Shared tasks',
|
||||
jobCopyOutput: 'Copy output',
|
||||
jobCopied: 'Copied',
|
||||
jobFollowTail: 'Follow latest',
|
||||
jobDragHint: 'Drag to move',
|
||||
workflowRunning: 'Running',
|
||||
workflowCompleted: 'Completed',
|
||||
workflowCancelled: 'Cancelled',
|
||||
|
||||
@@ -180,6 +180,19 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.nodeGlyph {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.treeGlyph {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.nodeMeta {
|
||||
margin-top: 2px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
@@ -191,6 +204,66 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Node shared-task line (team members only) ─────────────────────────── */
|
||||
|
||||
/* The node's detail affordance: a small ghost button pinned to the corner. */
|
||||
.nodeInfo {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 18px;
|
||||
padding: 0 2px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.node:hover .nodeInfo,
|
||||
.nodeInfo:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.taskLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
min-width: 0;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.3;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.taskGlyph {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.taskSubject {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.taskState {
|
||||
flex: none;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 8.5px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.taskMore {
|
||||
flex: none;
|
||||
padding: 0 3px;
|
||||
border: 1px solid var(--dsw-alias-border-l4);
|
||||
border-radius: 2px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 8.5px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* ── Live activity row (icon + tool + args; shared by both modes) ──────── */
|
||||
|
||||
.live {
|
||||
@@ -443,6 +516,49 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.popHeadActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.popFoot {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.popFoot > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jobPopTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jobPopMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 10px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.jobPopActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.popTitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -583,6 +699,34 @@
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
/* ── Task create/edit dialog ───────────────────────────────────────────── */
|
||||
|
||||
.taskForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.taskField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.taskLabel {
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.taskOwnerRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── Team board (always-visible strip under the header) ────────────────── */
|
||||
|
||||
.teamBoard {
|
||||
@@ -623,6 +767,28 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.teamBoardIcon {
|
||||
display: inline-flex;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.teamMemberPill {
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.teamNote {
|
||||
padding: 0 12px 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.teamActions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
|
||||
.teamMembers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarTeamMemberView,
|
||||
SidebarTeamTaskView,
|
||||
} from '../context-types.ts'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { WorkflowRunView } from '../workflow-runs.ts'
|
||||
@@ -33,6 +34,15 @@ import { isSideThreadSummary } from './subagent-detect.ts'
|
||||
/** Display state of one agent node (drives the dot + fold candidacy). */
|
||||
export type TasksNodeState = 'running' | 'idle' | 'done' | 'error'
|
||||
|
||||
/** One shared task as a node shows it (the board owns the full editing). */
|
||||
export interface TasksNodeTask {
|
||||
id: string
|
||||
subject: string
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
/** Not ready = blocked by another task. */
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
/** One agent node (root, subagent, teammate, or synthesized workflow member). */
|
||||
export interface TasksAgentNode {
|
||||
kind: 'agent'
|
||||
@@ -60,6 +70,8 @@ export interface TasksAgentNode {
|
||||
}
|
||||
/** The catalog's durable children signal (fold candidacy's leaf test). */
|
||||
hasChildren?: boolean
|
||||
/** Shared tasks owned by this agent (team boards only; empty otherwise). */
|
||||
tasks?: TasksNodeTask[]
|
||||
/** Synthesized from a workflow run's member row (no catalog entry). */
|
||||
synthesized?: boolean
|
||||
/** Jump target of the row (absent on the root node). */
|
||||
@@ -97,6 +109,8 @@ export interface TasksModelInput {
|
||||
live: Readonly<Record<string, LastActivity | undefined>>
|
||||
runs: readonly WorkflowRunView[]
|
||||
teamMembers: readonly SidebarTeamMemberView[]
|
||||
/** The team's shared tasks; each lands on its OWNER's node. */
|
||||
teamTasks?: readonly SidebarTeamTaskView[]
|
||||
/** Whether settled leaves collapse into fold nodes. */
|
||||
folded: boolean
|
||||
}
|
||||
@@ -138,7 +152,23 @@ function memberOutcomeState(outcome: 'completed' | 'failed' | 'cancelled' | unde
|
||||
*/
|
||||
export function buildTasksModel(input: TasksModelInput): TasksNode[] {
|
||||
const { byId, catalogs, rootId, currentSessionId, live, runs, teamMembers, folded } = input
|
||||
const teamTasks = input.teamTasks ?? []
|
||||
const teamById = new Map(teamMembers.map(member => [member.id, member]))
|
||||
/** Owner display name → node id (the board assigns by member name). */
|
||||
const nodeByOwner = new Map<string, string>()
|
||||
for (const member of teamMembers) nodeByOwner.set(member.name, member.id)
|
||||
const tasksByNode = new Map<string, TasksNodeTask[]>()
|
||||
for (const task of teamTasks) {
|
||||
if (task.status === 'deleted' || task.ownerName === undefined) continue
|
||||
const nodeId = nodeByOwner.get(task.ownerName)
|
||||
if (nodeId === undefined) continue
|
||||
const list = tasksByNode.get(nodeId)
|
||||
const entry: TasksNodeTask = {
|
||||
id: task.id, subject: task.subject, status: task.status, ready: task.ready,
|
||||
}
|
||||
if (list === undefined) tasksByNode.set(nodeId, [entry])
|
||||
else list.push(entry)
|
||||
}
|
||||
const runsByOrigin = new Map<string, WorkflowRunView[]>()
|
||||
for (const run of runs) {
|
||||
const list = runsByOrigin.get(run.originSessionId)
|
||||
@@ -183,6 +213,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
|
||||
current: entry.id === currentSessionId,
|
||||
...(live[entry.id] !== undefined ? { live: live[entry.id] } : {}),
|
||||
...(team !== undefined ? { team } : {}),
|
||||
...(tasksByNode.get(entry.id) !== undefined ? { tasks: tasksByNode.get(entry.id) } : {}),
|
||||
hasChildren: entry.hasChildren,
|
||||
childAddress: { parentSessionId: parentId, childSessionId: entry.id, mode: entry.mode },
|
||||
})
|
||||
@@ -203,6 +234,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
|
||||
: undefined
|
||||
if (existing !== undefined) {
|
||||
existing.parentId = runNode.id
|
||||
if (tasksByNode.get(existing.id) !== undefined) existing.tasks = tasksByNode.get(existing.id)
|
||||
members.push(existing)
|
||||
} else {
|
||||
const childKnown = member.childId !== ''
|
||||
@@ -215,6 +247,8 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
|
||||
activity: member.outcome === undefined ? 'running' : 'inactive',
|
||||
current: member.childId !== '' && member.childId === currentSessionId,
|
||||
synthesized: true,
|
||||
...(childKnown && tasksByNode.get(member.childId) !== undefined
|
||||
? { tasks: tasksByNode.get(member.childId) } : {}),
|
||||
...(childKnown
|
||||
? { childAddress: { parentSessionId: parentId, childSessionId: member.childId, mode: 'one-shot' as const } }
|
||||
: {}),
|
||||
@@ -298,6 +332,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
|
||||
activity: rootSummary?.running === true ? 'running' : 'inactive',
|
||||
current: rootId === currentSessionId,
|
||||
...(rootTeam !== undefined ? { team: rootTeam } : {}),
|
||||
...(tasksByNode.get(rootId) !== undefined ? { tasks: tasksByNode.get(rootId) } : {}),
|
||||
})
|
||||
appendChildren(rootId)
|
||||
return out
|
||||
|
||||
+80
-17
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* Shared presentation bits of the Tasks page's two modes (graph + tree): the
|
||||
* state-dot mapping, the SHORT mono meta line (one or two tokens — the card
|
||||
* is 132px wide in the narrow native sidebar, so anything longer moves into
|
||||
* the ⓘ popover), the iconified live activity row ("glyph + tool + args",
|
||||
* the approved format), and the fold aggregate caption.
|
||||
* Shared presentation bits of the Tasks page's two modes (graph + tree):
|
||||
* the state dot, the agent/workflow/task GLYPHS (host primitive icons — the
|
||||
* page draws no glyphs of its own), the short mono meta line, the node task
|
||||
* line, and the iconified live activity row ("tool icon + tool + args").
|
||||
*
|
||||
* Card content contract (the page's answer to "everything is ellipsized"):
|
||||
* line 1 dot + name (11px, semibold, one line)
|
||||
* line 2 mode/model · activity (9px mono, one line)
|
||||
* line 3 live line (running nodes only)
|
||||
* Everything else — display title, full model id, team role, phase names,
|
||||
* latest output text, jump action — lives in the anchored popover.
|
||||
* line 1 state dot + agent icon + name (11px semibold, one line)
|
||||
* line 2 mode/model · activity (9px mono, one line)
|
||||
* line 3 live tool line (running nodes only)
|
||||
* line 4 owned shared task (team members only)
|
||||
* Everything else lives in the popovers.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
IconAgentPresetOutline16, IconBranchOutline16, IconChecklistOutline14, IconUserOutline16,
|
||||
type StateDotState,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { TasksAgentNode, TasksNodeState, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import type { TasksAgentNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import { toolGlyph } from './tool-icons.tsx'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
|
||||
/** Preview cap of one tool-call argument line. */
|
||||
const ARGS_PREVIEW = 42
|
||||
const ARGS_PREVIEW = 48
|
||||
/** Preview cap of one text line. */
|
||||
const TEXT_PREVIEW = 72
|
||||
|
||||
@@ -45,6 +47,21 @@ export function stateLabel(state: TasksNodeState): string {
|
||||
return t(key)
|
||||
}
|
||||
|
||||
/** The task status label key. */
|
||||
export function taskStatusKey(status: TasksNodeTask['status']): CopyKey {
|
||||
switch (status) {
|
||||
case 'pending': return 'teamTaskPending'
|
||||
case 'in_progress': return 'teamTaskInProgress'
|
||||
case 'completed': return 'teamTaskCompleted'
|
||||
}
|
||||
}
|
||||
|
||||
/** The host StateDot semantic of a task row/line. */
|
||||
export function taskDotState(task: TasksNodeTask): StateDotState {
|
||||
if (task.status === 'completed') return 'done'
|
||||
return task.ready ? 'ongoing' : 'warning'
|
||||
}
|
||||
|
||||
/** First `limit` characters with an ellipsis when truncated. */
|
||||
export function preview(text: string, limit: number = ARGS_PREVIEW): string {
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text
|
||||
@@ -55,6 +72,23 @@ export function flatten(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** The host icon of one agent node (the lead and plain children share one). */
|
||||
export function AgentGlyph(props: { node: TasksAgentNode; size?: number }): ReactNode {
|
||||
const size = props.size ?? 11
|
||||
if (props.node.team?.role === 'teammate') return <IconUserOutline16 size={size} />
|
||||
return <IconAgentPresetOutline16 size={size} />
|
||||
}
|
||||
|
||||
/** The workflow-run glyph. */
|
||||
export function WorkflowGlyph(props: { size?: number }): ReactNode {
|
||||
return <IconBranchOutline16 size={props.size ?? 11} />
|
||||
}
|
||||
|
||||
/** The fold aggregate glyph. */
|
||||
export function FoldGlyph(props: { size?: number }): ReactNode {
|
||||
return <IconChecklistOutline14 size={props.size ?? 11} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The mono meta line of an agent card — at most two short tokens:
|
||||
* - the topology root: 主代理 · 状态
|
||||
@@ -97,10 +131,39 @@ export function workflowStatusKey(status: TasksWorkflowNode['run']['status']): C
|
||||
}
|
||||
}
|
||||
|
||||
/** The task a node surfaces first: in-progress, else pending, else the last. */
|
||||
export function primaryTask(tasks: readonly TasksNodeTask[]): TasksNodeTask | undefined {
|
||||
return tasks.find(task => task.status === 'in_progress')
|
||||
?? tasks.find(task => task.status === 'pending')
|
||||
?? tasks[tasks.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* The live activity row of a RUNNING agent: "glyph + tool + args" (the
|
||||
* approved format), plus the flattened last text line underneath. A running
|
||||
* node with neither reads as thinking.
|
||||
* The node's shared-task line: an icon, the primary task's subject, its
|
||||
* status word, and a `+N` tail when the agent owns more. Renders nothing
|
||||
* without tasks, so non-team nodes keep the three-line shape.
|
||||
*/
|
||||
export function TaskLine(props: { tasks: readonly TasksNodeTask[] | undefined }): ReactNode {
|
||||
const tasks = props.tasks ?? []
|
||||
const primary = primaryTask(tasks)
|
||||
if (primary === undefined) return null
|
||||
return (
|
||||
<span
|
||||
className={css.taskLine}
|
||||
title={tasks.map(task => `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')}
|
||||
>
|
||||
<span className={css.taskGlyph} aria-hidden="true"><IconChecklistOutline14 size={9} /></span>
|
||||
<span className={css.taskSubject}>{primary.subject}</span>
|
||||
<span className={css.taskState}>{t(taskStatusKey(primary.status))}</span>
|
||||
{tasks.length > 1 && <span className={css.taskMore}>{`+${tasks.length - 1}`}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The live activity row of a RUNNING agent: the tool's own icon + tool name +
|
||||
* args (the approved format), plus the flattened last text line underneath.
|
||||
* A running node with neither reads as thinking.
|
||||
*/
|
||||
export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
|
||||
const { live } = props
|
||||
@@ -111,7 +174,7 @@ export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
|
||||
<>
|
||||
{live.tool !== undefined && (
|
||||
<span className={css.live}>
|
||||
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(8)}</span>
|
||||
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(9)}</span>
|
||||
<span className={css.liveTool}>{live.tool.name}</span>
|
||||
{live.tool.args !== '' && <span className={css.liveArgs}>{preview(live.tool.args)}</span>}
|
||||
</span>
|
||||
|
||||
+133
-3
@@ -54,14 +54,33 @@ function jsonResponse(value: unknown): Response {
|
||||
}
|
||||
|
||||
let teamPayload: unknown = { available: false }
|
||||
const teamMutations: Array<{ method: string; body: Record<string, unknown> }> = []
|
||||
const fetchedMethods: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
teamPayload = { available: false }
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request) => {
|
||||
teamMutations.length = 0
|
||||
fetchedMethods.length = 0
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
fetchedMethods.push(method ?? '')
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
|
||||
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } })
|
||||
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } })
|
||||
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
|
||||
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
|
||||
teamMutations.push({ method: method ?? '', body })
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
ok: true,
|
||||
value: {
|
||||
id: 't9', revision: 9, subject: body.subject ?? 'x', description: body.description ?? '',
|
||||
status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'jobs.output') return jsonResponse({ ok: true, value: { text: 'out', truncated: false, read: true } })
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
@@ -178,8 +197,8 @@ describe('Tasks page interactions', () => {
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
// The root node's ⓘ opens the detail card (portaled to body).
|
||||
const info = container.querySelector('button[aria-label="状态"]') as HTMLButtonElement
|
||||
// The root node's detail affordance opens the detail card (portaled to body).
|
||||
const info = container.querySelector('button[aria-label="节点详情"]') as HTMLButtonElement
|
||||
await act(async () => { info.click() })
|
||||
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('查看转录')
|
||||
@@ -257,3 +276,114 @@ describe('Tasks page graph interactions and team board', () => {
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tasks page: owned tasks, host-primitive controls, draggable output', () => {
|
||||
/** A team payload whose only task is owned by the child agent. */
|
||||
function teamWithOwnedTask(): unknown {
|
||||
return {
|
||||
available: true,
|
||||
team: {
|
||||
members: [
|
||||
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
|
||||
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', model: 'glm-5.3', diagnostics: [] },
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: 't1', revision: 1, subject: '收窄卡片与图标化', description: '细节', status: 'in_progress',
|
||||
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
},
|
||||
{
|
||||
id: 't2', revision: 2, subject: '补点击回归', description: '', status: 'pending',
|
||||
ownerName: 'writer', blockedBy: ['t1'], writeScopes: [], ready: false, writeScopeWarnings: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('renders the owned task on its agent node', async () => {
|
||||
teamPayload = teamWithOwnedTask()
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
|
||||
expect(node).not.toBeNull()
|
||||
// The node carries the task subject + its status word + the +N tail.
|
||||
expect(node.textContent).toContain('收窄卡片与图标化')
|
||||
expect(node.textContent).toContain('进行中')
|
||||
expect(node.textContent).toContain('+1')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ships no native select/input: the board edits through host primitives', async () => {
|
||||
teamPayload = teamWithOwnedTask()
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
// No native form controls anywhere on the page.
|
||||
expect(container.querySelectorAll('select')).toHaveLength(0)
|
||||
expect(container.querySelectorAll('input')).toHaveLength(0)
|
||||
// The row's overflow menu is a host Menu opened from a host Button.
|
||||
const menuButton = container.querySelector('button[aria-label^="任务操作"]') as HTMLButtonElement
|
||||
expect(menuButton).not.toBeNull()
|
||||
await act(async () => { menuButton.click() })
|
||||
expect(document.body.textContent).toContain('完成')
|
||||
expect(document.body.textContent).toContain('编辑')
|
||||
expect(document.body.textContent).toContain('删除')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('creates a task through the dialog (host Input + modal) and posts the CAS-free create', async () => {
|
||||
teamPayload = teamWithOwnedTask()
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const create = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('新建任务'))
|
||||
expect(create).toBeDefined()
|
||||
await act(async () => { create?.click() })
|
||||
// The dialog's field is a host Input (rendered as a real <input> inside the modal).
|
||||
const field = document.querySelector('input[aria-label="标题"]') as HTMLInputElement
|
||||
expect(field).not.toBeNull()
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(field, '新任务标题')
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
expect(dialog).not.toBeNull()
|
||||
const save = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('新建任务'))
|
||||
expect(save).toBeDefined()
|
||||
expect(field.value).toBe('新任务标题')
|
||||
expect((save as HTMLButtonElement).disabled).toBe(false)
|
||||
await act(async () => { save?.click() })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(fetchedMethods).toContain('teams.taskCreate')
|
||||
expect(teamMutations[0]?.body.subject).toBe('新任务标题')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('drags the job output popover away from its anchor', async () => {
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
const card = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
expect(card).not.toBeNull()
|
||||
const before = { left: card.style.left, top: card.style.top }
|
||||
await act(async () => {
|
||||
card.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 40, clientY: 40 }))
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 120 }))
|
||||
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
})
|
||||
expect({ left: card.style.left, top: card.style.top }).not.toEqual(before)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user