@linxin666/dsh-client-ui-task-board 0.4.3 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +56 -18
- package/README.zh.md +56 -18
- package/lib/client.js +6242 -958
- package/lib/client.js.map +1 -1
- package/lib/index.js +3475 -234
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +23 -2
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/board-card-seat.d.ts +34 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +141 -3
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +38 -1
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +110 -5
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification.d.ts +386 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +107 -11
- package/lib/types/host-runner.d.ts +99 -4
- package/lib/types/host-service.d.ts +114 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +53 -3
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +30 -15
- package/src/client/board/NewTaskModal.tsx +288 -151
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +238 -86
- package/src/client/board/TaskDetail.tsx +154 -59
- package/src/client/board/TaskForm.tsx +61 -6
- package/src/client/board/VerificationReport.tsx +203 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board/status-key.ts +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +602 -68
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +48 -6
- package/src/client/locales.ts +290 -12
- package/src/client/native-panel.tsx +18 -2
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +101 -5
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +26 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +138 -10
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +58 -24
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +342 -51
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +219 -20
- package/src/host-service.ts +346 -26
- package/src/index.ts +204 -16
- package/src/protocol.ts +122 -9
- package/src/tool-surface.ts +95 -0
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
|
@@ -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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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={
|
|
212
|
-
data-
|
|
213
|
-
aria-pressed={
|
|
214
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
{
|
|
391
|
-
<TaskDetail controller={controller} task={
|
|
538
|
+
{detailTask !== undefined && detailPresence.mounted && (
|
|
539
|
+
<TaskDetail controller={controller} task={detailTask} phase={detailPresence.phase} />
|
|
392
540
|
)}
|
|
393
|
-
{
|
|
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>
|