fix(task-board,ssh): make center panels mutually exclusive and jump out on sidebar clicks

The real defect behind the "second button appears dead" reports: both the
task board and the ssh panel take over the single-occupant conversation
column through independent html activation attributes
(data-dsh-taskboard-active / data-dsh-ssh-active). Opening the second
panel never evicted the first, so both attributes coexisted and the two
visibility rule sets fought - the second click looked dead and the state
only cleared by accident later.

- board-mount.tsx / mount.tsx: opening a panel now removes the sibling's
  html attribute and broadcasts a dsh-panel-activate event; the sibling
  controller closes itself, so entries, highlights and the column stay in
  sync. CSS rules are additionally gated with :not([sibling-active]).
- board-mount.tsx / mount.tsx: a capture-phase document click listener
  closes the open panel when the click lands on a sidebar session or
  workspace row (sessionRow / projectRow / searchResult* / newSession),
  so clicking the sidebar - including the already-current session, which
  produces no session-change event - hands the center column back to the
  conversation without a manual close first.
- sidebar-entry.ts (both): harden the injected rows - anchor placement to
  the family block (no append-at-end fallback), re-query the sidebar root
  after a shell rebuild (task board previously kept a detached root and
  lost its entry permanently), and disconnect the body-wide watcher after
  the first successful placement.
This commit is contained in:
LittleDarkZero
2026-08-14 07:23:58 +08:00
committed by zhu1090093659
parent 18f5e55f04
commit c0a98c7150
6 changed files with 120 additions and 24 deletions
+31
View File
@@ -20,6 +20,11 @@ export const PANEL_VIEW_SELECTOR = '[data-dsh-ssh-view]'
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
const ACTIVE_ATTR = 'data-dsh-ssh-active'
/** The sibling panel's activation attribute (task board), removed when this panel opens. */
const OTHER_ACTIVE_ATTR = 'data-dsh-taskboard-active'
/** Cross-plugin activation event; detail is the activating panel name. */
const ACTIVATE_EVENT = 'dsh-panel-activate'
const PANEL_NAME = 'ssh'
/** Find the center column, or undefined while the frame is not mounted. */
function conversationColumn(): HTMLElement | undefined {
@@ -62,16 +67,42 @@ export function mountPanel(controller: PanelController, api: SshApi): () => void
const applyActive = (): void => {
if (controller.getSnapshot().panelOpen) {
// Single-occupant center column: opening this panel must evict the
// sibling panel (task board), both its html attribute and its
// controller state, otherwise the two panels' visibility rules fight
// and the second click appears dead.
document.documentElement.removeAttribute(OTHER_ACTIVE_ATTR)
document.documentElement.setAttribute(ACTIVE_ATTR, '')
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
} else {
document.documentElement.removeAttribute(ACTIVE_ATTR)
}
}
const onOtherActivate = (event: Event): void => {
if ((event as CustomEvent).detail === 'taskboard' && controller.getSnapshot().panelOpen) {
controller.close()
}
}
// Jump out on sidebar context clicks: clicking a session/workspace row
// (including the already-current one, which produces no session-change
// event) hands the center column back to the conversation. Capture phase,
// so the panel closes before the shell processes the click.
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
const onClickSidebarRow = (event: MouseEvent): void => {
if (!controller.getSnapshot().panelOpen) return
const target = event.target as HTMLElement | null
if (target === null) return
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
}
document.addEventListener('click', onClickSidebarRow, true)
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
const unsubscribe = controller.subscribe(applyActive)
applyActive()
ensure()
return () => {
document.removeEventListener('click', onClickSidebarRow, true)
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
waitObserver.disconnect()
unsubscribe()
document.documentElement.removeAttribute(ACTIVE_ATTR)
@@ -21,13 +21,16 @@
z-index: 5;
}
html[data-dsh-ssh-active] [data-dsh-ssh-view] {
/* The center column is single-occupant; the :not() guards keep the two
sibling panels (task board / ssh) from fighting over visibility if both
activation attributes ever coexist. */
html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view] {
display: block;
}
/* While the panel is active, the conversation content underneath is hidden
(it stays mounted and stateful). */
html[data-dsh-ssh-active] [data-pane='conversation'] > :not([data-dsh-ssh-view]) {
html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane='conversation'] > :not([data-dsh-ssh-view]) {
display: none;
}
+21 -10
View File
@@ -61,16 +61,21 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
const button = newSessionButton(root)
if (button === undefined) return false
if (entry.parentElement !== root) {
// Current shells nest the button inside the logo row: insert after that
// row. Legacy shells keep the button as a direct child: insert after it.
// Position relative to the family block (entries injected by sibling
// plugins), never relative to transient logoRow geometry: every family
// plugin that self-heals during a re-render then lands in the same
// relative order, so the entries cannot swap positions regardless of
// observer callback order or of shell wrapper changes. There is no
// append-to-end fallback: appending at the end would randomly reorder
// the block after a shell re-render.
const row = button.closest('[class*="logoRow"]')
if (row !== null && row.parentElement === root) {
root.insertBefore(entry, row.nextElementSibling)
} else if (button.parentElement === root) {
root.insertBefore(entry, button.nextElementSibling)
} else {
root.appendChild(entry)
}
const base = (row !== null && row.parentElement === root) ? row : button
const family = Array.from(root.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
)
// ssh sits after the whole family block.
const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
root.insertBefore(entry, anchor)
}
return true
}
@@ -96,7 +101,13 @@ export function mountSidebarEntry(controller: PanelController): () => void {
root ??= sidebarRoot()
if (root === undefined) return
placed = placeEntry(root, entry)
if (placed) rootObserver.observe(root, { childList: true, subtree: true })
if (placed) {
rootObserver.observe(root, { childList: true, subtree: true })
// Placement done; the root observer alone keeps the entry healed.
// Disconnect the body-wide watcher so unrelated app mutations (e.g.
// chat streaming) no longer churn every plugin's self-heal loop.
waitObserver.disconnect()
}
}
// The shell renders after boot settlement; watch for its arrival.
@@ -19,6 +19,11 @@ export const BOARD_VIEW_SELECTOR = '[data-dsh-taskboard-view]'
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
const ACTIVE_ATTR = 'data-dsh-taskboard-active'
/** The sibling panel's activation attribute (ssh), removed when this panel opens. */
const OTHER_ACTIVE_ATTR = 'data-dsh-ssh-active'
/** Cross-plugin activation event; detail is the activating panel name. */
const ACTIVATE_EVENT = 'dsh-panel-activate'
const PANEL_NAME = 'taskboard'
/** Find the center column, or undefined while the frame is not mounted. */
function conversationColumn(): HTMLElement | undefined {
@@ -53,16 +58,42 @@ export function mountBoard(controller: BoardController): () => void {
const applyActive = (): void => {
if (controller.getSnapshot().boardOpen) {
// Single-occupant center column: opening this panel must evict the
// sibling panel (ssh), both its html attribute and its controller
// state, otherwise the two panels' visibility rules fight and the
// second click appears dead.
document.documentElement.removeAttribute(OTHER_ACTIVE_ATTR)
document.documentElement.setAttribute(ACTIVE_ATTR, '')
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
} else {
document.documentElement.removeAttribute(ACTIVE_ATTR)
}
}
const onOtherActivate = (event: Event): void => {
if ((event as CustomEvent).detail === 'ssh' && controller.getSnapshot().boardOpen) {
controller.closeBoard()
}
}
// Jump out on sidebar context clicks: clicking a session/workspace row
// (including the already-current one, which produces no session-change
// event) hands the center column back to the conversation. Capture phase,
// so the panel closes before the shell processes the click.
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
const onClickSidebarRow = (event: MouseEvent): void => {
if (!controller.getSnapshot().boardOpen) return
const target = event.target as HTMLElement | null
if (target === null) return
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.closeBoard()
}
document.addEventListener('click', onClickSidebarRow, true)
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
const unsubscribe = controller.subscribe(applyActive)
applyActive()
ensure()
return () => {
document.removeEventListener('click', onClickSidebarRow, true)
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
waitObserver.disconnect()
unsubscribe()
document.documentElement.removeAttribute(ACTIVE_ATTR)
@@ -19,13 +19,16 @@
z-index: 5;
}
html[data-dsh-taskboard-active] [data-dsh-taskboard-view] {
/* The center column is single-occupant; the :not() guards keep the two
sibling panels (task board / ssh) from fighting over visibility if both
activation attributes ever coexist. */
html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [data-dsh-taskboard-view] {
display: block;
}
/* While the board is active, the conversation content underneath is hidden
(it stays mounted and stateful). */
html[data-dsh-taskboard-active] [data-pane='conversation'] > :not([data-dsh-taskboard-view]) {
html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [data-pane='conversation'] > :not([data-dsh-taskboard-view]) {
display: none;
}
@@ -61,16 +61,21 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
const button = newSessionButton(root)
if (button === undefined) return false
if (entry.parentElement !== root) {
// Current shells nest the button inside the logo row: insert after that
// row. Legacy shells keep the button as a direct child: insert after it.
// Position relative to the family block (entries injected by sibling
// plugins), never relative to transient logoRow geometry: every family
// plugin that self-heals during a re-render then lands in the same
// relative order, so the entries cannot swap positions regardless of
// observer callback order or of shell wrapper changes. There is no
// append-to-end fallback: appending at the end would randomly reorder
// the block after a shell re-render.
const row = button.closest('[class*="logoRow"]')
if (row !== null && row.parentElement === root) {
root.insertBefore(entry, row.nextElementSibling)
} else if (button.parentElement === root) {
root.insertBefore(entry, button.nextElementSibling)
} else {
root.appendChild(entry)
}
const base = (row !== null && row.parentElement === root) ? row : button
const family = Array.from(root.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
)
// task board sits before the whole family block.
const anchor = family.length > 0 ? family[0] : base.nextElementSibling
root.insertBefore(entry, anchor)
}
return true
}
@@ -88,10 +93,22 @@ export function mountSidebarEntry(controller: BoardController): () => void {
const tryPlace = (): void => {
if (placed) return
if (root !== undefined && !root.isConnected) {
// The shell re-created the sidebar pane; re-query from scratch so the
// entry is not re-inserted into a detached subtree (which would leave
// it permanently invisible).
root = undefined
}
root ??= sidebarRoot()
if (root === undefined) return
placed = placeEntry(root, entry)
if (placed) rootObserver.observe(root, { childList: true, subtree: true })
if (placed) {
rootObserver.observe(root, { childList: true, subtree: true })
// Placement done; the root observer alone keeps the entry healed.
// Disconnect the body-wide watcher so unrelated app mutations (e.g.
// chat streaming) no longer churn every plugin's self-heal loop.
waitObserver.disconnect()
}
}
// The shell renders after boot settlement; watch for its arrival.