@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.
- package/README.i18n.yaml +2 -2
- package/README.md +5 -0
- package/README.zh.md +5 -0
- package/lib/client.js +467 -67
- package/lib/client.js.map +1 -1
- package/lib/index.js +97 -13
- package/lib/types/client/board/EditTaskModal.d.ts +1 -1
- package/lib/types/client/board/TaskBoard.d.ts +8 -1
- package/lib/types/client/board/TaskForm.d.ts +18 -0
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +11 -0
- package/lib/types/client/panel-mount-core.d.ts +1 -1
- package/lib/types/client/sidebar-entry-core.d.ts +0 -15
- package/lib/types/core/controller.d.ts +2 -3
- package/lib/types/core/tasks.d.ts +66 -1
- package/lib/types/core/use-cases/task-archive.d.ts +6 -6
- package/lib/types/core/use-cases/task-update.d.ts +8 -1
- package/package.json +1 -1
- package/src/client/board/EditTaskModal.tsx +16 -3
- package/src/client/board/NewTaskModal.tsx +9 -2
- package/src/client/board/TaskBoard.tsx +61 -6
- package/src/client/board/TaskCard.tsx +17 -1
- package/src/client/board/TaskDetail.tsx +4 -2
- package/src/client/board/TaskForm.tsx +92 -0
- package/src/client/board.module.css +119 -0
- package/src/client/body-mutations.ts +133 -0
- package/src/client/index.ts +52 -20
- package/src/client/locales.ts +22 -0
- package/src/client/panel-mount-core.ts +21 -13
- package/src/client/sidebar-entry-core.ts +10 -7
- package/src/core/controller.ts +2 -3
- package/src/core/schedule.ts +4 -1
- package/src/core/store.ts +5 -1
- package/src/core/tasks.ts +119 -2
- package/src/core/use-cases/task-archive.ts +6 -6
- package/src/core/use-cases/task-update.ts +12 -2
- package/src/host-runner.ts +24 -1
- 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 &&
|
|
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 &&
|
|
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
|
-
|
|
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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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)
|
package/src/client/locales.ts
CHANGED
|
@@ -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}',
|