mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 22:38:17 +08:00
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:
committed by
zhu1090093659
parent
18f5e55f04
commit
c0a98c7150
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user