@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.
- package/README.i18n.yaml +2 -2
- package/README.md +5 -0
- package/README.zh.md +5 -0
- package/lib/client.js +723 -29
- package/lib/client.js.map +1 -1
- package/lib/index.js +2629 -158
- package/lib/types/client/board/EditTaskModal.d.ts +7 -1
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskForm.d.ts +18 -0
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +36 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/core/tasks.d.ts +60 -0
- package/lib/types/core/use-cases/task-update.d.ts +8 -1
- package/lib/types/host-ai.d.ts +51 -0
- package/lib/types/host-routes.d.ts +11 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/protocol.d.ts +19 -0
- package/package.json +1 -1
- package/src/client/board/EditTaskModal.tsx +52 -3
- package/src/client/board/NewTaskModal.tsx +100 -5
- package/src/client/board/TaskBoard.tsx +150 -6
- package/src/client/board/TaskCard.tsx +17 -1
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +85 -0
- package/src/client/board.module.css +180 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +7 -0
- package/src/client/locales.ts +72 -0
- package/src/core/controller.ts +57 -1
- package/src/core/store.ts +5 -1
- package/src/core/tasks.ts +112 -0
- package/src/core/use-cases/task-update.ts +12 -2
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +25 -3
- package/src/host-routes.ts +68 -3
- package/src/host-runner.ts +24 -1
- package/src/index.ts +26 -1
- 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
|
-
/**
|
|
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
|
-
&&
|
|
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 &&
|
|
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 &&
|
|
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
|
+
}
|