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.
256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
/**
|
|
* Host-feed subscriptions (extracted from Sidebar.tsx, behavior identical):
|
|
* 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 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-opens push loop
|
|
* (the loop restarts on session switch). */
|
|
const FAILURE_LIMIT = 3
|
|
|
|
/**
|
|
* Subagent auto-open debounce (ms). The host delivers a new child's origin
|
|
* and its title in SEPARATE frames: a Side Chat thread's first visible
|
|
* frame still shows a fallback title (no 'Side: ' prefix), so an immediate
|
|
* 0→N decision mistakes it for a genuine subagent and pops the task page.
|
|
* The trigger therefore re-evaluates against the live snapshot once the
|
|
* title frame has had time to land.
|
|
*/
|
|
const AUTO_OPEN_DEBOUNCE_MS = 500
|
|
|
|
/**
|
|
* The native column's public face, as this module reaches it. Both actions
|
|
* act on the session whose surface is MOUNTED (the controller reads its
|
|
* binding), which is why the park below is gated on the target session being
|
|
* the on-screen one.
|
|
*/
|
|
interface NativeColumnFace {
|
|
isExpanded?: () => boolean
|
|
toggleExpanded?: () => void
|
|
}
|
|
|
|
/**
|
|
* Activate the Tasks page (the `subagent` tab type) in DSH's native right
|
|
* Sidebar — the landing the two auto-open switches promise: the right column
|
|
* IS the sidebar the user means, while the plugin's own bottom workbench only
|
|
* serves its own flows (its `+` menu and the first-expansion terminal). Every
|
|
* other open in this plugin already lands there, so a `target: 'bottom'` here
|
|
* puts the Tasks page in the bottom bar instead.
|
|
*
|
|
* A background activation must not take over a narrow viewport: below 768px
|
|
* the host draws that column FULLSCREEN, so the tab is placed and the column
|
|
* is put back to collapsed — parked, waiting behind the expand control,
|
|
* instead of covering the chat. The host expands on every open
|
|
* (`openContent` plans `setExpanded(true)`) and offers no option to suppress
|
|
* it, hence the read-then-restore pair: both commits land in one React batch,
|
|
* so the column never renders the intermediate state. The viewport is read
|
|
* when the activation FIRES (the debounced subagent trigger included), so a
|
|
* resize while arming is honoured.
|
|
*
|
|
* @param ctx - the client context (`ctx.sidebarRight` + `ctx.betterSidebar`).
|
|
* @param sessionId - the session the feed reports the activity for.
|
|
* @param options.background - `true` for background activity (parks on narrow
|
|
* viewports); `false` for the explicit topology jump-back, which is a user
|
|
* gesture and always leaves the column as the host expanded it.
|
|
*/
|
|
function activateTasksPage(ctx: Context, sessionId: string, options: { background: boolean }): void {
|
|
const column = ctx.get('sidebarRight') as unknown as NativeColumnFace | undefined
|
|
const park = options.background
|
|
// The face acts on the MOUNTED session: parking is only meaningful (and
|
|
// only safe) when the activation targets the one on screen.
|
|
&& ctx.sessions.list.getSnapshot().current === sessionId
|
|
&& isNarrowWidth(window.innerWidth)
|
|
// Only a column the user had COLLAPSED is put back: an expanded one is in
|
|
// use, and closing it under the user would be worse than the takeover.
|
|
&& column?.isExpanded?.() === false
|
|
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
|
if (park) column?.toggleExpanded?.()
|
|
}
|
|
|
|
export function useHostFeeds(feeds: {
|
|
ctx: Context
|
|
store: SidebarStore
|
|
sessionList: SidebarSessionList
|
|
sessionId: string | undefined
|
|
}): { subagentJumpRef: { current: string | undefined } } {
|
|
const { ctx, store, sessionList, sessionId } = feeds
|
|
|
|
/**
|
|
* 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 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
|
|
* queued while no view was connected.
|
|
* While the side-card setting is off, pushes are ignored as a defensive
|
|
* gate — the host already unregisters the tool and drains the queue.
|
|
*/
|
|
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-opens', 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 request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
|
|
if (request === null || typeof request !== 'object') return
|
|
if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
|
|
if (typeof request.target !== 'string' || request.target === '') return
|
|
if (store.getPrefs().agentOpenTools !== true) return
|
|
const scope = { sessionId }
|
|
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
|
|
if (request.kind === 'url') {
|
|
// 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',
|
|
title,
|
|
path: request.target,
|
|
id: `editor:${request.target}`,
|
|
meta: { dir: true },
|
|
}, scope)
|
|
} else {
|
|
ctx.get('betterSidebar')?.openFile(scope, request.target, title)
|
|
}
|
|
} catch {
|
|
// Malformed push: ignore (the next push carries its own request).
|
|
}
|
|
}
|
|
socket.onclose = () => {
|
|
if (closed) return
|
|
failures += 1
|
|
if (failures >= FAILURE_LIMIT) {
|
|
console.error('[dsh-better-sidebar] agent-opens 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])
|
|
|
|
/**
|
|
* Subagent auto-activation: the moment the current conversation spawns its
|
|
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
|
* open" pref is on, and the Tasks tab type is enabled in settings, activate
|
|
* the Tasks page in DSH's native right Sidebar. Single-instance semantics
|
|
* focus an existing tab in place; a new tab lands in that column and is
|
|
* never duplicated. Landing it EXPANDS the column on wide viewports, while
|
|
* a narrow viewport (where the host draws that column fullscreen) parks the
|
|
* tab instead of taking the screen over — see {@link activateTasksPage}.
|
|
* Switching to a session that already has subagents never triggers — its
|
|
* baseline starts at the current count — so a deliberate layout is never
|
|
* fought.
|
|
*
|
|
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
|
|
* is also a subagent-origin child, and its 'Side: ' title lands one frame
|
|
* after its origin — an immediate check would misread that first frame as
|
|
* a new subagent and pop this page on every thread creation. The timer
|
|
* re-evaluates the ORIGINAL baseline against the live snapshot; by then
|
|
* the title filter (isSideThreadSummary) sees the settled label.
|
|
*/
|
|
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
|
const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
|
|
useEffect(() => {
|
|
const prev = listBaselineRef.current
|
|
listBaselineRef.current = sessionList
|
|
if (sessionId === undefined || prev === undefined) return
|
|
if (autoOpenPendingRef.current !== null) return
|
|
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
|
const baseline = prev
|
|
const timer = window.setTimeout(() => {
|
|
autoOpenPendingRef.current = null
|
|
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
|
if (!store.getPrefs().autoOpenSubagent) return
|
|
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
|
activateTasksPage(ctx, sessionId, { background: true })
|
|
}, AUTO_OPEN_DEBOUNCE_MS)
|
|
autoOpenPendingRef.current = { baseline, timer }
|
|
}, [sessionList, sessionId, store, ctx])
|
|
|
|
// A session switch (or unmount) voids any armed auto-open recheck.
|
|
useEffect(() => () => {
|
|
const pending = autoOpenPendingRef.current
|
|
if (pending !== null) window.clearTimeout(pending.timer)
|
|
autoOpenPendingRef.current = null
|
|
}, [sessionId])
|
|
|
|
/**
|
|
* Job auto-activation: the moment a NEW background job appears for the
|
|
* current conversation (a job id the previous snapshot lacked), the
|
|
* auto-open pref is on, and the Tasks tab type is enabled, activate the Tasks
|
|
* page that contains the background-jobs section — in DSH's native right
|
|
* Sidebar, expanded on wide viewports and parked on narrow ones exactly like
|
|
* the subagent trigger ({@link activateTasksPage}). Unlike that trigger
|
|
* (0 → N only), ANY new job id triggers: the agent may start several jobs in
|
|
* one session, and each should surface. A fresh page load never triggers —
|
|
* its baseline starts at the current snapshot.
|
|
*/
|
|
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
|
useEffect(() => {
|
|
const prev = jobBaselineRef.current
|
|
jobBaselineRef.current = sessionList
|
|
if (sessionId === undefined || prev === undefined) return
|
|
if (!detectNewJob(prev, sessionList, sessionId)) return
|
|
if (!store.getPrefs().autoOpenJobs) return
|
|
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
|
activateTasksPage(ctx, sessionId, { background: true })
|
|
}, [sessionList, sessionId, store, ctx])
|
|
|
|
/**
|
|
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
|
* the official `openSubagent`, which switches the sidebar to that child
|
|
* session's OWN layout (a fresh child session defaults to the explorer).
|
|
* The README contract says the Subagent page must stay open with the jumped
|
|
* node highlighted — so once the current session becomes the recorded jump
|
|
* target, re-open the Tasks page on top of the child's layout, in DSH's
|
|
* native right Sidebar. This one is an explicit user gesture, so it always
|
|
* takes the host's expansion (no narrow-viewport parking). Only this node
|
|
* click arms the flag, so switching to a subagent session by any other means
|
|
* keeps that session's own layout untouched.
|
|
*/
|
|
const subagentJumpRef = useRef<string | undefined>(undefined)
|
|
useEffect(() => {
|
|
const pending = subagentJumpRef.current
|
|
if (pending === undefined || sessionId !== pending) return
|
|
subagentJumpRef.current = undefined
|
|
activateTasksPage(ctx, sessionId, { background: false })
|
|
}, [sessionId, store, ctx])
|
|
|
|
return { subagentJumpRef }
|
|
}
|