@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -11
  3. package/README.zh.md +42 -11
  4. package/lib/client.js +4958 -472
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2841 -121
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/board-card-seat.d.ts +34 -0
  26. package/lib/types/client/index.d.ts +19 -0
  27. package/lib/types/client/locales.d.ts +103 -1
  28. package/lib/types/client/native-panel.d.ts +6 -20
  29. package/lib/types/client/seats.d.ts +18 -0
  30. package/lib/types/client/service.d.ts +32 -0
  31. package/lib/types/client/settings-form.d.ts +16 -0
  32. package/lib/types/core/controller.d.ts +37 -1
  33. package/lib/types/core/extension.d.ts +283 -0
  34. package/lib/types/core/handover.d.ts +13 -1
  35. package/lib/types/core/issue-form.d.ts +16 -0
  36. package/lib/types/core/tasks.d.ts +84 -3
  37. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  38. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  39. package/lib/types/core/verification.d.ts +386 -0
  40. package/lib/types/core/workspace-target.d.ts +41 -0
  41. package/lib/types/host/agent-tools.d.ts +1 -1
  42. package/lib/types/host/extension-registry.d.ts +111 -0
  43. package/lib/types/host/llm-dispatch.d.ts +63 -0
  44. package/lib/types/host/verification-gate.d.ts +71 -0
  45. package/lib/types/host/verification-runner.d.ts +108 -0
  46. package/lib/types/host/workspace-evidence.d.ts +53 -0
  47. package/lib/types/host-ledger.d.ts +52 -8
  48. package/lib/types/host-runner.d.ts +23 -1
  49. package/lib/types/host-service.d.ts +88 -2
  50. package/lib/types/index.d.ts +80 -26
  51. package/lib/types/protocol.d.ts +49 -5
  52. package/lib/types/tool-surface.d.ts +71 -0
  53. package/package.json +27 -26
  54. package/src/client/PluginSettingsCard.tsx +9 -0
  55. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  56. package/src/client/board/ClampedMarkdown.tsx +69 -0
  57. package/src/client/board/ConfirmDialog.tsx +7 -3
  58. package/src/client/board/EditTaskModal.tsx +5 -2
  59. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  60. package/src/client/board/NewTaskModal.tsx +17 -1
  61. package/src/client/board/TagManagerModal.tsx +235 -0
  62. package/src/client/board/TaskBoard.tsx +259 -103
  63. package/src/client/board/TaskCard.tsx +214 -81
  64. package/src/client/board/TaskDetail.tsx +71 -26
  65. package/src/client/board/TaskForm.tsx +13 -3
  66. package/src/client/board/VerificationReport.tsx +203 -0
  67. package/src/client/board/card-view.ts +220 -0
  68. package/src/client/board/icons.tsx +89 -0
  69. package/src/client/board/overlay.tsx +189 -0
  70. package/src/client/board/status-key.ts +27 -0
  71. package/src/client/board/task-markdown.module.css +137 -0
  72. package/src/client/board/task-markdown.tsx +106 -0
  73. package/src/client/board-card-seat.ts +102 -0
  74. package/src/client/board-settings.module.css +66 -0
  75. package/src/client/board.module.css +488 -63
  76. package/src/client/index.ts +43 -5
  77. package/src/client/locales.ts +208 -4
  78. package/src/client/native-panel.tsx +18 -2
  79. package/src/client/seats.tsx +58 -0
  80. package/src/client/service.ts +94 -0
  81. package/src/client/settings-card.module.css +31 -5
  82. package/src/client/settings-form.ts +27 -1
  83. package/src/core/controller.ts +94 -3
  84. package/src/core/extension.ts +376 -0
  85. package/src/core/freeze-snapshot.ts +2 -2
  86. package/src/core/handover.ts +16 -1
  87. package/src/core/issue-form.ts +19 -0
  88. package/src/core/store.ts +16 -0
  89. package/src/core/tasks.ts +100 -6
  90. package/src/core/use-cases/task-parent.ts +3 -2
  91. package/src/core/use-cases/task-tag.ts +110 -0
  92. package/src/core/use-cases/task-update.ts +10 -4
  93. package/src/core/verification.ts +826 -0
  94. package/src/core/workspace-target.ts +59 -0
  95. package/src/host/agent-tools.ts +20 -18
  96. package/src/host/extension-registry.ts +509 -0
  97. package/src/host/llm-dispatch.ts +87 -0
  98. package/src/host/verification-gate.ts +331 -0
  99. package/src/host/verification-runner.ts +535 -0
  100. package/src/host/workspace-evidence.ts +176 -0
  101. package/src/host-ai.ts +15 -9
  102. package/src/host-ledger.ts +143 -32
  103. package/src/host-routes.ts +15 -2
  104. package/src/host-runner.ts +60 -11
  105. package/src/host-service.ts +253 -10
  106. package/src/index.ts +204 -16
  107. package/src/protocol.ts +102 -8
  108. package/src/tool-surface.ts +95 -0
@@ -3,25 +3,65 @@
3
3
  * active. Cards open the task detail (never execute directly); the header
4
4
  * offers filter, new-task, and a back-to-chat escape.
5
5
  */
6
- import { memo, useCallback, useEffect, useState } from 'react'
6
+ import { memo, useCallback, useEffect, useRef, useState } from 'react'
7
7
  import { selectedTaskOf, type BoardController } from '../../core/controller.ts'
8
- import { COLUMNS, canMoveManually, collectKnownTags, tagTone, type TaskRecord } from '../../core/tasks.ts'
8
+ import { COLUMNS, MANUAL_STATUSES, canMoveTask, collectKnownTags, hasOpenExecution, tagTone, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
9
9
  import { t } from '../locales.ts'
10
10
  import css from '../board.module.css'
11
+ import { IconChevronLeft, IconCompactRows, IconExpandRows, IconPlus } from './icons.tsx'
11
12
  import { NewTaskModal } from './NewTaskModal.tsx'
13
+ import { usePresence } from './overlay.tsx'
12
14
  import { STATUS_KEY } from './status-key.ts'
15
+ import { TagManagerModal } from './TagManagerModal.tsx'
16
+ import { cardTimeline, groupByRecency, readCompactColumns, RECENCY_KEY, writeCompactColumns, type RecencyGroup } from './card-view.ts'
13
17
  import { TaskCard } from './TaskCard.tsx'
14
18
  import { TaskDetail } from './TaskDetail.tsx'
15
19
 
16
20
  /** Sentinel option value of the project row's "register a new project" entry. */
17
21
  export const NEW_PROJECT_VALUE = '__dsh_new_project__'
18
22
 
19
- /** Case-insensitive title/description/tag/freeze-snapshot match. */
23
+ /**
24
+ * Collect every string leaf of an opaque extension payload. The board does not
25
+ * interpret provider data, but a provider's identifiers and labels should stay
26
+ * searchable from the board's own filter, so the leaf strings join the haystack.
27
+ */
28
+ function collectSearchLeaves(value: unknown, out: string[]): void {
29
+ if (typeof value === 'string') {
30
+ out.push(value)
31
+ return
32
+ }
33
+ if (typeof value === 'number' || typeof value === 'boolean') {
34
+ out.push(String(value))
35
+ return
36
+ }
37
+ if (Array.isArray(value)) {
38
+ for (const item of value) collectSearchLeaves(item, out)
39
+ return
40
+ }
41
+ if (typeof value === 'object' && value !== null) {
42
+ for (const nested of Object.values(value)) collectSearchLeaves(nested, out)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Case-insensitive title/description/tag/freeze-snapshot/provider-payload match.
48
+ *
49
+ * A leading `#` is dropped from the needle. Providers render their own
50
+ * identifiers with that prefix (`#1758`) and the board attaches no meaning to
51
+ * it: stripping it keeps the identifier searchable exactly as it is written on
52
+ * screen, without the board learning any provider's vocabulary.
53
+ */
20
54
  export function matchesFilter(task: TaskRecord, filter: string): boolean {
21
- if (filter.trim() === '') return true
22
- const needle = filter.trim().toLowerCase()
55
+ const query = filter.trim().toLowerCase()
56
+ if (query === '') return true
57
+ const needle = query.startsWith('#') ? query.slice(1) : query
23
58
  const haystacks = [task.title, task.description, ...(task.tags ?? []).map(tag => tag.name)]
24
59
  if (task.freeze !== undefined) haystacks.push(task.freeze.goal, task.freeze.progress, task.freeze.next)
60
+ if (task.integrations !== undefined) {
61
+ const leaves: string[] = []
62
+ collectSearchLeaves(task.integrations, leaves)
63
+ haystacks.push(...leaves)
64
+ }
25
65
  return haystacks.some(text => text.toLowerCase().includes(needle))
26
66
  }
27
67
 
@@ -43,11 +83,13 @@ export function matchesTagFilter(task: TaskRecord, selected: readonly string[]):
43
83
  * re-renders only when its own task changes — not when a sibling card status,
44
84
  * the filter, or the selection moves.
45
85
  */
46
- const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen, subtaskCount, isSubtask, subtasksDone, subtasksRunning, subtasksFailed }: {
86
+ const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen, onOpenSession, compact, subtaskCount, isSubtask, subtasksDone, subtasksRunning, subtasksFailed }: {
47
87
  task: TaskRecord
48
88
  pending: boolean
49
89
  timeZone?: string
50
90
  onOpen: (id: string) => void
91
+ onOpenSession: (sessionId: string) => void
92
+ compact: boolean
51
93
  subtaskCount: number
52
94
  isSubtask: boolean
53
95
  subtasksDone: number
@@ -61,6 +103,8 @@ const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpe
61
103
  pending={pending}
62
104
  timeZone={timeZone}
63
105
  onClick={onClick}
106
+ onOpenSession={onOpenSession}
107
+ compact={compact}
64
108
  subtaskCount={subtaskCount}
65
109
  isSubtask={isSubtask}
66
110
  subtasksDone={subtasksDone}
@@ -70,6 +114,15 @@ const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpe
70
114
  )
71
115
  })
72
116
 
117
+ /** Settled columns whose cards group by recency and fold the oldest group. */
118
+ const GROUPED_COLUMNS: readonly TaskStatus[] = ['done', 'failed']
119
+
120
+ /** Cards a grouped column renders before the oldest group folds. */
121
+ export const COLUMN_FOLD_THRESHOLD = 30
122
+
123
+ /** Column key of the archive view in the fold-open set. */
124
+ const ARCHIVE_COLUMN = 'archived'
125
+
73
126
  /** Board component; subscribes to the controller snapshot. */
74
127
  export function TaskBoard({ controller }: { controller: BoardController }) {
75
128
  const [snapshot, setSnapshot] = useState(controller.getSnapshot())
@@ -84,7 +137,13 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
84
137
  // the header switch reveals the flat view. A text or label filter re-enables
85
138
  // them automatically, so searching a subtask title still finds it.
86
139
  const [hideSubtasks, setHideSubtasks] = useState(true)
140
+ // Density is a per-column browser preference: the long settled column starts
141
+ // compact, and each column header toggles its own density.
142
+ const [compactColumns, setCompactColumns] = useState<TaskStatus[]>(() => readCompactColumns(globalThis.localStorage))
143
+ // Grouped columns whose folded "earlier" group the user expanded this visit.
144
+ const [unfolded, setUnfolded] = useState<string[]>([])
87
145
  const [showNew, setShowNew] = useState(false)
146
+ const [showTagManager, setShowTagManager] = useState(false)
88
147
  // Project partition (#1536): '' means "all projects". A selected project
89
148
  // narrows the board and becomes the new-task form's default workspace.
90
149
  const [projectId, setProjectId] = useState('')
@@ -94,13 +153,28 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
94
153
  const [newProjectPending, setNewProjectPending] = useState(false)
95
154
  const selected = selectedTaskOf(snapshot)
96
155
  const archiveView = snapshot.archiveView
156
+ // Overlay lifecycle: both the detail view and the create dialog keep the
157
+ // last thing they showed while their exit leg runs, so closing one is the
158
+ // return trip of opening it instead of a node disappearing from under the
159
+ // pointer. The detail view's task is remembered so the surface still has its
160
+ // content to animate out after the selection is cleared.
161
+ const detailPresence = usePresence(selected !== undefined)
162
+ const lastDetail = useRef<TaskRecord | undefined>(undefined)
163
+ useEffect(() => { if (selected !== undefined) lastDetail.current = selected }, [selected])
164
+ const detailTask = selected ?? lastDetail.current
165
+ const newTaskPresence = usePresence(showNew)
166
+ const tagManagerPresence = usePresence(showTagManager)
97
167
  // Every label in use across the ledger (board and archive alike), so the
98
168
  // filter never loses an option just because its task was archived.
99
169
  const knownTags = collectKnownTags(snapshot.tasks)
100
- // The family of cards this view owns: the board columns, or the archive.
101
- const onBoard = snapshot.tasks.filter(task =>
102
- archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined,
103
- )
170
+ // The family of cards this view owns: the board columns, or the archive. A
171
+ // provider-hidden card (board hidden flag, or any registered visibility
172
+ // predicate rejecting it) never appears here.
173
+ const onBoard = snapshot.tasks.filter(task => {
174
+ if (task.hidden === true) return false
175
+ if (!(snapshot.visibility ?? []).every(predicate => predicate(task))) return false
176
+ return archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined
177
+ })
104
178
  // Direct subtask count and state roll-up per task, for the card badge: a
105
179
  // hidden tree still has to report how many children run, fail, or finish.
106
180
  const subtaskCounts = new Map<string, number>()
@@ -109,8 +183,10 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
109
183
  if (task.parentId === undefined) continue
110
184
  subtaskCounts.set(task.parentId, (subtaskCounts.get(task.parentId) ?? 0) + 1)
111
185
  const rollup = subtaskRollup.get(task.parentId) ?? { done: 0, running: 0, failed: 0 }
112
- if (task.status === 'done') rollup.done += 1
113
- else if (task.status === 'running') rollup.running += 1
186
+ // "Running" in the badge means the runner is executing that subtask; a
187
+ // subtask parked in the running column by hand is not executing anything.
188
+ if (hasOpenExecution(task)) rollup.running += 1
189
+ else if (task.status === 'done') rollup.done += 1
114
190
  else if (task.status === 'failed') rollup.failed += 1
115
191
  subtaskRollup.set(task.parentId, rollup)
116
192
  }
@@ -147,6 +223,68 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
147
223
  : [...current, name])
148
224
  }, [])
149
225
  const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
226
+ const openSession = useCallback((sessionId: string): void => { controller.openSession(sessionId) }, [controller])
227
+ const toggleCompact = useCallback((status: TaskStatus): void => {
228
+ setCompactColumns(current => {
229
+ const next = current.includes(status) ? current.filter(entry => entry !== status) : [...current, status]
230
+ writeCompactColumns(globalThis.localStorage, next)
231
+ return next
232
+ })
233
+ }, [])
234
+ const timeZone = snapshot.host?.scheduler.timeZone
235
+ const now = Date.now()
236
+ const renderCard = (task: TaskRecord, compact: boolean) => (
237
+ <MemoTaskCard
238
+ key={task.id}
239
+ task={task}
240
+ pending={snapshot.pendingTaskIds.includes(task.id)}
241
+ timeZone={timeZone}
242
+ onOpen={openTask}
243
+ onOpenSession={openSession}
244
+ compact={compact}
245
+ subtaskCount={subtaskCounts.get(task.id) ?? 0}
246
+ isSubtask={task.parentId !== undefined}
247
+ subtasksDone={subtaskRollup.get(task.id)?.done ?? 0}
248
+ subtasksRunning={subtaskRollup.get(task.id)?.running ?? 0}
249
+ subtasksFailed={subtaskRollup.get(task.id)?.failed ?? 0}
250
+ />
251
+ )
252
+ /**
253
+ * Settled cards in recency groups, newest first. A column longer than the
254
+ * fold threshold folds its oldest group behind a disclosure, so a column of
255
+ * a hundred finished issues opens on what happened lately; a text or label
256
+ * search never folds, because a match hidden behind a fold reads as no match.
257
+ */
258
+ const renderGrouped = (columnKey: string, tasks: TaskRecord[], compact: boolean, archived: boolean) => {
259
+ const buckets = groupByRecency(tasks, task => cardTimeline(task, archived).at, now, timeZone)
260
+ const foldable = !searchActive && tasks.length > COLUMN_FOLD_THRESHOLD && buckets.length > 1
261
+ const open = unfolded.includes(columnKey)
262
+ return buckets.map(bucket => {
263
+ const folded = foldable && bucket.group === buckets[buckets.length - 1]!.group && !open
264
+ return (
265
+ <div key={bucket.group} className={css.cardGroup} data-dsh-part="card-group" data-group={bucket.group}>
266
+ <div className={css.cardGroupHeader}>
267
+ <span>{t(RECENCY_KEY[bucket.group as RecencyGroup])}</span>
268
+ <span className={css.cardGroupCount}>{bucket.items.length}</span>
269
+ {foldable && bucket.group === buckets[buckets.length - 1]!.group && (
270
+ <button
271
+ type="button"
272
+ className={css.linkButton}
273
+ data-dsh-part="card-group-toggle"
274
+ aria-expanded={!folded}
275
+ onClick={() => {
276
+ setUnfolded(current => current.includes(columnKey) ? current.filter(entry => entry !== columnKey) : [...current, columnKey])
277
+ }}
278
+ >
279
+ {folded ? t('board.group.expand', { count: String(bucket.items.length) }) : t('board.group.collapse')}
280
+ </button>
281
+ )}
282
+ </div>
283
+ {!folded && bucket.items.map(task => renderCard(task, compact))}
284
+ </div>
285
+ )
286
+ })
287
+ }
150
288
 
151
289
  return (
152
290
  <div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
@@ -159,7 +297,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
159
297
  aria-label={t('board.close')}
160
298
  onClick={() => { controller.closeBoard() }}
161
299
  >
162
- <span aria-hidden="true">‹</span>
300
+ <IconChevronLeft size={15} />
163
301
  <span>{t('board.close')}</span>
164
302
  </button>
165
303
  <h2 className={css.boardTitle}>{t('board.title')}</h2>
@@ -171,68 +309,78 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
171
309
  })}
172
310
  </span>
173
311
  )}
174
- {(projects.length > 0 || canCreateProject) && (
175
- <label className={css.projectFilter}>
176
- <span className={css.projectFilterLabel}>{t('board.project')}</span>
177
- <select
178
- className={css.select}
179
- data-dsh-part="project-filter"
180
- value={projectId}
181
- aria-label={t('board.project')}
182
- onChange={event => {
183
- const value = event.target.value
184
- if (value === NEW_PROJECT_VALUE) {
185
- setNewProjectError(undefined)
186
- setShowNewProject(true)
187
- return
188
- }
189
- setProjectId(value)
190
- }}
312
+ {/* Filters, view toggles and the create action share one toolbar group so
313
+ the header reads as identity + tools instead of one long strip. The
314
+ toggles keep the outlined control and only change role colour when
315
+ they are on, so pressing one never resizes the row. */}
316
+ <div className={css.boardTools}>
317
+ {(projects.length > 0 || canCreateProject) && (
318
+ <label className={css.projectFilter}>
319
+ <span className={css.projectFilterLabel}>{t('board.project')}</span>
320
+ <select
321
+ className={css.select}
322
+ data-dsh-part="project-filter"
323
+ value={projectId}
324
+ aria-label={t('board.project')}
325
+ onChange={event => {
326
+ const value = event.target.value
327
+ if (value === NEW_PROJECT_VALUE) {
328
+ setNewProjectError(undefined)
329
+ setShowNewProject(true)
330
+ return
331
+ }
332
+ setProjectId(value)
333
+ }}
334
+ >
335
+ <option value="">{t('board.projectAll')}</option>
336
+ {projects.map(project => (
337
+ <option key={project.workspaceId} value={project.workspaceId}>{project.title}</option>
338
+ ))}
339
+ {canCreateProject && <option value={NEW_PROJECT_VALUE}>{t('board.projectNew')}</option>}
340
+ </select>
341
+ </label>
342
+ )}
343
+ <input
344
+ className={css.search}
345
+ type="search"
346
+ placeholder={t('board.search')}
347
+ value={filter}
348
+ onChange={event => { setFilter(event.target.value) }}
349
+ aria-label={t('board.search')}
350
+ />
351
+ {hasSubtasks && (
352
+ <button
353
+ type="button"
354
+ className={css.ghostButton}
355
+ data-dsh-part="subtask-filter"
356
+ data-active={hidingSubtasks ? 'true' : undefined}
357
+ aria-pressed={hidingSubtasks}
358
+ title={t('board.subtaskFilterHint')}
359
+ onClick={() => { setHideSubtasks(value => !value) }}
191
360
  >
192
- <option value="">{t('board.projectAll')}</option>
193
- {projects.map(project => (
194
- <option key={project.workspaceId} value={project.workspaceId}>{project.title}</option>
195
- ))}
196
- {canCreateProject && <option value={NEW_PROJECT_VALUE}>{t('board.projectNew')}</option>}
197
- </select>
198
- </label>
199
- )}
200
- <input
201
- className={css.search}
202
- type="search"
203
- placeholder={t('board.search')}
204
- value={filter}
205
- onChange={event => { setFilter(event.target.value) }}
206
- aria-label={t('board.search')}
207
- />
208
- {hasSubtasks && (
361
+ {hidingSubtasks ? t('board.showSubtasks') : t('board.hideSubtasks')}
362
+ </button>
363
+ )}
209
364
  <button
210
365
  type="button"
211
- className={hidingSubtasks ? css.primaryButton : css.ghostButton}
212
- data-dsh-part="subtask-filter"
213
- aria-pressed={hidingSubtasks}
214
- title={t('board.subtaskFilterHint')}
215
- onClick={() => { setHideSubtasks(value => !value) }}
366
+ className={css.ghostButton}
367
+ data-active={archiveView ? 'true' : undefined}
368
+ aria-pressed={archiveView}
369
+ onClick={() => { controller.toggleArchiveView() }}
216
370
  >
217
- {hidingSubtasks ? t('board.showSubtasks') : t('board.hideSubtasks')}
371
+ {archiveView
372
+ ? t('board.backToBoard')
373
+ : t('board.archiveView', { count: String(snapshot.tasks.filter(task => task.archivedAt !== undefined).length) })}
218
374
  </button>
219
- )}
220
- <button
221
- type="button"
222
- className={archiveView ? css.primaryButton : css.ghostButton}
223
- onClick={() => { controller.toggleArchiveView() }}
224
- >
225
- {archiveView
226
- ? t('board.backToBoard')
227
- : t('board.archiveView', { count: String(snapshot.tasks.filter(task => task.archivedAt !== undefined).length) })}
228
- </button>
229
- <button
230
- type="button"
231
- className={css.primaryButton}
232
- onClick={() => { setShowNew(true) }}
233
- >
234
- + {t('board.new')}
235
- </button>
375
+ <button
376
+ type="button"
377
+ className={css.primaryButton}
378
+ onClick={() => { setShowNew(true) }}
379
+ >
380
+ <IconPlus size={15} />
381
+ {t('board.new')}
382
+ </button>
383
+ </div>
236
384
  </header>
237
385
 
238
386
  {showNewProject && (
@@ -289,6 +437,16 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
289
437
  </button>
290
438
  )
291
439
  })}
440
+ {/* The row can only shrink through the manager: without it a label
441
+ created once stays on the board forever. */}
442
+ <button
443
+ type="button"
444
+ className={css.linkButton}
445
+ data-dsh-part="tag-manage"
446
+ onClick={() => { setShowTagManager(true) }}
447
+ >
448
+ {t('board.tagManage')}
449
+ </button>
292
450
  {tagFilter.length > 0 && (
293
451
  <button type="button" className={css.linkButton} onClick={() => { setTagFilter([]) }}>
294
452
  {t('board.tagFilterClear')}
@@ -314,20 +472,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
314
472
  <span className={css.columnCount}>{visible.length}</span>
315
473
  </header>
316
474
  <div className={css.cards}>
317
- {visible.map(task => (
318
- <MemoTaskCard
319
- key={task.id}
320
- task={task}
321
- pending={snapshot.pendingTaskIds.includes(task.id)}
322
- timeZone={snapshot.host?.scheduler.timeZone}
323
- onOpen={openTask}
324
- subtaskCount={subtaskCounts.get(task.id) ?? 0}
325
- isSubtask={task.parentId !== undefined}
326
- subtasksDone={subtaskRollup.get(task.id)?.done ?? 0}
327
- subtasksRunning={subtaskRollup.get(task.id)?.running ?? 0}
328
- subtasksFailed={subtaskRollup.get(task.id)?.failed ?? 0}
329
- />
330
- ))}
475
+ {renderGrouped(ARCHIVE_COLUMN, visible, false, true)}
331
476
  {visible.length === 0 && (
332
477
  <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('archive.empty')}</div>
333
478
  )}
@@ -336,7 +481,11 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
336
481
  ) : (
337
482
  COLUMNS.map(column => {
338
483
  const tasks = visible.filter(task => task.status === column.status)
339
- const isManualDropTarget = column.status === 'backlog' || column.status === 'todo'
484
+ // Every manually reachable column accepts a drop; `running`
485
+ // never does, because only the runner opens an execution.
486
+ const isManualDropTarget = MANUAL_STATUSES.includes(column.status)
487
+ const compact = compactColumns.includes(column.status)
488
+ const grouped = GROUPED_COLUMNS.includes(column.status)
340
489
  return (
341
490
  <section
342
491
  key={column.status}
@@ -352,7 +501,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
352
501
  const taskId = event.dataTransfer.getData('text/plain')
353
502
  if (!taskId) return
354
503
  const dropped = snapshot.tasks.find(t => t.id === taskId)
355
- if (dropped && canMoveManually(dropped.status, column.status) && dropped.status !== column.status) {
504
+ if (dropped && canMoveTask(dropped, column.status)) {
356
505
  controller.moveTask(taskId, column.status)
357
506
  }
358
507
  } : undefined}
@@ -361,22 +510,21 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
361
510
  <span className={css.statusDot} data-status={column.status} aria-hidden="true" />
362
511
  <h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
363
512
  <span className={css.columnCount}>{tasks.length}</span>
513
+ <button
514
+ type="button"
515
+ className={css.densityToggle}
516
+ data-dsh-part="density-toggle"
517
+ data-active={compact ? 'true' : undefined}
518
+ aria-pressed={compact}
519
+ title={compact ? t('board.density.comfortable') : t('board.density.compact')}
520
+ aria-label={compact ? t('board.density.comfortable') : t('board.density.compact')}
521
+ onClick={() => { toggleCompact(column.status) }}
522
+ >
523
+ {compact ? <IconExpandRows size={13} /> : <IconCompactRows size={13} />}
524
+ </button>
364
525
  </header>
365
526
  <div className={css.cards}>
366
- {tasks.map(task => (
367
- <MemoTaskCard
368
- key={task.id}
369
- task={task}
370
- pending={snapshot.pendingTaskIds.includes(task.id)}
371
- timeZone={snapshot.host?.scheduler.timeZone}
372
- onOpen={openTask}
373
- subtaskCount={subtaskCounts.get(task.id) ?? 0}
374
- isSubtask={task.parentId !== undefined}
375
- subtasksDone={subtaskRollup.get(task.id)?.done ?? 0}
376
- subtasksRunning={subtaskRollup.get(task.id)?.running ?? 0}
377
- subtasksFailed={subtaskRollup.get(task.id)?.failed ?? 0}
378
- />
379
- ))}
527
+ {grouped ? renderGrouped(column.status, tasks, compact, false) : tasks.map(task => renderCard(task, compact))}
380
528
  {tasks.length === 0 && (
381
529
  <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('board.empty')}</div>
382
530
  )}
@@ -387,14 +535,22 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
387
535
  )}
388
536
  </div>
389
537
 
390
- {selected !== undefined && (
391
- <TaskDetail controller={controller} task={selected} />
538
+ {detailTask !== undefined && detailPresence.mounted && (
539
+ <TaskDetail controller={controller} task={detailTask} phase={detailPresence.phase} />
392
540
  )}
393
- {showNew && (
541
+ {newTaskPresence.mounted && (
394
542
  <NewTaskModal
395
543
  controller={controller}
396
544
  {...(projectId === '' ? {} : { defaultWorkspaceId: projectId })}
397
545
  onClose={() => { setShowNew(false) }}
546
+ phase={newTaskPresence.phase}
547
+ />
548
+ )}
549
+ {tagManagerPresence.mounted && (
550
+ <TagManagerModal
551
+ controller={controller}
552
+ onClose={() => { setShowTagManager(false) }}
553
+ phase={tagManagerPresence.phase}
398
554
  />
399
555
  )}
400
556
  </div>