Merge origin/main into feat/wait-banner: resolve floats retirement + openTabInBottomPane
This commit is contained in:
@@ -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} />))
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user