@linxin666/dsh-client-ui-task-board 0.3.21 → 0.3.23

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 (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -0
  3. package/README.zh.md +5 -0
  4. package/lib/client.js +723 -29
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2629 -158
  7. package/lib/types/client/board/EditTaskModal.d.ts +7 -1
  8. package/lib/types/client/board/NewTaskModal.d.ts +6 -1
  9. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  10. package/lib/types/client/board/TaskForm.d.ts +18 -0
  11. package/lib/types/client/host-api.d.ts +23 -1
  12. package/lib/types/client/locales.d.ts +36 -0
  13. package/lib/types/core/controller.d.ts +35 -1
  14. package/lib/types/core/tasks.d.ts +60 -0
  15. package/lib/types/core/use-cases/task-update.d.ts +8 -1
  16. package/lib/types/host-ai.d.ts +51 -0
  17. package/lib/types/host-routes.d.ts +11 -1
  18. package/lib/types/index.d.ts +9 -0
  19. package/lib/types/protocol.d.ts +19 -0
  20. package/package.json +1 -1
  21. package/src/client/board/EditTaskModal.tsx +52 -3
  22. package/src/client/board/NewTaskModal.tsx +100 -5
  23. package/src/client/board/TaskBoard.tsx +150 -6
  24. package/src/client/board/TaskCard.tsx +17 -1
  25. package/src/client/board/TaskDetail.tsx +38 -2
  26. package/src/client/board/TaskForm.tsx +85 -0
  27. package/src/client/board.module.css +180 -0
  28. package/src/client/host-api.ts +124 -5
  29. package/src/client/index.ts +7 -0
  30. package/src/client/locales.ts +72 -0
  31. package/src/core/controller.ts +57 -1
  32. package/src/core/store.ts +5 -1
  33. package/src/core/tasks.ts +112 -0
  34. package/src/core/use-cases/task-update.ts +12 -2
  35. package/src/host-ai.ts +152 -0
  36. package/src/host-ledger.ts +25 -3
  37. package/src/host-routes.ts +68 -3
  38. package/src/host-runner.ts +24 -1
  39. package/src/index.ts +26 -1
  40. package/src/protocol.ts +45 -3
@@ -2,14 +2,14 @@
2
2
  * New-task modal: title + description + the prompt that execution will send.
3
3
  * Creates through the Host and closes only after the Host confirms it.
4
4
  */
5
- import { useEffect, useState } from 'react'
5
+ import { useEffect, useRef, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
7
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
8
  import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
9
- import { TASK_PERMISSIONS, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
9
+ import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
10
10
  import { t, type TaskBoardKey } from '../locales.ts'
11
11
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
12
- import { ModalShell, TaskContentFields } from './TaskForm.tsx'
12
+ import { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
13
13
  import css from '../board.module.css'
14
14
 
15
15
  export interface NewTaskModalProps {
@@ -17,17 +17,22 @@ export interface NewTaskModalProps {
17
17
  onClose: () => void
18
18
  /** Optional task template to clone/duplicate from. */
19
19
  initialTask?: TaskRecord
20
+ /**
21
+ * Workspace the board's project filter has selected (#1536): a task created
22
+ * while a project is open belongs to that project unless the user changes it.
23
+ */
24
+ defaultWorkspaceId?: string
20
25
  /** Optional callback after successful duplication (e.g. to archive source). */
21
26
  onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>
22
27
  }
23
28
 
24
29
  /** New-task form overlay. */
25
- export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps) {
30
+ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess }: NewTaskModalProps) {
26
31
  const isDuplicate = initialTask !== undefined
27
32
  const [title, setTitle] = useState(initialTask?.title ?? '')
28
33
  const [description, setDescription] = useState(initialTask?.description ?? '')
29
34
  const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
30
- const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? '')
35
+ const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? defaultWorkspaceId ?? '')
31
36
  const [mode, setMode] = useState(initialTask?.mode ?? '')
32
37
  const [permission, setPermission] = useState(initialTask?.permission ?? '')
33
38
  const [model, setModel] = useState(initialTask?.model ?? '')
@@ -40,10 +45,20 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
40
45
  const [handoverText, setHandoverText] = useState(
41
46
  initialTask?.handover?.references !== undefined ? initialTask.handover.references.join('\n') : '',
42
47
  )
48
+ const [tags, setTags] = useState<TaskTag[]>(initialTask?.tags ?? [])
43
49
  const [archiveOriginal, setArchiveOriginal] = useState(true)
44
50
  const [error, setError] = useState<string | undefined>(undefined)
45
51
  const [pending, setPending] = useState(false)
46
52
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
53
+ // "Parse pasted text" (issue #1540) exists only when the deployment carries a
54
+ // parse face; the section stays hidden otherwise.
55
+ const [canParse] = useState(controller.getSnapshot().canParseTask === true)
56
+ const [parseText, setParseText] = useState('')
57
+ const [parseModel, setParseModel] = useState('')
58
+ const [parsePending, setParsePending] = useState(false)
59
+ const [parseError, setParseError] = useState<string | undefined>(undefined)
60
+ const parseAbort = useRef<AbortController | undefined>(undefined)
61
+ const parseModels = options.models ?? []
47
62
 
48
63
  // The workspace list and preset roster arrive from the runtime after mount;
49
64
  // follow them so the pickers never freeze on an empty snapshot.
@@ -52,6 +67,36 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
52
67
  [controller],
53
68
  )
54
69
 
70
+ // The model roster arrives asynchronously: default to its first entry, which
71
+ // the user can change before parsing.
72
+ useEffect(() => {
73
+ if (parseModel === '' && parseModels.length > 0) setParseModel(parseModels[0]!.id)
74
+ }, [parseModel, options.models])
75
+
76
+ const runParse = async (): Promise<void> => {
77
+ const text = parseText.trim()
78
+ if (text === '') {
79
+ setParseError(t('new.aiParseEmpty'))
80
+ return
81
+ }
82
+ const abort = new AbortController()
83
+ parseAbort.current = abort
84
+ setParsePending(true)
85
+ setParseError(undefined)
86
+ try {
87
+ const draft = await controller.parseTaskDraft({ text, ...(parseModel === '' ? {} : { model: parseModel }) }, abort.signal)
88
+ setTitle(draft.title)
89
+ setDescription(draft.description)
90
+ setPrompt(draft.prompt)
91
+ } catch (parseFailure) {
92
+ // A cancelled parse reports nothing: the user asked for it to stop.
93
+ if (!abort.signal.aborted) setParseError(parseFailure instanceof Error ? parseFailure.message : String(parseFailure))
94
+ } finally {
95
+ parseAbort.current = undefined
96
+ setParsePending(false)
97
+ }
98
+ }
99
+
55
100
  const submit = async (): Promise<void> => {
56
101
  if (scheduleEnabled) {
57
102
  const cron = scheduleCron.trim()
@@ -81,6 +126,9 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
81
126
  mode: mode === '' ? undefined : mode,
82
127
  permission: permission === '' ? undefined : permission as TaskPermission,
83
128
  }
129
+ // Blank rows never reach the wire: the protocol rejects a tag with an
130
+ // empty name, and an empty list is expressed by omitting the field.
131
+ const tagList = cleanTags(tags)
84
132
  setPending(true)
85
133
  const task = await controller.createTaskConfirmed({
86
134
  title,
@@ -93,6 +141,7 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
93
141
  permission: permission === '' ? undefined : permission as TaskPermission,
94
142
  model: model === '' ? undefined : model,
95
143
  ...(reuseSession ? { reuseSession: true } : {}),
144
+ ...(tagList.length > 0 ? { tags: tagList } : {}),
96
145
  schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
97
146
  })
98
147
  if (task === undefined) {
@@ -127,6 +176,50 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
127
176
  onSubmit={() => { void submit() }}
128
177
  onClose={onClose}
129
178
  >
179
+ {canParse && (
180
+ <section className={css.aiParse} data-dsh-part="ai-parse">
181
+ <span className={css.fieldLabel}>{t('new.aiParse')}</span>
182
+ <p className={css.fieldHint}>{t('new.aiParseHint')}</p>
183
+ <textarea
184
+ className={css.input}
185
+ rows={3}
186
+ value={parseText}
187
+ placeholder={t('new.aiParsePlaceholder')}
188
+ spellCheck={false}
189
+ onChange={event => { setParseText(event.target.value); setParseError(undefined) }}
190
+ />
191
+ <div className={css.aiParseRow}>
192
+ <select
193
+ className={css.select}
194
+ value={parseModel}
195
+ aria-label={t('new.aiParseModel')}
196
+ onChange={event => { setParseModel(event.target.value) }}
197
+ >
198
+ {parseModels.map(option => (
199
+ <option key={option.id} value={option.id}>{option.name ?? option.id}</option>
200
+ ))}
201
+ </select>
202
+ {parsePending
203
+ ? (
204
+ <button type="button" className={css.ghostButton} onClick={() => { parseAbort.current?.abort() }}>
205
+ {t('new.aiParseCancel')}
206
+ </button>
207
+ )
208
+ : (
209
+ <button
210
+ type="button"
211
+ className={css.primaryButton}
212
+ disabled={parseText.trim() === ''}
213
+ onClick={() => { void runParse() }}
214
+ >
215
+ {t('new.aiParseRun')}
216
+ </button>
217
+ )}
218
+ </div>
219
+ {parseError !== undefined && <p className={css.formError}>{parseError}</p>}
220
+ </section>
221
+ )}
222
+
130
223
  <TaskContentFields
131
224
  title={title}
132
225
  description={description}
@@ -136,6 +229,8 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
136
229
  onPromptChange={setPrompt}
137
230
  />
138
231
 
232
+ <TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
233
+
139
234
  <label className={css.field}>
140
235
  <span className={css.fieldLabel}>{t('new.freeze')}</span>
141
236
  <textarea
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { memo, useCallback, useEffect, useState } from 'react'
7
7
  import { selectedTaskOf, type BoardController } from '../../core/controller.ts'
8
- import { COLUMNS, canMoveManually, type TaskRecord } from '../../core/tasks.ts'
8
+ import { COLUMNS, canMoveManually, collectKnownTags, tagTone, type TaskRecord } from '../../core/tasks.ts'
9
9
  import { t } from '../locales.ts'
10
10
  import css from '../board.module.css'
11
11
  import { NewTaskModal } from './NewTaskModal.tsx'
@@ -13,15 +13,30 @@ import { STATUS_KEY } from './status-key.ts'
13
13
  import { TaskCard } from './TaskCard.tsx'
14
14
  import { TaskDetail } from './TaskDetail.tsx'
15
15
 
16
- /** Case-insensitive title/description/freeze-snapshot match. */
16
+ /** Sentinel option value of the project row's "register a new project" entry. */
17
+ export const NEW_PROJECT_VALUE = '__dsh_new_project__'
18
+
19
+ /** Case-insensitive title/description/tag/freeze-snapshot match. */
17
20
  export function matchesFilter(task: TaskRecord, filter: string): boolean {
18
21
  if (filter.trim() === '') return true
19
22
  const needle = filter.trim().toLowerCase()
20
- const haystacks = [task.title, task.description]
23
+ const haystacks = [task.title, task.description, ...(task.tags ?? []).map(tag => tag.name)]
21
24
  if (task.freeze !== undefined) haystacks.push(task.freeze.goal, task.freeze.progress, task.freeze.next)
22
25
  return haystacks.some(text => text.toLowerCase().includes(needle))
23
26
  }
24
27
 
28
+ /**
29
+ * Whether a task carries every selected label (issue #1521). Multi-select is
30
+ * conjunctive: adding a label narrows the board instead of widening it, which
31
+ * is the only reading that keeps "工作" selected from dragging unrelated cards
32
+ * back in when a second label is added.
33
+ */
34
+ export function matchesTagFilter(task: TaskRecord, selected: readonly string[]): boolean {
35
+ if (selected.length === 0) return true
36
+ const names = new Set((task.tags ?? []).map(tag => tag.name))
37
+ return selected.every(name => names.has(name))
38
+ }
39
+
25
40
  /**
26
41
  * Memoized per-card adapter: with a stable `onOpen` from the board and an
27
42
  * immutable task record (only the changed card gets a new object ref), a card
@@ -41,14 +56,50 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
41
56
  [controller],
42
57
  )
43
58
  const [filter, setFilter] = useState('')
59
+ const [tagFilter, setTagFilter] = useState<string[]>([])
44
60
  const [showNew, setShowNew] = useState(false)
61
+ // Project partition (#1536): '' means "all projects". A selected project
62
+ // narrows the board and becomes the new-task form's default workspace.
63
+ const [projectId, setProjectId] = useState('')
64
+ const [showNewProject, setShowNewProject] = useState(false)
65
+ const [newProjectPath, setNewProjectPath] = useState('')
66
+ const [newProjectError, setNewProjectError] = useState<string | undefined>(undefined)
67
+ const [newProjectPending, setNewProjectPending] = useState(false)
45
68
  const selected = selectedTaskOf(snapshot)
46
69
  const archiveView = snapshot.archiveView
70
+ // Every label in use across the ledger (board and archive alike), so the
71
+ // filter never loses an option just because its task was archived.
72
+ const knownTags = collectKnownTags(snapshot.tasks)
47
73
  // Archived tasks leave the columns; the archive view shows them instead.
48
74
  const visible = snapshot.tasks.filter(task =>
49
75
  (archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
50
- && matchesFilter(task, filter),
76
+ && (projectId === '' || task.workspaceId === projectId)
77
+ && matchesFilter(task, filter)
78
+ && matchesTagFilter(task, tagFilter),
51
79
  )
80
+ const projects = snapshot.executionOptions.workspaces
81
+ const canCreateProject = snapshot.canCreateWorkspace === true
82
+ const submitNewProject = async (): Promise<void> => {
83
+ const path = newProjectPath.trim()
84
+ if (path === '') return
85
+ setNewProjectPending(true)
86
+ setNewProjectError(undefined)
87
+ try {
88
+ const created = await controller.createWorkspace(path)
89
+ setProjectId(created.workspaceId)
90
+ setShowNewProject(false)
91
+ setNewProjectPath('')
92
+ } catch (error) {
93
+ setNewProjectError(error instanceof Error ? error.message : String(error))
94
+ } finally {
95
+ setNewProjectPending(false)
96
+ }
97
+ }
98
+ const toggleTag = useCallback((name: string): void => {
99
+ setTagFilter(current => current.includes(name)
100
+ ? current.filter(entry => entry !== name)
101
+ : [...current, name])
102
+ }, [])
52
103
  const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
53
104
 
54
105
  return (
@@ -74,6 +125,32 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
74
125
  })}
75
126
  </span>
76
127
  )}
128
+ {(projects.length > 0 || canCreateProject) && (
129
+ <label className={css.projectFilter}>
130
+ <span className={css.projectFilterLabel}>{t('board.project')}</span>
131
+ <select
132
+ className={css.select}
133
+ data-dsh-part="project-filter"
134
+ value={projectId}
135
+ aria-label={t('board.project')}
136
+ onChange={event => {
137
+ const value = event.target.value
138
+ if (value === NEW_PROJECT_VALUE) {
139
+ setNewProjectError(undefined)
140
+ setShowNewProject(true)
141
+ return
142
+ }
143
+ setProjectId(value)
144
+ }}
145
+ >
146
+ <option value="">{t('board.projectAll')}</option>
147
+ {projects.map(project => (
148
+ <option key={project.workspaceId} value={project.workspaceId}>{project.title}</option>
149
+ ))}
150
+ {canCreateProject && <option value={NEW_PROJECT_VALUE}>{t('board.projectNew')}</option>}
151
+ </select>
152
+ </label>
153
+ )}
77
154
  <input
78
155
  className={css.search}
79
156
  type="search"
@@ -100,6 +177,68 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
100
177
  </button>
101
178
  </header>
102
179
 
180
+ {showNewProject && (
181
+ <div className={css.projectDialog} data-dsh-part="project-dialog">
182
+ <label className={css.field}>
183
+ <span className={css.fieldLabel}>{t('board.projectNewPath')}</span>
184
+ <input
185
+ className={css.input}
186
+ value={newProjectPath}
187
+ placeholder={t('board.projectNewPathPlaceholder')}
188
+ spellCheck={false}
189
+ onChange={event => { setNewProjectPath(event.target.value); setNewProjectError(undefined) }}
190
+ />
191
+ </label>
192
+ {newProjectError !== undefined && <p className={css.formError}>{t('board.projectCreateFailed', { error: newProjectError })}</p>}
193
+ <div className={css.projectDialogActions}>
194
+ <button
195
+ type="button"
196
+ className={css.ghostButton}
197
+ onClick={() => { setShowNewProject(false); setNewProjectError(undefined) }}
198
+ >
199
+ {t('new.cancel')}
200
+ </button>
201
+ <button
202
+ type="button"
203
+ className={css.primaryButton}
204
+ disabled={newProjectPending || newProjectPath.trim() === ''}
205
+ onClick={() => { void submitNewProject() }}
206
+ >
207
+ {t('board.projectCreate')}
208
+ </button>
209
+ </div>
210
+ </div>
211
+ )}
212
+
213
+ {!archiveView && knownTags.length > 0 && (
214
+ <div className={css.tagFilter} data-dsh-part="tag-filter">
215
+ <span className={css.tagFilterLabel}>{t('board.tagFilter')}</span>
216
+ {knownTags.map(tag => {
217
+ const active = tagFilter.includes(tag.name)
218
+ return (
219
+ <button
220
+ key={tag.name}
221
+ type="button"
222
+ className={css.tagChip}
223
+ data-dsh-part="tag-chip"
224
+ data-tag-tone={tagTone(tag.name)}
225
+ data-active={active ? 'true' : undefined}
226
+ aria-pressed={active}
227
+ title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
228
+ onClick={() => { toggleTag(tag.name) }}
229
+ >
230
+ {tag.name}
231
+ </button>
232
+ )
233
+ })}
234
+ {tagFilter.length > 0 && (
235
+ <button type="button" className={css.linkButton} onClick={() => { setTagFilter([]) }}>
236
+ {t('board.tagFilterClear')}
237
+ </button>
238
+ )}
239
+ </div>
240
+ )}
241
+
103
242
  {snapshot.transportError !== undefined && (
104
243
  <div className={css.formError}>
105
244
  {t('board.hostError', { error: snapshot.transportError })}{' '}
@@ -120,7 +259,9 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
120
259
  {visible.map(task => (
121
260
  <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
122
261
  ))}
123
- {visible.length === 0 && <div className={css.columnEmpty}>{t('archive.empty')}</div>}
262
+ {visible.length === 0 && (
263
+ <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('archive.empty')}</div>
264
+ )}
124
265
  </div>
125
266
  </section>
126
267
  ) : (
@@ -156,7 +297,9 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
156
297
  {tasks.map(task => (
157
298
  <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
158
299
  ))}
159
- {tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
300
+ {tasks.length === 0 && (
301
+ <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('board.empty')}</div>
302
+ )}
160
303
  </div>
161
304
  </section>
162
305
  )
@@ -170,6 +313,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
170
313
  {showNew && (
171
314
  <NewTaskModal
172
315
  controller={controller}
316
+ {...(projectId === '' ? {} : { defaultWorkspaceId: projectId })}
173
317
  onClose={() => { setShowNew(false) }}
174
318
  />
175
319
  )}
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { memo } from 'react'
11
11
  import type { TaskRecord } from '../../core/tasks.ts'
12
- import { executionLabel } from '../../core/tasks.ts'
12
+ import { executionLabel, tagTone } from '../../core/tasks.ts'
13
13
  import { t } from '../locales.ts'
14
14
  import css from '../board.module.css'
15
15
 
@@ -59,6 +59,22 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
59
59
  title={task.description !== '' ? task.description : task.title}
60
60
  >
61
61
  <span className={css.cardTitle}>{task.title}</span>
62
+ {task.tags !== undefined && task.tags.length > 0 && (
63
+ <span className={css.cardTags}>
64
+ {task.tags.map(tag => (
65
+ <span
66
+ key={tag.name}
67
+ className={css.cardTag}
68
+ data-tag-tone={tagTone(tag.name)}
69
+ data-dsh-part="tag-badge"
70
+ data-tag-hint={tag.promptPrefix === undefined ? undefined : tag.promptPrefix}
71
+ title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
72
+ >
73
+ {tag.name}
74
+ </span>
75
+ ))}
76
+ </span>
77
+ )}
62
78
  {task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
63
79
  <span className={css.cardMeta}>
64
80
  <span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
@@ -7,14 +7,14 @@
7
7
  import { useEffect, useState } from 'react'
8
8
  import type { BoardController } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
- import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
10
+ import { MANUAL_STATUSES, TASK_PERMISSIONS, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
11
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
12
12
  import { requiresPermissionConfirmation } from '../../core/handover.ts'
13
13
  import { t, type TaskBoardKey } from '../locales.ts'
14
14
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
15
15
  import css from '../board.module.css'
16
16
  import { ConfirmDialog } from './ConfirmDialog.tsx'
17
- import { EditTaskModal } from './EditTaskModal.tsx'
17
+ import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
18
18
  import { NewTaskModal } from './NewTaskModal.tsx'
19
19
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
20
20
  import { STATUS_KEY } from './status-key.ts'
@@ -269,6 +269,7 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
269
269
  export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
270
270
  const [confirmDelete, setConfirmDelete] = useState(false)
271
271
  const [showEdit, setShowEdit] = useState(false)
272
+ const [showEditTags, setShowEditTags] = useState(false)
272
273
  const [showDuplicate, setShowDuplicate] = useState(false)
273
274
 
274
275
  // Keep the overlay in sync if the task record changes underneath.
@@ -277,6 +278,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
277
278
  // A re-used overlay instance must not carry an edit session across tasks.
278
279
  useEffect(() => {
279
280
  setShowEdit(false)
281
+ setShowEditTags(false)
280
282
  setShowDuplicate(false)
281
283
  }, [task.id])
282
284
  const current = latest
@@ -320,6 +322,26 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
320
322
  <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
321
323
  </section>
322
324
 
325
+ {current.tags !== undefined && current.tags.length > 0 && (
326
+ <section className={css.detailSection} data-dsh-part="tags">
327
+ <h4>{t('new.tags')}</h4>
328
+ <div className={css.cardTags}>
329
+ {current.tags.map(tag => (
330
+ <span
331
+ key={tag.name}
332
+ className={css.cardTag}
333
+ data-tag-tone={tagTone(tag.name)}
334
+ data-dsh-part="tag-badge"
335
+ data-tag-hint={tag.promptPrefix === undefined ? undefined : tag.promptPrefix}
336
+ title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
337
+ >
338
+ {tag.name}
339
+ </span>
340
+ ))}
341
+ </div>
342
+ </section>
343
+ )}
344
+
323
345
  {current.freeze !== undefined && (
324
346
  <section className={css.detailSection} data-dsh-part="freeze">
325
347
  <h4>{t('detail.freeze')}</h4>
@@ -431,6 +453,16 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
431
453
  {t('detail.edit')}
432
454
  </button>
433
455
  )}
456
+ {!archived && !canEditTaskContent(current) && current.status !== 'running' && (
457
+ <button
458
+ type="button"
459
+ className={css.ghostButton}
460
+ disabled={pending}
461
+ onClick={() => { setShowEditTags(true) }}
462
+ >
463
+ {t('detail.editTags')}
464
+ </button>
465
+ )}
434
466
  {!archived && (
435
467
  <button
436
468
  type="button"
@@ -514,6 +546,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
514
546
  <EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
515
547
  )}
516
548
 
549
+ {showEditTags && !archived && current.status !== 'running' && (
550
+ <EditTagsModal controller={controller} task={current} onClose={() => { setShowEditTags(false) }} />
551
+ )}
552
+
517
553
  {showDuplicate && !archived && (
518
554
  <NewTaskModal
519
555
  controller={controller}
@@ -5,9 +5,13 @@
5
5
  * are controlled components.
6
6
  */
7
7
  import type { ReactNode } from 'react'
8
+ import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, normalizeTags, type TaskTag } from '../../core/tasks.ts'
8
9
  import { t } from '../locales.ts'
9
10
  import css from '../board.module.css'
10
11
 
12
+ /** DOM id shared by the tag-name inputs and their datalist (one board at a time). */
13
+ const TAG_NAME_LIST_ID = 'dsh-task-board-tag-names'
14
+
11
15
  /** Modal overlay: closes on backdrop press, submits through the form. */
12
16
  export function ModalShell({
13
17
  ariaLabel,
@@ -109,3 +113,84 @@ export function TaskContentFields({
109
113
  </>
110
114
  )
111
115
  }
116
+
117
+ /**
118
+ * Task labels (issue #1521): one row per label holding the badge name and an
119
+ * optional execution hint. The name inputs offer the labels already used on the
120
+ * board through a datalist, and picking one adopts its hint when the row has
121
+ * none — so a business line is defined once and reused by every later task.
122
+ */
123
+ export function TaskTagFields({
124
+ tags,
125
+ knownTags,
126
+ onChange,
127
+ }: {
128
+ tags: TaskTag[]
129
+ /** Labels already carried elsewhere on the board. */
130
+ knownTags: TaskTag[]
131
+ onChange: (tags: TaskTag[]) => void
132
+ }) {
133
+ const update = (index: number, patch: Partial<TaskTag>): void => {
134
+ onChange(tags.map((tag, position) => (position === index ? { ...tag, ...patch } : tag)))
135
+ }
136
+
137
+ return (
138
+ <div className={css.field}>
139
+ <span className={css.fieldLabel}>{t('new.tags')}</span>
140
+ <span className={css.fieldHint}>{t('new.tagsHint')}</span>
141
+ {tags.map((tag, index) => (
142
+ <div className={css.tagRow} key={index}>
143
+ <input
144
+ className={css.input}
145
+ list={TAG_NAME_LIST_ID}
146
+ value={tag.name}
147
+ maxLength={TAG_NAME_MAX_LENGTH}
148
+ placeholder={t('new.tagNamePlaceholder')}
149
+ aria-label={t('new.tagName')}
150
+ onChange={(event) => {
151
+ const name = event.target.value
152
+ const known = knownTags.find(candidate => candidate.name === name)
153
+ const adoptsHint = known?.promptPrefix !== undefined && (tag.promptPrefix ?? '').trim() === ''
154
+ update(index, adoptsHint ? { name, promptPrefix: known.promptPrefix } : { name })
155
+ }}
156
+ />
157
+ <input
158
+ className={css.input}
159
+ value={tag.promptPrefix ?? ''}
160
+ maxLength={TAG_PROMPT_MAX_LENGTH}
161
+ placeholder={t('new.tagPromptPlaceholder')}
162
+ aria-label={t('new.tagPrompt')}
163
+ onChange={event => { update(index, { promptPrefix: event.target.value }) }}
164
+ />
165
+ <button
166
+ type="button"
167
+ className={css.ghostButton}
168
+ aria-label={t('new.tagRemove', { name: tag.name })}
169
+ onClick={() => { onChange(tags.filter((_, position) => position !== index)) }}
170
+ >
171
+ ×
172
+ </button>
173
+ </div>
174
+ ))}
175
+ <datalist id={TAG_NAME_LIST_ID}>
176
+ {knownTags.map(tag => <option key={tag.name} value={tag.name} />)}
177
+ </datalist>
178
+ <button
179
+ type="button"
180
+ className={css.ghostButton + ' ' + css.tagAddButton}
181
+ disabled={tags.length >= TASK_TAG_LIMIT}
182
+ onClick={() => { onChange([...tags, { name: '' }]) }}
183
+ >
184
+ + {t('new.tagAdd')}
185
+ </button>
186
+ </div>
187
+ )
188
+ }
189
+
190
+ /**
191
+ * Clean a tag list via normalizeTags: trim, drop blanks and duplicates, cap
192
+ * lengths and count, so the wire always carries a valid tag list.
193
+ */
194
+ export function cleanTags(tags: readonly TaskTag[]): TaskTag[] {
195
+ return normalizeTags(tags) ?? []
196
+ }