feat(tasks-ui): one task window everywhere, cards are their own detail entry
Round-three review feedback: - NO PER-CARD DETAIL BUTTON: the ellipsis control is gone from graph cards and tree rows — the card IS the affordance, and clicking it opens the detail window (the transcript jump moved inside as the primary button). Fold aggregates keep click-to-expand. - ONE TASK WINDOW: every task surface (board row, agent node task line, the node detail list, and creating a task) now opens the same non-fullscreen DRAGGABLE window (TaskWindow.tsx + its TaskPopover shell): markdown description by default, 编辑 switches the same card to multi-line editing, plus owner reassignment, complete/reopen and a two-step delete. The board lost its overflow menu and its Modal entirely; create uses the same window in create mode. - REUSE: TaskPopover (4 call sites), MultilineField (the plugin's own multi-line input — the host primitive set has none), OwnerPicker, TaskCreateButton, and the shared glyph/TaskLine helpers across both modes. - 3 new copy keys across zh/en/ja and the 18 third-language dictionaries. Tests: the node-click spec now asserts the detail window (and that the jump button inside it navigates), a new spec pins 'no nested control inside a card', and two more cover the shared window: markdown-first view then multi-line edit posting an 'edit' with the current revision, and immediate owner reassignment. Full suite 1406 passed / 133 files.
This commit is contained in:
@@ -115,6 +115,26 @@ client 半(src/client/)
|
||||
|
||||
i18n:本轮新增 19 条文案,zh/en/ja + 18 份第三语言词典同步(`tests/locales.spec.ts` 16/16)。
|
||||
|
||||
## 第三轮返工(2026-09-14 深夜,交互统一)
|
||||
|
||||
用户第三轮反馈两点,都指向「减少控件、统一入口」:
|
||||
|
||||
| 反馈 | 做法 |
|
||||
|---|---|
|
||||
| 图卡片去掉详情按钮,「统一改为弹出详细」 | 删除每张卡片右上角的 `…` 按钮(树行同理):**卡片/行本身就是详情入口**,点击即弹详情窗口;跳转转录改为详情窗口内的主按钮。折叠聚合节点仍保留「点击=展开/再折叠」的语义 |
|
||||
| 任务所有入口统一为「点击弹出非全屏可拖动编辑(兼查看)窗口」,含 编辑owner + 编辑 + 重开 + 删除;默认多行 markdown 预览,点编辑才进入多行编辑 | 新增单一组件 `TaskWindow.tsx`:`TaskWindow`(内容)+ `TaskPopover`(可拖动外壳,宽 430)+ `MultilineField`(自建多行输入)+ `OwnerPicker`(Pill 即点即改派)+ `TaskCreateButton`。三处入口全部复用它——任务板行、节点任务行、节点详情里的任务清单;「新建」也走同一个窗口(create 模式直接进入编辑态)。任务板由此**删掉了溢出菜单与 Modal**,只留筛选 Pills、任务行与新建按钮 |
|
||||
|
||||
新文案 3 条(任务详情 / 暂无描述 / 被阻塞),zh/en/ja + 18 份第三语言同步。
|
||||
|
||||
组件复用清单(本轮):
|
||||
|
||||
| 组件 | 复用点 |
|
||||
|---|---|
|
||||
| `TaskPopover` | 任务板行、节点任务行、节点详情任务清单、新建按钮(4 处) |
|
||||
| `MultilineField` | 任务描述编辑(宿主 primitives 无多行输入,故自建) |
|
||||
| `TaskCreateButton` | 任务板 / 其它入口共用同一外观 |
|
||||
| `AgentGlyph` / `WorkflowGlyph` / `FoldGlyph` / `TaskLine` | 图与树两种模式共用 |
|
||||
|
||||
## 验证结果(2026-09-14)
|
||||
|
||||
PR:[#680](https://github.com/omdsh-dev/DSH-better-sidebar/pull/680)(分支 `feat/tasks-graph-workflow-teams`)。
|
||||
|
||||
@@ -86,7 +86,7 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
|
||||
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
|
||||
if (target.closest('button, input, textarea, select, a, [data-popover-no-drag]') !== null) return
|
||||
event.preventDefault()
|
||||
const startX = event.clientX
|
||||
const startY = event.clientY
|
||||
|
||||
+54
-21
@@ -49,6 +49,7 @@ import { JobsDrawer, JobOutputPopoverContent } from './JobsDrawer.tsx'
|
||||
import { AnchoredPopover } from './AnchoredPopover.tsx'
|
||||
import { AgentNodePopover, WorkflowNodePopover } from './TasksPopovers.tsx'
|
||||
import { TeamBoard } from './TeamBoard.tsx'
|
||||
import { TaskPopover } from './TaskWindow.tsx'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type { WorkflowRunView } from '../workflow-runs.ts'
|
||||
import legacy from './SubagentView.module.css'
|
||||
@@ -133,6 +134,8 @@ type PagePopover =
|
||||
| { kind: 'node'; nodeId: string; anchor: HTMLElement }
|
||||
| { kind: 'workflow'; nodeId: string; anchor: HTMLElement }
|
||||
| { kind: 'job'; jobId: string; anchor: HTMLElement }
|
||||
/** The shared task window; taskId undefined = create mode. */
|
||||
| { kind: 'task'; taskId: string | undefined; anchor: HTMLElement }
|
||||
|
||||
/**
|
||||
* The sidebar's Tasks page.
|
||||
@@ -274,9 +277,8 @@ export function SubagentView(props: {
|
||||
void sessions.refreshSubagents?.(parentSessionId)
|
||||
}, [sessions])
|
||||
|
||||
/** Node click = jump to the transcript (synthesized members without a
|
||||
* session address open their detail popover instead). */
|
||||
const activateNode = useCallback((node: TasksAgentNode): void => {
|
||||
/** Jump from the detail window into the node's transcript. */
|
||||
const jumpToNode = useCallback((node: TasksAgentNode): void => {
|
||||
if (node.childAddress !== undefined) {
|
||||
openChild(node.childAddress)
|
||||
setPopover(null)
|
||||
@@ -286,10 +288,21 @@ export function SubagentView(props: {
|
||||
openMain()
|
||||
setPopover(null)
|
||||
}
|
||||
// Synthesized workflow members without a childId have no transcript to
|
||||
// jump to: their ⓘ popover is the only detail surface.
|
||||
}, [openChild, openMain])
|
||||
|
||||
/** Open the shared task window from a board row (undefined = create). */
|
||||
const openTaskFromBoard = useCallback((
|
||||
task: { id: string } | undefined,
|
||||
anchor: HTMLElement,
|
||||
): void => {
|
||||
setPopover({ kind: 'task', taskId: task?.id, anchor })
|
||||
}, [])
|
||||
|
||||
/** Open the shared task window by id (node task lines, detail lists). */
|
||||
const openTaskById = useCallback((taskId: string, anchor: HTMLElement): void => {
|
||||
setPopover({ kind: 'task', taskId, anchor })
|
||||
}, [])
|
||||
|
||||
const totals = useMemo(
|
||||
() => rootId === undefined
|
||||
? { count: 0, runningCount: 0 }
|
||||
@@ -351,6 +364,25 @@ export function SubagentView(props: {
|
||||
/** The current popover's resolved content (subjects re-resolve live). */
|
||||
const popoverContent = ((): ReactNode => {
|
||||
if (popover === null) return null
|
||||
if (popover.kind === 'task') {
|
||||
if (teamView?.available !== true || teamView.team === null || rootId === undefined) return null
|
||||
const task = popover.taskId === undefined
|
||||
? undefined
|
||||
: teamView.team.tasks.find(candidate => candidate.id === popover.taskId)
|
||||
// A task that vanished (deleted elsewhere) closes the window instead of
|
||||
// showing a stale card.
|
||||
if (popover.taskId !== undefined && task === undefined) return null
|
||||
return (
|
||||
<TaskPopover
|
||||
rootId={rootId}
|
||||
task={task}
|
||||
members={teamView.team.members}
|
||||
onChanged={team.refresh}
|
||||
onClose={closePopover}
|
||||
anchor={popover.anchor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (popover.kind === 'job') {
|
||||
const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
|
||||
if (row === undefined) return null
|
||||
@@ -370,11 +402,8 @@ export function SubagentView(props: {
|
||||
return (
|
||||
<AgentNodePopover
|
||||
node={node}
|
||||
onJump={(target) => {
|
||||
if (target.childAddress !== undefined) openChild(target.childAddress)
|
||||
else openMain()
|
||||
setPopover(null)
|
||||
}}
|
||||
onJump={jumpToNode}
|
||||
onOpenTask={openTaskById}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -410,7 +439,7 @@ export function SubagentView(props: {
|
||||
rootId={rootId}
|
||||
members={teamView.team.members}
|
||||
tasks={teamView.team.tasks}
|
||||
onChanged={team.refresh}
|
||||
onOpenTask={openTaskFromBoard}
|
||||
collapsed={teamBoardCollapsed}
|
||||
onToggleCollapsed={() => { setTeamBoardCollapsed(current => !current) }}
|
||||
/>
|
||||
@@ -441,9 +470,9 @@ export function SubagentView(props: {
|
||||
nodes={model}
|
||||
folded={folded}
|
||||
rootId={rootId}
|
||||
onActivate={activateNode}
|
||||
onNodeInfo={(node, anchor) => { setPopover({ kind: 'node', nodeId: node.id, anchor }) }}
|
||||
onWorkflowInfo={(node, anchor) => { setPopover({ kind: 'workflow', nodeId: node.id, anchor }) }}
|
||||
onOpenTask={openTaskById}
|
||||
onToggleFold={() => { setFolded(current => !current) }}
|
||||
mode={mode}
|
||||
onModeChange={setModeOverride}
|
||||
@@ -454,9 +483,9 @@ export function SubagentView(props: {
|
||||
nodes={model}
|
||||
folded={folded}
|
||||
loading={summaryBackedLoading}
|
||||
onActivate={activateNode}
|
||||
onNodeInfo={(node, anchor) => { setPopover({ kind: 'node', nodeId: node.id, anchor }) }}
|
||||
onWorkflowInfo={(node, anchor) => { setPopover({ kind: 'workflow', nodeId: node.id, anchor }) }}
|
||||
onOpenTask={openTaskById}
|
||||
onToggleFold={() => { setFolded(current => !current) }}
|
||||
mode={mode}
|
||||
onModeChange={setModeOverride}
|
||||
@@ -473,14 +502,18 @@ export function SubagentView(props: {
|
||||
: { kind: 'job', jobId: row.job.id, anchor })
|
||||
}}
|
||||
/>
|
||||
<AnchoredPopover
|
||||
anchor={popover?.anchor ?? null}
|
||||
onClose={closePopover}
|
||||
draggable={popover?.kind === 'job'}
|
||||
width={popover?.kind === 'job' ? 380 : 280}
|
||||
>
|
||||
{popoverContent}
|
||||
</AnchoredPopover>
|
||||
{popover?.kind === 'task'
|
||||
? popoverContent
|
||||
: (
|
||||
<AnchoredPopover
|
||||
anchor={popover?.anchor ?? null}
|
||||
onClose={closePopover}
|
||||
draggable={popover?.kind === 'job'}
|
||||
width={popover?.kind === 'job' ? 380 : 280}
|
||||
>
|
||||
{popoverContent}
|
||||
</AnchoredPopover>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* The ONE task window: view, edit and create all live here, and every task
|
||||
* surface (the board's rows, an agent node's task line, the node detail
|
||||
* popover) opens THIS component — pointer targets stay thin, the window owns
|
||||
* the behaviour.
|
||||
*
|
||||
* Shape: a non-fullscreen, DRAGGABLE card (never a modal dialog). Details
|
||||
* render as multi-line MARKDOWN by default; 编辑 switches the same card into
|
||||
* multi-line editing, so reading and writing never jump between surfaces.
|
||||
* Actions: owner reassignment (Pills, CAS-immediate), edit/save, reopen or
|
||||
* complete, and a two-step delete.
|
||||
*
|
||||
* Host primitives everywhere (Button / Input / Pill / Tag / StateDot) plus
|
||||
* the plugin's own `MultilineField` (the host set ships no multi-line input)
|
||||
* and the shared `MarkdownText` renderer with the plugin's copy labels.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16,
|
||||
IconRefreshOutline14, IconTrashOutline16, Input, MarkdownText, Pill, StateDot, Tag,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
|
||||
import { api } from './api.ts'
|
||||
import { markdownTextProps } from './markdown-labels.tsx'
|
||||
import { AnchoredPopover } from './AnchoredPopover.tsx'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
|
||||
/** One mutation outcome (the host route's own union). */
|
||||
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** The task status label key. */
|
||||
function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
|
||||
switch (status) {
|
||||
case 'pending': return 'teamTaskPending'
|
||||
case 'in_progress': return 'teamTaskInProgress'
|
||||
case 'completed': return 'teamTaskCompleted'
|
||||
case 'deleted': return 'teamTaskDeleted'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's own multi-line input: the host primitive set ships no
|
||||
* textarea, so this is OUR component (token-styled, drag-exempt) and every
|
||||
* multi-line edit in the page goes through it.
|
||||
*/
|
||||
export function MultilineField(props: {
|
||||
value: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
onChange?(next: string): void
|
||||
}): ReactNode {
|
||||
return (
|
||||
<textarea
|
||||
className={css.taskTextarea}
|
||||
value={props.value}
|
||||
rows={props.rows ?? 7}
|
||||
placeholder={props.placeholder}
|
||||
aria-label={props.label}
|
||||
data-popover-no-drag
|
||||
onChange={(event) => { props.onChange?.(event.target.value) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** The owner picker (Pills; CAS-immediate, so it is never a draft field). */
|
||||
function OwnerPicker(props: {
|
||||
members: readonly SidebarTeamMemberView[]
|
||||
owner: string | undefined
|
||||
disabled: boolean
|
||||
onPick(owner: string): void
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className={css.taskOwnerRow}>
|
||||
<Pill
|
||||
active={props.owner === undefined}
|
||||
disabled={props.disabled}
|
||||
onClick={() => { props.onPick('') }}
|
||||
>
|
||||
{t('teamTaskUnowned')}
|
||||
</Pill>
|
||||
{props.members.map(member => (
|
||||
<Pill
|
||||
key={member.id}
|
||||
active={props.owner === member.name}
|
||||
disabled={props.disabled}
|
||||
onClick={() => { props.onPick(member.name) }}
|
||||
>
|
||||
{member.name}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The read-only body: markdown description, meta, owner picker. */
|
||||
function TaskViewBody(props: {
|
||||
task: SidebarTeamTaskView
|
||||
teammates: readonly SidebarTeamMemberView[]
|
||||
busy: boolean
|
||||
onReassign(owner: string): void
|
||||
}): ReactNode {
|
||||
const { task } = props
|
||||
const body = task.description.trim()
|
||||
return (
|
||||
<>
|
||||
<div className={css.jobPopTitle}>
|
||||
<StateDot
|
||||
size={6}
|
||||
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
|
||||
/>
|
||||
<span className={css.popTitle} title={task.subject}>{task.subject}</span>
|
||||
<Tag tone={task.status === 'completed' ? 'success' : task.ready ? 'info' : 'warning'}>
|
||||
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={css.jobPopMeta}>
|
||||
<span className={css.popKey}>{t('teamTaskOwner')}</span>
|
||||
<span>{task.ownerName ?? t('teamTaskUnowned')}</span>
|
||||
{task.blockedBy.length > 0 && (
|
||||
<>
|
||||
<span className={css.popKey}>{t('teamTaskBlockedBy')}</span>
|
||||
<span>{task.blockedBy.join(' · ')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.taskMarkdown}>
|
||||
{body === ''
|
||||
? <div className={css.popHint}>{t('teamTaskNoDescription')}</div>
|
||||
: (
|
||||
<MarkdownText
|
||||
{...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.taskField}>
|
||||
<span className={css.taskLabel}>{t('teamTaskOwner')}</span>
|
||||
<OwnerPicker
|
||||
members={props.teammates}
|
||||
owner={task.ownerName}
|
||||
disabled={props.busy}
|
||||
onPick={props.onReassign}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The editing body: subject (host Input) + description (our multi-line field). */
|
||||
function TaskEditBody(props: {
|
||||
subject: string
|
||||
description: string
|
||||
onSubject(next: string): void
|
||||
onDescription(next: string): void
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className={css.taskForm} data-popover-no-drag>
|
||||
<label className={css.taskField}>
|
||||
<span className={css.taskLabel}>{t('teamTaskSubject')}</span>
|
||||
<Input
|
||||
value={props.subject}
|
||||
placeholder={t('teamTaskSubjectPlaceholder')}
|
||||
aria-label={t('teamTaskSubject')}
|
||||
onChange={(event) => { props.onSubject(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.taskField}>
|
||||
<span className={css.taskLabel}>{t('teamTaskDescription')}</span>
|
||||
<MultilineField
|
||||
value={props.description}
|
||||
label={t('teamTaskDescription')}
|
||||
placeholder={t('teamTaskDescriptionPlaceholder')}
|
||||
onChange={props.onDescription}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface TaskWindowProps {
|
||||
rootId: string
|
||||
/** The task under view/edit; undefined = create mode. */
|
||||
task: SidebarTeamTaskView | undefined
|
||||
members: readonly SidebarTeamMemberView[]
|
||||
/** Re-pull `teams.view` after a mutation (the parent owns the poller). */
|
||||
onChanged(): void
|
||||
/** Close the window (the caller's popover state). */
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
/** The task window content (the caller supplies the popover shell). */
|
||||
export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
const { rootId, task, members, onChanged, onClose } = props
|
||||
const creating = task === undefined
|
||||
const [editing, setEditing] = useState(creating)
|
||||
const [subject, setSubject] = useState(task?.subject ?? '')
|
||||
const [description, setDescription] = useState(task?.description ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [note, setNote] = useState<string | undefined>(undefined)
|
||||
const [armedDelete, setArmedDelete] = useState(false)
|
||||
|
||||
// Re-seed the draft when the caller swaps the subject task: the poller
|
||||
// hands us fresh revisions, and unsaved text must not leak across tasks.
|
||||
// Keyed by id on purpose — a same-id revision bump must NOT clobber the
|
||||
// reader's in-flight edit (the seed rides a render-time ref so the effect
|
||||
// depends on the id alone).
|
||||
const seedRef = useRef({
|
||||
subject: task?.subject ?? '',
|
||||
description: task?.description ?? '',
|
||||
creating: task === undefined,
|
||||
})
|
||||
seedRef.current = {
|
||||
subject: task?.subject ?? '',
|
||||
description: task?.description ?? '',
|
||||
creating: task === undefined,
|
||||
}
|
||||
useEffect(() => {
|
||||
const seed = seedRef.current
|
||||
setSubject(seed.subject)
|
||||
setDescription(seed.description)
|
||||
setEditing(seed.creating)
|
||||
}, [task?.id])
|
||||
|
||||
const teammates = members.filter(member => member.role === 'teammate')
|
||||
|
||||
/** Run one CAS mutation with the shared busy/conflict handling. */
|
||||
const mutate = async (
|
||||
action: () => Promise<MutationResult>,
|
||||
options: { close?: boolean } = {},
|
||||
): Promise<void> => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setNote(undefined)
|
||||
try {
|
||||
const result = await action()
|
||||
if (!result.ok) {
|
||||
setNote(result.error.code === 'team-task-conflict'
|
||||
? t('teamTaskConflict')
|
||||
: t('teamTaskError', { message: result.error.message }))
|
||||
} else {
|
||||
if (options.close === true) onClose()
|
||||
else setEditing(false)
|
||||
}
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the draft (create closes the window; edit returns to the view). */
|
||||
const save = (): void => {
|
||||
const nextSubject = subject.trim()
|
||||
if (nextSubject === '') return
|
||||
void mutate(() => (creating
|
||||
? api.teamsTaskCreate(rootId, { subject: nextSubject, description: description.trim() })
|
||||
: api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id,
|
||||
expectedRevision: task.revision,
|
||||
action: 'edit',
|
||||
subject: nextSubject,
|
||||
description: description.trim(),
|
||||
})), { close: creating })
|
||||
}
|
||||
|
||||
/** Reassign the task (or return it to the unowned pool). */
|
||||
const reassign = (owner: string): void => {
|
||||
if (task === undefined || busy) return
|
||||
void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id,
|
||||
expectedRevision: task.revision,
|
||||
action: 'reassign',
|
||||
...(owner === '' ? {} : { owner }),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.popCard} data-popover-handle>
|
||||
<div className={css.popHead}>
|
||||
<span>{creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}</span>
|
||||
<span className={css.popHeadActions} data-popover-no-drag>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<IconCloseOutline16 size={12} />}
|
||||
aria-label={t('teamTaskCancel')}
|
||||
title={t('teamTaskCancel')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editing || task === undefined
|
||||
? (
|
||||
<TaskEditBody
|
||||
subject={subject}
|
||||
description={description}
|
||||
onSubject={setSubject}
|
||||
onDescription={setDescription}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<TaskViewBody
|
||||
task={task}
|
||||
teammates={teammates}
|
||||
busy={busy}
|
||||
onReassign={reassign}
|
||||
/>
|
||||
)}
|
||||
|
||||
{note !== undefined && <div className={css.teamNote}>{note}</div>}
|
||||
|
||||
<div className={css.jobPopActions} data-popover-no-drag>
|
||||
{editing
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<IconCheckOutline14 size={12} />}
|
||||
disabled={busy || subject.trim() === ''}
|
||||
onClick={save}
|
||||
>
|
||||
{creating ? t('teamTaskCreate') : t('teamTaskSave')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (creating) onClose()
|
||||
else if (task !== undefined) {
|
||||
setSubject(task.subject)
|
||||
setDescription(task.description)
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('teamTaskCancel')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: task === undefined ? null : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<IconEditOutline16 size={12} />}
|
||||
onClick={() => { setEditing(true) }}
|
||||
>
|
||||
{t('teamTaskEdit')}
|
||||
</Button>
|
||||
{task.status === 'completed'
|
||||
? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<IconRefreshOutline14 size={12} />}
|
||||
disabled={busy}
|
||||
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
|
||||
}))}
|
||||
>
|
||||
{t('teamTaskReopen')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<IconCheckOutline14 size={12} />}
|
||||
disabled={busy}
|
||||
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'complete',
|
||||
}))}
|
||||
>
|
||||
{t('teamTaskComplete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={armedDelete ? css.jobPopKillArmed : undefined}
|
||||
icon={<IconTrashOutline16 size={12} />}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!armedDelete) { setArmedDelete(true); return }
|
||||
setArmedDelete(false)
|
||||
void mutate(() => api.teamsTaskUpdate(rootId, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'delete',
|
||||
}), { close: true })
|
||||
}}
|
||||
>
|
||||
{armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The task window inside its draggable popover shell — the single entry point
|
||||
* every task surface uses (board row, node task line, node detail list).
|
||||
*/
|
||||
export function TaskPopover(props: TaskWindowProps & { anchor: HTMLElement | null }): ReactNode {
|
||||
const { anchor, onClose, ...windowProps } = props
|
||||
return (
|
||||
<AnchoredPopover anchor={anchor} onClose={onClose} draggable width={430}>
|
||||
{anchor === null ? null : <TaskWindow {...windowProps} onClose={onClose} />}
|
||||
</AnchoredPopover>
|
||||
)
|
||||
}
|
||||
|
||||
/** The create affordance every surface shares. */
|
||||
export function TaskCreateButton(props: {
|
||||
disabled?: boolean
|
||||
onClick(anchor: HTMLElement): void
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<IconPlusOutline16 size={13} />}
|
||||
disabled={props.disabled}
|
||||
onClick={(event) => { props.onClick(event.currentTarget) }}
|
||||
>
|
||||
{t('teamTaskCreate')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -24,8 +24,7 @@ import {
|
||||
} from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconChecklistOutline14, IconEllipsisOutline16, IconFullscreenOutline16, IconTreeCorner8x10,
|
||||
StateDot,
|
||||
Button, IconChecklistOutline14, 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'
|
||||
@@ -63,9 +62,10 @@ export interface TasksGraphProps {
|
||||
folded: boolean
|
||||
/** Re-fit trigger: the topology root id (a reroot re-centers). */
|
||||
rootId: string | undefined
|
||||
onActivate(node: TasksAgentNode): void
|
||||
onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void
|
||||
onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void
|
||||
/** Open the shared task window for one task id. */
|
||||
onOpenTask(taskId: string, anchor: HTMLElement): void
|
||||
onToggleFold(): void
|
||||
mode: 'graph' | 'tree'
|
||||
onModeChange(mode: 'graph' | 'tree'): void
|
||||
@@ -112,7 +112,7 @@ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void
|
||||
}
|
||||
|
||||
export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
const { nodes, folded, rootId, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange } = props
|
||||
const { nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange } = props
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [tf, setTf] = useState({ x: 0, y: 0, k: 1 })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
@@ -335,7 +335,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
if (node.kind === 'workflow') {
|
||||
return renderWorkflowNode(node, style, onWorkflowInfo, clickAllowed)
|
||||
}
|
||||
return renderAgentNode(node, style, onActivate, onNodeInfo, clickAllowed)
|
||||
return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed)
|
||||
})}
|
||||
</div>
|
||||
<div className={css.controls} data-graph-controls>
|
||||
@@ -381,8 +381,8 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
function renderAgentNode(
|
||||
node: TasksAgentNode,
|
||||
style: { left: number; top: number; width: number; minHeight: number },
|
||||
onActivate: (node: TasksAgentNode) => void,
|
||||
onNodeInfo: (node: TasksAgentNode, anchor: HTMLElement) => void,
|
||||
onOpenTask: (taskId: string, anchor: HTMLElement) => void,
|
||||
clickAllowed: () => boolean,
|
||||
): ReactNode {
|
||||
return (
|
||||
@@ -402,9 +402,9 @@ function renderAgentNode(
|
||||
node.state === 'error' && css.nodeError,
|
||||
)}
|
||||
style={style}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
if (!clickAllowed()) return
|
||||
onActivate(node)
|
||||
onNodeInfo(node, event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<span className={css.nodeHeader}>
|
||||
@@ -414,19 +414,7 @@ function renderAgentNode(
|
||||
</span>
|
||||
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span>
|
||||
{node.state === 'running' && <LiveLine live={node.live} />}
|
||||
<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)
|
||||
}}
|
||||
/>
|
||||
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,8 +46,10 @@ function PopRow(props: { label: string; mono?: boolean; children: ReactNode }):
|
||||
export function AgentNodePopover(props: {
|
||||
node: TasksAgentNode
|
||||
onJump(node: TasksAgentNode): void
|
||||
/** Open the shared task window for one of the node's tasks. */
|
||||
onOpenTask(taskId: string, anchor: HTMLElement): void
|
||||
}): ReactNode {
|
||||
const { node, onJump } = props
|
||||
const { node, onJump, onOpenTask } = props
|
||||
const liveText = node.live?.text !== undefined ? flatten(node.live.text) : undefined
|
||||
const liveTool = node.live?.tool !== undefined
|
||||
? `${node.live.tool.name}${node.live.tool.args === '' ? '' : ` ${node.live.tool.args}`}`
|
||||
@@ -87,13 +89,19 @@ export function AgentNodePopover(props: {
|
||||
<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}>
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
className={css.popListRow}
|
||||
title={task.subject}
|
||||
onClick={(event) => { onOpenTask(task.id, event.currentTarget) }}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Button, IconChevronUpOutline14, IconEllipsisOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import {
|
||||
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
|
||||
@@ -20,9 +20,10 @@ import css from './tasks-graph.module.css'
|
||||
export interface TasksTreeProps {
|
||||
nodes: readonly TasksNode[]
|
||||
folded: boolean
|
||||
onActivate(node: TasksAgentNode): void
|
||||
onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void
|
||||
onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void
|
||||
/** Open the shared task window for one task id. */
|
||||
onOpenTask(taskId: string, anchor: HTMLElement): void
|
||||
onToggleFold(): void
|
||||
mode: 'graph' | 'tree'
|
||||
onModeChange(mode: 'graph' | 'tree'): void
|
||||
@@ -32,7 +33,7 @@ export interface TasksTreeProps {
|
||||
|
||||
export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
const {
|
||||
nodes, folded, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange, loading,
|
||||
nodes, folded, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange, loading,
|
||||
} = props
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -147,8 +148,10 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
node.current && css.treeRowActive,
|
||||
(node.state === 'done' || node.state === 'error') && !node.current && css.treeRowSettled,
|
||||
)}
|
||||
onClick={() => { onActivate(node) }}
|
||||
onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
|
||||
onClick={(event) => { onNodeInfo(node, event.currentTarget) }}
|
||||
onKeyDown={(event) => {
|
||||
activateOnKey(event, () => { onNodeInfo(node, event.currentTarget as HTMLElement) })
|
||||
}}
|
||||
>
|
||||
<StateDot state={nodeDotState(node.state)} size={6} />
|
||||
<span className={css.treeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span>
|
||||
@@ -156,20 +159,8 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
<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} />
|
||||
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.treeInfo}
|
||||
icon={<IconEllipsisOutline16 size={12} />}
|
||||
aria-label={t('tasksNodeDetail')}
|
||||
title={t('tasksNodeDetail')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onNodeInfo(node, event.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
if (children.length === 0) return <div key={node.id}>{row}</div>
|
||||
|
||||
+17
-265
@@ -14,13 +14,10 @@
|
||||
*/
|
||||
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,
|
||||
Button, IconChecklistOutline14, IconChevronUpOutline14, IconPlusOutline16, Pill, StateDot, Tag,
|
||||
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'
|
||||
import css from './tasks-graph.module.css'
|
||||
|
||||
@@ -41,177 +38,27 @@ function taskTone(task: SidebarTeamTaskView): TagTone {
|
||||
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[]
|
||||
tasks: readonly SidebarTeamTaskView[]
|
||||
/** Re-pull `teams.view` now (after every mutation / conflict). */
|
||||
onChanged(): void
|
||||
/** Open the shared task window (undefined = create) anchored at the click. */
|
||||
onOpenTask(task: SidebarTeamTaskView | undefined, anchor: HTMLElement): void
|
||||
/** The strip's own collapse state (the reader's choice, remembered while mounted). */
|
||||
collapsed: boolean
|
||||
onToggleCollapsed(): void
|
||||
}
|
||||
|
||||
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 [draft, setDraft] = useState<TaskDraft | undefined>(undefined)
|
||||
const [menuTaskId, setMenuTaskId] = useState<string | undefined>(undefined)
|
||||
const [armedDeleteId, setArmedDeleteId] = useState<string | undefined>(undefined)
|
||||
const { members, tasks, onOpenTask, collapsed, onToggleCollapsed } = props
|
||||
const [ownerFilter, setOwnerFilter] = useState<string | undefined>(undefined)
|
||||
|
||||
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> => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setNote(undefined)
|
||||
try {
|
||||
const result = await action()
|
||||
if (!result.ok) {
|
||||
setNote(result.error.code === 'team-task-conflict'
|
||||
? t('teamTaskConflict')
|
||||
: t('teamTaskError', { message: result.error.message }))
|
||||
}
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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')}>
|
||||
<button
|
||||
@@ -266,136 +113,41 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
<div className={css.teamTasks}>
|
||||
{shown.length === 0 && <div className={css.teamEmpty}>{t('teamTasksEmpty')}</div>}
|
||||
{shown.map(task => (
|
||||
<div key={task.id} className={css.teamTask}>
|
||||
<Button
|
||||
key={task.id}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.teamTask}
|
||||
aria-label={`${t('teamTaskDetail')} ${task.subject}`}
|
||||
title={t('teamTaskDetail')}
|
||||
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
|
||||
>
|
||||
<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>
|
||||
<span className={css.teamTaskSubject}>{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>
|
||||
</Button>
|
||||
))}
|
||||
</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: '' }) }}
|
||||
onClick={(event) => { onOpenTask(undefined, event.currentTarget) }}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -444,6 +444,9 @@ export const ar: Record<string, string> = {
|
||||
teamTaskError: 'فشلت العملية: {message}',
|
||||
teamTaskDeleted: 'محذوفة',
|
||||
teamTaskActions: 'إجراءات المهمة',
|
||||
teamTaskDetail: 'تفاصيل المهمة',
|
||||
teamTaskNoDescription: 'لا يوجد وصف بعد — انقر على «تحرير» لإضافة واحد',
|
||||
teamTaskBlockedBy: 'محجوبة بسبب',
|
||||
teamTaskSubjectPlaceholder: 'سطر واحد يصف العمل',
|
||||
teamTaskDescriptionPlaceholder: 'تفاصيل إضافية (اختياري)',
|
||||
teamFilterAll: 'الكل',
|
||||
|
||||
@@ -429,6 +429,9 @@ export const de: Record<string, string> = {
|
||||
teamTaskError: 'Vorgang fehlgeschlagen: {message}',
|
||||
teamTaskDeleted: 'Gelöscht',
|
||||
teamTaskActions: 'Aufgabenaktionen',
|
||||
teamTaskDetail: 'Aufgabendetails',
|
||||
teamTaskNoDescription: 'Noch keine Beschreibung – über „Bearbeiten“ ergänzen',
|
||||
teamTaskBlockedBy: 'Blockiert durch',
|
||||
teamTaskSubjectPlaceholder: 'Eine Zeile, die die Arbeit beschreibt',
|
||||
teamTaskDescriptionPlaceholder: 'Zusätzliche Details (optional)',
|
||||
teamFilterAll: 'Alle',
|
||||
|
||||
@@ -436,6 +436,9 @@ export const fr: Record<string, string> = {
|
||||
teamTaskError: 'Échec de l’opération : {message}',
|
||||
teamTaskDeleted: 'Supprimée',
|
||||
teamTaskActions: 'Actions sur la tâche',
|
||||
teamTaskDetail: 'Détails de la tâche',
|
||||
teamTaskNoDescription: 'Aucune description pour le moment — cliquez sur « Modifier » pour en ajouter une',
|
||||
teamTaskBlockedBy: 'Bloquée par',
|
||||
teamTaskSubjectPlaceholder: 'Décrire le travail en une ligne',
|
||||
teamTaskDescriptionPlaceholder: 'Précisions (facultatif)',
|
||||
teamFilterAll: 'Tout',
|
||||
|
||||
@@ -443,6 +443,9 @@ export const hi: Record<string, string> = {
|
||||
teamTaskError: 'ऑपरेशन विफल: {message}',
|
||||
teamTaskDeleted: 'हटाया गया',
|
||||
teamTaskActions: 'कार्य क्रियाएँ',
|
||||
teamTaskDetail: 'कार्य विवरण',
|
||||
teamTaskNoDescription: 'अभी कोई विवरण नहीं — जोड़ने के लिए “संपादित करें” पर क्लिक करें',
|
||||
teamTaskBlockedBy: 'इनसे अवरुद्ध',
|
||||
teamTaskSubjectPlaceholder: 'काम को एक पंक्ति में बताएँ',
|
||||
teamTaskDescriptionPlaceholder: 'अतिरिक्त जानकारी (वैकल्पिक)',
|
||||
teamFilterAll: 'सभी',
|
||||
|
||||
@@ -441,6 +441,9 @@ export const id: Record<string, string> = {
|
||||
teamTaskError: 'Operasi gagal: {message}',
|
||||
teamTaskDeleted: 'Terhapus',
|
||||
teamTaskActions: 'Aksi tugas',
|
||||
teamTaskDetail: 'Detail tugas',
|
||||
teamTaskNoDescription: 'Belum ada deskripsi — klik “Edit” untuk menambahkan',
|
||||
teamTaskBlockedBy: 'Diblokir oleh',
|
||||
teamTaskSubjectPlaceholder: 'Jelaskan pekerjaan dalam satu baris',
|
||||
teamTaskDescriptionPlaceholder: 'Keterangan tambahan (opsional)',
|
||||
teamFilterAll: 'Semua',
|
||||
|
||||
@@ -434,6 +434,9 @@ export const it: Record<string, string> = {
|
||||
teamTaskError: 'Operazione non riuscita: {message}',
|
||||
teamTaskDeleted: 'Eliminata',
|
||||
teamTaskActions: 'Azioni attività',
|
||||
teamTaskDetail: 'Dettagli attività',
|
||||
teamTaskNoDescription: 'Nessuna descrizione — fai clic su «Modifica» per aggiungerne una',
|
||||
teamTaskBlockedBy: 'Bloccata da',
|
||||
teamTaskSubjectPlaceholder: 'Descrivi il lavoro in una riga',
|
||||
teamTaskDescriptionPlaceholder: 'Dettagli aggiuntivi (opzionale)',
|
||||
teamFilterAll: 'Tutti',
|
||||
|
||||
@@ -443,6 +443,9 @@ export const ja: Record<string, string> = {
|
||||
teamTaskError: '操作に失敗: {message}',
|
||||
teamTaskDeleted: '削除済み',
|
||||
teamTaskActions: 'タスク操作',
|
||||
teamTaskDetail: 'タスク詳細',
|
||||
teamTaskNoDescription: '説明はまだありません(「編集」で追加)',
|
||||
teamTaskBlockedBy: 'ブロック元',
|
||||
teamTaskSubjectPlaceholder: 'やることを一行で',
|
||||
teamTaskDescriptionPlaceholder: '補足(任意)',
|
||||
teamFilterAll: 'すべて',
|
||||
|
||||
@@ -435,6 +435,9 @@ export const ko: Record<string, string> = {
|
||||
teamTaskError: '작업 실패: {message}',
|
||||
teamTaskDeleted: '삭제됨',
|
||||
teamTaskActions: '작업 동작',
|
||||
teamTaskDetail: '작업 상세',
|
||||
teamTaskNoDescription: '설명이 아직 없음 — “편집”을 클릭해 추가',
|
||||
teamTaskBlockedBy: '차단 원인',
|
||||
teamTaskSubjectPlaceholder: '할 일을 한 줄로 설명',
|
||||
teamTaskDescriptionPlaceholder: '추가 설명 (선택)',
|
||||
teamFilterAll: '모두',
|
||||
|
||||
@@ -441,6 +441,9 @@ export const nl: Record<string, string> = {
|
||||
teamTaskError: 'Bewerking mislukt: {message}',
|
||||
teamTaskDeleted: 'Verwijderd',
|
||||
teamTaskActions: 'Taakacties',
|
||||
teamTaskDetail: 'Taakdetails',
|
||||
teamTaskNoDescription: 'Nog geen beschrijving — klik op “Bewerken” om er een toe te voegen',
|
||||
teamTaskBlockedBy: 'Geblokkeerd door',
|
||||
teamTaskSubjectPlaceholder: 'Beschrijf het werk in één regel',
|
||||
teamTaskDescriptionPlaceholder: 'Aanvullende details (optioneel)',
|
||||
teamFilterAll: 'Alles',
|
||||
|
||||
@@ -445,6 +445,9 @@ export const pl: Record<string, string> = {
|
||||
teamTaskError: 'Operacja nie powiodła się: {message}',
|
||||
teamTaskDeleted: 'Usunięte',
|
||||
teamTaskActions: 'Akcje zadania',
|
||||
teamTaskDetail: 'Szczegóły zadania',
|
||||
teamTaskNoDescription: 'Brak opisu — kliknij „Edytuj”, aby dodać',
|
||||
teamTaskBlockedBy: 'Zablokowane przez',
|
||||
teamTaskSubjectPlaceholder: 'Opisz pracę w jednym zdaniu',
|
||||
teamTaskDescriptionPlaceholder: 'Dodatkowe informacje (opcjonalnie)',
|
||||
teamFilterAll: 'Wszystko',
|
||||
|
||||
@@ -426,6 +426,9 @@ export const pt: Record<string, string> = {
|
||||
teamTaskError: 'Operação falhou: {message}',
|
||||
teamTaskDeleted: 'Excluída',
|
||||
teamTaskActions: 'Ações da tarefa',
|
||||
teamTaskDetail: 'Detalhes da tarefa',
|
||||
teamTaskNoDescription: 'Ainda sem descrição — clique em “Editar” para adicionar',
|
||||
teamTaskBlockedBy: 'Bloqueada por',
|
||||
teamTaskSubjectPlaceholder: 'Descreva o trabalho em uma linha',
|
||||
teamTaskDescriptionPlaceholder: 'Detalhes adicionais (opcional)',
|
||||
teamFilterAll: 'Tudo',
|
||||
|
||||
@@ -441,6 +441,9 @@ export const ru: Record<string, string> = {
|
||||
teamTaskError: 'Не удалось выполнить: {message}',
|
||||
teamTaskDeleted: 'Удалена',
|
||||
teamTaskActions: 'Действия с задачей',
|
||||
teamTaskDetail: 'Детали задачи',
|
||||
teamTaskNoDescription: 'Описание пока отсутствует — нажмите «Изменить», чтобы добавить',
|
||||
teamTaskBlockedBy: 'Заблокирована задачей',
|
||||
teamTaskSubjectPlaceholder: 'Опишите работу одной строкой',
|
||||
teamTaskDescriptionPlaceholder: 'Дополнительные сведения (необязательно)',
|
||||
teamFilterAll: 'Все',
|
||||
|
||||
@@ -426,6 +426,9 @@ export const sv: Record<string, string> = {
|
||||
teamTaskError: 'Åtgärden misslyckades: {message}',
|
||||
teamTaskDeleted: 'Borttagen',
|
||||
teamTaskActions: 'Uppgiftsåtgärder',
|
||||
teamTaskDetail: 'Uppgiftsdetaljer',
|
||||
teamTaskNoDescription: 'Ingen beskrivning än — klicka på “Redigera” för att lägga till en',
|
||||
teamTaskBlockedBy: 'Blockerad av',
|
||||
teamTaskSubjectPlaceholder: 'Beskriv arbetet på en rad',
|
||||
teamTaskDescriptionPlaceholder: 'Ytterligare information (valfritt)',
|
||||
teamFilterAll: 'Alla',
|
||||
|
||||
@@ -443,6 +443,9 @@ export const th: Record<string, string> = {
|
||||
teamTaskError: 'ดำเนินการไม่สำเร็จ: {message}',
|
||||
teamTaskDeleted: 'ลบแล้ว',
|
||||
teamTaskActions: 'การดำเนินการของงาน',
|
||||
teamTaskDetail: 'รายละเอียดงาน',
|
||||
teamTaskNoDescription: 'ยังไม่มีคำอธิบาย — คลิก “แก้ไข” เพื่อเพิ่ม',
|
||||
teamTaskBlockedBy: 'ถูกบล็อกโดย',
|
||||
teamTaskSubjectPlaceholder: 'อธิบายงานที่ต้องทำในบรรทัดเดียว',
|
||||
teamTaskDescriptionPlaceholder: 'รายละเอียดเพิ่มเติม (ไม่บังคับ)',
|
||||
teamFilterAll: 'ทั้งหมด',
|
||||
|
||||
@@ -443,6 +443,9 @@ export const tr: Record<string, string> = {
|
||||
teamTaskError: 'İşlem başarısız: {message}',
|
||||
teamTaskDeleted: 'Silindi',
|
||||
teamTaskActions: 'Görev eylemleri',
|
||||
teamTaskDetail: 'Görev ayrıntısı',
|
||||
teamTaskNoDescription: 'Henüz açıklama yok — eklemek için Düzenle adımına tıklayın',
|
||||
teamTaskBlockedBy: 'Engellendiği görevler',
|
||||
teamTaskSubjectPlaceholder: 'İşi tek satırda açıkla',
|
||||
teamTaskDescriptionPlaceholder: 'Ek ayrıntılar (isteğe bağlı)',
|
||||
teamFilterAll: 'Tümü',
|
||||
|
||||
@@ -443,6 +443,9 @@ export const vi: Record<string, string> = {
|
||||
teamTaskError: 'Thao tác thất bại: {message}',
|
||||
teamTaskDeleted: 'Đã xóa',
|
||||
teamTaskActions: 'Thao tác với tác vụ',
|
||||
teamTaskDetail: 'Chi tiết tác vụ',
|
||||
teamTaskNoDescription: 'Chưa có mô tả — bấm “Sửa” để thêm',
|
||||
teamTaskBlockedBy: 'Bị chặn bởi',
|
||||
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ả',
|
||||
|
||||
@@ -458,6 +458,9 @@ export const zhHK: Record<string, string> = {
|
||||
teamTaskError: '操作失敗:{message}',
|
||||
teamTaskDeleted: '已刪除',
|
||||
teamTaskActions: '任務操作',
|
||||
teamTaskDetail: '任務詳情',
|
||||
teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
|
||||
teamTaskBlockedBy: '受阻於',
|
||||
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
|
||||
teamTaskDescriptionPlaceholder: '補充說明(可選)',
|
||||
teamFilterAll: '全部',
|
||||
|
||||
@@ -458,6 +458,9 @@ export const zhMO: Record<string, string> = {
|
||||
teamTaskError: '操作失敗:{message}',
|
||||
teamTaskDeleted: '已刪除',
|
||||
teamTaskActions: '任務操作',
|
||||
teamTaskDetail: '任務詳情',
|
||||
teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
|
||||
teamTaskBlockedBy: '受阻於',
|
||||
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
|
||||
teamTaskDescriptionPlaceholder: '補充說明(可選)',
|
||||
teamFilterAll: '全部',
|
||||
|
||||
@@ -458,6 +458,9 @@ export const zhTW: Record<string, string> = {
|
||||
teamTaskError: '操作失敗:{message}',
|
||||
teamTaskDeleted: '已刪除',
|
||||
teamTaskActions: '任務操作',
|
||||
teamTaskDetail: '任務詳情',
|
||||
teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
|
||||
teamTaskBlockedBy: '受阻於',
|
||||
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
|
||||
teamTaskDescriptionPlaceholder: '補充說明(可選)',
|
||||
teamFilterAll: '全部',
|
||||
|
||||
@@ -455,6 +455,9 @@ export const zh = {
|
||||
teamTaskError: '操作失败:{message}',
|
||||
teamTaskDeleted: '已删除',
|
||||
teamTaskActions: '任务操作',
|
||||
teamTaskDetail: '任务详情',
|
||||
teamTaskNoDescription: '暂无描述,点击「编辑」补充',
|
||||
teamTaskBlockedBy: '被阻塞',
|
||||
teamTaskSubjectPlaceholder: '一句话说明要做什么',
|
||||
teamTaskDescriptionPlaceholder: '补充说明(可选)',
|
||||
teamFilterAll: '全部',
|
||||
@@ -959,6 +962,9 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
teamTaskError: 'Operation failed: {message}',
|
||||
teamTaskDeleted: 'Deleted',
|
||||
teamTaskActions: 'Task actions',
|
||||
teamTaskDetail: 'Task detail',
|
||||
teamTaskNoDescription: 'No description yet — click Edit to add one',
|
||||
teamTaskBlockedBy: 'Blocked by',
|
||||
teamTaskSubjectPlaceholder: 'One line describing the work',
|
||||
teamTaskDescriptionPlaceholder: 'Extra context (optional)',
|
||||
teamFilterAll: 'All',
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
|
||||
.taskLine {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
@@ -240,6 +241,16 @@
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.taskLine:hover .taskSubject {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.taskLine:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.taskSubject {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -699,6 +710,38 @@
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
/* ── Task window: markdown view + multi-line edit ──────────────────────── */
|
||||
|
||||
.taskMarkdown {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.taskTextarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l4);
|
||||
border-radius: 3px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.taskTextarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* ── Task create/edit dialog ───────────────────────────────────────────── */
|
||||
|
||||
.taskForm {
|
||||
@@ -836,8 +879,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
padding: 3px 5px;
|
||||
border-radius: 3px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.teamTask > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.teamTask:hover {
|
||||
|
||||
@@ -143,14 +143,29 @@ export function primaryTask(tasks: readonly TasksNodeTask[]): TasksNodeTask | un
|
||||
* 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 {
|
||||
export function TaskLine(props: {
|
||||
tasks: readonly TasksNodeTask[] | undefined
|
||||
onOpenTask(taskId: string, anchor: HTMLElement): void
|
||||
}): ReactNode {
|
||||
const tasks = props.tasks ?? []
|
||||
const primary = primaryTask(tasks)
|
||||
if (primary === undefined) return null
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={css.taskLine}
|
||||
title={tasks.map(task => `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onOpenTask(primary.id, event.currentTarget)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onOpenTask(primary.id, event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<span className={css.taskGlyph} aria-hidden="true"><IconChecklistOutline14 size={9} /></span>
|
||||
<span className={css.taskSubject}>{primary.subject}</span>
|
||||
|
||||
+116
-17
@@ -161,7 +161,7 @@ describe('Tasks page interactions', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('jumps to the transcript on node click and opens the ⓘ popover', async () => {
|
||||
it('opens the node detail window on card click; the jump button goes to the transcript', async () => {
|
||||
const opened: unknown[] = []
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
|
||||
@@ -171,8 +171,16 @@ describe('Tasks page interactions', () => {
|
||||
// Folded by default: unfold so the settled child is visible.
|
||||
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
|
||||
await act(async () => { foldToggle.click() })
|
||||
const node = container.querySelector('[aria-label*="子代理 0"]') as HTMLElement
|
||||
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
|
||||
await act(async () => { node.click() })
|
||||
// The card no longer jumps directly: it IS the detail affordance.
|
||||
expect(opened).toEqual([])
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.textContent).toContain('节点详情')
|
||||
const jump = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('查看转录'))
|
||||
expect(jump).toBeDefined()
|
||||
await act(async () => { jump?.click() })
|
||||
expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
|
||||
unmount()
|
||||
})
|
||||
@@ -192,14 +200,16 @@ describe('Tasks page interactions', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('opens the node detail popover from the ⓘ button', async () => {
|
||||
it('ships no per-card detail button any more (the card itself is the affordance)', async () => {
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
// 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() })
|
||||
const root = container.querySelector('[data-graph-node="root"]') as HTMLElement
|
||||
expect(root).not.toBeNull()
|
||||
// A card is a single click target: no nested control inside it.
|
||||
expect(root.querySelectorAll('button')).toHaveLength(0)
|
||||
await act(async () => { root.click() })
|
||||
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('查看转录')
|
||||
unmount()
|
||||
@@ -226,7 +236,9 @@ describe('Tasks page graph interactions and team board', () => {
|
||||
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
|
||||
expect(node).not.toBeNull()
|
||||
await act(async () => { node.click() })
|
||||
expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
|
||||
// The click is not stolen: the card's own action (its detail window) ran.
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
expect(opened).toEqual([])
|
||||
unmount()
|
||||
})
|
||||
|
||||
@@ -317,23 +329,29 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ships no native select/input: the board edits through host primitives', async () => {
|
||||
it('opens the shared task window from a board row (markdown first, no native select)', 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('删除')
|
||||
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
|
||||
expect(row).not.toBeNull()
|
||||
await act(async () => { row.click() })
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
expect(dialog).not.toBeNull()
|
||||
// View mode first: the description renders as markdown, edited only on demand.
|
||||
expect(dialog.textContent).toContain('细节')
|
||||
expect(dialog.querySelector('textarea')).toBeNull()
|
||||
const edit = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('编辑'))
|
||||
expect(edit).toBeDefined()
|
||||
await act(async () => { edit?.click() })
|
||||
// Editing turns the SAME window into the multi-line editor.
|
||||
const textarea = dialog.querySelector('textarea')
|
||||
expect(textarea).not.toBeNull()
|
||||
expect(textarea?.getAttribute('aria-label')).toBe('描述')
|
||||
unmount()
|
||||
})
|
||||
|
||||
@@ -387,3 +405,84 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tasks page: the shared task window', () => {
|
||||
it('opens from an agent node task line and saves a multi-line edit', async () => {
|
||||
teamPayload = {
|
||||
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: 4, subject: '收窄卡片', description: '第一行\n\n- 第二行', status: 'in_progress',
|
||||
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
}],
|
||||
},
|
||||
}
|
||||
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
|
||||
const taskLine = node.querySelector('[role="button"]') as HTMLElement
|
||||
expect(taskLine).not.toBeNull()
|
||||
await act(async () => { taskLine.click() })
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.textContent).toContain('收窄卡片')
|
||||
// Markdown view renders the multi-line body before any editing.
|
||||
expect(dialog.querySelector('textarea')).toBeNull()
|
||||
const edit = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('编辑'))
|
||||
await act(async () => { edit?.click() })
|
||||
const area = dialog.querySelector('textarea') as HTMLTextAreaElement
|
||||
expect(area.value).toBe('第一行\n\n- 第二行')
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||
setter?.call(area, '改过的描述')
|
||||
area.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
const save = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('保存'))
|
||||
await act(async () => { save?.click() })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const update = teamMutations.find(entry => entry.method === 'teams.taskUpdate')
|
||||
expect(update?.body.action).toBe('edit')
|
||||
expect(update?.body.expectedRevision).toBe(4)
|
||||
expect(update?.body.description).toBe('改过的描述')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('reassigns the owner straight from the window (CAS on the current revision)', async () => {
|
||||
teamPayload = {
|
||||
available: true,
|
||||
team: {
|
||||
members: [
|
||||
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
|
||||
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', diagnostics: [] },
|
||||
{ id: 'child-1', name: 'reviewer', role: 'teammate', status: 'idle', diagnostics: [] },
|
||||
],
|
||||
tasks: [{
|
||||
id: 't1', revision: 7, subject: '收窄卡片', description: '', status: 'pending',
|
||||
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
}],
|
||||
},
|
||||
}
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
const reviewer = [...dialog.querySelectorAll('button')].find(button => button.textContent === 'reviewer')
|
||||
expect(reviewer).toBeDefined()
|
||||
await act(async () => { reviewer?.click() })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const update = teamMutations.find(entry => entry.method === 'teams.taskUpdate')
|
||||
expect(update?.body).toMatchObject({ action: 'reassign', owner: 'reviewer', expectedRevision: 7 })
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user