feat: yield the terminal and browser to DSH 0.1.6's own sidebar types
DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser of its own. Both were things this plugin had built from scratch, so keeping them meant two implementations of one surface plus a plugin that shadows a host type the product is actively developing. Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the agent-owned registry, the lazy node-pty loader, the eight model-facing terminal_* tools, the xterm view and its font/link/scrollback helpers, and the cross-session 'pinned terminal' rail that existed only to keep a terminal alive across a session switch. The host's terminal kind becomes the only provider, which also retires the node-pty dependency, its pnpm allowBuilds entry, and the installer's -Repair mode (issue #140's whole class of 'node-pty failed to load' support). Model-facing capability note: the plugin used to inject terminal_* tools (off by default). Those are the model's only cross-call persistent terminal, and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by any shipped bundle, so removing them leaves the model with the one-shot bash/pwsh tools only. That is the accepted cost of not maintaining a second PTY surface; the README documents how to enable the upstream toolset. Browser: the plugin's browser tab type is dropped, so the host's own browser kind owns embedded pages. The DOM-level link takeover STAYS (it is the only thing that can route an https link to the system browser by protocol, which the host does not do) but now delegates to the host's browser tab via ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the two settings that only ever configured the plugin's own iframe (sandbox escape hatch, loopback allowlist) go with it — along with the host-side browser.probe route they fed. Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe (0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding) so the plugin's 'pick what opens here' surface reads as the same design as DSH's own. A theme.spec guard pins the geometry and the token-only paint — including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined nowhere, so their declarations are silently dropped upstream) never appear here. Also drops the dead state the removed features left behind (SidebarTab.pin, nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the pinned-tab memo key), re-points every test fixture that used them at surviving equivalents, and prunes the specs of the deleted features.
This commit is contained in:
@@ -33,22 +33,17 @@ interface TabContentProps extends TabContentMemoKey {
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
export const TabContent = memo(function TabContent(props: TabContentProps) {
|
||||
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
||||
const { tab, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
||||
const scope = { sessionId, cwd }
|
||||
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
||||
if (descriptor === undefined) {
|
||||
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
|
||||
}
|
||||
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
|
||||
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
|
||||
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
|
||||
// restores the real id at the component boundary.
|
||||
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
|
||||
return createElement(
|
||||
RenderBoundary,
|
||||
{ className: css.tabBoundaryError },
|
||||
createElement(descriptor.component, {
|
||||
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
|
||||
ctx, store, scope, tab, visible, expanded, revealed,
|
||||
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
||||
}),
|
||||
)
|
||||
@@ -56,7 +51,7 @@ export const TabContent = memo(function TabContent(props: TabContentProps) {
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
|
||||
* a disabled row instead of hiding the option.
|
||||
* Tabs the user disabled in the side card settings are filtered out
|
||||
* entirely — re-enabling them is the settings page's job. */
|
||||
export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* Host-feed subscriptions (extracted from Sidebar.tsx, behavior identical):
|
||||
* the WebSocket pushes (agent terminals, agent opens) and the session-list
|
||||
* driven auto-activation triggers (subagent, background jobs, topology
|
||||
* the WebSocket push (agent opens) and the session-list driven
|
||||
* auto-activation triggers (subagent, background jobs, topology
|
||||
* jump-back). All of it reacts to the host's live feeds for the CURRENT
|
||||
* session; the sidebar shell only consumes the returned jump-back ref.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Context, SidebarSessionList } from '../../context-types.ts'
|
||||
import { mirrorAgentWaits, reconcileAgentTerminals, type SidebarStore } from '../state.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import { isNarrowWidth } from '../breakpoints.ts'
|
||||
import { detectNewDirectSubagent } from '../subagent-detect.ts'
|
||||
import { detectNewJob } from '../subagent-jobs.ts'
|
||||
import { t } from '../locales.ts'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
||||
/** How many consecutive reconnect failures stop the agent-opens push loop
|
||||
* (the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
@@ -84,75 +84,13 @@ export function useHostFeeds(feeds: {
|
||||
}): { subagentJumpRef: { current: string | undefined } } {
|
||||
const { ctx, store, sessionList, sessionId } = feeds
|
||||
|
||||
/**
|
||||
* Agent terminals push: subscribe to the host's live list of agent-owned
|
||||
* terminals for this session (created by the model through the
|
||||
* `terminal_create` tool). The host pushes a JSON array on every
|
||||
* create / close / exit; the sidebar reconciles the list into tabs
|
||||
* (id `agent:<uuid>`, title from the agent). A disconnected socket
|
||||
* retries with a short backoff so a refresh or transient drop reattaches
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
* While the terminal tab type is disabled in settings, pushes add / remove
|
||||
* no tabs — but the authoritative wait map is STILL mirrored (see the
|
||||
* branch below); re-enabling makes the next push converge on both.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.search = new URLSearchParams({ sessionId }).toString()
|
||||
socket = new WebSocket(url.toString())
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') return
|
||||
try {
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }>
|
||||
if (!Array.isArray(list)) return
|
||||
store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
|
||||
// Terminal tabs are disabled: skip tab add/remove reconciliation,
|
||||
// but STILL mirror the authoritative wait map — a wait resolving
|
||||
// during the disabled window must clear its banner state, or a
|
||||
// re-enabled terminal keeps a stale banner until the next
|
||||
// unrelated push.
|
||||
? mirrorAgentWaits(s, list)
|
||||
: reconcileAgentTerminals(s, list))
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push will reconcile).
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (closed) return
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
|
||||
return
|
||||
}
|
||||
retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => { socket?.close() }
|
||||
}
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [sessionId, ctx, store])
|
||||
|
||||
/**
|
||||
* Agent opens push: subscribe to the host's `sidebar_open` requests for
|
||||
* this session (the model actively opens a file / folder / HTTP(S) page).
|
||||
* The host pushes one JSON request per open; the sidebar routes it to the
|
||||
* matching built-in tab: a file opens in the editor (per-path dedupe), a
|
||||
* folder opens a file window whose tree is rooted at the folder
|
||||
* (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
|
||||
* (`meta.dir`), and a URL opens in DSH's own browser tab type. A disconnected socket
|
||||
* retries with a short backoff (mirror of the agent-terminals loop): the
|
||||
* host queue keeps undelivered requests and replays them on the first
|
||||
* attach, so a refresh or a session switch lands the opens the model
|
||||
@@ -183,7 +121,14 @@ export function useHostFeeds(feeds: {
|
||||
const scope = { sessionId }
|
||||
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
|
||||
if (request.kind === 'url') {
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
|
||||
// DSH's own browser tab type (absent from the plugin's registry
|
||||
// since 0.20.0); an unregistered kind throws, so the agent-open
|
||||
// path degrades to doing nothing rather than breaking the push.
|
||||
try {
|
||||
ctx.get('sidebarRight')?.openTab('browser', { params: { url: request.target } })
|
||||
} catch {
|
||||
// Host without a browser tab type: nothing to open into.
|
||||
}
|
||||
} else if (request.kind === 'folder') {
|
||||
ctx.get('betterSidebar')?.openTab({
|
||||
type: 'editor',
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
|
||||
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
|
||||
* 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, closeTab, isAgentTabId, leafWithTab, setTabPin,
|
||||
type SidebarSnapshot, type SidebarStore, type SplitNode,
|
||||
} from '../state.ts'
|
||||
import {
|
||||
collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree,
|
||||
isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry,
|
||||
} from '../pinned.ts'
|
||||
import type { WorkbenchActions } from '../split-pane.tsx'
|
||||
import { api } from '../api.ts'
|
||||
|
||||
export function usePinnedTabs(input: {
|
||||
store: SidebarStore
|
||||
sessionId: string | undefined
|
||||
cwd: string | undefined
|
||||
snapshot: SidebarSnapshot
|
||||
actions: WorkbenchActions
|
||||
}): { augmentedTree: SplitNode | undefined; wrappedActions: WorkbenchActions } {
|
||||
const { store, sessionId, cwd, snapshot, actions } = input
|
||||
const state = snapshot.state
|
||||
|
||||
/**
|
||||
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
|
||||
* 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
|
||||
* the home session's PTY via WS, no session jump).
|
||||
*
|
||||
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
|
||||
* (which doesn't notify — targeted opens must not re-render the active
|
||||
* session). The `pinnedRevision` state bump forces the pinnedEntries
|
||||
* useMemo to recompute after such an action.
|
||||
*/
|
||||
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
|
||||
const [pinnedRevision, setPinnedRevision] = useState(0)
|
||||
|
||||
/**
|
||||
* Cross-session pinned-tab collection. Recomputed on every store notify,
|
||||
* session-list change, and pinned action (the revision bump covers
|
||||
* reduceFor updates that don't notify). Only tabs from OTHER sessions —
|
||||
* the viewer's own pinned tabs are already on its tab strip.
|
||||
*/
|
||||
const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
|
||||
if (sessionId === undefined) return []
|
||||
return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
|
||||
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
|
||||
// above): they recompute this memo for changes that carry no dep of their own.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [store, sessionId, cwd, snapshot, pinnedRevision])
|
||||
|
||||
/** Virtual SidebarTab objects for the pinned entries (stable references
|
||||
* via useMemo so TabContent's memo comparator holds). */
|
||||
const pinnedVirtualTabs = useMemo(
|
||||
() => pinnedEntries.map(createPinnedVirtualTab),
|
||||
[pinnedEntries],
|
||||
)
|
||||
|
||||
/** 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.bottomSplits, pinnedVirtualTabs, activePinnedTabId),
|
||||
[state, pinnedVirtualTabs, activePinnedTabId],
|
||||
)
|
||||
|
||||
/**
|
||||
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
|
||||
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
|
||||
* detected by the `pinned:` prefix and routed to the HOME session via
|
||||
* reduceFor (which doesn't notify — the revision bump is the local signal).
|
||||
*/
|
||||
const wrappedActions = useMemo<WorkbenchActions>(() => {
|
||||
if (pinnedVirtualTabs.length === 0) return actions
|
||||
const closePinnedInHome = (virtualId: string): void => {
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
|
||||
// The home cwd lives in the virtual tab's meta (snapshotted at pin
|
||||
// time) — pass it to ptyClose so the host resolves the PTY in the
|
||||
// correct workspace container (same scope the WS open used).
|
||||
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
|
||||
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
|
||||
store.reduceFor(homeSessionId, 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 */ })
|
||||
} else {
|
||||
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
|
||||
}
|
||||
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
}
|
||||
return {
|
||||
...actions,
|
||||
activateTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
setActivePinnedTabId(tabId)
|
||||
} else {
|
||||
setActivePinnedTabId(null)
|
||||
actions.activateTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
closeTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
closePinnedInHome(tabId)
|
||||
} else {
|
||||
actions.closeTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
moveTabBefore: (payload, toPane, beforeTabId) => {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
if (isPinnedVirtualId(beforeTabId)) {
|
||||
actions.moveTabToEdge(payload, toPane, 'center')
|
||||
} else {
|
||||
actions.moveTabBefore(payload, toPane, beforeTabId)
|
||||
}
|
||||
},
|
||||
moveTabToEdge: (payload, toPane, zone) => {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
actions.moveTabToEdge(payload, toPane, zone)
|
||||
},
|
||||
pinTab: (tabId, scope) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
if (scope !== null) return
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
|
||||
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
|
||||
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
} else {
|
||||
actions.pinTab?.(tabId, scope)
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
||||
|
||||
return { augmentedTree, wrappedActions }
|
||||
}
|
||||
Reference in New Issue
Block a user