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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +2 -0
  3. package/README.zh.md +2 -0
  4. package/lib/client.js +726 -71
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2619 -175
  7. package/lib/types/client/board/EditTaskModal.d.ts +6 -0
  8. package/lib/types/client/board/NewTaskModal.d.ts +6 -1
  9. package/lib/types/client/board/TaskBoard.d.ts +2 -0
  10. package/lib/types/client/board/TaskForm.d.ts +8 -3
  11. package/lib/types/client/board/parse-model-pref.d.ts +4 -0
  12. package/lib/types/client/host-api.d.ts +23 -1
  13. package/lib/types/client/locales.d.ts +26 -0
  14. package/lib/types/client/plugin-card-seat.d.ts +96 -0
  15. package/lib/types/client/sidebar-entry-core.d.ts +32 -0
  16. package/lib/types/core/controller.d.ts +35 -1
  17. package/lib/types/core/session-reuse.d.ts +4 -2
  18. package/lib/types/host-ai.d.ts +51 -0
  19. package/lib/types/host-ledger.d.ts +15 -0
  20. package/lib/types/host-routes.d.ts +11 -1
  21. package/lib/types/index.d.ts +9 -0
  22. package/lib/types/protocol.d.ts +19 -0
  23. package/package.json +2 -1
  24. package/src/client/board/EditTaskModal.tsx +36 -0
  25. package/src/client/board/NewTaskModal.tsx +119 -5
  26. package/src/client/board/TaskBoard.tsx +89 -0
  27. package/src/client/board/TaskDetail.tsx +38 -2
  28. package/src/client/board/TaskForm.tsx +17 -11
  29. package/src/client/board/parse-model-pref.ts +35 -0
  30. package/src/client/board.module.css +61 -0
  31. package/src/client/host-api.ts +124 -5
  32. package/src/client/index.ts +18 -17
  33. package/src/client/locales.ts +52 -0
  34. package/src/client/plugin-card-seat.ts +185 -0
  35. package/src/client/sidebar-entry-core.ts +96 -12
  36. package/src/core/controller.ts +57 -1
  37. package/src/core/session-reuse.ts +14 -5
  38. package/src/host-ai.ts +152 -0
  39. package/src/host-ledger.ts +84 -14
  40. package/src/host-routes.ts +68 -3
  41. package/src/index.ts +26 -1
  42. package/src/protocol.ts +34 -0
@@ -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 body = await response.json() as T & { error?: string }
21
- if (!response.ok) throw new Error(body.error ?? `task-board request failed: ${response.status}`)
22
- return body
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 (controller.signal.aborted) throw new Error(`task-board Host request timed out after ${REQUEST_TIMEOUT_MS / 1_000}s`)
80
- throw error
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 () => {
@@ -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 the Web UI plugin group.
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.slots.inject('web-ui.plugin.item', () => {
172
- try {
173
- const unregister = ctx.slots.register({
174
- name: 'web-ui.plugin.item',
175
- id: 'task-board',
176
- order: 110,
177
- locale: NS,
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)
@@ -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 (a detached button; insert once the shell is up). */
88
- function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
89
- const entry = document.createElement('button')
90
- entry.type = 'button'
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
- entry.append(iconSpan, labelSpan)
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
- entry.setAttribute('aria-label', options.label())
105
- if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
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
- entry.addEventListener('click', options.onToggle)
110
- return { entry, applyLabel }
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: HTMLButtonElement, options: SidebarEntryOptions): boolean {
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
- if (options.active!.isOpen()) entry.dataset.active = 'true'
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()