Merge origin/main into feat/wait-banner: resolve floats retirement + openTabInBottomPane

This commit is contained in:
HuanLinOTO
2026-09-10 18:21:09 +08:00
119 changed files with 4690 additions and 7539 deletions
+29
View File
@@ -0,0 +1,29 @@
/**
* The bottom workbench's expand/collapse control, registered into DSH's
* session header utilities list (`conversation.session.header.utilities`).
*
* DSH 0.1.5 draws its own right-Sidebar expand button in the header's corner
* slot (`conversation.session.header.corner`, a SINGLE slot owned by the
* native sidebar package), so the plugin cannot take that seat. The utilities
* list sits immediately left of it — the same row as the host's "Session
* log" capsule — which is where a dock toggle belongs: in flow, so it never
* overlaps host chrome and needs no absolute positioning or measurement.
*
* Registration goes through `slots.inject` because the declaration may not be
* on the ledger yet at plugin activation (the ui-conversation parent entry
* declares it); the callback re-runs if the declaration collapses and is
* re-declared. `order: 10` places the button after the host's own utilities.
*/
import type { Context } from '../../context-types.ts'
import type { SidebarStore } from '../state.ts'
import { BottomDockToggle } from '../Sidebar.tsx'
/** Register the header toggle; returns the disposer. */
export function registerBottomToggle(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
name: 'conversation.session.header.utilities',
id: 'dsh-better-sidebar:bottom-toggle',
order: 10,
registrant: 'dsh-better-sidebar',
}, () => <BottomDockToggle store={store} />))
}
-167
View File
@@ -1,167 +0,0 @@
/**
* Free windows (extracted from Sidebar.tsx, behavior identical): the
* drag-out gesture that floats a tab onto the conversation column, plus the
* render layer for the floating windows and its drop-zone hint overlay.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { Context } from '../../context-types.ts'
import {
dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat,
type FloatWindow, type SidebarStore, type SidebarTab,
} from '../state.ts'
import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx'
import { FreeWindow } from '../FreeWindow.tsx'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/** The dashed drop-zone hint's geometry (viewport coordinates). */
export interface FloatDropHint {
left: number
top: number
width: number
height: number
}
/**
* Free windows — drag-out detection. The tab strips already drive HTML5
* DnD (payload application/x-dsh-tab) with drops owned by the panes
* (split/merge); this hook watches the DOCUMENT (capture) for the same
* drag hovering OUTSIDE the panel host: while the pointer is over the
* conversation column it arms the drop (preventDefault) and shows a hint
* overlay there, and the drop floats the tab at the release point. Targets
* inside the host are ignored here, so pane drops keep their behavior
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
* OS file drags and any DSH drags pass through). Narrow viewports skip
* the gesture — the merged drawer covers the conversation, leaving
* nothing to drop onto (the tab context menu entry still floats tabs).
*/
export function useFloatDragout(input: {
narrow: boolean
sessionId: string | undefined
store: SidebarStore
/** The AppFrame center column (from useCenterColumn); stable ref object. */
centerColRef: { readonly current: HTMLElement | null }
}): { floatHint: FloatDropHint | null } {
const { narrow, sessionId, store, centerColRef } = input
const [floatHint, setFloatHint] = useState<FloatDropHint | null>(null)
const floatHintRef = useRef(false)
useEffect(() => {
if (narrow || sessionId === undefined) return
const inPanelHost = (target: EventTarget | null): boolean =>
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
/** The conversation column's rect when the pointer is over it (and not
* over our own surfaces); null otherwise. */
const overConversation = (event: DragEvent): DOMRect | null => {
if (inPanelHost(event.target)) return null
const col = centerColRef.current
if (col === null || !col.isConnected) return null
const rect = col.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return null
const { clientX: x, clientY: y } = event
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
return rect
}
const onDragOver = (event: DragEvent): void => {
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
const rect = overConversation(event)
if (rect !== null) {
// preventDefault on dragover is what makes the browser deliver the
// drop (and drop the "no" cursor) over the conversation area.
event.preventDefault()
setFloatHint((prev) => {
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
if (prev !== null && prev.left === next.left && prev.top === next.top
&& prev.width === next.width && prev.height === next.height) return prev
return next
})
floatHintRef.current = true
} else if (floatHintRef.current) {
floatHintRef.current = false
setFloatHint(null)
}
}
const onDrop = (event: DragEvent): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
const rect = overConversation(event)
if (rect === null) return
event.preventDefault()
event.stopPropagation()
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
if (payload === null) return
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
}
const clear = (): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
}
document.addEventListener('dragover', onDragOver, true)
document.addEventListener('drop', onDrop, true)
window.addEventListener('dragend', clear, true)
window.addEventListener('blur', clear)
return () => {
document.removeEventListener('dragover', onDragOver, true)
document.removeEventListener('drop', onDrop, true)
window.removeEventListener('dragend', clear, true)
window.removeEventListener('blur', clear)
}
// centerColRef is a stable ref object from useCenterColumn (it lost its
// useRef provenance crossing the hook boundary, so it is listed here).
}, [narrow, sessionId, store, centerColRef])
return { floatHint }
}
/**
* The free-window render layer: tabs dragged out onto the conversation area
* (or floated from the tab context menu). They live in the panel host like
* the panels (viewport coordinates, immune to desktop-shell transforms) but
* are independent of panel state — a window stays up while panels collapse.
* The floats array's order is the stacking order; the content reuses the
* regular tab renderer, so every tab type floats unchanged. Renders a
* fragment so the DOM is exactly the floats followed by the drag-out hint.
*/
export function FreeWindowLayer(props: {
floats: readonly FloatWindow[]
hint: FloatDropHint | null
renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode
getTabIcon: (tab: SidebarTab) => ReactNode
store: SidebarStore
ctx: Context
sessionId: string
cwd: string | undefined
}) {
const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props
return (
<>
{floats.map(float => (
<FreeWindow
key={float.id}
float={float}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
getTabIcon={getTabIcon}
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
/>
))}
{/*
The drag-out hint: while a tab drag hovers the conversation column,
a dashed overlay marks the drop zone there (pointer-transparent — it
must not disturb the drag it describes).
*/}
{hint !== null && (
<div
className={css.floatDropHint}
style={{ left: hint.left, top: hint.top, width: hint.width, height: hint.height }}
>
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
</div>
)}
</>
)
}
+21 -17
View File
@@ -1,13 +1,15 @@
/**
* Center-column tracking (extracted from Sidebar.tsx, behavior identical):
* the bottom panel spans ONLY the app shell's center column ("squeezes the
* agent output area") — it starts at the app sidebar's right edge and ends
* at the details column's left edge (the details column sits between the
* center and the right panel). Measured directly from the AppFrame's center
* column DOM (the parent of the [data-slot="conversation"] wrapper —
* layout.css's center column) so the bottom panel tracks the column's real
* horizontal edges — including the animated AppFrame padding reservation
* while the right panel opens/closes; a frame that never appears keeps the
* the bottom workbench spans ONLY the app shell's center column ("squeezes
* the agent output area") — it starts at the app sidebar's right edge and
* ends at the details column's left edge. Measured directly from the
* AppFrame's center column DOM (center-column.ts anchors on the conversation
* slot host — `main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on
* alpha.1 — and walks up the transparent `display: contents` slot hosts to
* the column; layout.css's center column) so the workbench tracks the
* column's
* real horizontal edges, including the animated track moves while DSH's
* native right Sidebar opens/closes; a frame that never appears keeps the
* initial zero-size fallback (the panel renders at 0 width until measured).
* The rect lives in a REF (not state): the open/close transition resizes
* the center column EVERY frame for its duration, and reacting per frame
@@ -68,15 +70,17 @@ export function useCenterColumn(
let disposed = false
let observer: ResizeObserver | undefined
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
// [data-slot] containers: the conversation slot wrapper
// ([data-slot="conversation"]) sits directly inside the center column,
// so its parent IS that column — no hashed-class or positional
// dependency (layout.css uses the same anchor). The shell swaps the
// boot page for the AppFrame only AFTER boot settles, so the first
// query may miss it. Never give up: watch #root's subtree (the swap and
// HMR re-renders mutate it) and re-run this locator — querying once and
// bailing would strand the panel at the zero-size fallback forever
// (observed: a 1px sliver at the viewport's left edge).
// [data-slot] containers: the conversation slot host (key
// `main.conversation` on 0.1.5-alpha.2+, `conversation` on alpha.1)
// sits inside the center column behind transparent `display: contents`
// slot hosts, so the resolver walks up to the first ancestor that
// renders a box — no hashed-class or positional dependency
// (layout.css uses the same anchor). The shell swaps the boot page for
// the AppFrame only AFTER boot settles, so the first query may miss it.
// Never give up: watch #root's subtree (the swap and HMR re-renders
// mutate it) and re-run this locator — querying once and bailing would
// strand the panel at the zero-size fallback forever (observed: a 1px
// sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
// Hot path (#403): streaming output mutates #root at token cadence.
+4 -21
View File
@@ -7,8 +7,7 @@
*/
import { useEffect, useRef } from 'react'
import type { Context, SidebarSessionList } from '../../context-types.ts'
import { firstLeaf, reconcileAgentTerminals, togglePanel, type SidebarStore } from '../state.ts'
import { isNarrowWidth } from '../breakpoints.ts'
import { reconcileAgentTerminals, type SidebarStore } from '../state.ts'
import { detectNewDirectSubagent } from '../subagent-detect.ts'
import { detectNewJob } from '../subagent-jobs.ts'
import { t } from '../locales.ts'
@@ -197,17 +196,7 @@ export function useHostFeeds(feeds: {
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
// Read the viewport when the delayed activation fires: a resize while
// the debounce is armed must not let background activity force the
// narrow full-screen drawer open over the chat.
if (!isNarrowWidth(window.innerWidth)) {
store.reduce(s => s.panelOpen ? s : togglePanel(s))
}
// Choose the right panel as the landing pane for a newly created Tasks
// tab. Single-instance dedupe still activates an existing pane tab in
// place or raises an existing free window.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, AUTO_OPEN_DEBOUNCE_MS)
autoOpenPendingRef.current = { baseline, timer }
}, [sessionList, sessionId, store, ctx])
@@ -237,11 +226,7 @@ export function useHostFeeds(feeds: {
if (!detectNewJob(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenJobs) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
if (!isNarrowWidth(window.innerWidth)) {
store.reduce(s => s.panelOpen ? s : togglePanel(s))
}
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, [sessionList, sessionId, store, ctx])
/**
@@ -260,9 +245,7 @@ export function useHostFeeds(feeds: {
const pending = subagentJumpRef.current
if (pending === undefined || sessionId !== pending) return
subagentJumpRef.current = undefined
store.reduce(s => s.panelOpen ? s : togglePanel(s))
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, [sessionId, store, ctx])
return { subagentJumpRef }
+10 -16
View File
@@ -1,13 +1,13 @@
/**
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
* leaf of the right panel's split tree, and the workbench actions are
* leaf of the bottom workbench's split tree, and the workbench actions are
* wrapped so virtual ids route back to the HOME session. The shell only
* consumes the augmented tree and the wrapped actions.
*/
import { useMemo, useState } from 'react'
import {
agentUuidOf, closeFloatByTab, closeTab, isAgentTabId, leafWithTab, setTabPin,
agentUuidOf, closeTab, isAgentTabId, leafWithTab, setTabPin,
type SidebarSnapshot, type SidebarStore, type SplitNode,
} from '../state.ts'
import {
@@ -29,8 +29,8 @@ export function usePinnedTabs(input: {
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
* inject as VIRTUAL tabs into the first leaf of the right panel's split
* tree. The virtual tabs have unique ids (prefixed with the home session)
* inject as VIRTUAL tabs into the first leaf of the bottom workbench's
* split tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
@@ -65,11 +65,11 @@ export function usePinnedTabs(input: {
[pinnedEntries],
)
/** The right panel's split tree with pinned virtual tabs injected into the
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
* overridden so the pinned tab's content is visible. */
/** The bottom workbench's split tree with pinned virtual tabs injected
* into the first leaf. When `activePinnedTabId` is set, that leaf's
* `active` is overridden so the pinned tab's content is visible. */
const augmentedTree = useMemo(
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
() => state === undefined ? undefined : injectPinnedIntoTree(state.bottomSplits, pinnedVirtualTabs, activePinnedTabId),
[state, pinnedVirtualTabs, activePinnedTabId],
)
@@ -89,10 +89,8 @@ export function usePinnedTabs(input: {
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
store.reduceFor(homeSessionId, s => {
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
return s
const leaf = leafWithTab(s.bottomSplits, originalId)
return leaf === undefined ? s : closeTab(s, leaf.id, originalId)
})
if (isAgentTabId(originalId)) {
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
@@ -131,10 +129,6 @@ export function usePinnedTabs(input: {
if (isPinnedVirtualId(payload.tabId)) return
actions.moveTabToEdge(payload, toPane, zone)
},
floatTab: (tabId) => {
if (isPinnedVirtualId(tabId)) return
actions.floatTab(tabId)
},
pinTab: (tabId, scope) => {
if (isPinnedVirtualId(tabId)) {
if (scope !== null) return