@linxin666/dsh-client-ui-task-board 0.3.22 → 0.3.24
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 +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +726 -71
- package/lib/client.js.map +1 -1
- package/lib/index.js +2619 -175
- package/lib/types/client/board/EditTaskModal.d.ts +6 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +2 -0
- package/lib/types/client/board/TaskForm.d.ts +8 -3
- package/lib/types/client/board/parse-model-pref.d.ts +4 -0
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +26 -0
- package/lib/types/client/plugin-card-seat.d.ts +96 -0
- package/lib/types/client/sidebar-entry-core.d.ts +32 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/core/session-reuse.d.ts +4 -2
- package/lib/types/host-ai.d.ts +51 -0
- package/lib/types/host-ledger.d.ts +15 -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 +2 -1
- package/src/client/board/EditTaskModal.tsx +36 -0
- package/src/client/board/NewTaskModal.tsx +119 -5
- package/src/client/board/TaskBoard.tsx +89 -0
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +17 -11
- package/src/client/board/parse-model-pref.ts +35 -0
- package/src/client/board.module.css +61 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +18 -17
- package/src/client/locales.ts +52 -0
- package/src/client/plugin-card-seat.ts +185 -0
- package/src/client/sidebar-entry-core.ts +96 -12
- package/src/core/controller.ts +57 -1
- package/src/core/session-reuse.ts +14 -5
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +84 -14
- package/src/host-routes.ts +68 -3
- package/src/index.ts +26 -1
- package/src/protocol.ts +34 -0
|
@@ -2,7 +2,7 @@
|
|
|
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'
|
|
@@ -10,6 +10,7 @@ import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecor
|
|
|
10
10
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
11
11
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
12
12
|
import { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
|
|
13
|
+
import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts'
|
|
13
14
|
import css from '../board.module.css'
|
|
14
15
|
|
|
15
16
|
export interface NewTaskModalProps {
|
|
@@ -17,17 +18,22 @@ export interface NewTaskModalProps {
|
|
|
17
18
|
onClose: () => void
|
|
18
19
|
/** Optional task template to clone/duplicate from. */
|
|
19
20
|
initialTask?: TaskRecord
|
|
21
|
+
/**
|
|
22
|
+
* Workspace the board's project filter has selected (#1536): a task created
|
|
23
|
+
* while a project is open belongs to that project unless the user changes it.
|
|
24
|
+
*/
|
|
25
|
+
defaultWorkspaceId?: string
|
|
20
26
|
/** Optional callback after successful duplication (e.g. to archive source). */
|
|
21
27
|
onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/** New-task form overlay. */
|
|
25
|
-
export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps) {
|
|
31
|
+
export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess }: NewTaskModalProps) {
|
|
26
32
|
const isDuplicate = initialTask !== undefined
|
|
27
33
|
const [title, setTitle] = useState(initialTask?.title ?? '')
|
|
28
34
|
const [description, setDescription] = useState(initialTask?.description ?? '')
|
|
29
35
|
const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
|
|
30
|
-
const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? '')
|
|
36
|
+
const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? defaultWorkspaceId ?? '')
|
|
31
37
|
const [mode, setMode] = useState(initialTask?.mode ?? '')
|
|
32
38
|
const [permission, setPermission] = useState(initialTask?.permission ?? '')
|
|
33
39
|
const [model, setModel] = useState(initialTask?.model ?? '')
|
|
@@ -45,6 +51,17 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
|
|
|
45
51
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
46
52
|
const [pending, setPending] = useState(false)
|
|
47
53
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
54
|
+
// "Parse pasted text" (issue #1540) exists only when the deployment carries a
|
|
55
|
+
// parse face; the section stays hidden otherwise.
|
|
56
|
+
const [canParse] = useState(controller.getSnapshot().canParseTask === true)
|
|
57
|
+
const [parseText, setParseText] = useState('')
|
|
58
|
+
// Issue #1621: start from the model this browser used last, not from the
|
|
59
|
+
// roster's first entry; '' is the Host default and stays valid.
|
|
60
|
+
const [parseModel, setParseModel] = useState(() => readParseModelPreference())
|
|
61
|
+
const [parsePending, setParsePending] = useState(false)
|
|
62
|
+
const [parseError, setParseError] = useState<string | undefined>(undefined)
|
|
63
|
+
const parseAbort = useRef<AbortController | undefined>(undefined)
|
|
64
|
+
const parseModels = options.models ?? []
|
|
48
65
|
|
|
49
66
|
// The workspace list and preset roster arrive from the runtime after mount;
|
|
50
67
|
// follow them so the pickers never freeze on an empty snapshot.
|
|
@@ -53,7 +70,47 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
|
|
|
53
70
|
[controller],
|
|
54
71
|
)
|
|
55
72
|
|
|
56
|
-
|
|
73
|
+
// The model roster arrives asynchronously. A remembered model the deployment
|
|
74
|
+
// no longer offers falls back to the Host default; an empty value is the
|
|
75
|
+
// Host-default choice and is never overwritten by the roster (issue #1621).
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (parseModel === '' || parseModels.length === 0) return
|
|
78
|
+
if (parseModels.some(option => option.id === parseModel)) return
|
|
79
|
+
setParseModel('')
|
|
80
|
+
writeParseModelPreference('')
|
|
81
|
+
}, [parseModel, options.models])
|
|
82
|
+
|
|
83
|
+
const runParse = async (): Promise<void> => {
|
|
84
|
+
const text = parseText.trim()
|
|
85
|
+
if (text === '') {
|
|
86
|
+
setParseError(t('new.aiParseEmpty'))
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const abort = new AbortController()
|
|
90
|
+
parseAbort.current = abort
|
|
91
|
+
setParsePending(true)
|
|
92
|
+
setParseError(undefined)
|
|
93
|
+
try {
|
|
94
|
+
const draft = await controller.parseTaskDraft({ text, ...(parseModel === '' ? {} : { model: parseModel }) }, abort.signal)
|
|
95
|
+
setTitle(draft.title)
|
|
96
|
+
setDescription(draft.description)
|
|
97
|
+
setPrompt(draft.prompt)
|
|
98
|
+
} catch (parseFailure) {
|
|
99
|
+
// A cancelled parse reports nothing: the user asked for it to stop.
|
|
100
|
+
if (!abort.signal.aborted) setParseError(parseFailure instanceof Error ? parseFailure.message : String(parseFailure))
|
|
101
|
+
} finally {
|
|
102
|
+
parseAbort.current = undefined
|
|
103
|
+
setParsePending(false)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create the task through the Host, then optionally start it.
|
|
109
|
+
* @param runAfterCreate - true for the "create and run" action: the task is
|
|
110
|
+
* committed either way, and a refused start opens the task instead of
|
|
111
|
+
* reporting the creation as failed.
|
|
112
|
+
*/
|
|
113
|
+
const submit = async (runAfterCreate: boolean): Promise<void> => {
|
|
57
114
|
if (scheduleEnabled) {
|
|
58
115
|
const cron = scheduleCron.trim()
|
|
59
116
|
if (cron === '' || !isValidCron(cron)) {
|
|
@@ -112,6 +169,14 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
|
|
|
112
169
|
await controller.archiveTask(initialTask.id)
|
|
113
170
|
}
|
|
114
171
|
}
|
|
172
|
+
if (runAfterCreate) {
|
|
173
|
+
// The task exists from here on, so a refused start (a permission above
|
|
174
|
+
// the session default, a pinned target that went stale) must not read as
|
|
175
|
+
// a failed creation: open the task, whose detail view owns the
|
|
176
|
+
// confirmation step and the refusal message.
|
|
177
|
+
const started = await controller.runTask(task.id)
|
|
178
|
+
if (!started) controller.openTask(task.id)
|
|
179
|
+
}
|
|
115
180
|
onClose()
|
|
116
181
|
}
|
|
117
182
|
|
|
@@ -129,9 +194,58 @@ export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSucc
|
|
|
129
194
|
error={error}
|
|
130
195
|
pending={pending}
|
|
131
196
|
submitLabel={t('new.submit')}
|
|
132
|
-
onSubmit={() => { void submit() }}
|
|
197
|
+
onSubmit={() => { void submit(false) }}
|
|
133
198
|
onClose={onClose}
|
|
199
|
+
secondaryAction={{ label: t('new.createAndRun'), onSubmit: () => { void submit(true) } }}
|
|
134
200
|
>
|
|
201
|
+
{canParse && (
|
|
202
|
+
<section className={css.aiParse} data-dsh-part="ai-parse">
|
|
203
|
+
<span className={css.fieldLabel}>{t('new.aiParse')}</span>
|
|
204
|
+
<p className={css.fieldHint}>{t('new.aiParseHint')}</p>
|
|
205
|
+
<textarea
|
|
206
|
+
className={css.input}
|
|
207
|
+
rows={3}
|
|
208
|
+
value={parseText}
|
|
209
|
+
placeholder={t('new.aiParsePlaceholder')}
|
|
210
|
+
spellCheck={false}
|
|
211
|
+
onChange={event => { setParseText(event.target.value); setParseError(undefined) }}
|
|
212
|
+
/>
|
|
213
|
+
<div className={css.aiParseRow}>
|
|
214
|
+
<select
|
|
215
|
+
className={css.select}
|
|
216
|
+
value={parseModel}
|
|
217
|
+
aria-label={t('new.aiParseModel')}
|
|
218
|
+
onChange={event => {
|
|
219
|
+
setParseModel(event.target.value)
|
|
220
|
+
writeParseModelPreference(event.target.value)
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<option value="">{t('exec.model.default')}</option>
|
|
224
|
+
{parseModels.map(option => (
|
|
225
|
+
<option key={option.id} value={option.id}>{option.name ?? option.id}</option>
|
|
226
|
+
))}
|
|
227
|
+
</select>
|
|
228
|
+
{parsePending
|
|
229
|
+
? (
|
|
230
|
+
<button type="button" className={css.ghostButton} onClick={() => { parseAbort.current?.abort() }}>
|
|
231
|
+
{t('new.aiParseCancel')}
|
|
232
|
+
</button>
|
|
233
|
+
)
|
|
234
|
+
: (
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
className={css.primaryButton}
|
|
238
|
+
disabled={parseText.trim() === ''}
|
|
239
|
+
onClick={() => { void runParse() }}
|
|
240
|
+
>
|
|
241
|
+
{t('new.aiParseRun')}
|
|
242
|
+
</button>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
{parseError !== undefined && <p className={css.formError}>{parseError}</p>}
|
|
246
|
+
</section>
|
|
247
|
+
)}
|
|
248
|
+
|
|
135
249
|
<TaskContentFields
|
|
136
250
|
title={title}
|
|
137
251
|
description={description}
|
|
@@ -13,6 +13,9 @@ import { STATUS_KEY } from './status-key.ts'
|
|
|
13
13
|
import { TaskCard } from './TaskCard.tsx'
|
|
14
14
|
import { TaskDetail } from './TaskDetail.tsx'
|
|
15
15
|
|
|
16
|
+
/** Sentinel option value of the project row's "register a new project" entry. */
|
|
17
|
+
export const NEW_PROJECT_VALUE = '__dsh_new_project__'
|
|
18
|
+
|
|
16
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
|
|
@@ -55,6 +58,13 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
55
58
|
const [filter, setFilter] = useState('')
|
|
56
59
|
const [tagFilter, setTagFilter] = useState<string[]>([])
|
|
57
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)
|
|
58
68
|
const selected = selectedTaskOf(snapshot)
|
|
59
69
|
const archiveView = snapshot.archiveView
|
|
60
70
|
// Every label in use across the ledger (board and archive alike), so the
|
|
@@ -63,9 +73,28 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
63
73
|
// Archived tasks leave the columns; the archive view shows them instead.
|
|
64
74
|
const visible = snapshot.tasks.filter(task =>
|
|
65
75
|
(archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
|
|
76
|
+
&& (projectId === '' || task.workspaceId === projectId)
|
|
66
77
|
&& matchesFilter(task, filter)
|
|
67
78
|
&& matchesTagFilter(task, tagFilter),
|
|
68
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
|
+
}
|
|
69
98
|
const toggleTag = useCallback((name: string): void => {
|
|
70
99
|
setTagFilter(current => current.includes(name)
|
|
71
100
|
? current.filter(entry => entry !== name)
|
|
@@ -96,6 +125,32 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
96
125
|
})}
|
|
97
126
|
</span>
|
|
98
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
|
+
)}
|
|
99
154
|
<input
|
|
100
155
|
className={css.search}
|
|
101
156
|
type="search"
|
|
@@ -122,6 +177,39 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
122
177
|
</button>
|
|
123
178
|
</header>
|
|
124
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
|
+
|
|
125
213
|
{!archiveView && knownTags.length > 0 && (
|
|
126
214
|
<div className={css.tagFilter} data-dsh-part="tag-filter">
|
|
127
215
|
<span className={css.tagFilterLabel}>{t('board.tagFilter')}</span>
|
|
@@ -225,6 +313,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
225
313
|
{showNew && (
|
|
226
314
|
<NewTaskModal
|
|
227
315
|
controller={controller}
|
|
316
|
+
{...(projectId === '' ? {} : { defaultWorkspaceId: projectId })}
|
|
228
317
|
onClose={() => { setShowNew(false) }}
|
|
229
318
|
/>
|
|
230
319
|
)}
|
|
@@ -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,7 +5,7 @@
|
|
|
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, type TaskTag } from '../../core/tasks.ts'
|
|
8
|
+
import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, normalizeTags, type TaskTag } from '../../core/tasks.ts'
|
|
9
9
|
import { t } from '../locales.ts'
|
|
10
10
|
import css from '../board.module.css'
|
|
11
11
|
|
|
@@ -21,6 +21,7 @@ export function ModalShell({
|
|
|
21
21
|
submitLabel,
|
|
22
22
|
onSubmit,
|
|
23
23
|
onClose,
|
|
24
|
+
secondaryAction,
|
|
24
25
|
children,
|
|
25
26
|
}: {
|
|
26
27
|
ariaLabel: string
|
|
@@ -30,6 +31,8 @@ export function ModalShell({
|
|
|
30
31
|
submitLabel: string
|
|
31
32
|
onSubmit: () => void
|
|
32
33
|
onClose: () => void
|
|
34
|
+
/** Optional second action beside the primary submit (e.g. create and run). */
|
|
35
|
+
secondaryAction?: { label: string; onSubmit: () => void }
|
|
33
36
|
children: ReactNode
|
|
34
37
|
}) {
|
|
35
38
|
return (
|
|
@@ -50,6 +53,16 @@ export function ModalShell({
|
|
|
50
53
|
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
51
54
|
{t('new.cancel')}
|
|
52
55
|
</button>
|
|
56
|
+
{secondaryAction !== undefined && (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className={css.ghostButton}
|
|
60
|
+
disabled={pending}
|
|
61
|
+
onClick={secondaryAction.onSubmit}
|
|
62
|
+
>
|
|
63
|
+
{secondaryAction.label}
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
53
66
|
<button type="submit" className={css.primaryButton} disabled={pending}>
|
|
54
67
|
{submitLabel}
|
|
55
68
|
</button>
|
|
@@ -188,16 +201,9 @@ export function TaskTagFields({
|
|
|
188
201
|
}
|
|
189
202
|
|
|
190
203
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
204
|
+
* Clean a tag list via normalizeTags: trim, drop blanks and duplicates, cap
|
|
205
|
+
* lengths and count, so the wire always carries a valid tag list.
|
|
193
206
|
*/
|
|
194
207
|
export function cleanTags(tags: readonly TaskTag[]): TaskTag[] {
|
|
195
|
-
|
|
196
|
-
for (const tag of tags) {
|
|
197
|
-
const name = tag.name.trim()
|
|
198
|
-
if (name === '') continue
|
|
199
|
-
const promptPrefix = tag.promptPrefix?.trim()
|
|
200
|
-
cleaned.push(promptPrefix === undefined || promptPrefix === '' ? { name } : { name, promptPrefix })
|
|
201
|
-
}
|
|
202
|
-
return cleaned
|
|
208
|
+
return normalizeTags(tags) ?? []
|
|
203
209
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model the user last picked for AI parsing, remembered per browser so the
|
|
3
|
+
* next new-task modal starts from it instead of the roster's first entry
|
|
4
|
+
* (issue #1621). An empty value is a real choice — "let the Host use its
|
|
5
|
+
* default model" — and is remembered as such.
|
|
6
|
+
*/
|
|
7
|
+
const PARSE_MODEL_KEY = 'dsh-task-board.parse-model'
|
|
8
|
+
|
|
9
|
+
/** The nearest browser storage, or undefined when the global itself throws. */
|
|
10
|
+
function defaultStorage(): Storage | undefined {
|
|
11
|
+
try {
|
|
12
|
+
return globalThis.localStorage
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The remembered parse model; '' means the Host default (or no preference). */
|
|
19
|
+
export function readParseModelPreference(storage: Pick<Storage, 'getItem'> | undefined = defaultStorage()): string {
|
|
20
|
+
try {
|
|
21
|
+
return storage?.getItem(PARSE_MODEL_KEY) ?? ''
|
|
22
|
+
} catch {
|
|
23
|
+
// A blocked or full store must never break the form.
|
|
24
|
+
return ''
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Remember the picked parse model; '' records the Host-default choice. */
|
|
29
|
+
export function writeParseModelPreference(model: string, storage: Pick<Storage, 'setItem'> | undefined = defaultStorage()): void {
|
|
30
|
+
try {
|
|
31
|
+
storage?.setItem(PARSE_MODEL_KEY, model)
|
|
32
|
+
} catch {
|
|
33
|
+
// Storage is optional: the picker still works without it.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1240,3 +1240,64 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
1240
1240
|
.tagAddButton {
|
|
1241
1241
|
align-self: flex-start;
|
|
1242
1242
|
}
|
|
1243
|
+
|
|
1244
|
+
/* --- project partition (#1536) ------------------------------------------------ */
|
|
1245
|
+
|
|
1246
|
+
.projectFilter {
|
|
1247
|
+
display: flex;
|
|
1248
|
+
align-items: center;
|
|
1249
|
+
gap: 6px;
|
|
1250
|
+
flex: none;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
.projectFilterLabel {
|
|
1254
|
+
font-size: 12px;
|
|
1255
|
+
color: var(--dsw-alias-label-secondary);
|
|
1256
|
+
white-space: nowrap;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
.projectDialog {
|
|
1260
|
+
display: flex;
|
|
1261
|
+
flex-direction: column;
|
|
1262
|
+
gap: 8px;
|
|
1263
|
+
flex: none;
|
|
1264
|
+
margin-bottom: 8px;
|
|
1265
|
+
padding: 10px 12px;
|
|
1266
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
1267
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1268
|
+
border-radius: 10px;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
.projectDialogActions {
|
|
1272
|
+
display: flex;
|
|
1273
|
+
justify-content: flex-end;
|
|
1274
|
+
gap: 8px;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/* --- AI parse of pasted text (#1540) ------------------------------------------ */
|
|
1278
|
+
|
|
1279
|
+
.aiParse {
|
|
1280
|
+
display: flex;
|
|
1281
|
+
flex-direction: column;
|
|
1282
|
+
gap: 6px;
|
|
1283
|
+
padding: 10px 12px;
|
|
1284
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
1285
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1286
|
+
border-radius: 10px;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
.aiParseRow {
|
|
1290
|
+
display: flex;
|
|
1291
|
+
align-items: center;
|
|
1292
|
+
gap: 8px;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
.aiParseRow .select {
|
|
1296
|
+
flex: 1 1 0;
|
|
1297
|
+
min-width: 0;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
.aiParseRow .ghostButton,
|
|
1301
|
+
.aiParseRow .primaryButton {
|
|
1302
|
+
flex: none;
|
|
1303
|
+
}
|