@linxin666/dsh-client-ui-task-board 0.3.20 → 0.3.22

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 (38) 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 +467 -67
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +97 -13
  7. package/lib/types/client/board/EditTaskModal.d.ts +1 -1
  8. package/lib/types/client/board/TaskBoard.d.ts +8 -1
  9. package/lib/types/client/board/TaskForm.d.ts +18 -0
  10. package/lib/types/client/body-mutations.d.ts +45 -0
  11. package/lib/types/client/locales.d.ts +11 -0
  12. package/lib/types/client/panel-mount-core.d.ts +1 -1
  13. package/lib/types/client/sidebar-entry-core.d.ts +0 -15
  14. package/lib/types/core/controller.d.ts +2 -3
  15. package/lib/types/core/tasks.d.ts +66 -1
  16. package/lib/types/core/use-cases/task-archive.d.ts +6 -6
  17. package/lib/types/core/use-cases/task-update.d.ts +8 -1
  18. package/package.json +1 -1
  19. package/src/client/board/EditTaskModal.tsx +16 -3
  20. package/src/client/board/NewTaskModal.tsx +9 -2
  21. package/src/client/board/TaskBoard.tsx +61 -6
  22. package/src/client/board/TaskCard.tsx +17 -1
  23. package/src/client/board/TaskDetail.tsx +4 -2
  24. package/src/client/board/TaskForm.tsx +92 -0
  25. package/src/client/board.module.css +119 -0
  26. package/src/client/body-mutations.ts +133 -0
  27. package/src/client/index.ts +52 -20
  28. package/src/client/locales.ts +22 -0
  29. package/src/client/panel-mount-core.ts +21 -13
  30. package/src/client/sidebar-entry-core.ts +10 -7
  31. package/src/core/controller.ts +2 -3
  32. package/src/core/schedule.ts +4 -1
  33. package/src/core/store.ts +5 -1
  34. package/src/core/tasks.ts +119 -2
  35. package/src/core/use-cases/task-archive.ts +6 -6
  36. package/src/core/use-cases/task-update.ts +12 -2
  37. package/src/host-runner.ts +24 -1
  38. package/src/protocol.ts +11 -3
@@ -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,27 @@ 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
+ /** Case-insensitive title/description/tag/freeze-snapshot match. */
17
17
  export function matchesFilter(task: TaskRecord, filter: string): boolean {
18
18
  if (filter.trim() === '') return true
19
19
  const needle = filter.trim().toLowerCase()
20
- const haystacks = [task.title, task.description]
20
+ const haystacks = [task.title, task.description, ...(task.tags ?? []).map(tag => tag.name)]
21
21
  if (task.freeze !== undefined) haystacks.push(task.freeze.goal, task.freeze.progress, task.freeze.next)
22
22
  return haystacks.some(text => text.toLowerCase().includes(needle))
23
23
  }
24
24
 
25
+ /**
26
+ * Whether a task carries every selected label (issue #1521). Multi-select is
27
+ * conjunctive: adding a label narrows the board instead of widening it, which
28
+ * is the only reading that keeps "工作" selected from dragging unrelated cards
29
+ * back in when a second label is added.
30
+ */
31
+ export function matchesTagFilter(task: TaskRecord, selected: readonly string[]): boolean {
32
+ if (selected.length === 0) return true
33
+ const names = new Set((task.tags ?? []).map(tag => tag.name))
34
+ return selected.every(name => names.has(name))
35
+ }
36
+
25
37
  /**
26
38
  * Memoized per-card adapter: with a stable `onOpen` from the board and an
27
39
  * immutable task record (only the changed card gets a new object ref), a card
@@ -41,14 +53,24 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
41
53
  [controller],
42
54
  )
43
55
  const [filter, setFilter] = useState('')
56
+ const [tagFilter, setTagFilter] = useState<string[]>([])
44
57
  const [showNew, setShowNew] = useState(false)
45
58
  const selected = selectedTaskOf(snapshot)
46
59
  const archiveView = snapshot.archiveView
60
+ // Every label in use across the ledger (board and archive alike), so the
61
+ // filter never loses an option just because its task was archived.
62
+ const knownTags = collectKnownTags(snapshot.tasks)
47
63
  // Archived tasks leave the columns; the archive view shows them instead.
48
64
  const visible = snapshot.tasks.filter(task =>
49
65
  (archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
50
- && matchesFilter(task, filter),
66
+ && matchesFilter(task, filter)
67
+ && matchesTagFilter(task, tagFilter),
51
68
  )
69
+ const toggleTag = useCallback((name: string): void => {
70
+ setTagFilter(current => current.includes(name)
71
+ ? current.filter(entry => entry !== name)
72
+ : [...current, name])
73
+ }, [])
52
74
  const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
53
75
 
54
76
  return (
@@ -100,6 +122,35 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
100
122
  </button>
101
123
  </header>
102
124
 
125
+ {!archiveView && knownTags.length > 0 && (
126
+ <div className={css.tagFilter} data-dsh-part="tag-filter">
127
+ <span className={css.tagFilterLabel}>{t('board.tagFilter')}</span>
128
+ {knownTags.map(tag => {
129
+ const active = tagFilter.includes(tag.name)
130
+ return (
131
+ <button
132
+ key={tag.name}
133
+ type="button"
134
+ className={css.tagChip}
135
+ data-dsh-part="tag-chip"
136
+ data-tag-tone={tagTone(tag.name)}
137
+ data-active={active ? 'true' : undefined}
138
+ aria-pressed={active}
139
+ title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
140
+ onClick={() => { toggleTag(tag.name) }}
141
+ >
142
+ {tag.name}
143
+ </button>
144
+ )
145
+ })}
146
+ {tagFilter.length > 0 && (
147
+ <button type="button" className={css.linkButton} onClick={() => { setTagFilter([]) }}>
148
+ {t('board.tagFilterClear')}
149
+ </button>
150
+ )}
151
+ </div>
152
+ )}
153
+
103
154
  {snapshot.transportError !== undefined && (
104
155
  <div className={css.formError}>
105
156
  {t('board.hostError', { error: snapshot.transportError })}{' '}
@@ -120,7 +171,9 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
120
171
  {visible.map(task => (
121
172
  <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
122
173
  ))}
123
- {visible.length === 0 && <div className={css.columnEmpty}>{t('archive.empty')}</div>}
174
+ {visible.length === 0 && (
175
+ <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('archive.empty')}</div>
176
+ )}
124
177
  </div>
125
178
  </section>
126
179
  ) : (
@@ -156,7 +209,9 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
156
209
  {tasks.map(task => (
157
210
  <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
158
211
  ))}
159
- {tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
212
+ {tasks.length === 0 && (
213
+ <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('board.empty')}</div>
214
+ )}
160
215
  </div>
161
216
  </section>
162
217
  )
@@ -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>
@@ -347,8 +347,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
347
347
  </p>
348
348
  <p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
349
349
  <ul className={css.executionList}>
350
- {current.handover.references.map(reference => (
351
- <li key={reference} className={css.executionRow}><code>{reference}</code></li>
350
+ {current.handover.references.map((reference, index) => (
351
+ // References are free text from the freeze block, so the same
352
+ // string can appear twice; the index keeps the key unique (#1492).
353
+ <li key={`${reference}-${index}`} className={css.executionRow}><code>{reference}</code></li>
352
354
  ))}
353
355
  </ul>
354
356
  <p className={css.detailMeta}>{t('detail.handover.bundledAt', { time: formatHostTimestamp(current.handover.bundledAt, timeZone) })}</p>
@@ -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, 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,91 @@ 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
+ * 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).
193
+ */
194
+ 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
203
+ }
@@ -1121,3 +1121,122 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1121
1121
  animation: none;
1122
1122
  }
1123
1123
  }
1124
+
1125
+ /* --- task tags (issue #1521) ---------------------------------------------------
1126
+ * A badge takes its tone from a fixed six-colour palette chosen by hashing the
1127
+ * name, so one label always paints the same way and nothing per-tag has to be
1128
+ * stored. The fill is translucent and the text keeps the theme's own primary
1129
+ * label colour, so badges stay readable on light, dark, and every skin without
1130
+ * depending on a token a skin may not define.
1131
+ * ----------------------------------------------------------------------------- */
1132
+
1133
+ .cardTags {
1134
+ display: flex;
1135
+ flex-wrap: wrap;
1136
+ gap: 4px;
1137
+ }
1138
+
1139
+ .cardTag {
1140
+ max-width: 100%;
1141
+ padding: 0 7px;
1142
+ overflow: hidden;
1143
+ border: 1px solid var(--dsh-task-tag-border);
1144
+ border-radius: 999px;
1145
+ background: var(--dsh-task-tag-fill);
1146
+ color: var(--dsw-alias-label-primary);
1147
+ font-size: 10px;
1148
+ line-height: 16px;
1149
+ text-overflow: ellipsis;
1150
+ white-space: nowrap;
1151
+ }
1152
+
1153
+ .tagFilter {
1154
+ display: flex;
1155
+ flex-wrap: wrap;
1156
+ align-items: center;
1157
+ gap: 6px;
1158
+ margin: 0 0 10px;
1159
+ }
1160
+
1161
+ .tagFilterLabel {
1162
+ color: var(--dsw-alias-label-tertiary);
1163
+ font-size: 11px;
1164
+ }
1165
+
1166
+ .tagChip {
1167
+ padding: 1px 9px;
1168
+ border: 1px solid var(--dsh-task-tag-border);
1169
+ border-radius: 999px;
1170
+ background: transparent;
1171
+ color: var(--dsw-alias-label-secondary);
1172
+ cursor: pointer;
1173
+ font-family: inherit;
1174
+ font-size: 11px;
1175
+ line-height: 18px;
1176
+ }
1177
+
1178
+ .tagChip[data-active='true'] {
1179
+ background: var(--dsh-task-tag-fill);
1180
+ color: var(--dsw-alias-label-primary);
1181
+ }
1182
+
1183
+ .cardTag[data-tag-tone='0'],
1184
+ .tagChip[data-tag-tone='0'] {
1185
+ --dsh-task-tag-fill: #4e93e82e;
1186
+ --dsh-task-tag-border: #4e93e866;
1187
+ }
1188
+
1189
+ .cardTag[data-tag-tone='1'],
1190
+ .tagChip[data-tag-tone='1'] {
1191
+ --dsh-task-tag-fill: #2ea36a2e;
1192
+ --dsh-task-tag-border: #2ea36a66;
1193
+ }
1194
+
1195
+ .cardTag[data-tag-tone='2'],
1196
+ .tagChip[data-tag-tone='2'] {
1197
+ --dsh-task-tag-fill: #d08a2a2e;
1198
+ --dsh-task-tag-border: #d08a2a66;
1199
+ }
1200
+
1201
+ .cardTag[data-tag-tone='3'],
1202
+ .tagChip[data-tag-tone='3'] {
1203
+ --dsh-task-tag-fill: #b456c82e;
1204
+ --dsh-task-tag-border: #b456c866;
1205
+ }
1206
+
1207
+ .cardTag[data-tag-tone='4'],
1208
+ .tagChip[data-tag-tone='4'] {
1209
+ --dsh-task-tag-fill: #cf5f7a2e;
1210
+ --dsh-task-tag-border: #cf5f7a66;
1211
+ }
1212
+
1213
+ .cardTag[data-tag-tone='5'],
1214
+ .tagChip[data-tag-tone='5'] {
1215
+ --dsh-task-tag-fill: #4a9fb52e;
1216
+ --dsh-task-tag-border: #4a9fb566;
1217
+ }
1218
+
1219
+ .fieldHint {
1220
+ color: var(--dsw-alias-label-tertiary);
1221
+ font-size: 11px;
1222
+ line-height: 1.4;
1223
+ }
1224
+
1225
+ .tagRow {
1226
+ display: flex;
1227
+ align-items: center;
1228
+ gap: 6px;
1229
+ }
1230
+
1231
+ .tagRow .input {
1232
+ flex: 1 1 0;
1233
+ min-width: 0;
1234
+ }
1235
+
1236
+ .tagRow .ghostButton {
1237
+ flex: none;
1238
+ }
1239
+
1240
+ .tagAddButton {
1241
+ align-self: flex-start;
1242
+ }
@@ -0,0 +1,133 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * One `document.body` MutationObserver per page, shared by every family
4
+ * plugin bundle.
5
+ *
6
+ * Family plugins that inject at the DOM level must notice when the shell
7
+ * re-renders around their injected node: the sidebar entry rows
8
+ * (sidebar-entry-core), the center-column panel containers
9
+ * (panel-mount-core), and the aggregate shell's column shims. Each consumer
10
+ * used to install its OWN `MutationObserver` on `document.body` with
11
+ * `{ childList: true, subtree: true }`, so a page carrying N family plugins
12
+ * paid N native observers and N callback invocations for EVERY mutation batch
13
+ * the app produced — chat token streaming alone produces many per second, and
14
+ * the count grows with the number of installed plugins.
15
+ *
16
+ * This module keeps exactly ONE body observer per page. The registry lives on
17
+ * `globalThis` under a `Symbol.for` key, so the per-package generated copies
18
+ * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
19
+ * reach the same hub at runtime instead of one hub per module instance.
20
+ * Record subscribers receive the mutations accumulated since the last flush.
21
+ * Consumers that only re-check their DOM use subscribeBodyInvalidations:
22
+ * when all subscribers use that path, the hub retains no mutation records
23
+ * (or detached subtrees) while a background page's animation frames pause.
24
+ * Both paths run at most once per frame. The last subscriber disconnects the
25
+ * observer, cancels the pending frame and releases its records.
26
+ *
27
+ * Failure policy: without a DOM or `MutationObserver` the subscription is a
28
+ * no-op disposer (the same silence the per-consumer observers had), and when
29
+ * `requestAnimationFrame` is unavailable the flush runs synchronously so a
30
+ * subscriber is never silently dropped. A throwing subscriber cannot stop the
31
+ * others.
32
+ */
33
+
34
+ /** The page-wide hub: one observer plus its subscribers and pending records. */
35
+ interface BodyMutationHub {
36
+ observer: MutationObserver
37
+ subscribers: Set<(records: MutationRecord[]) => void>
38
+ pending: MutationRecord[]
39
+ scheduled: boolean
40
+ frame?: number
41
+ }
42
+
43
+ /** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
44
+ const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
45
+ const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
46
+ type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
47
+
48
+ function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
49
+ for (const listener of subscribers) {
50
+ if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * Subscribe to a coalesced DOM re-check without retaining mutation records.
57
+ * The marked wrapper also works with an older hub, which delivers records
58
+ * that it simply ignores until a page reload picks up the updated hub.
59
+ */
60
+ export function subscribeBodyInvalidations(subscriber: () => void): () => void {
61
+ const listener: Subscriber = () => { subscriber() }
62
+ listener[INVALIDATION_ONLY] = true
63
+ return subscribeBodyMutations(listener)
64
+ }
65
+
66
+ /**
67
+ * Subscribe to body-level childList mutations.
68
+ * @param subscriber - called at most once per animation frame with the records
69
+ * collected since the previous flush; must be safe to run repeatedly.
70
+ * @returns the disposer removing this subscriber (and the observer when it was
71
+ * the last one).
72
+ */
73
+ export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
74
+ if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
75
+ if (typeof MutationObserver !== 'function') return () => {}
76
+ const registry = globalThis as unknown as Record<symbol, unknown>
77
+ let hub = registry[HUB_KEY] as BodyMutationHub | undefined
78
+ if (hub === undefined) {
79
+ const subscribers = new Set<(records: MutationRecord[]) => void>()
80
+ const created: BodyMutationHub = {
81
+ observer: undefined as unknown as MutationObserver,
82
+ subscribers,
83
+ pending: [],
84
+ scheduled: false,
85
+ }
86
+ const flush = (): void => {
87
+ created.frame = undefined
88
+ created.scheduled = false
89
+ const batch = created.pending
90
+ created.pending = []
91
+ for (const listener of [...subscribers]) {
92
+ if (!subscribers.has(listener)) continue
93
+ try {
94
+ listener(batch)
95
+ } catch {
96
+ // One subscriber must never break the others; the next flush retries it.
97
+ }
98
+ }
99
+ }
100
+ const schedule = (): void => {
101
+ if (created.scheduled) return
102
+ created.scheduled = true
103
+ if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
104
+ else flush()
105
+ }
106
+ created.observer = new MutationObserver((records) => {
107
+ if (needsRecords(subscribers)) {
108
+ for (const record of records) created.pending.push(record)
109
+ }
110
+ schedule()
111
+ })
112
+ created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
113
+ registry[HUB_KEY] = created
114
+ hub = created
115
+ }
116
+ const active = hub
117
+ active.subscribers.add(subscriber)
118
+ let subscribed = true
119
+ return () => {
120
+ if (!subscribed) return
121
+ subscribed = false
122
+ active.subscribers.delete(subscriber)
123
+ if (!needsRecords(active.subscribers)) active.pending = []
124
+ if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
125
+ active.observer.disconnect()
126
+ if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
127
+ active.frame = undefined
128
+ active.pending = []
129
+ active.scheduled = false
130
+ delete registry[HUB_KEY]
131
+ }
132
+ }
133
+ }
@@ -80,7 +80,7 @@ declare module '@deepseek-ai/cordis' {
80
80
  * on hosts below that cohort, which serve the same roster through the
81
81
  * connection RPC face.
82
82
  */
83
- export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote']
83
+ export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote', 'remote.session']
84
84
 
85
85
  /** One agent-preset row the mode picker consumes (either face's wire shape). */
86
86
  interface PresetRosterRow {
@@ -251,31 +251,63 @@ export function apply(ctx: ClientContext): void {
251
251
  }
252
252
  const pushModelOptions = async (): Promise<void> => {
253
253
  try {
254
- const conn = ctx.get('connection') as { api?: { llm?: { discoverModels?: () => Promise<unknown> }; sessions?: { modelCatalog?: () => Promise<unknown> } } } | undefined
255
- if (conn?.api) {
256
- let models: Array<{ id: string; name?: string; provider?: string }> = []
257
- if (typeof conn.api.llm?.discoverModels === 'function') {
258
- const res = await conn.api.llm.discoverModels() as { result?: { value?: { models?: Array<{ id: string; name?: string }> } } }
259
- const list = res?.result?.value?.models
260
- if (Array.isArray(list)) {
261
- models = list.map(m => ({ id: m.id, name: m.name }))
254
+ let models: Array<{ id: string; name?: string; provider?: string }> = []
255
+ let sessionRemote: ClientRemote['session'] | undefined
256
+ try {
257
+ sessionRemote = (remote as Partial<ClientRemote>).session
258
+ } catch {
259
+ sessionRemote = undefined
260
+ }
261
+ if (typeof sessionRemote?.modelCatalog === 'function') {
262
+ const res = await sessionRemote.modelCatalog()
263
+ if (res.ok && Array.isArray(res.value?.groups)) {
264
+ for (const g of res.value.groups) {
265
+ const provider = (g as { id?: string; provider?: string }).id ?? (g as { id?: string; provider?: string }).provider
266
+ for (const m of g.models ?? []) {
267
+ const qualifiedId = provider ? `${provider}/${m.id}` : m.id
268
+ models.push({ id: qualifiedId, name: m.name ?? m.id, provider })
269
+ }
262
270
  }
263
271
  }
264
- if (models.length === 0 && typeof conn.api.sessions?.modelCatalog === 'function') {
265
- const res = await conn.api.sessions.modelCatalog() as { result?: { value?: { groups?: Array<{ provider?: string; models?: Array<{ id: string; name?: string }> }> } } }
266
- const groups = res?.result?.value?.groups
267
- if (Array.isArray(groups)) {
268
- for (const g of groups) {
269
- for (const m of g.models ?? []) {
270
- const qualifiedId = g.provider ? `${g.provider}/${m.id}` : m.id
271
- models.push({ id: qualifiedId, name: m.name ?? m.id, provider: g.provider })
272
+ }
273
+ if (models.length === 0) {
274
+ const conn = ctx.get('connection') as {
275
+ api?: {
276
+ llm?: { discoverModels?: () => Promise<unknown> }
277
+ sessions?: { modelCatalog?: () => Promise<unknown> }
278
+ session?: { modelCatalog?: () => Promise<unknown> }
279
+ }
280
+ } | undefined
281
+ if (conn?.api) {
282
+ if (typeof conn.api.llm?.discoverModels === 'function') {
283
+ const res = await conn.api.llm.discoverModels() as { result?: { value?: { models?: Array<{ id: string; name?: string }> } } }
284
+ const list = res?.result?.value?.models
285
+ if (Array.isArray(list)) {
286
+ models = list.map(m => ({ id: m.id, name: m.name }))
287
+ }
288
+ }
289
+ const catalogFn = typeof conn.api.session?.modelCatalog === 'function'
290
+ ? conn.api.session.modelCatalog
291
+ : typeof conn.api.sessions?.modelCatalog === 'function'
292
+ ? conn.api.sessions.modelCatalog
293
+ : undefined
294
+ if (models.length === 0 && catalogFn !== undefined) {
295
+ const res = await catalogFn() as { result?: { value?: { groups?: Array<{ id?: string; provider?: string; models?: Array<{ id: string; name?: string }> }> } } }
296
+ const groups = res?.result?.value?.groups
297
+ if (Array.isArray(groups)) {
298
+ for (const g of groups) {
299
+ const provider = g.id ?? g.provider
300
+ for (const m of g.models ?? []) {
301
+ const qualifiedId = provider ? `${provider}/${m.id}` : m.id
302
+ models.push({ id: qualifiedId, name: m.name ?? m.id, provider })
303
+ }
272
304
  }
273
305
  }
274
306
  }
275
307
  }
276
- if (models.length > 0) {
277
- controller.setExecutionOptions({ models })
278
- }
308
+ }
309
+ if (models.length > 0) {
310
+ controller.setExecutionOptions({ models })
279
311
  }
280
312
  } catch (error) {
281
313
  console.error('[dsh-task-board] model options read failed', error)
@@ -55,6 +55,17 @@ export const zh = {
55
55
  'detail.handover': '交接包',
56
56
  'detail.handover.bundledAt': '打包于 {time}',
57
57
  'detail.handover.references': '引用',
58
+ 'new.tags': '标签(可选)',
59
+ 'new.tagsHint': '标签用于卡片徽章与顶栏筛选;填了「执行提示」的行会在每次执行前注入 Prompt,留空则只作展示。',
60
+ 'new.tagName': '标签名',
61
+ 'new.tagNamePlaceholder': '例如:工作',
62
+ 'new.tagPrompt': '执行提示(可选)',
63
+ 'new.tagPromptPlaceholder': '例如:产物归档到 02-工作/',
64
+ 'new.tagAdd': '添加标签',
65
+ 'new.tagRemove': '移除标签 {name}',
66
+ 'board.tagFilter': '按标签筛选',
67
+ 'board.tagFilterClear': '清除标签筛选',
68
+ 'board.tagEmpty': '没有同时带这些标签的任务',
58
69
  'detail.permissionPending': '权限待确认:本卡有效权限({permission})高于会话默认,手动执行与定时调度都会被拒绝,需人工确认后才能运行。',
59
70
  'detail.permissionConfirm': '确认权限绑定',
60
71
  'detail.permissionConfirmed': '已确认权限绑定 · {time}',
@@ -209,6 +220,17 @@ export const en: Record<keyof typeof zh, string> = {
209
220
  'detail.handover': 'Handover bundle',
210
221
  'detail.handover.bundledAt': 'Bundled at {time}',
211
222
  'detail.handover.references': 'References',
223
+ 'new.tags': 'Tags (optional)',
224
+ 'new.tagsHint': 'Tags drive the card badges and the board filter; a row with an execution hint is injected ahead of the prompt on every run, an empty one is display-only.',
225
+ 'new.tagName': 'Tag name',
226
+ 'new.tagNamePlaceholder': 'e.g. Work',
227
+ 'new.tagPrompt': 'Execution hint (optional)',
228
+ 'new.tagPromptPlaceholder': 'e.g. Archive artefacts under 02-work/',
229
+ 'new.tagAdd': 'Add tag',
230
+ 'new.tagRemove': 'Remove tag {name}',
231
+ 'board.tagFilter': 'Filter by tag',
232
+ 'board.tagFilterClear': 'Clear tag filter',
233
+ 'board.tagEmpty': 'No task carries all of these tags',
212
234
  'detail.permissionPending': 'Permission pending confirmation: this card\'s effective permission ({permission}) is above the session default; manual runs and cron are refused until a human confirms.',
213
235
  'detail.permissionConfirm': 'Confirm permission binding',
214
236
  'detail.permissionConfirmed': 'Permission confirmed · {time}',