@linxin666/dsh-client-ui-task-board 0.3.21 → 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 +3 -0
- package/README.zh.md +3 -0
- package/lib/client.js +282 -16
- package/lib/client.js.map +1 -1
- package/lib/index.js +80 -6
- 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/locales.d.ts +11 -0
- package/lib/types/core/tasks.d.ts +60 -0
- 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/TaskForm.tsx +92 -0
- package/src/client/board.module.css +119 -0
- package/src/client/locales.ts +22 -0
- package/src/core/store.ts +5 -1
- package/src/core/tasks.ts +112 -0
- package/src/core/use-cases/task-update.ts +12 -2
- package/src/host-runner.ts +24 -1
- package/src/protocol.ts +11 -3
|
@@ -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
|
+
}
|
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}',
|
package/src/core/store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
12
|
import { isValidCron } from './schedule.ts'
|
|
13
|
-
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
13
|
+
import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
14
|
import type { TaskHandover } from './handover.ts'
|
|
15
15
|
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
16
16
|
import { sanitizeHandover } from './handover.ts'
|
|
@@ -189,6 +189,10 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
189
189
|
task.reuseSession = row.reuseSession === true ? true : undefined
|
|
190
190
|
task.freeze = normalizeFreeze(row.freeze)
|
|
191
191
|
task.handover = normalizeHandover(row.handover)
|
|
192
|
+
// Tags are repaired field by field like the schedule: a malformed entry is
|
|
193
|
+
// dropped, and a list that repairs to nothing clears the field instead of
|
|
194
|
+
// dropping the task row.
|
|
195
|
+
task.tags = normalizeTags(row.tags)
|
|
192
196
|
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
|
|
193
197
|
tasks.push(task)
|
|
194
198
|
}
|
package/src/core/tasks.ts
CHANGED
|
@@ -94,6 +94,105 @@ export interface TaskFreeze extends FreezeSnapshot {
|
|
|
94
94
|
frozenBy?: string
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* One task label (issue #1521). The name is the badge and the tag-filter key;
|
|
99
|
+
* the optional prompt line rides the execution prompt, so a label may be
|
|
100
|
+
* display-only (no prefix) or carry an instruction (business-line routing,
|
|
101
|
+
* output directory, house style) that every run of the task inherits.
|
|
102
|
+
*/
|
|
103
|
+
export interface TaskTag {
|
|
104
|
+
/** Display name; trimmed, non-empty, unique within the task. */
|
|
105
|
+
name: string
|
|
106
|
+
/**
|
|
107
|
+
* Prompt line injected ahead of the execution prompt. Absent (or blank
|
|
108
|
+
* after trimming) keeps the tag display-only: it never touches the prompt,
|
|
109
|
+
* so a label with no prefix has zero execution side effects.
|
|
110
|
+
*/
|
|
111
|
+
promptPrefix?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Maximum number of tags carried by one task. */
|
|
115
|
+
export const TASK_TAG_LIMIT = 8
|
|
116
|
+
/** Maximum length of a tag name. */
|
|
117
|
+
export const TAG_NAME_MAX_LENGTH = 32
|
|
118
|
+
/** Maximum length of a tag's injected prompt line. */
|
|
119
|
+
export const TAG_PROMPT_MAX_LENGTH = 200
|
|
120
|
+
|
|
121
|
+
/** Whether an unknown value is a well-formed tag (strict: the wire gate). */
|
|
122
|
+
export function isTaskTag(value: unknown): value is TaskTag {
|
|
123
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
124
|
+
const tag = value as Record<string, unknown>
|
|
125
|
+
if (Object.keys(tag).some(key => key !== 'name' && key !== 'promptPrefix')) return false
|
|
126
|
+
if (typeof tag.name !== 'string') return false
|
|
127
|
+
const name = tag.name.trim()
|
|
128
|
+
if (name === '' || name.length > TAG_NAME_MAX_LENGTH) return false
|
|
129
|
+
if (tag.promptPrefix !== undefined && typeof tag.promptPrefix !== 'string') return false
|
|
130
|
+
return tag.promptPrefix === undefined || (tag.promptPrefix as string).trim().length <= TAG_PROMPT_MAX_LENGTH
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether an unknown value is a well-formed tag list (strict: the wire gate).
|
|
135
|
+
* An empty list is rejected — clearing tags is expressed by omitting the field
|
|
136
|
+
* (create) or by an explicit null (update), never by an empty array.
|
|
137
|
+
*/
|
|
138
|
+
export function isTaskTagList(value: unknown): value is TaskTag[] {
|
|
139
|
+
return Array.isArray(value) && value.length > 0 && value.length <= TASK_TAG_LIMIT && value.every(isTaskTag)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Repair a persisted tag list: keep the well-formed entries, trim, drop
|
|
144
|
+
* blanks and repeats, cap the count, and collapse a blank prompt line to
|
|
145
|
+
* "display-only". Returns undefined when nothing usable remains, so the caller
|
|
146
|
+
* clears the field instead of storing an empty array.
|
|
147
|
+
*/
|
|
148
|
+
export function normalizeTags(value: unknown): TaskTag[] | undefined {
|
|
149
|
+
if (!Array.isArray(value)) return undefined
|
|
150
|
+
const tags: TaskTag[] = []
|
|
151
|
+
const seen = new Set<string>()
|
|
152
|
+
for (const entry of value) {
|
|
153
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue
|
|
154
|
+
const row = entry as Record<string, unknown>
|
|
155
|
+
if (typeof row.name !== 'string') continue
|
|
156
|
+
const name = row.name.trim()
|
|
157
|
+
if (name === '' || name.length > TAG_NAME_MAX_LENGTH || seen.has(name)) continue
|
|
158
|
+
const raw = typeof row.promptPrefix === 'string' ? row.promptPrefix.trim() : ''
|
|
159
|
+
const promptPrefix = raw === '' ? undefined : raw.slice(0, TAG_PROMPT_MAX_LENGTH)
|
|
160
|
+
seen.add(name)
|
|
161
|
+
tags.push(promptPrefix === undefined ? { name } : { name, promptPrefix })
|
|
162
|
+
if (tags.length >= TASK_TAG_LIMIT) break
|
|
163
|
+
}
|
|
164
|
+
return tags.length === 0 ? undefined : tags
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Stable palette slot (0..5) for a tag name. The same label always lands on the
|
|
169
|
+
* same tone, so a badge needs no stored colour and two tasks sharing a label
|
|
170
|
+
* cannot disagree about it.
|
|
171
|
+
*/
|
|
172
|
+
export function tagTone(name: string): number {
|
|
173
|
+
let hash = 0
|
|
174
|
+
for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0
|
|
175
|
+
return hash % 6
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Union of the labels already carried by `tasks`, first occurrence wins (the
|
|
180
|
+
* oldest task's hint is the one offered). Feeds the editor's name datalist and
|
|
181
|
+
* the board's tag filter, so a label defined once can be reused everywhere.
|
|
182
|
+
*/
|
|
183
|
+
export function collectKnownTags(tasks: readonly TaskRecord[]): TaskTag[] {
|
|
184
|
+
const known: TaskTag[] = []
|
|
185
|
+
const seen = new Set<string>()
|
|
186
|
+
for (const task of tasks) {
|
|
187
|
+
for (const tag of task.tags ?? []) {
|
|
188
|
+
if (seen.has(tag.name)) continue
|
|
189
|
+
seen.add(tag.name)
|
|
190
|
+
known.push(tag)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return known
|
|
194
|
+
}
|
|
195
|
+
|
|
97
196
|
/** One task on the board. */
|
|
98
197
|
export interface TaskRecord {
|
|
99
198
|
/** Stable task id (uuid). */
|
|
@@ -158,6 +257,12 @@ export interface TaskRecord {
|
|
|
158
257
|
* bundle's triplet overrides the legacy pin fields at execution time.
|
|
159
258
|
*/
|
|
160
259
|
handover?: TaskHandover
|
|
260
|
+
/**
|
|
261
|
+
* Task labels (issue #1521): optional, additive, and absent on every task
|
|
262
|
+
* created before the field existed. A tag whose `promptPrefix` is set is
|
|
263
|
+
* prepended to the execution prompt; a bare name is display and filter only.
|
|
264
|
+
*/
|
|
265
|
+
tags?: TaskTag[]
|
|
161
266
|
/**
|
|
162
267
|
* Human confirmation stamp for an above-default effective permission
|
|
163
268
|
* (ms epoch). Absent while the binding awaits confirmation; any permission
|
|
@@ -223,6 +328,11 @@ export interface NewTaskInput {
|
|
|
223
328
|
* sanitized by the protocol gate) attached at creation.
|
|
224
329
|
*/
|
|
225
330
|
handover?: TaskHandoverInput
|
|
331
|
+
/**
|
|
332
|
+
* Optional task labels. A tag with a non-blank `promptPrefix` is injected
|
|
333
|
+
* ahead of the execution prompt; a bare name changes nothing at run time.
|
|
334
|
+
*/
|
|
335
|
+
tags?: TaskTag[]
|
|
226
336
|
}
|
|
227
337
|
|
|
228
338
|
/** The five kanban columns, in display order. */
|
|
@@ -281,6 +391,7 @@ export function freezeOf(
|
|
|
281
391
|
|
|
282
392
|
/** Create a task from user input. */
|
|
283
393
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
394
|
+
const tags = normalizeTags(input.tags)
|
|
284
395
|
return {
|
|
285
396
|
id,
|
|
286
397
|
title: input.title.trim(),
|
|
@@ -297,6 +408,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
297
408
|
reuseSession: input.reuseSession === true ? true : undefined,
|
|
298
409
|
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
299
410
|
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
411
|
+
...(tags === undefined ? {} : { tags }),
|
|
300
412
|
}
|
|
301
413
|
}
|
|
302
414
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* back to the runtime default); an unknown permission string is ignored so
|
|
9
9
|
* stale UI can never persist a value the execution service rejects.
|
|
10
10
|
*/
|
|
11
|
-
import { freezeOf, isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
11
|
+
import { freezeOf, isTaskPermission, normalizeTags, normalizeTargetId, type TaskRecord, type TaskPermission, type TaskTag } from '../tasks.ts'
|
|
12
12
|
import type { FreezeSnapshot } from '../freeze-snapshot.ts'
|
|
13
13
|
import type { TaskHandoverInput } from '../handover.ts'
|
|
14
14
|
|
|
@@ -20,6 +20,13 @@ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' |
|
|
|
20
20
|
freeze?: FreezeSnapshot & { redacted?: boolean } | null
|
|
21
21
|
/** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
|
|
22
22
|
handover?: TaskHandoverInput | null
|
|
23
|
+
/**
|
|
24
|
+
* Replaces the task's labels (issue #1521). Unlike the content fields, tags
|
|
25
|
+
* stay editable after the first run: they classify the task and shape the
|
|
26
|
+
* NEXT execution prompt, they are not the record of what already ran. An
|
|
27
|
+
* explicit null (or a list that normalizes to nothing) clears them.
|
|
28
|
+
*/
|
|
29
|
+
tags?: TaskTag[] | null
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/** The fields that edit the task's content (what the user reads and what the
|
|
@@ -68,7 +75,7 @@ export function applyUpdateTask(
|
|
|
68
75
|
): readonly TaskRecord[] {
|
|
69
76
|
return tasks.map(task => {
|
|
70
77
|
if (task.id !== id) return task
|
|
71
|
-
const { freeze: freezePatch, handover: handoverPatch, ...rest } = patch
|
|
78
|
+
const { freeze: freezePatch, handover: handoverPatch, tags: tagsPatch, ...rest } = patch
|
|
72
79
|
const workspaceId = 'workspaceId' in patch ? normalizeTargetId(patch.workspaceId) : undefined
|
|
73
80
|
const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
|
|
74
81
|
const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
|
|
@@ -86,6 +93,9 @@ export function applyUpdateTask(
|
|
|
86
93
|
next.freeze = freezePatch == null ? undefined : freezeOf(freezePatch, now)
|
|
87
94
|
// Handover follows the same null-clears convention, restamping bundledAt.
|
|
88
95
|
next.handover = handoverPatch == null ? undefined : { ...handoverPatch, bundledAt: now }
|
|
96
|
+
// Tags use the same convention: a present array replaces the set (after
|
|
97
|
+
// repair), an explicit null or a fully-invalid list clears it.
|
|
98
|
+
if ('tags' in patch) next.tags = tagsPatch == null ? undefined : normalizeTags(tagsPatch)
|
|
89
99
|
// The permission confirmation binds the exact permission value: a change
|
|
90
100
|
// of the pinned permission or of the handover bundle re-arms the gate
|
|
91
101
|
// (blocking confirm-then-swap escalation). handover was destructured out
|
package/src/host-runner.ts
CHANGED
|
@@ -110,9 +110,15 @@ function escapeProvenanceDelimiter(value: string): string {
|
|
|
110
110
|
export function promptText(task: TaskRecord): string {
|
|
111
111
|
const body = task.prompt !== '' ? task.prompt : task.title
|
|
112
112
|
const handover = task.handover
|
|
113
|
-
const
|
|
113
|
+
const handoverPreamble = handover === undefined || handover.references.length === 0
|
|
114
114
|
? undefined
|
|
115
115
|
: `交接包引用(来自任务看板续接卡片,冻结于 ${new Date(handover.bundledAt).toISOString()}):\n${handover.references.map(reference => `- ${reference}`).join('\n')}`
|
|
116
|
+
// Tag prompts come first: they are the run's standing context (business line,
|
|
117
|
+
// output location), the handover preamble is a per-card note, and the task
|
|
118
|
+
// body is the instruction itself.
|
|
119
|
+
const tagPreamble = tagPromptPreamble(task)
|
|
120
|
+
const preambles = [tagPreamble, handoverPreamble].filter((part): part is string => part !== undefined)
|
|
121
|
+
const preamble = preambles.length === 0 ? undefined : preambles.join('\n\n')
|
|
116
122
|
const freeze = task.freeze
|
|
117
123
|
if (freeze === undefined) {
|
|
118
124
|
return preamble === undefined ? body : `${preamble}\n\n${body}`
|
|
@@ -122,6 +128,23 @@ export function promptText(task: TaskRecord): string {
|
|
|
122
128
|
return preamble === undefined ? declaration : `${preamble}\n\n${declaration}`
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Build the tag section of the execution prompt (issue #1521). Only tags with
|
|
133
|
+
* a non-blank `promptPrefix` contribute; a task whose tags are all bare names
|
|
134
|
+
* (or which has no tags at all) yields undefined and the prompt is byte-for-byte
|
|
135
|
+
* what it was before the feature.
|
|
136
|
+
*/
|
|
137
|
+
function tagPromptPreamble(task: TaskRecord): string | undefined {
|
|
138
|
+
const lines: string[] = []
|
|
139
|
+
for (const tag of task.tags ?? []) {
|
|
140
|
+
const prefix = tag.promptPrefix?.trim()
|
|
141
|
+
if (prefix === undefined || prefix === '') continue
|
|
142
|
+
lines.push(`- [${tag.name}] ${escapeProvenanceDelimiter(prefix)}`)
|
|
143
|
+
}
|
|
144
|
+
if (lines.length === 0) return undefined
|
|
145
|
+
return `标签提示(任务看板标签,每次执行前注入):\n${lines.join('\n')}`
|
|
146
|
+
}
|
|
147
|
+
|
|
125
148
|
function isErrorTurnEnd(data: unknown): boolean {
|
|
126
149
|
if (typeof data !== 'object' || data === null) return false
|
|
127
150
|
const reason = (data as { reason?: unknown }).reason
|
package/src/protocol.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
|
|
2
|
-
import { isTaskPermission, isTaskStatus, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
|
|
2
|
+
import { isTaskPermission, isTaskStatus, isTaskTagList, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
|
|
3
3
|
import { parseLedger } from './core/store.ts'
|
|
4
4
|
import { sanitizeFreezeSnapshot, type FreezeSnapshot } from './core/freeze-snapshot.ts'
|
|
5
5
|
import { sanitizeHandover, type TaskHandoverInput } from './core/handover.ts'
|
|
@@ -101,6 +101,10 @@ function optionalFiniteNumber(value: unknown): boolean {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
function validImportedKnownFields(value: Record<string, unknown>): boolean {
|
|
104
|
+
// Tags are validated (not repaired) on the import path: an imported task
|
|
105
|
+
// must carry a well-formed list or none at all, so a hand-edited export
|
|
106
|
+
// cannot smuggle a malformed tag past the gate.
|
|
107
|
+
if (value.tags !== undefined && !isTaskTagList(value.tags)) return false
|
|
104
108
|
if (value.schedule !== undefined) {
|
|
105
109
|
const schedule = record(value.schedule)
|
|
106
110
|
if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
@@ -161,6 +165,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
|
|
|
161
165
|
...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
|
|
162
166
|
...(task.freeze === undefined ? {} : { freeze: task.freeze }),
|
|
163
167
|
...(task.handover === undefined ? {} : { handover: task.handover }),
|
|
168
|
+
...(task.tags === undefined ? {} : { tags: task.tags }),
|
|
164
169
|
// 安全门(对抗场景 b):import 不是人工确认动作,确认戳一律剥除——
|
|
165
170
|
// 高于会话默认权限的绑定经 import 进入后必须重新武装 confirm-permission 门。
|
|
166
171
|
}
|
|
@@ -194,11 +199,12 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
|
|
|
194
199
|
|
|
195
200
|
function createInput(value: unknown): value is NewTaskInput {
|
|
196
201
|
const input = record(value)
|
|
197
|
-
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession'])) return false
|
|
202
|
+
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
|
|
198
203
|
if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
|
|
199
204
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
|
|
200
205
|
if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
|
|
201
206
|
if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
|
|
207
|
+
if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
|
|
202
208
|
if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
|
|
203
209
|
if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
|
|
204
210
|
if (input.schedule !== undefined) {
|
|
@@ -211,13 +217,15 @@ function createInput(value: unknown): value is NewTaskInput {
|
|
|
211
217
|
|
|
212
218
|
function updatePatch(value: unknown): boolean {
|
|
213
219
|
const patch = record(value)
|
|
214
|
-
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession'])) return false
|
|
220
|
+
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
|
|
215
221
|
// null (or false) clears the reuse opt-in; only a real boolean is accepted.
|
|
216
222
|
if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
|
|
217
223
|
for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
|
|
218
224
|
if (!optionalString(patch[key])) return false
|
|
219
225
|
}
|
|
220
226
|
if (patch.permission !== undefined && !isTaskPermission(patch.permission)) return false
|
|
227
|
+
// null clears the label set; a present array must be a well-formed list.
|
|
228
|
+
if (patch.tags !== undefined && patch.tags !== null && !isTaskTagList(patch.tags)) return false
|
|
221
229
|
// null clears the snapshot; an object must pass the freeze gate.
|
|
222
230
|
if (patch.freeze !== undefined && patch.freeze !== null && freezePayload(patch.freeze) === undefined) return false
|
|
223
231
|
// Same convention for the handover bundle.
|