@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4
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 +21 -13
- package/README.zh.md +21 -13
- package/lib/client.js +1566 -929
- package/lib/client.js.map +1 -1
- package/lib/index.js +934 -176
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +42 -2
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +65 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +77 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +26 -23
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +130 -136
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +50 -18
- package/src/client/locales.ts +82 -8
- package/src/client/native-panel.tsx +124 -0
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +66 -4
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +223 -23
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +232 -39
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +27 -8
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
- package/src/host/run-guarded.ts +0 -76
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
// Generated by scripts/sync-shared.mjs from shared/client/panel-mount-core.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
-
/**
|
|
3
|
-
* Center-column panel takeover lifecycle.
|
|
4
|
-
*
|
|
5
|
-
* The `conversation` slot is single-occupant (ui-conversation) and external
|
|
6
|
-
* plugins cannot declare slots, so a family panel takes over the center
|
|
7
|
-
* column at the DOM level: a container is appended inside the center column
|
|
8
|
-
* (`[class*="centerCol"]`, the 0.1.0-rc.6+ AppFrame layout; previously
|
|
9
|
-
* `[data-pane="conversation"]` on older shells — the mount selector keeps
|
|
10
|
-
* both, ssh #243 / task-board #107) as an extra trailing child React never
|
|
11
|
-
* manages, and a stylesheet rule hides the conversation content while the
|
|
12
|
-
* panel is active. Toggling is a data attribute on <html> — no React
|
|
13
|
-
* involvement, so the conversation subtree underneath stays mounted and
|
|
14
|
-
* stateful.
|
|
15
|
-
*
|
|
16
|
-
* Consuming plugins keep a thin wrapper that supplies the panel tree,
|
|
17
|
-
* container attribute names, and stylesheet class; those names are pinned by
|
|
18
|
-
* each package's CSS, skins, and the semantic-attributes contract. The
|
|
19
|
-
* sidebar row toggling the panel shares its core the same way
|
|
20
|
-
* (shared/client/sidebar-entry-core.ts, synced copy).
|
|
21
|
-
*/
|
|
22
|
-
import { createRoot, type Root } from 'react-dom/client'
|
|
23
|
-
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
24
|
-
|
|
25
|
-
/** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
|
|
26
|
-
export interface CenterPanelMountOptions {
|
|
27
|
-
/** Render the panel tree (first open, remount while open, locale refresh). */
|
|
28
|
-
render: (root: Root) => void
|
|
29
|
-
/** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
|
|
30
|
-
viewDatasetKey: string
|
|
31
|
-
/** value of the container's L2 `data-dsh-plugin` semantic attribute. */
|
|
32
|
-
pluginName: string
|
|
33
|
-
/** stylesheet class applied to the injected container. */
|
|
34
|
-
viewClassName: string
|
|
35
|
-
/** <html> attribute set while this panel is active. */
|
|
36
|
-
activeAttribute: string
|
|
37
|
-
/** the sibling panel's active attribute, removed from <html> when this panel opens. */
|
|
38
|
-
siblingActiveAttribute: string
|
|
39
|
-
/** detail value this panel broadcasts on the cross-plugin activation event. */
|
|
40
|
-
panelName: string
|
|
41
|
-
/** sibling detail value whose activation closes this panel. */
|
|
42
|
-
siblingPanelName: string
|
|
43
|
-
/** open flag of the owning controller. */
|
|
44
|
-
isOpen: () => boolean
|
|
45
|
-
/** close the panel, handing the center column back to the conversation. */
|
|
46
|
-
close: () => void
|
|
47
|
-
/** subscribe to the owning controller's open-state changes; returns an unsubscriber. */
|
|
48
|
-
subscribe: (listener: () => void) => () => void
|
|
49
|
-
/** locale-change source; when given, re-renders an open panel on a Language switch. */
|
|
50
|
-
locale?: { subscribe(listener: () => void): () => void }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
54
|
-
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
55
|
-
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
56
|
-
// Sidebar rows whose clicks hand the center column back to the conversation
|
|
57
|
-
// (including the already-current row, which produces no session-change
|
|
58
|
-
// event). Capture phase, so the panel closes before the shell processes the
|
|
59
|
-
// click.
|
|
60
|
-
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
61
|
-
|
|
62
|
-
/** Find the center column, or undefined while the frame is not mounted. */
|
|
63
|
-
function conversationColumn(): HTMLElement | undefined {
|
|
64
|
-
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Mount a family panel into the center column and bind its visibility to the
|
|
69
|
-
* owning controller's open state.
|
|
70
|
-
* @returns disposer unmounting the tree and restoring the column.
|
|
71
|
-
*/
|
|
72
|
-
export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
|
|
73
|
-
let root: Root | undefined
|
|
74
|
-
let container: HTMLDivElement | undefined
|
|
75
|
-
let unsubscribeLocale: (() => void) | undefined
|
|
76
|
-
try {
|
|
77
|
-
unsubscribeLocale = options.locale?.subscribe(() => {
|
|
78
|
-
if (root !== undefined) options.render(root)
|
|
79
|
-
})
|
|
80
|
-
} catch { /* locale service absent: the panel follows its next natural re-render */ }
|
|
81
|
-
|
|
82
|
-
const ensure = (): void => {
|
|
83
|
-
if (container !== undefined && !container.isConnected) {
|
|
84
|
-
// The conversation pane was replaced; drop the stale tree and remount.
|
|
85
|
-
root?.unmount()
|
|
86
|
-
root = undefined
|
|
87
|
-
container.remove()
|
|
88
|
-
container = undefined
|
|
89
|
-
}
|
|
90
|
-
if (container === undefined) {
|
|
91
|
-
const column = conversationColumn()
|
|
92
|
-
if (column === undefined) return
|
|
93
|
-
container = document.createElement('div')
|
|
94
|
-
container.dataset[options.viewDatasetKey] = ''
|
|
95
|
-
container.dataset.dshPlugin = options.pluginName
|
|
96
|
-
container.className = options.viewClassName
|
|
97
|
-
column.appendChild(container)
|
|
98
|
-
}
|
|
99
|
-
// Keep a visited tree mounted so drafts and terminal sessions survive
|
|
100
|
-
// close/reopen; an unused panel needs neither a React root nor effects.
|
|
101
|
-
if (root !== undefined || !options.isOpen()) return
|
|
102
|
-
root = createRoot(container)
|
|
103
|
-
options.render(root)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// The frame mounts after boot settlement; watch for the column's arrival.
|
|
107
|
-
// The observation is the page-wide hub (shared/client/body-mutations.ts):
|
|
108
|
-
// every family panel used to hold its own document.body subtree observer, so
|
|
109
|
-
// the per-mutation cost grew with the number of installed plugins.
|
|
110
|
-
const unsubscribeBody = subscribeBodyInvalidations(() => { ensure() })
|
|
111
|
-
|
|
112
|
-
const applyActive = (): void => {
|
|
113
|
-
if (options.isOpen()) {
|
|
114
|
-
ensure()
|
|
115
|
-
// Single-occupant center column: opening this panel must evict the
|
|
116
|
-
// sibling panel, both its html attribute and its controller state,
|
|
117
|
-
// otherwise the two panels' visibility rules fight and the second
|
|
118
|
-
// click appears dead.
|
|
119
|
-
document.documentElement.removeAttribute(options.siblingActiveAttribute)
|
|
120
|
-
document.documentElement.setAttribute(options.activeAttribute, '')
|
|
121
|
-
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: options.panelName }))
|
|
122
|
-
} else {
|
|
123
|
-
document.documentElement.removeAttribute(options.activeAttribute)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
const onOtherActivate = (event: Event): void => {
|
|
127
|
-
if ((event as CustomEvent).detail === options.siblingPanelName && options.isOpen()) {
|
|
128
|
-
options.close()
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
132
|
-
if (!options.isOpen()) return
|
|
133
|
-
const target = event.target as HTMLElement | null
|
|
134
|
-
if (target === null) return
|
|
135
|
-
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) options.close()
|
|
136
|
-
}
|
|
137
|
-
document.addEventListener('click', onClickSidebarRow, true)
|
|
138
|
-
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
139
|
-
const unsubscribe = options.subscribe(applyActive)
|
|
140
|
-
applyActive()
|
|
141
|
-
ensure()
|
|
142
|
-
|
|
143
|
-
return () => {
|
|
144
|
-
document.removeEventListener('click', onClickSidebarRow, true)
|
|
145
|
-
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
146
|
-
unsubscribeBody()
|
|
147
|
-
unsubscribe()
|
|
148
|
-
unsubscribeLocale?.()
|
|
149
|
-
document.documentElement.removeAttribute(options.activeAttribute)
|
|
150
|
-
root?.unmount()
|
|
151
|
-
root = undefined
|
|
152
|
-
container?.remove()
|
|
153
|
-
container = undefined
|
|
154
|
-
}
|
|
155
|
-
}
|
|
@@ -1,237 +0,0 @@
|
|
|
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
|
-
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
18
|
-
|
|
19
|
-
/** Per-package configuration for one sidebar entry row. */
|
|
20
|
-
export interface SidebarEntryOptions {
|
|
21
|
-
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
22
|
-
rowAttribute: string
|
|
23
|
-
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
|
24
|
-
rowSelector: string
|
|
25
|
-
/**
|
|
26
|
-
* L2 semantic-attribute plugin id (issue #506, enum table:
|
|
27
|
-
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
|
28
|
-
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
|
29
|
-
* leaves the row without semantic attributes.
|
|
30
|
-
*/
|
|
31
|
-
plugin?: string
|
|
32
|
-
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
|
33
|
-
icon: string
|
|
34
|
-
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
|
35
|
-
css: Record<string, string>
|
|
36
|
-
/** Localized row label (aria-label + visible text). */
|
|
37
|
-
label(): string
|
|
38
|
-
/** Optional localized tooltip (title attribute). */
|
|
39
|
-
tooltip?(): string
|
|
40
|
-
/**
|
|
41
|
-
* Optional locale-change subscription: re-applies label / aria-label /
|
|
42
|
-
* tooltip whenever the active locale changes. Plain-DOM rows would
|
|
43
|
-
* otherwise keep the label captured at mount; pass the SDK locale runtime
|
|
44
|
-
* subscription (ctx.locale.subscribe) so the row follows the language
|
|
45
|
-
* switch without a reload.
|
|
46
|
-
*/
|
|
47
|
-
refresh?: { subscribe(listener: () => void): () => void }
|
|
48
|
-
/** Click action (open/toggle the owning panel). */
|
|
49
|
-
onToggle(): void
|
|
50
|
-
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
51
|
-
position: 'before' | 'after'
|
|
52
|
-
/**
|
|
53
|
-
* Selectors of the sibling plugin entry rows this package orders against
|
|
54
|
-
* (its own row included — the placement guard excludes a row that is
|
|
55
|
-
* already inside the root). Each package passes the same list it used
|
|
56
|
-
* before the consolidation so the rendered order stays stable.
|
|
57
|
-
*/
|
|
58
|
-
familySelectors: readonly string[]
|
|
59
|
-
/** Optional active-state bridge; highlights the row while the panel is open. */
|
|
60
|
-
active?: {
|
|
61
|
-
subscribe(listener: () => void): () => void
|
|
62
|
-
isOpen(): boolean
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
67
|
-
function sidebarRoot(): HTMLElement | undefined {
|
|
68
|
-
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
69
|
-
if (column === null) return undefined
|
|
70
|
-
// Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
|
|
71
|
-
// Prefer the element that owns the logo row — the real sidebar UI root —
|
|
72
|
-
// and fall back to the column's first child for legacy shells.
|
|
73
|
-
const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
|
|
74
|
-
return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
|
|
78
|
-
function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
79
|
-
const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
|
|
80
|
-
if (nested !== null) return nested
|
|
81
|
-
for (const child of root.children) {
|
|
82
|
-
if (child.tagName === 'BUTTON') return child as HTMLButtonElement
|
|
83
|
-
}
|
|
84
|
-
return undefined
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Build the entry row (detached; inserted once the shell is up). */
|
|
88
|
-
function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void } {
|
|
89
|
-
const entry = document.createElement('button')
|
|
90
|
-
entry.type = 'button'
|
|
91
|
-
entry.setAttribute(options.rowAttribute, '')
|
|
92
|
-
if (options.plugin !== undefined) {
|
|
93
|
-
entry.setAttribute('data-dsh-plugin', options.plugin)
|
|
94
|
-
entry.setAttribute('data-dsh-part', 'sidebar-entry')
|
|
95
|
-
}
|
|
96
|
-
entry.className = options.css['entry'] ?? ''
|
|
97
|
-
const labelSpan = document.createElement('span')
|
|
98
|
-
labelSpan.className = options.css['entryLabel'] ?? ''
|
|
99
|
-
const iconSpan = document.createElement('span')
|
|
100
|
-
iconSpan.className = options.css['entryIcon'] ?? ''
|
|
101
|
-
iconSpan.innerHTML = options.icon
|
|
102
|
-
entry.append(iconSpan, labelSpan)
|
|
103
|
-
const applyLabel = (): void => {
|
|
104
|
-
entry.setAttribute('aria-label', options.label())
|
|
105
|
-
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
|
106
|
-
labelSpan.textContent = options.label()
|
|
107
|
-
}
|
|
108
|
-
applyLabel()
|
|
109
|
-
entry.addEventListener('click', options.onToggle)
|
|
110
|
-
return { entry, applyLabel }
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
114
|
-
function placeEntry(root: HTMLElement, entry: HTMLElement, options: SidebarEntryOptions): boolean {
|
|
115
|
-
const button = newSessionButton(root)
|
|
116
|
-
if (button === undefined) return false
|
|
117
|
-
if (entry.parentElement !== root) {
|
|
118
|
-
// Position relative to the family block (entries injected by sibling
|
|
119
|
-
// plugins), never relative to transient logoRow geometry: every family
|
|
120
|
-
// plugin that self-heals during a re-render then lands in the same
|
|
121
|
-
// relative order, so the entries cannot swap positions regardless of
|
|
122
|
-
// observer callback order or of shell wrapper changes. There is no
|
|
123
|
-
// append-to-end fallback: appending at the end would randomly reorder
|
|
124
|
-
// the block after a shell re-render.
|
|
125
|
-
const row = button.closest('[class*="logoRow"]')
|
|
126
|
-
const base = (row !== null && row.parentElement === root) ? row : button
|
|
127
|
-
const family = Array.from(root.children).filter(
|
|
128
|
-
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
|
|
129
|
-
)
|
|
130
|
-
const anchor = options.position === 'before'
|
|
131
|
-
? (family.length > 0 ? family[0] : base.nextElementSibling)
|
|
132
|
-
: (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
|
|
133
|
-
root.insertBefore(entry, anchor)
|
|
134
|
-
}
|
|
135
|
-
return true
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
140
|
-
* on later React re-renders.
|
|
141
|
-
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
142
|
-
* @returns disposer removing the entry and its observers.
|
|
143
|
-
*/
|
|
144
|
-
export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
145
|
-
// DOM-level idempotency: whatever path mounted an entry row before this
|
|
146
|
-
// call (a duplicated apply, an HMR re-injection, a stale module still
|
|
147
|
-
// alive), never mount a second one. The existing row keeps working; a full
|
|
148
|
-
// page reload is the ultimate reset.
|
|
149
|
-
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
|
150
|
-
return () => {}
|
|
151
|
-
}
|
|
152
|
-
const { entry, applyLabel } = createEntry(options)
|
|
153
|
-
let root: HTMLElement | undefined
|
|
154
|
-
let placed = false
|
|
155
|
-
let unsubscribeRefresh: (() => void) | undefined
|
|
156
|
-
if (options.refresh !== undefined) {
|
|
157
|
-
try {
|
|
158
|
-
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
|
|
159
|
-
} catch {
|
|
160
|
-
// A throwing subscription must not break the mount; the label stays at
|
|
161
|
-
// its initial value and the next reload resolves it again.
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const tryPlace = (): void => {
|
|
166
|
-
if (root !== undefined && !root.isConnected) {
|
|
167
|
-
// The shell rebuilt the sidebar pane (whole-tree teardown); the root
|
|
168
|
-
// observer is gone with the old tree, so detach it and re-query from
|
|
169
|
-
// scratch. The new pane is later noticed by the body-level watcher.
|
|
170
|
-
rootObserver.disconnect()
|
|
171
|
-
root = undefined
|
|
172
|
-
placed = false
|
|
173
|
-
}
|
|
174
|
-
if (placed) {
|
|
175
|
-
// Cheap short-circuit: entry still lives in a mountable subtree.
|
|
176
|
-
if (document.body.contains(entry)) return
|
|
177
|
-
// Entry was torn down together with the old tree; reset and re-place.
|
|
178
|
-
rootObserver.disconnect()
|
|
179
|
-
root = undefined
|
|
180
|
-
placed = false
|
|
181
|
-
}
|
|
182
|
-
root ??= sidebarRoot()
|
|
183
|
-
if (root === undefined) return
|
|
184
|
-
placed = placeEntry(root, entry, options)
|
|
185
|
-
if (placed) {
|
|
186
|
-
rootObserver.observe(root, { childList: true, subtree: true })
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Body-level watcher retained as the "whole rebuild" fallback: when the shell
|
|
191
|
-
// tears down the whole sidebar pane, the root observer is gone with it and
|
|
192
|
-
// only this body observation can notice the new pane mounting. It stays
|
|
193
|
-
// subscribed after placement; the placed-and-still-mounted case
|
|
194
|
-
// short-circuits through the cheap document.body.contains(entry) check, so
|
|
195
|
-
// unrelated app mutations (e.g. chat streaming) cost one contains check per
|
|
196
|
-
// frame instead of churning the full re-query. The body observation itself is
|
|
197
|
-
// the page-wide hub (shared/client/body-mutations.ts): every family plugin
|
|
198
|
-
// used to hold its own body subtree observer, so the per-mutation cost grew
|
|
199
|
-
// with the number of installed plugins; the hub keeps exactly one.
|
|
200
|
-
const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
|
|
201
|
-
|
|
202
|
-
// Self-heal: if a React re-render displaces the row, re-insert it in the
|
|
203
|
-
// same frame (microtask before paint -> no visible flicker).
|
|
204
|
-
const rootObserver = new MutationObserver(() => {
|
|
205
|
-
if (root === undefined || !root.isConnected) {
|
|
206
|
-
placed = false
|
|
207
|
-
tryPlace()
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
if (!root.contains(entry)) {
|
|
211
|
-
placed = placeEntry(root, entry, options)
|
|
212
|
-
}
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
// Reflect the panel's open state on the row (active highlight). Note: assigning
|
|
216
|
-
// undefined to dataset.active materializes data-active="undefined" and keeps the
|
|
217
|
-
// row permanently highlighted — delete the attribute instead.
|
|
218
|
-
const unsubscribeActive = options.active === undefined ? undefined : (() => {
|
|
219
|
-
const syncActive = (): void => {
|
|
220
|
-
if (options.active!.isOpen()) entry.dataset.active = 'true'
|
|
221
|
-
else delete entry.dataset.active
|
|
222
|
-
}
|
|
223
|
-
const unsubscribe = options.active.subscribe(syncActive)
|
|
224
|
-
syncActive()
|
|
225
|
-
return unsubscribe
|
|
226
|
-
})()
|
|
227
|
-
|
|
228
|
-
tryPlace()
|
|
229
|
-
|
|
230
|
-
return () => {
|
|
231
|
-
unsubscribeBody()
|
|
232
|
-
rootObserver.disconnect()
|
|
233
|
-
unsubscribeRefresh?.()
|
|
234
|
-
unsubscribeActive?.()
|
|
235
|
-
entry.remove()
|
|
236
|
-
}
|
|
237
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sidebar entry injection — package-specific wiring over the shared core.
|
|
3
|
-
*
|
|
4
|
-
* dsh's sidebar shell exposes no slot an external plugin can register into
|
|
5
|
-
* (`sidebar.workspaces` / `sidebar.settings` are single-occupant and already
|
|
6
|
-
* taken), so the entry row is injected between the shell's New Session button
|
|
7
|
-
* and the workspace browser. The DOM injection / self-healing / idempotency
|
|
8
|
-
* logic lives exactly once in shared/client/sidebar-entry-core.ts (synced
|
|
9
|
-
* copy); this wrapper supplies the task-board icon, copy, CSS module, and the
|
|
10
|
-
* board toggle. The row is plain DOM (no React tree); the board view it
|
|
11
|
-
* toggles is a separate React root mounted in the center column
|
|
12
|
-
* (see board-mount.ts).
|
|
13
|
-
*/
|
|
14
|
-
import type { BoardController } from '../core/controller.ts'
|
|
15
|
-
import { t } from './locales.ts'
|
|
16
|
-
import css from './board.module.css'
|
|
17
|
-
import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-core.ts'
|
|
18
|
-
|
|
19
|
-
/** Stable data attribute identifying the injected entry row. */
|
|
20
|
-
export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
|
|
21
|
-
|
|
22
|
-
/** Inline icon normalized to the shell's 16px navigation glyph size. */
|
|
23
|
-
const ICON = '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
|
|
24
|
-
|
|
25
|
-
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
|
|
26
|
-
export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
30
|
-
* on later React re-renders.
|
|
31
|
-
* @param controller - the board controller the entry toggles.
|
|
32
|
-
* @param locale - locale-change source; when given, re-applies the label on
|
|
33
|
-
* a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
|
|
34
|
-
* @returns disposer removing the entry and its observers.
|
|
35
|
-
*/
|
|
36
|
-
export function mountSidebarEntry(controller: BoardController, locale?: LocaleRefreshSource): () => void {
|
|
37
|
-
return mountSharedSidebarEntry({
|
|
38
|
-
rowAttribute: 'data-dsh-taskboard-entry',
|
|
39
|
-
rowSelector: ENTRY_SELECTOR,
|
|
40
|
-
plugin: 'task-board',
|
|
41
|
-
icon: ICON,
|
|
42
|
-
css,
|
|
43
|
-
label: () => t('entry.label'),
|
|
44
|
-
refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
|
|
45
|
-
onToggle: () => { controller.toggleBoard() },
|
|
46
|
-
position: 'before',
|
|
47
|
-
familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
|
|
48
|
-
active: {
|
|
49
|
-
subscribe: (listener) => controller.subscribe(listener),
|
|
50
|
-
isOpen: () => controller.getSnapshot().boardOpen,
|
|
51
|
-
},
|
|
52
|
-
})
|
|
53
|
-
}
|
package/src/host/run-guarded.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// Generated by scripts/sync-shared.mjs from shared/host/run-guarded.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
-
/**
|
|
3
|
-
* Async-boundary guard shared by the plugin family's Host halves: every
|
|
4
|
-
* fire-and-forget promise chain and callback the host runtime does not own
|
|
5
|
-
* (route handlers, timers, event listeners, spawned work) funnels through
|
|
6
|
-
* these helpers. The dsh host installs a process-level fail-loud guard that
|
|
7
|
-
* turns ANY unhandled promise rejection into a whole-process exit — one
|
|
8
|
-
* plugin's stray rejection would otherwise take every plugin down. These
|
|
9
|
-
* helpers exist so that failure mode is structurally impossible in family
|
|
10
|
-
* code: the rejection becomes a logged error at the plugin boundary instead.
|
|
11
|
-
*
|
|
12
|
-
* Complements the aggregate's shell isolation (packages/dsh-web-all): the
|
|
13
|
-
* shell contains import/activation failures at boot; runGuarded contains
|
|
14
|
-
* run-time failures after activation.
|
|
15
|
-
* @module dsh-web-shared/host/run-guarded
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** Format one failure line for logging. */
|
|
19
|
-
function formatFailure(label: string, error: unknown): Error {
|
|
20
|
-
return new Error(`[${label}] unhandled async failure: ${error instanceof Error ? error.stack ?? error.message : String(error)}`)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Run one async operation, logging (never propagating) a rejection. Use for
|
|
25
|
-
* promises whose lifecycle the caller does not await: spawned work, retry
|
|
26
|
-
* loops, background flushes. Returns the original promise so callers can
|
|
27
|
-
* still chain when they want to.
|
|
28
|
-
* @param promise - the work to shield; any thenable.
|
|
29
|
-
* @param label - log prefix naming the work site.
|
|
30
|
-
* @param log - error sink; defaults to console.error.
|
|
31
|
-
* @returns the input promise (rejection already consumed).
|
|
32
|
-
*/
|
|
33
|
-
export function runGuarded<T>(promise: PromiseLike<T>, label: string, log: (error: unknown) => void = console.error): PromiseLike<T> {
|
|
34
|
-
void Promise.resolve(promise).catch(error => {
|
|
35
|
-
log(formatFailure(label, error))
|
|
36
|
-
})
|
|
37
|
-
return promise
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Wrap one callback so every invocation is individually guarded: a rejection
|
|
42
|
-
* inside one call is logged and swallowed instead of escaping into whatever
|
|
43
|
-
* infrastructure invoked the callback (HTTP server, EventEmitter, interval).
|
|
44
|
-
* Sync throws are caught identically; a returned promise is replaced by
|
|
45
|
-
* `undefined` after guarding (callers that need the original rejection should
|
|
46
|
-
* await inside their own try/catch instead).
|
|
47
|
-
* @param label - log prefix naming the callback site.
|
|
48
|
-
* @param handler - the work to guard.
|
|
49
|
-
* @param log - error sink; defaults to console.error.
|
|
50
|
-
* @returns a wrapped callback with the same parameter list.
|
|
51
|
-
*/
|
|
52
|
-
export function guardedHandler<A extends unknown[]>(
|
|
53
|
-
label: string,
|
|
54
|
-
handler: (...args: A) => unknown,
|
|
55
|
-
log: (error: unknown) => void = console.error,
|
|
56
|
-
): (...args: A) => unknown {
|
|
57
|
-
return (...args: A) => {
|
|
58
|
-
try {
|
|
59
|
-
const result = handler(...args)
|
|
60
|
-
if (isPromiseLike(result)) {
|
|
61
|
-
void Promise.resolve(result).catch(error => {
|
|
62
|
-
log(formatFailure(label, error))
|
|
63
|
-
})
|
|
64
|
-
return undefined
|
|
65
|
-
}
|
|
66
|
-
return result
|
|
67
|
-
} catch (error) {
|
|
68
|
-
log(formatFailure(label, error))
|
|
69
|
-
return undefined
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
75
|
-
return typeof value === 'object' && value !== null && typeof (value as PromiseLike<unknown>).then === 'function'
|
|
76
|
-
}
|