@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.
Files changed (42) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +2 -0
  3. package/README.zh.md +2 -0
  4. package/lib/client.js +726 -71
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2619 -175
  7. package/lib/types/client/board/EditTaskModal.d.ts +6 -0
  8. package/lib/types/client/board/NewTaskModal.d.ts +6 -1
  9. package/lib/types/client/board/TaskBoard.d.ts +2 -0
  10. package/lib/types/client/board/TaskForm.d.ts +8 -3
  11. package/lib/types/client/board/parse-model-pref.d.ts +4 -0
  12. package/lib/types/client/host-api.d.ts +23 -1
  13. package/lib/types/client/locales.d.ts +26 -0
  14. package/lib/types/client/plugin-card-seat.d.ts +96 -0
  15. package/lib/types/client/sidebar-entry-core.d.ts +32 -0
  16. package/lib/types/core/controller.d.ts +35 -1
  17. package/lib/types/core/session-reuse.d.ts +4 -2
  18. package/lib/types/host-ai.d.ts +51 -0
  19. package/lib/types/host-ledger.d.ts +15 -0
  20. package/lib/types/host-routes.d.ts +11 -1
  21. package/lib/types/index.d.ts +9 -0
  22. package/lib/types/protocol.d.ts +19 -0
  23. package/package.json +2 -1
  24. package/src/client/board/EditTaskModal.tsx +36 -0
  25. package/src/client/board/NewTaskModal.tsx +119 -5
  26. package/src/client/board/TaskBoard.tsx +89 -0
  27. package/src/client/board/TaskDetail.tsx +38 -2
  28. package/src/client/board/TaskForm.tsx +17 -11
  29. package/src/client/board/parse-model-pref.ts +35 -0
  30. package/src/client/board.module.css +61 -0
  31. package/src/client/host-api.ts +124 -5
  32. package/src/client/index.ts +18 -17
  33. package/src/client/locales.ts +52 -0
  34. package/src/client/plugin-card-seat.ts +185 -0
  35. package/src/client/sidebar-entry-core.ts +96 -12
  36. package/src/core/controller.ts +57 -1
  37. package/src/core/session-reuse.ts +14 -5
  38. package/src/host-ai.ts +152 -0
  39. package/src/host-ledger.ts +84 -14
  40. package/src/host-routes.ts +68 -3
  41. package/src/index.ts +26 -1
  42. 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
- const submit = async (): Promise<void> => {
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
- * Drop blank rows and trim what survives, so the wire always carries a valid
192
- * tag list (the protocol rejects a list with a blank name or an empty array).
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
- const cleaned: TaskTag[] = []
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
+ }