+
+ {creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}
+
+ }
+ aria-label={t('teamTaskCancel')}
+ title={t('teamTaskCancel')}
+ onClick={onClose}
+ />
+
+
+
+ {editing || task === undefined
+ ? (
+
+ )
+ : (
+
+ )}
+
+ {note !== undefined &&
{note}
}
+
+
+ {editing
+ ? (
+ <>
+ }
+ disabled={busy || subject.trim() === ''}
+ onClick={save}
+ >
+ {creating ? t('teamTaskCreate') : t('teamTaskSave')}
+
+
+ >
+ )
+ : task === undefined ? null : (
+ <>
+ }
+ onClick={() => { setEditing(true) }}
+ >
+ {t('teamTaskEdit')}
+
+ {task.status === 'completed'
+ ? (
+ }
+ disabled={busy}
+ onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
+ taskId: task.id, expectedRevision: task.revision, action: 'reopen',
+ }))}
+ >
+ {t('teamTaskReopen')}
+
+ )
+ : (
+ }
+ disabled={busy}
+ onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
+ taskId: task.id, expectedRevision: task.revision, action: 'complete',
+ }))}
+ >
+ {t('teamTaskComplete')}
+
+ )}
+ }
+ 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')}
+
+ >
+ )}
+
+
+ )
+}
+
+/**
+ * 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 (
+ (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)
})}
@@ -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)
}}
>
@@ -414,19 +414,7 @@ function renderAgentNode(
{agentMeta(node)}
{node.state === 'running' && }
-
- }
- aria-label={t('tasksNodeDetail')}
- title={t('tasksNodeDetail')}
- onClick={(event) => {
- event.stopPropagation()
- onNodeInfo(node, event.currentTarget)
- }}
- />
+
)
}
diff --git a/src/client/TasksPopovers.tsx b/src/client/TasksPopovers.tsx
index f342077..1489bbf 100644
--- a/src/client/TasksPopovers.tsx
+++ b/src/client/TasksPopovers.tsx
@@ -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: {
{t('tasksNodeTasks')}
{(node.tasks ?? []).map(task => (
-
+
+
))}
diff --git a/src/client/TasksTree.tsx b/src/client/TasksTree.tsx
index c49c1ae..b3279d9 100644
--- a/src/client/TasksTree.tsx
+++ b/src/client/TasksTree.tsx
@@ -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(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) })
+ }}
>
@@ -156,20 +159,8 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
{node.label}
{agentMeta(node)}
{node.state === 'running' && }
-
+
- }
- aria-label={t('tasksNodeDetail')}
- title={t('tasksNodeDetail')}
- onClick={(event) => {
- event.stopPropagation()
- onNodeInfo(node, event.currentTarget)
- }}
- />
)
if (children.length === 0) return {row}
diff --git a/src/client/TeamBoard.tsx b/src/client/TeamBoard.tsx
index d8517ef..557f674 100644
--- a/src/client/TeamBoard.tsx
+++ b/src/client/TeamBoard.tsx
@@ -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(undefined)
- const [draft, setDraft] = useState(undefined)
- const [menuTaskId, setMenuTaskId] = useState(undefined)
- const [armedDeleteId, setArmedDeleteId] = useState(undefined)
+ const { members, tasks, onOpenTask, collapsed, onToggleCollapsed } = props
const [ownerFilter, setOwnerFilter] = useState(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): Promise => {
- 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: })
- } else {
- entries.push({ id: 'complete', label: t('teamTaskComplete'), icon: })
- }
- entries.push({ id: 'edit', label: t('teamTaskEdit'), icon: })
- if (teammates.length > 0) {
- entries.push({
- id: 'reassign',
- label: t('teamTaskOwner'),
- icon: ,
- 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: ,
- 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 => {
- 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 (
))}
- {note !== undefined && {note}
}
}
- disabled={busy}
- onClick={() => { setDraft({ task: undefined, subject: '', description: '', owner: '' }) }}
+ onClick={(event) => { onOpenTask(undefined, event.currentTarget) }}
>
{t('teamTaskCreate')}
>
)}
- { setDraft(undefined) }}
- title={draft?.task === undefined ? t('teamTaskCreate') : t('teamTaskEdit')}
- closeLabel={t('teamTaskCancel')}
- footer={(
- <>
- { setDraft(undefined) }}>
- {t('teamTaskCancel')}
-
- { void saveDraft() }}
- >
- {draft?.task === undefined ? t('teamTaskCreate') : t('teamTaskSave')}
-
- >
- )}
- >
-
-
-
-
-
{t('teamTaskOwner')}
-
-
{
- setDraft(current => (current === undefined ? current : { ...current, owner: '' }))
- }}
- >
- {t('teamTaskUnowned')}
-
- {teammates.map(member => (
-
{
- setDraft(current => (current === undefined ? current : { ...current, owner: member.name }))
- }}
- >
- {member.name}
-
- ))}
-
-
-
-
)
}
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index 8833520..8a720eb 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -444,6 +444,9 @@ export const ar: Record = {
teamTaskError: 'فشلت العملية: {message}',
teamTaskDeleted: 'محذوفة',
teamTaskActions: 'إجراءات المهمة',
+ teamTaskDetail: 'تفاصيل المهمة',
+ teamTaskNoDescription: 'لا يوجد وصف بعد — انقر على «تحرير» لإضافة واحد',
+ teamTaskBlockedBy: 'محجوبة بسبب',
teamTaskSubjectPlaceholder: 'سطر واحد يصف العمل',
teamTaskDescriptionPlaceholder: 'تفاصيل إضافية (اختياري)',
teamFilterAll: 'الكل',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index 3463e14..2d23afe 100644
--- a/src/client/locales-de.ts
+++ b/src/client/locales-de.ts
@@ -429,6 +429,9 @@ export const de: Record = {
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',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index aa4002d..7f88f29 100644
--- a/src/client/locales-fr.ts
+++ b/src/client/locales-fr.ts
@@ -436,6 +436,9 @@ export const fr: Record = {
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',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index 33aa194..9305a08 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -443,6 +443,9 @@ export const hi: Record = {
teamTaskError: 'ऑपरेशन विफल: {message}',
teamTaskDeleted: 'हटाया गया',
teamTaskActions: 'कार्य क्रियाएँ',
+ teamTaskDetail: 'कार्य विवरण',
+ teamTaskNoDescription: 'अभी कोई विवरण नहीं — जोड़ने के लिए “संपादित करें” पर क्लिक करें',
+ teamTaskBlockedBy: 'इनसे अवरुद्ध',
teamTaskSubjectPlaceholder: 'काम को एक पंक्ति में बताएँ',
teamTaskDescriptionPlaceholder: 'अतिरिक्त जानकारी (वैकल्पिक)',
teamFilterAll: 'सभी',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 3da931e..a94f160 100644
--- a/src/client/locales-id.ts
+++ b/src/client/locales-id.ts
@@ -441,6 +441,9 @@ export const id: Record = {
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',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index 396cf45..e406de3 100644
--- a/src/client/locales-it.ts
+++ b/src/client/locales-it.ts
@@ -434,6 +434,9 @@ export const it: Record = {
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',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index b8be79b..8892b2c 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -443,6 +443,9 @@ export const ja: Record = {
teamTaskError: '操作に失敗: {message}',
teamTaskDeleted: '削除済み',
teamTaskActions: 'タスク操作',
+ teamTaskDetail: 'タスク詳細',
+ teamTaskNoDescription: '説明はまだありません(「編集」で追加)',
+ teamTaskBlockedBy: 'ブロック元',
teamTaskSubjectPlaceholder: 'やることを一行で',
teamTaskDescriptionPlaceholder: '補足(任意)',
teamFilterAll: 'すべて',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index f2e211f..b02a367 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -435,6 +435,9 @@ export const ko: Record = {
teamTaskError: '작업 실패: {message}',
teamTaskDeleted: '삭제됨',
teamTaskActions: '작업 동작',
+ teamTaskDetail: '작업 상세',
+ teamTaskNoDescription: '설명이 아직 없음 — “편집”을 클릭해 추가',
+ teamTaskBlockedBy: '차단 원인',
teamTaskSubjectPlaceholder: '할 일을 한 줄로 설명',
teamTaskDescriptionPlaceholder: '추가 설명 (선택)',
teamFilterAll: '모두',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index 1485894..07dd104 100644
--- a/src/client/locales-nl.ts
+++ b/src/client/locales-nl.ts
@@ -441,6 +441,9 @@ export const nl: Record = {
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',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 05c0725..700ef24 100644
--- a/src/client/locales-pl.ts
+++ b/src/client/locales-pl.ts
@@ -445,6 +445,9 @@ export const pl: Record = {
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',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 79dd3ea..d027c35 100644
--- a/src/client/locales-pt.ts
+++ b/src/client/locales-pt.ts
@@ -426,6 +426,9 @@ export const pt: Record = {
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',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index 9d1b29a..09f5b9a 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -441,6 +441,9 @@ export const ru: Record = {
teamTaskError: 'Не удалось выполнить: {message}',
teamTaskDeleted: 'Удалена',
teamTaskActions: 'Действия с задачей',
+ teamTaskDetail: 'Детали задачи',
+ teamTaskNoDescription: 'Описание пока отсутствует — нажмите «Изменить», чтобы добавить',
+ teamTaskBlockedBy: 'Заблокирована задачей',
teamTaskSubjectPlaceholder: 'Опишите работу одной строкой',
teamTaskDescriptionPlaceholder: 'Дополнительные сведения (необязательно)',
teamFilterAll: 'Все',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index c51801f..7e6eef0 100644
--- a/src/client/locales-sv.ts
+++ b/src/client/locales-sv.ts
@@ -426,6 +426,9 @@ export const sv: Record = {
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',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index 1de2e56..a47ac67 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -443,6 +443,9 @@ export const th: Record = {
teamTaskError: 'ดำเนินการไม่สำเร็จ: {message}',
teamTaskDeleted: 'ลบแล้ว',
teamTaskActions: 'การดำเนินการของงาน',
+ teamTaskDetail: 'รายละเอียดงาน',
+ teamTaskNoDescription: 'ยังไม่มีคำอธิบาย — คลิก “แก้ไข” เพื่อเพิ่ม',
+ teamTaskBlockedBy: 'ถูกบล็อกโดย',
teamTaskSubjectPlaceholder: 'อธิบายงานที่ต้องทำในบรรทัดเดียว',
teamTaskDescriptionPlaceholder: 'รายละเอียดเพิ่มเติม (ไม่บังคับ)',
teamFilterAll: 'ทั้งหมด',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index 21c16cc..53e8e5c 100644
--- a/src/client/locales-tr.ts
+++ b/src/client/locales-tr.ts
@@ -443,6 +443,9 @@ export const tr: Record = {
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ü',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index 3a334c6..04c3df6 100644
--- a/src/client/locales-vi.ts
+++ b/src/client/locales-vi.ts
@@ -443,6 +443,9 @@ export const vi: Record = {
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ả',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index f6e3ea6..a12ac79 100644
--- a/src/client/locales-zh-HK.ts
+++ b/src/client/locales-zh-HK.ts
@@ -458,6 +458,9 @@ export const zhHK: Record = {
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
+ teamTaskDetail: '任務詳情',
+ teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
+ teamTaskBlockedBy: '受阻於',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index e5d9275..75b5ab9 100644
--- a/src/client/locales-zh-MO.ts
+++ b/src/client/locales-zh-MO.ts
@@ -458,6 +458,9 @@ export const zhMO: Record = {
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
+ teamTaskDetail: '任務詳情',
+ teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
+ teamTaskBlockedBy: '受阻於',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index 260c86f..da47f2e 100644
--- a/src/client/locales-zh-TW.ts
+++ b/src/client/locales-zh-TW.ts
@@ -458,6 +458,9 @@ export const zhTW: Record = {
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
+ teamTaskDetail: '任務詳情',
+ teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
+ teamTaskBlockedBy: '受阻於',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 3d219ad..4650a3c 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -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 = {
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',
diff --git a/src/client/tasks-graph.module.css b/src/client/tasks-graph.module.css
index da7bd4c..49d1b9b 100644
--- a/src/client/tasks-graph.module.css
+++ b/src/client/tasks-graph.module.css
@@ -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 {
diff --git a/src/client/tasks-shared.tsx b/src/client/tasks-shared.tsx
index 627a5b4..d83e077 100644
--- a/src/client/tasks-shared.tsx
+++ b/src/client/tasks-shared.tsx
@@ -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 (
`${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)
+ }}
>
{primary.subject}
diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx
index 37b3e86..f5e9d4c 100644
--- a/tests/tasks-page.spec.tsx
+++ b/tests/tasks-page.spec.tsx
@@ -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()
+ })
+})