@linxin666/dsh-client-ui-task-board 0.1.2 → 0.1.6

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Task board for the dsh web GUI: a sidebar entry plus a multi-column kanban view with local persistence, task details, and real execution through dsh sessions (session.prompt). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.2",
4
+ "version": "0.1.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -19,6 +19,11 @@ export const BOARD_VIEW_SELECTOR = '[data-dsh-taskboard-view]'
19
19
 
20
20
  const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
21
21
  const ACTIVE_ATTR = 'data-dsh-taskboard-active'
22
+ /** The sibling panel's activation attribute (ssh), removed when this panel opens. */
23
+ const OTHER_ACTIVE_ATTR = 'data-dsh-ssh-active'
24
+ /** Cross-plugin activation event; detail is the activating panel name. */
25
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
26
+ const PANEL_NAME = 'taskboard'
22
27
 
23
28
  /** Find the center column, or undefined while the frame is not mounted. */
24
29
  function conversationColumn(): HTMLElement | undefined {
@@ -53,16 +58,42 @@ export function mountBoard(controller: BoardController): () => void {
53
58
 
54
59
  const applyActive = (): void => {
55
60
  if (controller.getSnapshot().boardOpen) {
61
+ // Single-occupant center column: opening this panel must evict the
62
+ // sibling panel (ssh), both its html attribute and its controller
63
+ // state, otherwise the two panels' visibility rules fight and the
64
+ // second click appears dead.
65
+ document.documentElement.removeAttribute(OTHER_ACTIVE_ATTR)
56
66
  document.documentElement.setAttribute(ACTIVE_ATTR, '')
67
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
57
68
  } else {
58
69
  document.documentElement.removeAttribute(ACTIVE_ATTR)
59
70
  }
60
71
  }
72
+ const onOtherActivate = (event: Event): void => {
73
+ if ((event as CustomEvent).detail === 'ssh' && controller.getSnapshot().boardOpen) {
74
+ controller.closeBoard()
75
+ }
76
+ }
77
+ // Jump out on sidebar context clicks: clicking a session/workspace row
78
+ // (including the already-current one, which produces no session-change
79
+ // event) hands the center column back to the conversation. Capture phase,
80
+ // so the panel closes before the shell processes the click.
81
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
82
+ const onClickSidebarRow = (event: MouseEvent): void => {
83
+ if (!controller.getSnapshot().boardOpen) return
84
+ const target = event.target as HTMLElement | null
85
+ if (target === null) return
86
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.closeBoard()
87
+ }
88
+ document.addEventListener('click', onClickSidebarRow, true)
89
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
61
90
  const unsubscribe = controller.subscribe(applyActive)
62
91
  applyActive()
63
92
  ensure()
64
93
 
65
94
  return () => {
95
+ document.removeEventListener('click', onClickSidebarRow, true)
96
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
66
97
  waitObserver.disconnect()
67
98
  unsubscribe()
68
99
  document.documentElement.removeAttribute(ACTIVE_ATTR)
@@ -19,13 +19,16 @@
19
19
  z-index: 5;
20
20
  }
21
21
 
22
- html[data-dsh-taskboard-active] [data-dsh-taskboard-view] {
22
+ /* The center column is single-occupant; the :not() guards keep the two
23
+ sibling panels (task board / ssh) from fighting over visibility if both
24
+ activation attributes ever coexist. */
25
+ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [data-dsh-taskboard-view] {
23
26
  display: block;
24
27
  }
25
28
 
26
29
  /* While the board is active, the conversation content underneath is hidden
27
30
  (it stays mounted and stateful). */
28
- html[data-dsh-taskboard-active] [data-pane='conversation'] > :not([data-dsh-taskboard-view]) {
31
+ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [data-pane='conversation'] > :not([data-dsh-taskboard-view]) {
29
32
  display: none;
30
33
  }
31
34
 
@@ -20,21 +20,24 @@ import css from './board.module.css'
20
20
  /** Stable data attribute identifying the injected entry row. */
21
21
  export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
22
22
 
23
- /** The sidebar column is the grid item AppFrame renders with this pane marker. */
24
- const SIDEBAR_COLUMN_SELECTOR = '[data-pane="sidebar"]'
25
-
26
23
  /** Inline icon (matches the shell's 16px nav-icon look). */
27
24
  const ICON = `<svg viewBox="0 0 16 16" width="14" height="14" 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>`
28
25
 
29
26
  /** Find the sidebar shell root element, or undefined while not yet mounted. */
30
27
  function sidebarRoot(): HTMLElement | undefined {
31
- const column = document.querySelector<HTMLElement>(SIDEBAR_COLUMN_SELECTOR)
32
- const root = column?.firstElementChild as HTMLElement | undefined
33
- return root ?? undefined
28
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
29
+ if (column === null) return undefined
30
+ // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
31
+ // Prefer the element that owns the logo row — the real sidebar UI root —
32
+ // and fall back to the column's first child for legacy shells.
33
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
34
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
34
35
  }
35
36
 
36
- /** The New Session button: the shell's only direct-child button of the root. */
37
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
37
38
  function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
39
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
40
+ if (nested !== null) return nested
38
41
  for (const child of root.children) {
39
42
  if (child.tagName === 'BUTTON') return child as HTMLButtonElement
40
43
  }
@@ -53,14 +56,26 @@ function createEntry(controller: BoardController): HTMLButtonElement {
53
56
  return entry
54
57
  }
55
58
 
56
- /** Re-insert the entry after the New Session button (before the browser region). */
59
+ /** Re-insert the entry after the New Session row (before the browser region). */
57
60
  function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
58
61
  const button = newSessionButton(root)
59
62
  if (button === undefined) return false
60
63
  if (entry.parentElement !== root) {
61
- // Insert after the button: React never manages this node, and the shell
62
- // keeps its own child order intact around it.
63
- root.insertBefore(entry, button.nextElementSibling)
64
+ // Position relative to the family block (entries injected by sibling
65
+ // plugins), never relative to transient logoRow geometry: every family
66
+ // plugin that self-heals during a re-render then lands in the same
67
+ // relative order, so the entries cannot swap positions regardless of
68
+ // observer callback order or of shell wrapper changes. There is no
69
+ // append-to-end fallback: appending at the end would randomly reorder
70
+ // the block after a shell re-render.
71
+ const row = button.closest('[class*="logoRow"]')
72
+ const base = (row !== null && row.parentElement === root) ? row : button
73
+ const family = Array.from(root.children).filter(
74
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
75
+ )
76
+ // task board sits before the whole family block.
77
+ const anchor = family.length > 0 ? family[0] : base.nextElementSibling
78
+ root.insertBefore(entry, anchor)
64
79
  }
65
80
  return true
66
81
  }
@@ -77,14 +92,37 @@ export function mountSidebarEntry(controller: BoardController): () => void {
77
92
  let placed = false
78
93
 
79
94
  const tryPlace = (): void => {
80
- if (placed) return
95
+ if (root !== undefined && !root.isConnected) {
96
+ // The shell rebuilt the sidebar pane (whole-tree teardown); the root
97
+ // observer is gone with the old tree, so detach it and re-query from
98
+ // scratch. The new pane is later noticed by the body-level watcher.
99
+ rootObserver.disconnect()
100
+ root = undefined
101
+ placed = false
102
+ }
103
+ if (placed) {
104
+ // Cheap short-circuit: entry still lives in a mountable subtree.
105
+ if (document.body.contains(entry)) return
106
+ // Entry was torn down together with the old tree; reset and re-place.
107
+ rootObserver.disconnect()
108
+ root = undefined
109
+ placed = false
110
+ }
81
111
  root ??= sidebarRoot()
82
112
  if (root === undefined) return
83
113
  placed = placeEntry(root, entry)
84
- if (placed) rootObserver.observe(root, { childList: true, subtree: true })
114
+ if (placed) {
115
+ rootObserver.observe(root, { childList: true, subtree: true })
116
+ }
85
117
  }
86
118
 
87
- // The shell renders after boot settlement; watch for its arrival.
119
+ // Body-level watcher retained as the "whole rebuild" fallback: when the shell
120
+ // tears down the whole sidebar pane, the root observer is gone with it and
121
+ // only this body observation can notice the new pane mounting. It is no
122
+ // longer disconnected after placement; the placed-and-still-mounted case
123
+ // short-circuits through the cheap document.body.contains(entry) check, so
124
+ // unrelated app mutations (e.g. chat streaming) cost one contains check
125
+ // instead of churning the full re-query.
88
126
  const waitObserver = new MutationObserver(() => { tryPlace() })
89
127
  waitObserver.observe(document.body, { childList: true, subtree: true })
90
128