@linxin666/dsh-client-ui-task-board 0.3.22 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +726 -71
- package/lib/client.js.map +1 -1
- package/lib/index.js +2619 -175
- package/lib/types/client/board/EditTaskModal.d.ts +6 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +2 -0
- package/lib/types/client/board/TaskForm.d.ts +8 -3
- package/lib/types/client/board/parse-model-pref.d.ts +4 -0
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +26 -0
- package/lib/types/client/plugin-card-seat.d.ts +96 -0
- package/lib/types/client/sidebar-entry-core.d.ts +32 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/core/session-reuse.d.ts +4 -2
- package/lib/types/host-ai.d.ts +51 -0
- package/lib/types/host-ledger.d.ts +15 -0
- package/lib/types/host-routes.d.ts +11 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/protocol.d.ts +19 -0
- package/package.json +2 -1
- package/src/client/board/EditTaskModal.tsx +36 -0
- package/src/client/board/NewTaskModal.tsx +119 -5
- package/src/client/board/TaskBoard.tsx +89 -0
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +17 -11
- package/src/client/board/parse-model-pref.ts +35 -0
- package/src/client/board.module.css +61 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +18 -17
- package/src/client/locales.ts +52 -0
- package/src/client/plugin-card-seat.ts +185 -0
- package/src/client/sidebar-entry-core.ts +96 -12
- package/src/core/controller.ts +57 -1
- package/src/core/session-reuse.ts +14 -5
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +84 -14
- package/src/host-routes.ts +68 -3
- package/src/index.ts +26 -1
- package/src/protocol.ts +34 -0
package/src/client/host-api.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { TaskRecord } from '../core/tasks.ts'
|
|
2
|
+
import { t } from './locales.ts'
|
|
2
3
|
import {
|
|
4
|
+
isTaskParseDraft,
|
|
3
5
|
TASK_BOARD_API_PREFIX,
|
|
4
6
|
type TaskBoardAction,
|
|
5
7
|
type TaskBoardActionEnvelope,
|
|
6
8
|
type TaskBoardEventPayload,
|
|
9
|
+
type TaskBoardParseDraft,
|
|
10
|
+
type TaskBoardParseRequest,
|
|
7
11
|
type TaskBoardSnapshot,
|
|
8
12
|
} from '../protocol.ts'
|
|
9
13
|
|
|
@@ -11,15 +15,69 @@ const IMPORT_MARKER = 'dsh.taskBoard.v2.hostImported'
|
|
|
11
15
|
const SOURCE_KEY = 'dsh.taskBoard.v2.sourceId'
|
|
12
16
|
const IMPORT_REQUEST_KEY = 'dsh.taskBoard.v2.importRequestId'
|
|
13
17
|
const REQUEST_TIMEOUT_MS = 15_000
|
|
18
|
+
/** Re-notify the panel at most this often while the event stream stays broken. */
|
|
19
|
+
const STREAM_ERROR_NOTIFY_MS = 15_000
|
|
20
|
+
/** Mirrors the Host's own parse budget; only used to phrase the timeout. */
|
|
21
|
+
const TASK_PARSE_TIMEOUT_SECONDS = 45
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Failure classes of the Host API, in the language the panel renders
|
|
25
|
+
* (issue #1528). The Host is the only part that knows whether a task-board
|
|
26
|
+
* route exists at all, so a raw fetch/parse error must never reach the user:
|
|
27
|
+
* "Unexpected token 'o', \"not found\" is not valid JSON" is what a missing
|
|
28
|
+
* Host half looks like today.
|
|
29
|
+
*/
|
|
30
|
+
export type HostApiFailure = 'not-mounted' | 'unauthorized' | 'locked' | 'rejected' | 'timeout' | 'unreachable' | 'unexpected'
|
|
31
|
+
|
|
32
|
+
/** Transport failure carrying a stable class next to its user-facing message. */
|
|
33
|
+
export class HostApiError extends Error {
|
|
34
|
+
constructor(readonly failure: HostApiFailure, message: string, readonly status?: number) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = 'HostApiError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
14
39
|
|
|
15
40
|
function uuid(): string {
|
|
16
41
|
return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
17
42
|
}
|
|
18
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Read one Host response without ever handing a non-JSON body to JSON.parse:
|
|
46
|
+
* the core webserver answers an unmounted `/api/*` path with the plain text
|
|
47
|
+
* "not found", which used to surface as a JavaScript parse error in the panel.
|
|
48
|
+
*/
|
|
19
49
|
async function readJson<T>(response: Response): Promise<T> {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
50
|
+
const text = await response.text()
|
|
51
|
+
let parsed: unknown
|
|
52
|
+
let readable = false
|
|
53
|
+
if (text.trim() !== '') {
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(text)
|
|
56
|
+
readable = true
|
|
57
|
+
} catch {
|
|
58
|
+
readable = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const hostError = readable && typeof parsed === 'object' && parsed !== null && typeof (parsed as { error?: unknown }).error === 'string'
|
|
62
|
+
? (parsed as { error: string }).error
|
|
63
|
+
: undefined
|
|
64
|
+
if (response.ok) {
|
|
65
|
+
if (!readable) throw new HostApiError('unexpected', t('board.hostError.unexpected', { status: String(response.status) }), response.status)
|
|
66
|
+
return parsed as T
|
|
67
|
+
}
|
|
68
|
+
if (hostError !== undefined) {
|
|
69
|
+
// The Host answered with a reason of its own. A ledger lock is the one
|
|
70
|
+
// class worth naming in the panel's words; the authentication fence and
|
|
71
|
+
// every other action rejection are reported as they arrive.
|
|
72
|
+
if (hostError === 'forbidden') throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
|
|
73
|
+
if (response.status === 503 || /lock/i.test(hostError)) {
|
|
74
|
+
throw new HostApiError('locked', t('board.hostError.locked', { detail: hostError }), response.status)
|
|
75
|
+
}
|
|
76
|
+
throw new HostApiError('rejected', hostError, response.status)
|
|
77
|
+
}
|
|
78
|
+
if (response.status === 404) throw new HostApiError('not-mounted', t('board.hostError.notMounted'), 404)
|
|
79
|
+
if (response.status === 401 || response.status === 403) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
|
|
80
|
+
throw new HostApiError('unexpected', t('board.hostError.unexpected', { status: String(response.status) }), response.status)
|
|
23
81
|
}
|
|
24
82
|
|
|
25
83
|
export interface TaskBoardHostTransport {
|
|
@@ -27,6 +85,8 @@ export interface TaskBoardHostTransport {
|
|
|
27
85
|
state(): Promise<TaskBoardSnapshot>
|
|
28
86
|
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
|
|
29
87
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
88
|
+
/** One-shot model parse of pasted text (issue #1540). */
|
|
89
|
+
parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>
|
|
30
90
|
}
|
|
31
91
|
|
|
32
92
|
export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
@@ -76,15 +136,65 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
76
136
|
try {
|
|
77
137
|
return await readJson<TaskBoardSnapshot>(await fetch(url, { ...init, signal: controller.signal }))
|
|
78
138
|
} catch (error) {
|
|
79
|
-
if (
|
|
80
|
-
throw
|
|
139
|
+
if (error instanceof HostApiError) throw error
|
|
140
|
+
if (controller.signal.aborted) throw new HostApiError('timeout', t('board.hostError.timeout', { seconds: String(REQUEST_TIMEOUT_MS / 1_000) }))
|
|
141
|
+
// fetch itself failed (server stopped, connection reset, DNS): the panel
|
|
142
|
+
// says so in its own words instead of leaking "Failed to fetch".
|
|
143
|
+
throw new HostApiError('unreachable', t('board.hostError.unreachable'))
|
|
81
144
|
} finally {
|
|
82
145
|
globalThis.clearTimeout(timeout)
|
|
83
146
|
}
|
|
84
147
|
}
|
|
85
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Ask the Host to turn pasted text into task fields. The route answers a
|
|
151
|
+
* typed failure (no model, timeout, unparseable reply) already phrased for
|
|
152
|
+
* the form, so the UI never renders a raw status code (issue #1540).
|
|
153
|
+
*/
|
|
154
|
+
async parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft> {
|
|
155
|
+
let response: Response
|
|
156
|
+
try {
|
|
157
|
+
response = await fetch(`${TASK_BOARD_API_PREFIX}/parse`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'content-type': 'application/json' },
|
|
160
|
+
body: JSON.stringify(request),
|
|
161
|
+
...(signal === undefined ? {} : { signal }),
|
|
162
|
+
})
|
|
163
|
+
} catch {
|
|
164
|
+
// A cancelled parse and a stopped Host look the same to fetch; the form
|
|
165
|
+
// checks its own abort flag before showing anything.
|
|
166
|
+
throw new HostApiError('unreachable', t('board.hostError.unreachable'))
|
|
167
|
+
}
|
|
168
|
+
const text = await response.text()
|
|
169
|
+
let parsed: unknown
|
|
170
|
+
let readable = false
|
|
171
|
+
if (text.trim() !== '') {
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(text)
|
|
174
|
+
readable = true
|
|
175
|
+
} catch {
|
|
176
|
+
readable = false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const record = readable && typeof parsed === 'object' && parsed !== null
|
|
180
|
+
? parsed as { code?: unknown; error?: unknown; draft?: unknown }
|
|
181
|
+
: undefined
|
|
182
|
+
if (response.ok) {
|
|
183
|
+
if (record !== undefined && isTaskParseDraft(record.draft)) return record.draft
|
|
184
|
+
throw new HostApiError('unexpected', t('new.aiParseFailed', { error: t('board.hostError.unexpected', { status: String(response.status) }) }), response.status)
|
|
185
|
+
}
|
|
186
|
+
if (response.status === 404) throw new HostApiError('not-mounted', t('new.aiParseUnavailable'), 404)
|
|
187
|
+
if (response.status === 401 || response.status === 403) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
|
|
188
|
+
const code = typeof record?.code === 'string' ? record.code : undefined
|
|
189
|
+
if (code === 'no-model') throw new HostApiError('rejected', t('new.aiParseNoModel'), response.status)
|
|
190
|
+
if (code === 'timeout') throw new HostApiError('timeout', t('new.aiParseTimeout', { seconds: String(TASK_PARSE_TIMEOUT_SECONDS) }), response.status)
|
|
191
|
+
const detail = typeof record?.error === 'string' && record.error !== '' ? record.error : String(response.status)
|
|
192
|
+
throw new HostApiError('rejected', t('new.aiParseFailed', { error: detail }), response.status)
|
|
193
|
+
}
|
|
194
|
+
|
|
86
195
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void {
|
|
87
196
|
const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`)
|
|
197
|
+
let lastStreamErrorNotify = 0
|
|
88
198
|
events.onmessage = (message: MessageEvent<string>): void => {
|
|
89
199
|
try {
|
|
90
200
|
const parsed = JSON.parse(message.data) as TaskBoardEventPayload
|
|
@@ -94,6 +204,15 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
94
204
|
listener()
|
|
95
205
|
}
|
|
96
206
|
}
|
|
207
|
+
// A stream that cannot connect at all (unmounted route, refused socket) is
|
|
208
|
+
// how a Host half that never mounted looks from the browser; nudge the
|
|
209
|
+
// panel into one state read so the failure becomes visible (#1528).
|
|
210
|
+
events.onerror = (): void => {
|
|
211
|
+
const now = Date.now()
|
|
212
|
+
if (now - lastStreamErrorNotify < STREAM_ERROR_NOTIFY_MS) return
|
|
213
|
+
lastStreamErrorNotify = now
|
|
214
|
+
listener()
|
|
215
|
+
}
|
|
97
216
|
const onVisible = (): void => { if (document.visibilityState === 'visible') listener() }
|
|
98
217
|
document.addEventListener('visibilitychange', onVisible)
|
|
99
218
|
return () => {
|
package/src/client/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardS
|
|
|
30
30
|
import { en, zh, setRuntimeTranslate, type TaskBoardKey } from './locales.ts'
|
|
31
31
|
import { HttpTaskBoardHostTransport } from './host-api.ts'
|
|
32
32
|
import { reportDailyHeartbeat } from './telemetry.ts'
|
|
33
|
+
import { installPluginCard } from './plugin-card-seat.ts'
|
|
33
34
|
|
|
34
35
|
/** Locale namespace this plugin owns. */
|
|
35
36
|
const NS = 'task-board'
|
|
@@ -164,27 +165,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
164
165
|
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
|
|
165
166
|
|
|
166
167
|
// Plugin configuration card: one staged form over the `task-board` settings
|
|
167
|
-
// namespace, contributed to
|
|
168
|
+
// namespace, contributed to whichever plugin-card seat this host declares
|
|
169
|
+
// (issue #1589).
|
|
168
170
|
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
|
169
171
|
const settingsScope = binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
|
|
170
172
|
const settingsCard = new TaskBoardSettingsCardController(settingsScope)
|
|
171
|
-
ctx
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
inject: () => settingsCard.inject(),
|
|
179
|
-
}, TaskBoardSettingsCard)
|
|
180
|
-
return () => {
|
|
181
|
-
settingsCard.dispose()
|
|
182
|
-
unregister()
|
|
183
|
-
}
|
|
184
|
-
} catch {
|
|
185
|
-
return () => {}
|
|
186
|
-
}
|
|
173
|
+
installPluginCard(ctx, {
|
|
174
|
+
namespace: TASK_BOARD_NS,
|
|
175
|
+
id: 'task-board',
|
|
176
|
+
order: 110,
|
|
177
|
+
locale: NS,
|
|
178
|
+
inject: () => settingsCard.inject(),
|
|
179
|
+
component: TaskBoardSettingsCard,
|
|
187
180
|
})
|
|
181
|
+
ctx.effect(() => () => { settingsCard.dispose() }, 'task-board: settings card')
|
|
188
182
|
|
|
189
183
|
// The sidebar entry and board view mount once the settings scope settles;
|
|
190
184
|
// while the scope is still loading, the composition default is unknown, so
|
|
@@ -230,6 +224,13 @@ export function apply(ctx: ClientContext): void {
|
|
|
230
224
|
}
|
|
231
225
|
pushWorkspaceOptions()
|
|
232
226
|
disposers.push(workspaces.list.subscribe(pushWorkspaceOptions))
|
|
227
|
+
// "New project" on the board is the GUI's own add-project call (#1536);
|
|
228
|
+
// the runtime emits the created Workspace through workspaces.list, which
|
|
229
|
+
// refreshes the filter above without a local re-read.
|
|
230
|
+
controller.setWorkspaceCreator(async path => {
|
|
231
|
+
const created = await workspaces.create({ path })
|
|
232
|
+
return { workspaceId: created.workspaceId }
|
|
233
|
+
})
|
|
233
234
|
const pushPresetOptions = async (): Promise<void> => {
|
|
234
235
|
try {
|
|
235
236
|
const roster = await readPresetRoster(ctx, remote)
|
package/src/client/locales.ts
CHANGED
|
@@ -29,6 +29,30 @@ export const zh = {
|
|
|
29
29
|
'board.updated': '更新于',
|
|
30
30
|
'board.created': '创建于',
|
|
31
31
|
'board.hostError': 'Host 操作失败:{error}',
|
|
32
|
+
'board.hostError.notMounted': '任务看板的后台接口没有挂载:宿主可能没有加载该插件,也可能后台数据正被另一个 DSH 实例占用。重启 DSH 服务后再试',
|
|
33
|
+
'board.hostError.unauthorized': '登录状态已失效,请刷新页面后重试',
|
|
34
|
+
'board.hostError.locked': '任务看板的后台数据被占用:{detail}',
|
|
35
|
+
'board.hostError.timeout': '后台 {seconds} 秒内没有响应,请稍后重试',
|
|
36
|
+
'board.hostError.unreachable': '连接不上本机 DSH 服务,请确认它仍在运行',
|
|
37
|
+
'board.hostError.unexpected': '后台返回了无法识别的响应(HTTP {status})',
|
|
38
|
+
'board.project': '项目',
|
|
39
|
+
'board.projectAll': '全部项目',
|
|
40
|
+
'board.projectNew': '新建项目…',
|
|
41
|
+
'board.projectNewPath': '项目目录(宿主上的绝对路径)',
|
|
42
|
+
'board.projectNewPathPlaceholder': '例如 C:\\work\\my-project 或 /home/me/project',
|
|
43
|
+
'board.projectCreate': '创建项目',
|
|
44
|
+
'board.projectCreateFailed': '创建项目失败:{error}',
|
|
45
|
+
'new.aiParse': 'AI 解析',
|
|
46
|
+
'new.aiParseHint': '把从别处复制的一段话粘进来,让模型整理成标题、描述和执行 Prompt;解析结果只填进下面的表单,你可以先改再创建。',
|
|
47
|
+
'new.aiParsePlaceholder': '粘贴要变成任务的内容…',
|
|
48
|
+
'new.aiParseModel': '解析用模型',
|
|
49
|
+
'new.aiParseRun': '解析并填入',
|
|
50
|
+
'new.aiParseCancel': '取消解析',
|
|
51
|
+
'new.aiParseEmpty': '先粘贴一段内容再解析',
|
|
52
|
+
'new.aiParseNoModel': '这个部署还没有可用的模型,请先在 DSH 里配置模型提供商,或换一个模型再试',
|
|
53
|
+
'new.aiParseUnavailable': '后台没有解析接口可用,重启 DSH 服务后再试',
|
|
54
|
+
'new.aiParseTimeout': '模型 {seconds} 秒内没有返回结果,请重试或换一个更快的模型',
|
|
55
|
+
'new.aiParseFailed': '解析失败:{error}',
|
|
32
56
|
'board.retryHost': '重试连接 Host',
|
|
33
57
|
'board.hostMeta': 'Host 时区 {timeZone} · revision {revision}',
|
|
34
58
|
'new.title': '标题',
|
|
@@ -38,6 +62,7 @@ export const zh = {
|
|
|
38
62
|
'new.prompt': '执行 Prompt',
|
|
39
63
|
'new.promptPlaceholder': '发给 agent 的完整指令(留空则使用标题)',
|
|
40
64
|
'new.submit': '创建',
|
|
65
|
+
'new.createAndRun': '创建并执行',
|
|
41
66
|
'new.cancel': '取消',
|
|
42
67
|
'new.required': '标题不能为空',
|
|
43
68
|
'new.freeze': '冻结快照(可选)',
|
|
@@ -71,6 +96,7 @@ export const zh = {
|
|
|
71
96
|
'detail.permissionConfirmed': '已确认权限绑定 · {time}',
|
|
72
97
|
'detail.title': '任务详情',
|
|
73
98
|
'detail.edit': '编辑',
|
|
99
|
+
'detail.editTags': '编辑标签',
|
|
74
100
|
'detail.duplicate': '复制为新任务',
|
|
75
101
|
'detail.duplicateAndEdit': '修改并新建副本',
|
|
76
102
|
'detail.close': '关闭',
|
|
@@ -194,6 +220,30 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
194
220
|
'board.updated': 'Updated',
|
|
195
221
|
'board.created': 'Created',
|
|
196
222
|
'board.hostError': 'Host action failed: {error}',
|
|
223
|
+
'board.hostError.notMounted': 'The task board Host API is not mounted: the Host may not have loaded the plugin, or another DSH instance holds the ledger. Restart the DSH service and try again',
|
|
224
|
+
'board.hostError.unauthorized': 'This session is no longer signed in; reload the page and try again',
|
|
225
|
+
'board.hostError.locked': 'The task board ledger is locked: {detail}',
|
|
226
|
+
'board.hostError.timeout': 'The Host did not answer within {seconds}s; try again shortly',
|
|
227
|
+
'board.hostError.unreachable': 'Cannot reach the local DSH service; make sure it is still running',
|
|
228
|
+
'board.hostError.unexpected': 'The Host returned an unrecognized response (HTTP {status})',
|
|
229
|
+
'board.project': 'Project',
|
|
230
|
+
'board.projectAll': 'All projects',
|
|
231
|
+
'board.projectNew': 'New project…',
|
|
232
|
+
'board.projectNewPath': 'Project directory (absolute path on the Host)',
|
|
233
|
+
'board.projectNewPathPlaceholder': 'e.g. C:\\work\\my-project or /home/me/project',
|
|
234
|
+
'board.projectCreate': 'Create project',
|
|
235
|
+
'board.projectCreateFailed': 'Could not create the project: {error}',
|
|
236
|
+
'new.aiParse': 'Parse with AI',
|
|
237
|
+
'new.aiParseHint': 'Paste text copied from somewhere else and let a model turn it into a title, description, and run prompt; the result only fills the form below, so you can edit it before creating the task.',
|
|
238
|
+
'new.aiParsePlaceholder': 'Paste the content to turn into a task…',
|
|
239
|
+
'new.aiParseModel': 'Model used for parsing',
|
|
240
|
+
'new.aiParseRun': 'Parse and fill',
|
|
241
|
+
'new.aiParseCancel': 'Cancel parsing',
|
|
242
|
+
'new.aiParseEmpty': 'Paste something to parse first',
|
|
243
|
+
'new.aiParseNoModel': 'This deployment has no model available; configure a model provider in DSH or pick another model',
|
|
244
|
+
'new.aiParseUnavailable': 'The Host has no parse endpoint; restart the DSH service and try again',
|
|
245
|
+
'new.aiParseTimeout': 'The model did not answer within {seconds}s; try again or pick a faster model',
|
|
246
|
+
'new.aiParseFailed': 'Parsing failed: {error}',
|
|
197
247
|
'board.retryHost': 'Retry Host connection',
|
|
198
248
|
'board.hostMeta': 'Host time zone {timeZone} · revision {revision}',
|
|
199
249
|
'new.title': 'Title',
|
|
@@ -203,6 +253,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
203
253
|
'new.prompt': 'Run Prompt',
|
|
204
254
|
'new.promptPlaceholder': 'The full instruction sent to the agent (title is used when blank)',
|
|
205
255
|
'new.submit': 'Create',
|
|
256
|
+
'new.createAndRun': 'Create and run',
|
|
206
257
|
'new.cancel': 'Cancel',
|
|
207
258
|
'new.required': 'Title is required',
|
|
208
259
|
'new.freeze': 'Frozen snapshot (optional)',
|
|
@@ -236,6 +287,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
236
287
|
'detail.permissionConfirmed': 'Permission confirmed · {time}',
|
|
237
288
|
'detail.title': 'Task Detail',
|
|
238
289
|
'detail.edit': 'Edit',
|
|
290
|
+
'detail.editTags': 'Edit tags',
|
|
239
291
|
'detail.duplicate': 'Duplicate Task',
|
|
240
292
|
'detail.duplicateAndEdit': 'Edit as New Copy',
|
|
241
293
|
'detail.close': 'Close',
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/plugin-card-seat.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Family plugin-card seat.
|
|
4
|
+
*
|
|
5
|
+
* A family plugin contributes its settings card to whichever plugin-card seat
|
|
6
|
+
* the running host actually renders:
|
|
7
|
+
*
|
|
8
|
+
* - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
|
|
9
|
+
* group section (this family's own first-level "Web UI plugins" section);
|
|
10
|
+
* - `settings.plugin.item` — the official keyed seat of the harness's
|
|
11
|
+
* `ui-settings-plugins` tab, keyed by the settings namespace the card edits.
|
|
12
|
+
*
|
|
13
|
+
* SEAT SELECTION IS NOT A DECLARATION PROBE. The official `ui-settings-plugins`
|
|
14
|
+
* row belongs to the harness bundle and its `configurable` tab always declares
|
|
15
|
+
* `settings.plugin.item` before any external plugin's `apply()` runs, so
|
|
16
|
+
* "is the official seat declared?" answers yes even in the deployment whose
|
|
17
|
+
* whole point is the family group. Choosing on that probe sends every family
|
|
18
|
+
* card to the official Plugins tab and leaves the group's own section
|
|
19
|
+
* permanently empty — the family of reports where the section renders its
|
|
20
|
+
* heading and zero cards.
|
|
21
|
+
*
|
|
22
|
+
* The signal that actually distinguishes the two deployments is whether
|
|
23
|
+
* dsh-web-settings is loaded: it is the package that owns the group section and
|
|
24
|
+
* it publishes the `webUiSettings` service during `apply()`, which every
|
|
25
|
+
* family plugin already reads for its settings scope. Group loaded -> the family
|
|
26
|
+
* seat; group absent -> the official seat.
|
|
27
|
+
*
|
|
28
|
+
* The decision is re-evaluated on every `slots/changed` because the group may
|
|
29
|
+
* apply after this plugin (the family aggregate orders it first, a profile that
|
|
30
|
+
* installs the group separately need not): the initial contribution goes to the
|
|
31
|
+
* official seat, then moves to the family seat the moment the group's section
|
|
32
|
+
* registers. The entry is disposed before the replacement is registered, so a
|
|
33
|
+
* card is never in two seats at once.
|
|
34
|
+
*
|
|
35
|
+
* The shared tree has no client-SDK dependency, so this module reads its
|
|
36
|
+
* context through the structural shape below; callers pass the plugin's own
|
|
37
|
+
* `ctx`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The family list seat key. */
|
|
41
|
+
export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item'
|
|
42
|
+
|
|
43
|
+
/** The official keyed plugin-card seat key. */
|
|
44
|
+
export const OFFICIAL_PLUGIN_CARD_SEAT = 'settings.plugin.item'
|
|
45
|
+
|
|
46
|
+
/** The service dsh-web-settings publishes while it is loaded. */
|
|
47
|
+
export const FAMILY_GROUP_SERVICE = 'webUiSettings'
|
|
48
|
+
|
|
49
|
+
/** The slot-registry member this helper uses (structurally satisfied by ctx.slots). */
|
|
50
|
+
export interface PluginCardSlots {
|
|
51
|
+
/** Contribution of one card entry. */
|
|
52
|
+
register(options: never, component: never): unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The slice of the client context a card contribution needs. */
|
|
56
|
+
export interface PluginCardContext {
|
|
57
|
+
slots: PluginCardSlots
|
|
58
|
+
/** Service lookup; absent on a context double that only models the slots. */
|
|
59
|
+
get?(name: string): unknown
|
|
60
|
+
/** Event subscription seat; absent on an event-less test double. */
|
|
61
|
+
on?(event: string, listener: (...args: never[]) => void): unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Owner share of a plugin card (both seats supply nothing). */
|
|
65
|
+
export interface SettingsPluginItemOwnerProps {
|
|
66
|
+
/** Marker field: card owner props are intentionally empty. */
|
|
67
|
+
children?: never
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One family plugin's card contribution. */
|
|
71
|
+
export interface PluginCardSeat {
|
|
72
|
+
/** Settings namespace the card edits (the official seat's dispatch key). */
|
|
73
|
+
namespace: string
|
|
74
|
+
/** Family list-seat entry id. */
|
|
75
|
+
id: string
|
|
76
|
+
/** Family list-seat sort order. */
|
|
77
|
+
order?: number
|
|
78
|
+
/** Family list-seat display label; the official keyed seat carries none. */
|
|
79
|
+
label?: () => string
|
|
80
|
+
/** Locale namespace the card renders with. */
|
|
81
|
+
locale: string
|
|
82
|
+
/** Business-face factory of the registration. */
|
|
83
|
+
inject?: () => object
|
|
84
|
+
/**
|
|
85
|
+
* The card component. Its props type is the seat's composed shape, which is
|
|
86
|
+
* chosen at runtime, so the helper takes it erased; the registrant keeps its
|
|
87
|
+
* own precise typing at the call site.
|
|
88
|
+
*/
|
|
89
|
+
component: unknown
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether the family group (dsh-web-settings) is loaded in this page. The
|
|
94
|
+
* service is the group package's own contract, so the probe cannot be fooled
|
|
95
|
+
* by a harness release that starts declaring the official seat differently.
|
|
96
|
+
*/
|
|
97
|
+
export function familyGroupLoaded(ctx: PluginCardContext): boolean {
|
|
98
|
+
const get = ctx.get
|
|
99
|
+
if (typeof get !== 'function') return false
|
|
100
|
+
try {
|
|
101
|
+
return get.call(ctx, FAMILY_GROUP_SERVICE) !== undefined
|
|
102
|
+
} catch {
|
|
103
|
+
// A context that refuses the lookup: treat the group as absent, which
|
|
104
|
+
// falls back to the official seat the harness declares.
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Report a refused registration instead of leaving the user with no card. */
|
|
110
|
+
function warnRefusedSeat(seat: string, error: unknown): void {
|
|
111
|
+
try {
|
|
112
|
+
console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error)
|
|
113
|
+
} catch {
|
|
114
|
+
// Best-effort console write; a failed warning must not break the plugin.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Contribute one family plugin card to the seat this host renders, following
|
|
120
|
+
* the group if it loads later. The entry is disposed and re-registered on a
|
|
121
|
+
* seat change, never duplicated.
|
|
122
|
+
* @param ctx - client context (its slot registry decides the seat).
|
|
123
|
+
* @param seat - the card contribution.
|
|
124
|
+
*/
|
|
125
|
+
export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat): void {
|
|
126
|
+
const slots = ctx.slots
|
|
127
|
+
const component = seat.component as never
|
|
128
|
+
const inject = seat.inject as never
|
|
129
|
+
|
|
130
|
+
let dispose: (() => void) | undefined
|
|
131
|
+
let current: string | undefined
|
|
132
|
+
/**
|
|
133
|
+
* Re-entrancy latch. The registry emits a change event synchronously from
|
|
134
|
+
* inside both `register` and the previous entry's disposer, so an unguarded
|
|
135
|
+
* reconcile would re-enter itself mid-move and register the card twice into
|
|
136
|
+
* the seat it is leaving ("already has an entry for key ...").
|
|
137
|
+
*/
|
|
138
|
+
let reconciling = false
|
|
139
|
+
|
|
140
|
+
/** Reconcile the contribution with the currently live seat (no-op when unchanged). */
|
|
141
|
+
const reconcile = (): void => {
|
|
142
|
+
if (reconciling) return
|
|
143
|
+
const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT
|
|
144
|
+
if (current === target) return
|
|
145
|
+
reconciling = true
|
|
146
|
+
const previous = dispose
|
|
147
|
+
dispose = undefined
|
|
148
|
+
current = undefined
|
|
149
|
+
previous?.()
|
|
150
|
+
try {
|
|
151
|
+
dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
|
|
152
|
+
? {
|
|
153
|
+
name: FAMILY_PLUGIN_CARD_SEAT,
|
|
154
|
+
id: seat.id,
|
|
155
|
+
...(seat.order === undefined ? {} : { order: seat.order }),
|
|
156
|
+
...(seat.label === undefined ? {} : { label: seat.label }),
|
|
157
|
+
locale: seat.locale,
|
|
158
|
+
...(seat.inject === undefined ? {} : { inject }),
|
|
159
|
+
}
|
|
160
|
+
: {
|
|
161
|
+
name: OFFICIAL_PLUGIN_CARD_SEAT,
|
|
162
|
+
key: seat.namespace,
|
|
163
|
+
locale: seat.locale,
|
|
164
|
+
...(seat.inject === undefined ? {} : { inject }),
|
|
165
|
+
}) as never, component) as () => void
|
|
166
|
+
current = target
|
|
167
|
+
} catch (error) {
|
|
168
|
+
warnRefusedSeat(target, error)
|
|
169
|
+
} finally {
|
|
170
|
+
reconciling = false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (typeof ctx.on === 'function') {
|
|
175
|
+
try {
|
|
176
|
+
ctx.on('slots/changed', () => { reconcile() })
|
|
177
|
+
} catch {
|
|
178
|
+
// An event seat that refuses the subscription leaves the initial
|
|
179
|
+
// decision in place; the card still renders in the seat chosen below.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// The initial decision: the group, when it is already loaded (the aggregate
|
|
183
|
+
// order), otherwise the official seat the harness declares.
|
|
184
|
+
reconcile()
|
|
185
|
+
}
|
|
@@ -16,6 +16,33 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* One trailing action button seated at the entry row's right edge (e.g. a
|
|
21
|
+
* refresh command or a collapse chevron). Rows with actions switch from the
|
|
22
|
+
* classic single-button structure to a container with a main button plus the
|
|
23
|
+
* action buttons (nested interactive elements are invalid HTML); rows without
|
|
24
|
+
* actions keep the classic structure untouched.
|
|
25
|
+
*/
|
|
26
|
+
export interface SidebarEntryAction {
|
|
27
|
+
/** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
|
|
28
|
+
id: string
|
|
29
|
+
/** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
|
|
30
|
+
icon: string
|
|
31
|
+
/**
|
|
32
|
+
* Optional icon while the row is inactive. Setting it makes the action a
|
|
33
|
+
* state mirror (a collapse chevron): the core swaps the icon and stamps
|
|
34
|
+
* aria-expanded on every active-state change. Requires the active bridge.
|
|
35
|
+
*/
|
|
36
|
+
inactiveIcon?: string
|
|
37
|
+
/** Localized accessible label (aria-label + title); receives the active state. */
|
|
38
|
+
label(active: boolean): string
|
|
39
|
+
/**
|
|
40
|
+
* Click handler; receives the button so the caller can drive transient
|
|
41
|
+
* affordances (a refresh cooldown) without a re-render.
|
|
42
|
+
*/
|
|
43
|
+
onClick(button: HTMLButtonElement): void
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
/** Per-package configuration for one sidebar entry row. */
|
|
20
47
|
export interface SidebarEntryOptions {
|
|
21
48
|
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
@@ -47,6 +74,12 @@ export interface SidebarEntryOptions {
|
|
|
47
74
|
refresh?: { subscribe(listener: () => void): () => void }
|
|
48
75
|
/** Click action (open/toggle the owning panel). */
|
|
49
76
|
onToggle(): void
|
|
77
|
+
/**
|
|
78
|
+
* Optional trailing action buttons at the row's right edge. Requires the
|
|
79
|
+
* package CSS to seat entryMain / entryAction; empty keeps the classic
|
|
80
|
+
* single-button row.
|
|
81
|
+
*/
|
|
82
|
+
actions?: readonly SidebarEntryAction[]
|
|
50
83
|
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
51
84
|
position: 'before' | 'after'
|
|
52
85
|
/**
|
|
@@ -84,10 +117,12 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
|
84
117
|
return undefined
|
|
85
118
|
}
|
|
86
119
|
|
|
87
|
-
/** Build the entry row (
|
|
88
|
-
function createEntry(options: SidebarEntryOptions): { entry:
|
|
89
|
-
const
|
|
90
|
-
|
|
120
|
+
/** Build the entry row (detached; inserted once the shell is up). */
|
|
121
|
+
function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void; setOpen: (open: boolean) => void } {
|
|
122
|
+
const actions = options.actions ?? []
|
|
123
|
+
const composite = actions.length > 0
|
|
124
|
+
const entry = document.createElement(composite ? 'div' : 'button') as HTMLElement
|
|
125
|
+
if (!composite) (entry as HTMLButtonElement).type = 'button'
|
|
91
126
|
entry.setAttribute(options.rowAttribute, '')
|
|
92
127
|
if (options.plugin !== undefined) {
|
|
93
128
|
entry.setAttribute('data-dsh-plugin', options.plugin)
|
|
@@ -99,19 +134,66 @@ function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement;
|
|
|
99
134
|
const iconSpan = document.createElement('span')
|
|
100
135
|
iconSpan.className = options.css['entryIcon'] ?? ''
|
|
101
136
|
iconSpan.innerHTML = options.icon
|
|
102
|
-
|
|
137
|
+
// The main hit area: the classic row IS the button; with trailing actions
|
|
138
|
+
// the row becomes a container and the main area its own button, so the row
|
|
139
|
+
// never nests interactive elements.
|
|
140
|
+
const main = composite ? document.createElement('button') : (entry as HTMLButtonElement)
|
|
141
|
+
if (composite) {
|
|
142
|
+
main.type = 'button'
|
|
143
|
+
main.className = options.css['entryMain'] ?? ''
|
|
144
|
+
main.append(iconSpan, labelSpan)
|
|
145
|
+
entry.append(main)
|
|
146
|
+
} else {
|
|
147
|
+
entry.append(iconSpan, labelSpan)
|
|
148
|
+
}
|
|
149
|
+
const actionButtons: { action: SidebarEntryAction; button: HTMLButtonElement }[] = []
|
|
150
|
+
for (const action of actions) {
|
|
151
|
+
const button = document.createElement('button')
|
|
152
|
+
button.type = 'button'
|
|
153
|
+
button.className = options.css['entryAction'] ?? ''
|
|
154
|
+
button.setAttribute('data-dsh-entry-action', action.id)
|
|
155
|
+
button.innerHTML = action.icon
|
|
156
|
+
button.addEventListener('click', () => { action.onClick(button) })
|
|
157
|
+
entry.append(button)
|
|
158
|
+
actionButtons.push({ action, button })
|
|
159
|
+
}
|
|
160
|
+
let open = false
|
|
161
|
+
const applyActions = (): void => {
|
|
162
|
+
for (const { action, button } of actionButtons) {
|
|
163
|
+
button.innerHTML = open || action.inactiveIcon === undefined ? action.icon : action.inactiveIcon
|
|
164
|
+
const text = action.label(open)
|
|
165
|
+
button.setAttribute('aria-label', text)
|
|
166
|
+
button.setAttribute('title', text)
|
|
167
|
+
if (action.inactiveIcon !== undefined) button.setAttribute('aria-expanded', String(open))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
103
170
|
const applyLabel = (): void => {
|
|
104
|
-
|
|
105
|
-
if (options.tooltip !== undefined)
|
|
171
|
+
main.setAttribute('aria-label', options.label())
|
|
172
|
+
if (options.tooltip !== undefined) main.setAttribute('title', options.tooltip())
|
|
106
173
|
labelSpan.textContent = options.label()
|
|
174
|
+
applyActions()
|
|
107
175
|
}
|
|
108
176
|
applyLabel()
|
|
109
|
-
|
|
110
|
-
|
|
177
|
+
main.addEventListener('click', options.onToggle)
|
|
178
|
+
if (composite) {
|
|
179
|
+
// The old row was clickable edge to edge; keep the padding between the
|
|
180
|
+
// main area and the action buttons a toggle target as well.
|
|
181
|
+
entry.addEventListener('click', (event) => {
|
|
182
|
+
if (event.target === entry) options.onToggle()
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
entry,
|
|
187
|
+
applyLabel,
|
|
188
|
+
setOpen: (next: boolean) => {
|
|
189
|
+
open = next
|
|
190
|
+
applyActions()
|
|
191
|
+
},
|
|
192
|
+
}
|
|
111
193
|
}
|
|
112
194
|
|
|
113
195
|
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
114
|
-
function placeEntry(root: HTMLElement, entry:
|
|
196
|
+
function placeEntry(root: HTMLElement, entry: HTMLElement, options: SidebarEntryOptions): boolean {
|
|
115
197
|
const button = newSessionButton(root)
|
|
116
198
|
if (button === undefined) return false
|
|
117
199
|
if (entry.parentElement !== root) {
|
|
@@ -149,7 +231,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
|
149
231
|
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
|
150
232
|
return () => {}
|
|
151
233
|
}
|
|
152
|
-
const { entry, applyLabel } = createEntry(options)
|
|
234
|
+
const { entry, applyLabel, setOpen } = createEntry(options)
|
|
153
235
|
let root: HTMLElement | undefined
|
|
154
236
|
let placed = false
|
|
155
237
|
let unsubscribeRefresh: (() => void) | undefined
|
|
@@ -217,8 +299,10 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
|
217
299
|
// row permanently highlighted — delete the attribute instead.
|
|
218
300
|
const unsubscribeActive = options.active === undefined ? undefined : (() => {
|
|
219
301
|
const syncActive = (): void => {
|
|
220
|
-
|
|
302
|
+
const open = options.active!.isOpen()
|
|
303
|
+
if (open) entry.dataset.active = 'true'
|
|
221
304
|
else delete entry.dataset.active
|
|
305
|
+
setOpen(open)
|
|
222
306
|
}
|
|
223
307
|
const unsubscribe = options.active.subscribe(syncActive)
|
|
224
308
|
syncActive()
|