@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.1
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 +83 -105
- package/README.zh.md +83 -105
- package/lib/client.js +902 -633
- package/lib/client.js.map +1 -1
- package/lib/index.js +2274 -5
- package/lib/types/client/PluginSettingsCard.d.ts +29 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +47 -15
- package/lib/types/core/schedule.d.ts +11 -2
- package/lib/types/core/tasks.d.ts +6 -6
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +52 -0
- package/lib/types/host-routes.d.ts +26 -0
- package/lib/types/host-runner.d.ts +41 -0
- package/lib/types/host-service.d.ts +45 -0
- package/lib/types/index.d.ts +17 -9
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +78 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +24 -7
- package/src/client/board/TaskCard.tsx +25 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +104 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +222 -24
- package/src/core/schedule.ts +77 -13
- package/src/core/store.ts +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +539 -0
- package/src/host-routes.ts +196 -0
- package/src/host-runner.ts +152 -0
- package/src/host-service.ts +226 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +220 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
package/src/client/index.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* shell fails the whole boot when a plugin apply throws, and an external
|
|
9
9
|
* plugin must not take the GUI down.
|
|
10
10
|
*/
|
|
11
|
-
import type { ClientContext, SessionId, SettingsScope, SettingsScopeSpec
|
|
12
|
-
import type { ConnectionHandle
|
|
11
|
+
import type { ClientContext, ISessions, IWorkspaces, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
14
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
|
|
15
15
|
// LocaleNamespaceMap merge table.
|
|
@@ -17,14 +17,13 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
|
17
17
|
// Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
|
|
18
18
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
19
19
|
import { BoardController } from '../core/controller.ts'
|
|
20
|
-
import { ExecutionService } from '../core/execution.ts'
|
|
21
|
-
import { SchedulerService } from '../core/scheduler.ts'
|
|
22
20
|
import { LocalStorageTaskStore } from '../core/store.ts'
|
|
23
21
|
import { claimTaskboardApply, releaseTaskboardApply } from './apply-guard.ts'
|
|
24
22
|
import { mountBoard } from './board-mount.tsx'
|
|
25
23
|
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
26
24
|
import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardSettings } from './TaskBoardSettingsCard.tsx'
|
|
27
25
|
import { en, zh, type TaskBoardKey } from './locales.ts'
|
|
26
|
+
import { HttpTaskBoardHostTransport } from './host-api.ts'
|
|
28
27
|
|
|
29
28
|
/** Locale namespace this plugin owns. */
|
|
30
29
|
const NS = 'task-board'
|
|
@@ -113,64 +112,18 @@ export function apply(ctx: ClientContext): void {
|
|
|
113
112
|
let uiDisposer: (() => void) | undefined
|
|
114
113
|
const mountUi = (): void => {
|
|
115
114
|
if (uiDisposer !== undefined) return
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
// Host and browser SDK declarations share the Cordis Context name. Read
|
|
116
|
+
// the browser faces explicitly so Host-side declaration merging cannot
|
|
117
|
+
// narrow these two client services during a combined package build.
|
|
118
|
+
const sessions = ctx.get('sessions') as unknown as ISessions
|
|
119
|
+
const workspaces = ctx.get('workspaces') as unknown as IWorkspaces
|
|
118
120
|
const connection = ctx.get('connection') as ConnectionHandle
|
|
119
121
|
|
|
120
122
|
// Core wiring: real runtime faces into the framework-free services.
|
|
121
123
|
const store = new LocalStorageTaskStore()
|
|
122
|
-
const exec = new ExecutionService({
|
|
123
|
-
sessions: {
|
|
124
|
-
list: sessions.list,
|
|
125
|
-
binding: id => {
|
|
126
|
-
const binding = sessions.binding(id as SessionId)
|
|
127
|
-
if (binding === undefined) return undefined
|
|
128
|
-
const { session } = binding
|
|
129
|
-
return {
|
|
130
|
-
session: {
|
|
131
|
-
rename: title => session.rename(title),
|
|
132
|
-
prompt: (content, mode) =>
|
|
133
|
-
session.prompt(content as PromptContentPart[], mode).then(result =>
|
|
134
|
-
result.ok ? { ok: true as const } : { ok: false as const, error: result.error }),
|
|
135
|
-
command: line =>
|
|
136
|
-
session.command(line).then(result =>
|
|
137
|
-
result.ok ? { ok: true as const, matched: result.value.matched } : { ok: false as const, error: result.error }),
|
|
138
|
-
getSnapshot: () => session.getSnapshot(),
|
|
139
|
-
subscribe: fn => session.subscribe(fn),
|
|
140
|
-
},
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
noteAgentPreset: (sessionId, agentPreset) => sessions.noteAgentPreset(sessionId as SessionId, agentPreset),
|
|
144
|
-
},
|
|
145
|
-
workspaces: {
|
|
146
|
-
list: workspaces.list,
|
|
147
|
-
connectWorkspace: id => workspaces.connectWorkspace(id as WorkspaceId),
|
|
148
|
-
},
|
|
149
|
-
presets: {
|
|
150
|
-
select: async (sessionId, agentPreset) => {
|
|
151
|
-
try {
|
|
152
|
-
const response = await connection.api.agentPresets.select({ sessionId: sessionId as SessionId, agentPreset })
|
|
153
|
-
return response.result.ok ? { ok: true as const } : { ok: false as const, error: response.result.error }
|
|
154
|
-
} catch (error) {
|
|
155
|
-
return { ok: false as const, error }
|
|
156
|
-
}
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
history: {
|
|
160
|
-
loadTail: async sessionId => {
|
|
161
|
-
const response = await connection.api.sessions.history({
|
|
162
|
-
sessionId: sessionId as SessionId,
|
|
163
|
-
maxMessages: 20,
|
|
164
|
-
})
|
|
165
|
-
return response.result.ok
|
|
166
|
-
? { events: response.result.value.events.map(entry => entry.event) }
|
|
167
|
-
: undefined
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
})
|
|
171
124
|
const controller = new BoardController({
|
|
172
125
|
store,
|
|
173
|
-
|
|
126
|
+
transport: new HttpTaskBoardHostTransport(),
|
|
174
127
|
sessions: {
|
|
175
128
|
list: sessions.list,
|
|
176
129
|
open: id => sessions.open(id as SessionId),
|
|
@@ -178,27 +131,6 @@ export function apply(ctx: ClientContext): void {
|
|
|
178
131
|
})
|
|
179
132
|
controller.start()
|
|
180
133
|
|
|
181
|
-
// Scheduled runs: a browser-side heartbeat that triggers due tasks through
|
|
182
|
-
// the same run path as the manual Run button. The first tick is gated on
|
|
183
|
-
// the session list baseline so a page-load catch-up never fires into a
|
|
184
|
-
// not-yet-ready runtime; tab visibility recovery ticks immediately.
|
|
185
|
-
const scheduler = new SchedulerService({
|
|
186
|
-
tasks: () => controller.getSnapshot().tasks,
|
|
187
|
-
// Re-read the persisted ledger before every fire decision: a task
|
|
188
|
-
// deleted in another tab must never fire from a stale in-memory copy.
|
|
189
|
-
refresh: () => controller.reloadFromStore(),
|
|
190
|
-
now: () => Date.now(),
|
|
191
|
-
runTask: id => controller.runTask(id),
|
|
192
|
-
applySchedule: (id, nextRunAt, lastTriggeredAt) =>
|
|
193
|
-
controller.applyScheduleNextRun(id, nextRunAt, lastTriggeredAt),
|
|
194
|
-
ready: () => sessions.list.getSnapshot().phase === 'ready',
|
|
195
|
-
environment: {
|
|
196
|
-
addEventListener: (type, listener) => document.addEventListener(type, listener),
|
|
197
|
-
removeEventListener: (type, listener) => document.removeEventListener(type, listener),
|
|
198
|
-
},
|
|
199
|
-
})
|
|
200
|
-
scheduler.start()
|
|
201
|
-
|
|
202
134
|
const disposers: Array<() => void> = []
|
|
203
135
|
|
|
204
136
|
// Execution-target option feeds: the workspace list drives the workspace
|
|
@@ -248,7 +180,6 @@ export function apply(ctx: ClientContext): void {
|
|
|
248
180
|
|
|
249
181
|
uiDisposer = () => {
|
|
250
182
|
for (const dispose of disposers.splice(0)) dispose()
|
|
251
|
-
scheduler.dispose()
|
|
252
183
|
controller.dispose()
|
|
253
184
|
uiDisposer = undefined
|
|
254
185
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -24,9 +24,13 @@ export const zh = {
|
|
|
24
24
|
'board.status.running': '进行中',
|
|
25
25
|
'board.status.done': '已完成',
|
|
26
26
|
'board.status.failed': '已失败',
|
|
27
|
-
|
|
27
|
+
'board.runs': '次执行',
|
|
28
|
+
'board.pending': '正在提交',
|
|
28
29
|
'board.updated': '更新于',
|
|
29
30
|
'board.created': '创建于',
|
|
31
|
+
'board.hostError': 'Host 操作失败:{error}',
|
|
32
|
+
'board.retryHost': '重试连接 Host',
|
|
33
|
+
'board.hostMeta': 'Host 时区 {timeZone} · revision {revision}',
|
|
30
34
|
'new.title': '标题',
|
|
31
35
|
'new.titlePlaceholder': '一句话描述要做什么',
|
|
32
36
|
'new.description': '描述',
|
|
@@ -96,11 +100,17 @@ export const zh = {
|
|
|
96
100
|
'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
|
|
97
101
|
// 插件设置卡片(settings.plugin.item 席位)。
|
|
98
102
|
'settings.title': '任务看板',
|
|
99
|
-
'settings.description': '
|
|
103
|
+
'settings.description': '控制 Host 任务看板、agent 播报与运行期间的系统空闲睡眠保护。',
|
|
100
104
|
'settings.enabled': '启用任务看板',
|
|
101
105
|
'settings.enabledHint': '关闭后隐藏侧边栏入口与看板视图。',
|
|
102
106
|
'settings.announceToAgent': '向 agent 播报任务看板',
|
|
103
107
|
'settings.announceToAgentHint': '开启:每条 agent 系统提示都会包含本看板的说明;关闭:不播报,agent 仅在用户主动提及时了解看板。',
|
|
108
|
+
'settings.preventIdleSleep': '阻止系统空闲睡眠',
|
|
109
|
+
'settings.preventIdleSleepHint': '默认关闭。开启后,只要存在运行中的 DSH 会话、已启用的定时任务或会话状态尚未确认,Host 就阻止整机因空闲睡眠;屏幕仍可自动关闭。',
|
|
110
|
+
'settings.powerStatus': '平台:{platform};保护状态:{phase};运行会话:{running};已启用计划:{schedules}',
|
|
111
|
+
'settings.powerBoundary': '这可能增加电池消耗。合盖、手动睡眠、休眠、关机、低电量或企业策略不在保证范围内,也不会唤醒已经睡眠的机器。',
|
|
112
|
+
'settings.powerUnknown': '未知',
|
|
113
|
+
'settings.powerError': '最近一次电源保护错误:{error}',
|
|
104
114
|
'settings.inherit': '继承',
|
|
105
115
|
'settings.on': '开',
|
|
106
116
|
'settings.off': '关',
|
|
@@ -137,9 +147,13 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
137
147
|
'board.status.running': 'In Progress',
|
|
138
148
|
'board.status.done': 'Done',
|
|
139
149
|
'board.status.failed': 'Failed',
|
|
140
|
-
|
|
150
|
+
'board.runs': 'runs',
|
|
151
|
+
'board.pending': 'Submitting',
|
|
141
152
|
'board.updated': 'Updated',
|
|
142
153
|
'board.created': 'Created',
|
|
154
|
+
'board.hostError': 'Host action failed: {error}',
|
|
155
|
+
'board.retryHost': 'Retry Host connection',
|
|
156
|
+
'board.hostMeta': 'Host time zone {timeZone} · revision {revision}',
|
|
143
157
|
'new.title': 'Title',
|
|
144
158
|
'new.titlePlaceholder': 'What should be done, in one line',
|
|
145
159
|
'new.description': 'Description',
|
|
@@ -209,11 +223,17 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
209
223
|
'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
|
|
210
224
|
// Plugin settings card (the `settings.plugin.item` seat).
|
|
211
225
|
'settings.title': 'Task Board',
|
|
212
|
-
'settings.description': '
|
|
226
|
+
'settings.description': 'Configure the Host task board, agent announcement, and idle-system-sleep protection while work is pending.',
|
|
213
227
|
'settings.enabled': 'Enable the task board',
|
|
214
228
|
'settings.enabledHint': 'When off, the sidebar entry and board view are hidden.',
|
|
215
229
|
'settings.announceToAgent': 'Announce the task board to agents',
|
|
216
230
|
'settings.announceToAgentHint': 'On: every agent system prompt includes a note about this board. Off: no announcement; agents learn about the board only when you mention it.',
|
|
231
|
+
'settings.preventIdleSleep': 'Prevent idle system sleep',
|
|
232
|
+
'settings.preventIdleSleepHint': 'Off by default. When enabled, the Host prevents idle system sleep while any DSH session runs, any schedule is enabled, or session state is not yet known. The display may still turn off.',
|
|
233
|
+
'settings.powerStatus': 'Platform: {platform}; protection: {phase}; running sessions: {running}; enabled schedules: {schedules}',
|
|
234
|
+
'settings.powerBoundary': 'This may use more battery. Lid close, manual sleep, hibernation, shutdown, low-battery actions, and enterprise policy are outside the guarantee; an already sleeping computer is not woken.',
|
|
235
|
+
'settings.powerUnknown': 'unknown',
|
|
236
|
+
'settings.powerError': 'Latest power-protection error: {error}',
|
|
217
237
|
'settings.inherit': 'Inherit',
|
|
218
238
|
'settings.on': 'On',
|
|
219
239
|
'settings.off': 'Off',
|
|
@@ -290,6 +290,92 @@
|
|
|
290
290
|
border-color: var(--dsw-alias-label-error);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
.selectWrap {
|
|
294
|
+
position: relative;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.selectButton {
|
|
298
|
+
appearance: none;
|
|
299
|
+
width: 100%;
|
|
300
|
+
text-align: left;
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: space-between;
|
|
304
|
+
gap: 8px;
|
|
305
|
+
display: flex;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.selectLabel {
|
|
309
|
+
min-width: 0;
|
|
310
|
+
overflow: hidden;
|
|
311
|
+
text-overflow: ellipsis;
|
|
312
|
+
white-space: nowrap;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.selectChevron {
|
|
316
|
+
color: var(--dsw-alias-label-tertiary);
|
|
317
|
+
flex: none;
|
|
318
|
+
transition: transform 0.16s;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.selectChevronOpen {
|
|
322
|
+
transform: rotate(180deg);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.selectPopup {
|
|
326
|
+
position: absolute;
|
|
327
|
+
top: calc(100% + 4px);
|
|
328
|
+
left: 0;
|
|
329
|
+
right: 0;
|
|
330
|
+
z-index: 40;
|
|
331
|
+
flex-direction: column;
|
|
332
|
+
max-height: 240px;
|
|
333
|
+
overflow-y: auto;
|
|
334
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
335
|
+
border-radius: 8px;
|
|
336
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
337
|
+
box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-2);
|
|
338
|
+
padding: 4px;
|
|
339
|
+
opacity: 0;
|
|
340
|
+
transform: translateY(-4px);
|
|
341
|
+
transition: opacity 0.1s ease, transform 0.1s ease;
|
|
342
|
+
display: flex;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.selectPopupOpen {
|
|
346
|
+
opacity: 1;
|
|
347
|
+
transform: none;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.selectPopupClose {
|
|
351
|
+
opacity: 0;
|
|
352
|
+
transform: translateY(-4px);
|
|
353
|
+
pointer-events: none;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.selectOption {
|
|
357
|
+
border-radius: 6px;
|
|
358
|
+
padding: 6px 10px;
|
|
359
|
+
font-size: 13px;
|
|
360
|
+
line-height: 1.5;
|
|
361
|
+
color: var(--dsw-alias-label-primary);
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
white-space: nowrap;
|
|
364
|
+
overflow: hidden;
|
|
365
|
+
text-overflow: ellipsis;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.selectOption:hover,
|
|
369
|
+
.selectOptionActive {
|
|
370
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.selectOptionSelected {
|
|
374
|
+
color: var(--dsw-alias-brand-primary);
|
|
375
|
+
font-weight: 500;
|
|
376
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);
|
|
377
|
+
}
|
|
378
|
+
|
|
293
379
|
.invalid {
|
|
294
380
|
color: var(--dsw-alias-label-error);
|
|
295
381
|
margin: 0;
|
|
@@ -310,7 +396,10 @@
|
|
|
310
396
|
.chevron,
|
|
311
397
|
.chevronOpen,
|
|
312
398
|
.discard,
|
|
313
|
-
.save
|
|
399
|
+
.save,
|
|
400
|
+
.selectChevron,
|
|
401
|
+
.selectChevronOpen,
|
|
402
|
+
.selectPopup {
|
|
314
403
|
transition: none;
|
|
315
404
|
}
|
|
316
405
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/sidebar-entry-core.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Shared sidebar entry injection core.
|
|
4
|
+
*
|
|
5
|
+
* dsh's sidebar shell exposes no slot an external plugin can register into,
|
|
6
|
+
* so the entry row is injected between the shell's New Session button and the
|
|
7
|
+
* workspace browser. The injection self-heals: a MutationObserver watches the
|
|
8
|
+
* sidebar root and re-inserts the row whenever a React re-render displaces it
|
|
9
|
+
* (re-insertion happens in the same frame, before paint, so no flicker).
|
|
10
|
+
*
|
|
11
|
+
* The row is plain DOM (no React tree) so it can never disturb the shell's
|
|
12
|
+
* reconciliation; the view it toggles is a separate root owned by the caller.
|
|
13
|
+
*
|
|
14
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
15
|
+
* edit the shared source and re-run the sync instead of editing a copy.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Per-package configuration for one sidebar entry row. */
|
|
19
|
+
export interface SidebarEntryOptions {
|
|
20
|
+
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
21
|
+
rowAttribute: string
|
|
22
|
+
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
|
23
|
+
rowSelector: string
|
|
24
|
+
/**
|
|
25
|
+
* L2 semantic-attribute plugin id (issue #506, enum table:
|
|
26
|
+
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
|
27
|
+
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
|
28
|
+
* leaves the row without semantic attributes.
|
|
29
|
+
*/
|
|
30
|
+
plugin?: string
|
|
31
|
+
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
|
32
|
+
icon: string
|
|
33
|
+
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
|
34
|
+
css: Record<string, string>
|
|
35
|
+
/** Localized row label (aria-label + visible text). */
|
|
36
|
+
label(): string
|
|
37
|
+
/** Optional localized tooltip (title attribute). */
|
|
38
|
+
tooltip?(): string
|
|
39
|
+
/** Click action (open/toggle the owning panel). */
|
|
40
|
+
onToggle(): void
|
|
41
|
+
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
42
|
+
position: 'before' | 'after'
|
|
43
|
+
/**
|
|
44
|
+
* Selectors of the sibling plugin entry rows this package orders against
|
|
45
|
+
* (its own row included — the placement guard excludes a row that is
|
|
46
|
+
* already inside the root). Each package passes the same list it used
|
|
47
|
+
* before the consolidation so the rendered order stays stable.
|
|
48
|
+
*/
|
|
49
|
+
familySelectors: readonly string[]
|
|
50
|
+
/** Optional active-state bridge; highlights the row while the panel is open. */
|
|
51
|
+
active?: {
|
|
52
|
+
subscribe(listener: () => void): () => void
|
|
53
|
+
isOpen(): boolean
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
58
|
+
function sidebarRoot(): HTMLElement | undefined {
|
|
59
|
+
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
60
|
+
if (column === null) return undefined
|
|
61
|
+
// Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
|
|
62
|
+
// Prefer the element that owns the logo row — the real sidebar UI root —
|
|
63
|
+
// and fall back to the column's first child for legacy shells.
|
|
64
|
+
const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
|
|
65
|
+
return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
|
|
69
|
+
function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
70
|
+
const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
|
|
71
|
+
if (nested !== null) return nested
|
|
72
|
+
for (const child of root.children) {
|
|
73
|
+
if (child.tagName === 'BUTTON') return child as HTMLButtonElement
|
|
74
|
+
}
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
79
|
+
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
|
80
|
+
const entry = document.createElement('button')
|
|
81
|
+
entry.type = 'button'
|
|
82
|
+
entry.setAttribute(options.rowAttribute, '')
|
|
83
|
+
if (options.plugin !== undefined) {
|
|
84
|
+
entry.setAttribute('data-dsh-plugin', options.plugin)
|
|
85
|
+
entry.setAttribute('data-dsh-part', 'sidebar-entry')
|
|
86
|
+
}
|
|
87
|
+
entry.className = options.css['entry'] ?? ''
|
|
88
|
+
entry.setAttribute('aria-label', options.label())
|
|
89
|
+
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
|
90
|
+
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
|
|
91
|
+
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
|
|
92
|
+
entry.addEventListener('click', options.onToggle)
|
|
93
|
+
return entry
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
97
|
+
function placeEntry(root: HTMLElement, entry: HTMLButtonElement, options: SidebarEntryOptions): boolean {
|
|
98
|
+
const button = newSessionButton(root)
|
|
99
|
+
if (button === undefined) return false
|
|
100
|
+
if (entry.parentElement !== root) {
|
|
101
|
+
// Position relative to the family block (entries injected by sibling
|
|
102
|
+
// plugins), never relative to transient logoRow geometry: every family
|
|
103
|
+
// plugin that self-heals during a re-render then lands in the same
|
|
104
|
+
// relative order, so the entries cannot swap positions regardless of
|
|
105
|
+
// observer callback order or of shell wrapper changes. There is no
|
|
106
|
+
// append-to-end fallback: appending at the end would randomly reorder
|
|
107
|
+
// the block after a shell re-render.
|
|
108
|
+
const row = button.closest('[class*="logoRow"]')
|
|
109
|
+
const base = (row !== null && row.parentElement === root) ? row : button
|
|
110
|
+
const family = Array.from(root.children).filter(
|
|
111
|
+
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
|
|
112
|
+
)
|
|
113
|
+
const anchor = options.position === 'before'
|
|
114
|
+
? (family.length > 0 ? family[0] : base.nextElementSibling)
|
|
115
|
+
: (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
|
|
116
|
+
root.insertBefore(entry, anchor)
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
123
|
+
* on later React re-renders.
|
|
124
|
+
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
125
|
+
* @returns disposer removing the entry and its observers.
|
|
126
|
+
*/
|
|
127
|
+
export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
128
|
+
// DOM-level idempotency: whatever path mounted an entry row before this
|
|
129
|
+
// call (a duplicated apply, an HMR re-injection, a stale module still
|
|
130
|
+
// alive), never mount a second one. The existing row keeps working; a full
|
|
131
|
+
// page reload is the ultimate reset.
|
|
132
|
+
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
|
133
|
+
return () => {}
|
|
134
|
+
}
|
|
135
|
+
const entry = createEntry(options)
|
|
136
|
+
let root: HTMLElement | undefined
|
|
137
|
+
let placed = false
|
|
138
|
+
|
|
139
|
+
const tryPlace = (): void => {
|
|
140
|
+
if (root !== undefined && !root.isConnected) {
|
|
141
|
+
// The shell rebuilt the sidebar pane (whole-tree teardown); the root
|
|
142
|
+
// observer is gone with the old tree, so detach it and re-query from
|
|
143
|
+
// scratch. The new pane is later noticed by the body-level watcher.
|
|
144
|
+
rootObserver.disconnect()
|
|
145
|
+
root = undefined
|
|
146
|
+
placed = false
|
|
147
|
+
}
|
|
148
|
+
if (placed) {
|
|
149
|
+
// Cheap short-circuit: entry still lives in a mountable subtree.
|
|
150
|
+
if (document.body.contains(entry)) return
|
|
151
|
+
// Entry was torn down together with the old tree; reset and re-place.
|
|
152
|
+
rootObserver.disconnect()
|
|
153
|
+
root = undefined
|
|
154
|
+
placed = false
|
|
155
|
+
}
|
|
156
|
+
root ??= sidebarRoot()
|
|
157
|
+
if (root === undefined) return
|
|
158
|
+
placed = placeEntry(root, entry, options)
|
|
159
|
+
if (placed) {
|
|
160
|
+
rootObserver.observe(root, { childList: true, subtree: true })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Body-level watcher retained as the "whole rebuild" fallback: when the shell
|
|
165
|
+
// tears down the whole sidebar pane, the root observer is gone with it and
|
|
166
|
+
// only this body observation can notice the new pane mounting. It is no
|
|
167
|
+
// longer disconnected after placement; the placed-and-still-mounted case
|
|
168
|
+
// short-circuits through the cheap document.body.contains(entry) check, so
|
|
169
|
+
// unrelated app mutations (e.g. chat streaming) cost one contains check
|
|
170
|
+
// instead of churning the full re-query.
|
|
171
|
+
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
172
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
173
|
+
|
|
174
|
+
// Self-heal: if a React re-render displaces the row, re-insert it in the
|
|
175
|
+
// same frame (microtask before paint -> no visible flicker).
|
|
176
|
+
const rootObserver = new MutationObserver(() => {
|
|
177
|
+
if (root === undefined || !root.isConnected) {
|
|
178
|
+
placed = false
|
|
179
|
+
tryPlace()
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (!root.contains(entry)) {
|
|
183
|
+
placed = placeEntry(root, entry, options)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Reflect the panel's open state on the row (active highlight). Note: assigning
|
|
188
|
+
// undefined to dataset.active materializes data-active="undefined" and keeps the
|
|
189
|
+
// row permanently highlighted — delete the attribute instead.
|
|
190
|
+
const unsubscribeActive = options.active === undefined ? undefined : (() => {
|
|
191
|
+
const syncActive = (): void => {
|
|
192
|
+
if (options.active!.isOpen()) entry.dataset.active = 'true'
|
|
193
|
+
else delete entry.dataset.active
|
|
194
|
+
}
|
|
195
|
+
const unsubscribe = options.active.subscribe(syncActive)
|
|
196
|
+
syncActive()
|
|
197
|
+
return unsubscribe
|
|
198
|
+
})()
|
|
199
|
+
|
|
200
|
+
tryPlace()
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
waitObserver.disconnect()
|
|
204
|
+
rootObserver.disconnect()
|
|
205
|
+
unsubscribeActive?.()
|
|
206
|
+
entry.remove()
|
|
207
|
+
}
|
|
208
|
+
}
|