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:
Menghuan1918
2026-09-21 20:35:11 +08:00
parent d60899841d
commit 5912274e41
79 changed files with 381 additions and 9464 deletions
+3 -8
View File
@@ -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[] {
+14 -69
View File
@@ -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',
-147
View File
@@ -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 }
}