#1 GitView: hide/disable 'open in editor' for external linked worktrees
(path outside session workspace is rejected by the fence); add
isWithinWorkspace client helper for the gate.
#2 DiffTab: untracked diff read resolves against diff.repoRoot (child-repo
paths are repo-root-relative, not cwd-relative); fs.read route threads
selectedRepoOf into resolveGitPath so the host resolves the right base.
#3 GitView: chooseRepo handler mirrors chooseWorktree — clears status/
history/log and bumps generation before async refresh, preventing stale
rows from acting on the newly selected repository.
#4 git.ts: repoRoots/repoRoot compare roots via pathIdentity() (platform-
aware) instead of raw includes(), fixing win32 forward/backslash
mismatch that silently fell back to roots[0].
#5 markdown-images: mask fenced code blocks and inline code spans before
rewriting; only rewrite reference definitions whose label is actually
referenced by image syntax (not plain links). +3 tests.
#6 package.json: downgrade unrun ^0.3.1 → ^0.2.39 (0.3.x requires Node
^22.13, incompatible with declared engines >=20).
#7 market-manifest.spec: shell:true on win32 so execFileSync resolves the
pnpm .cmd shim.
#8 smoke.spec: build html-route URL via encodeHtmlUrl() (production
encoder) so the Windows drive separator is correct.
#9 index.ts git.worktrees route: thread selectedRepoOf so a workspace
container's selected child repo's linked worktrees appear.
* feat(editor): file tree 'open with' submenu (external editors, pin, SSH-remote, custom editors)
The file tree right-click menu gains an 'Open with >' submenu with the
resource manager (reveal in file manager), VS Code, Cursor and Zed, plus
user-defined custom editors (name + URL template + VSCode-family flag).
Each submenu row carries a pushpin that promotes the target to a direct
top-level menu item.
A configurable SSH host (settings: Files card gear) switches VSCode-family
openers to <scheme>://vscode-remote/ssh-remote+<host>/<path> and hides
host-local targets (file manager / Zed / non-VSCode custom editors).
External opens run through the new fenced /sidebar/api/open.external route
which spawns the platform opener with an argv array (no shell); the client
never navigates raw scheme URLs itself. The settings seam's render panel now
appends after the declarative rows instead of replacing them.
* feat(editor): polish open-with menu UI — brand icons, pushpin, chevron, row alignment
- Submenu rows: the custom label is now a block-level flex row filling the
primitives itemLabel, so the pushpin docks at the row's far right (matching
the trailing check of every other menu); menu row icons normalized to 16px
(DSH native menus use 16, ours were 14).
- The submenu parent row gains a right-aligned chevron (the primitives Menu
renders no arrow for submenu parents).
- Icons via react-icons: codicons VscPin/VscPinned pushpins (replacing the
hand-drawn pin), VscFile/VscFolder/VscFolderOpened tree glyphs (replacing
the </> file mark), VscLinkExternal for the open-with parent and the
browser tab's open-external button (globally), SiCursor/SiZedindustries
brand silhouettes; the VS Code mark is inlined from simple-icons@11.0.0
(CC0) as IconVscode16 since later simple-icons releases dropped it. All
monochrome currentColor to follow the active skin.
- tsdown: pin react-icons si/vsc to their ESM entries via resolve.alias —
the package's exports map lists require before import, so the shared
conditionNames picked the unshakeable CJS entry and bloated the core
bundle by ~6.4 MB; tree-shaken it now adds only ~12 KB.
Switching from session A to B unmounted A's TerminalView, whose socket
close the host treated as a transient drop and started the 30s reconnect-
grace countdown — leaving the user in B for >30s killed A's shell, so
switching back respawned a fresh one instead of reattaching.
Add a third control frame {type:'park'} distinct from refresh/crash
drops: the client sends it on unmount when the tab is still open in its
session's state but the active session has moved on; the host marks the
pty as parked and skips the grace countdown. Switching back (open()
cancels the parked state) or explicitly closing the tab (close frame's
scheduleClose clears it) resume the normal lifecycle. Agent terminals
skip park — their lifetime is already indefinite on a bare drop.
The Git panel only ran git status against the session's own worktree,
so when DSH (or any coding agent) created a linked worktree via
'git worktree add' and modified files there, the sidebar's Git panel
stayed empty - no changed files, no diff, nothing to commit.
Enumerate all linked checkouts of the session repository and let the
user select which one the panel targets. Auto-select the dirty linked
worktree when the primary checkout is clean and exactly one linked
worktree has changes (the common DSH agent pattern). Poll status every
2s while the panel is visible so model-authored edits appear without
a manual refresh.
Security: resolveWorktree validates the client-supplied worktree path
against 'git worktree list' before using it as a git -C cwd. Uses -z
porcelain framing, which disables core.quotePath C-style quoting so
non-ASCII checkout paths round-trip losslessly.
* feat(explorer): upload files into the workspace from the file tree
- TreePanel header: upload-files / upload-folder buttons (folder picker keeps
the tree via webkitdirectory), with a one-line progress/result hint
- FileTree: drop files/folders onto the tree body (workspace root) or onto a
directory row (that directory); directory context menu gains 上传到此处
- api.ts: uploadFile posts base64 to /sidebar/upload (host writes it through
fs + shell decode; route provided for now by the local demo plugin)
- upload.ts: bytes->base64 (global-btoa-independent), FileList/DataTransfer
collection, sequential upload with per-file results; ~48MB per-file cap
- locales: zh/en strings; icons: IconUploadOutline16
* fix(explorer): reload tree after upload and auto-dismiss the status hint
The drop/context-menu upload path cleared the level cache but never
refetched (the refreshTick bump belongs to the button path), so the
visible tree emptied and only the persistent '已上传 N 个文件' hint
remained — the tree only came back after reopening the tab.
- FileTree.uploadInto now reloads root + expanded levels after clearing,
matching the refresh-tick path
- success status auto-dismisses after 3.5s (failures persist until the
next action) in both TreePanel and FileTree
* feat(host): add the /sidebar/upload route (raw bytes, workspace-safe)
The file tree's upload UI needs a host route; until now it pointed at a
locally-injected demo route. This is the real implementation:
- src/index.ts: register POST /sidebar/upload (exact route, same fence and
session-cwd resolution as every /sidebar route). Query params sessionId,
dir, relativePath; the raw request body is streamed into the workspace.
- src/fs-operations.ts: writeWorkspaceUpload streams chunks to a temp
sibling and renames into place, so a failed or oversized upload never
leaves a partial file. Upload dir must sit inside the session cwd, the
relativePath is sanitized (no '.', '..', empty or absolute segments) and
the target must stay inside both.
- src/config.ts: uploadLimit (default 128MiB; oversized -> 413 too-large,
a new wire code in src/wire.ts).
- client: send the File straight into the POST body (no base64 inflation)
and cap at the same 128MiB (upload.ts, api.ts).
Tests: tests/fs-operations.spec.ts covers write, nested dirs, traversal /
containment refusal, the size cap, and no-partial-file on failure.
(pnpm test: only pre-existing node-pty/git sandbox failures, identical on
clean main; typecheck and build pass.)
* fix(host): harden upload containment, temp-file atomicity and stream failure cleanup
- relativePath: absolute paths and empty segments are refused (400), not
silently collapsed or re-anchored (the spec's 'reject' semantics)
- temp file: uniquely named per request (randomUUID) so concurrent uploads
to the same target stay independent; crashed-process leftovers never
block later uploads (no pid reuse)
- write stream: permanent 'error' listener + error checks after every await
so a failing disk rejects the operation instead of crashing the host or
hanging the drain wait; the temp file is removed after the stream fully
closes (Windows file locks), never leaving a partial file
- docstring now states the containment is lexical (symlink trust model)
* fix(explorer): thread upload error codes and cancellation through the client
- api.uploadFile/fetchUpload accept an AbortSignal (long-op convention);
an aborted request rejects with the DOMException as-is instead of being
wrapped into a 'network' error
- UploadResult carries the wire error code; the client pre-check now emits
the same 'too-large' code as the host, and summarizeResults localizes
'too-large' instead of showing the raw English 413 message
- uploadToDir stops the queue on abort: completed files stay, in-flight
request is cancelled, nothing after is started
- uploadTooLarge copy no longer hardcodes a byte number (the host limit is
configurable); MAX_UPLOAD_BYTES is documented as a mirror, not authority
- relativePathOf rejects absolute paths (isAbsolutePath) instead of
stripping leading slashes, matching the host's 400 semantics
- progress text shared via uploadHintText for the hint and the overlay;
success-hint duration is the exported UPLOAD_HINT_MS
* feat(explorer): full-window upload progress with cancel and VSCode-style drop targets
- UploadOverlay: blurred scrim + card over the file window (Modal token
recipe: bg-mask-1 + mask blur, bg-layer-2, border-inverted, shadow-lv3),
file-level progress bar, cancel button and Esc; scrim clicks do not abort
- TreePanel owns the upload session: header pickers, tree drag-drop and the
context-menu picker funnel through startUpload; one session at a time
(buttons disabled while busy, drops guarded); on settle the tree refreshes
and the status hint shows success (3.5s, transient), failure or cancel
(error color, stays)
- FileTree: drops on directory rows target that directory, drops on FILE
rows target their parent (VSCode semantics, no more accidental root
uploads), root row and body drop into the workspace root; drag-over
highlights the target row (explorerRowDropTarget) plus the body outline
and a hint pill (uploadDropHint / uploadTo) — raw-color inline outline
replaced with the repo's dashed-accent classes
- upload status hint uses editorSearchHint + editorError like the search
status; duplicated upload flow removed from FileTree (single caller)
* test: register the upload route in the smoke inventory
The new /sidebar/upload route joined the fenced route list, so the mount
assertion must expect it (the PR's CI gate was red).
* chore: bump to 0.14.1
Rebased onto main and cut a release version for the upload feature:
package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION stay in sync
(test-guarded), README changelogs get the v0.14.1 entry.
* feat(explorer): keep the drop hint above DSH's page overlay and suppress its intake over the tree
DSH's whole-page image-drop intake (ui-conversation InputBar) listens on
document in the bubble phase: any 'Files' drag entering the page shows its
full-screen overlay (z-1000) and a drop anywhere adds images to the chat.
Our tree handled dragover/drop but not dragenter, so the overlay flashed
over the file window and drops outside the tree rows could leak to the
chat.
- TreePanel root now swallows dragenter/dragover/drop (preventDefault +
stopPropagation) before the events reach document: the file window is an
upload target, not an image target. Row/body handlers still fire first
(bubble order) for their own targets; the root is the backstop for the
search row, gaps and the search-results view.
- The drop hint pill is portaled to document.body at z-index 1001 — above
DSH's overlay (z-1000) — so when a drag enters from outside the tree and
DSH's mask lingers until dragend, our 'Drop files/folders here' /
'Upload into {dir}' hint stays readable on top (transient decoration,
pointer-events none; the drop still lands on the tree).
* feat(explorer): zone-split the drop overlays and fix the mask flicker over the sidebar
Sidebar.tsx's panel host now swallows the whole file-drag quartet
(enter/over/leave/drop, gated on the 'Files' type): DSH's document-level
intake keeps an enter/leave depth counter, and the leaves that escaped
the tree unbalanced it — that was the native full-screen mask flickering
over the sidebar. The region is now a black hole to that listener, so the
conversation column keeps DSH's native overlay and image intake while the
sidebar presents its own upload surface (drops over it never add images
to the chat).
The tree's drop affordance is now a pointer-inert frame portaled to
document.body at z-1001, anchored to the explorer body's viewport rect:
it replaces DSH's mask over the sidebar instead of competing with it, and
the hint pill docks at the bottom edge so the rows — the actual drop
targets — stay visible and aimable instead of sitting behind a centered
pill. Inside the tree, enter/leave depth counting (InputBar's own
pattern) replaces the relatedTarget check, which is unreliable for drag
events and flickered the row highlight on icon/label transitions; the row
target is now set authoritatively by the continuously-firing dragover.
dragleave handlers on rows are gone.
Polish: capsule hint pill with the upload icon and a float shadow, the
progress overlay fades in (the repo's dsh-row-in motion), its title gains
the icon, and progress numbers render tabular. tests/file-tree-drop.spec
pins the depth-counter behavior (child transitions keep the zone) and the
parent-directory drop semantics.
* feat(explorer): dim the whole viewport behind the tree's drop zone
The bottom-docked pill sat below the fold of attention; the zone frame
alone also failed to teach the left/right split — a drag over the sidebar
gave no hint that the conversation column still accepts drops (into the
chat, natively). The zone frame now carries the full-viewport dim itself
(a 200vmax box-shadow spread in DSH's own mask-drop token): everything
outside the tree's rect fades, the tree stays clear and aimable, and a
muted invitation centered in the space left of the zone (skipped under
200px) names the chat as the image target. The pill moves to the TOP edge
of the zone, right under the search row — the first thing the eye meets.
* feat(explorer): colorful drop heroes for both zones
The overlay is this flow's one brand moment, so it gets the color the
rest of the UI never does: hand-drawn SVG illustrations in the palette of
DSH's own native drop art (#3964FE/#679EFE/#9CE5ED) so the two zones read
as one family. The upload zone docks a hero at its top edge — a tray
illustration over the hint pill, stacked so the rows below stay aimable;
the chat zone's invitation becomes a tilted photo-cards card over its
label.
* feat(explorer): traverse dropped folders instead of reading dataTransfer.files
A dropped OS folder never surfaces in dataTransfer.files (that flat list
carries files only), so folder drops were silent no-ops while the UI
advertised 'drop files/folders'. uploadItemsFromDrop now captures entry
handles via webkitGetAsEntry — synchronously, since the dataTransfer enters
protected mode once the drop dispatch ends, while the captured handles stay
readable — then drains each directory's readEntries batches recursively,
prefixing every descendant's relative path. Entry-derived paths go through
the same sanitizer as picker paths; an entry that fails to read is skipped
rather than failing the whole drop, and the flat file list remains the
fallback where the entry API is unavailable. The FileTree drop handlers
ride the resulting promise.
---------
Co-authored-by: dsh local <dsh@local>
Co-authored-by: Menghuan1918 <menghuan2003@outlook.com>
Reads the aionui-panel settings namespace's rightPanel provider choice: while
it resolves to 'aionui-panel', the whole sidebar does not mount (host
settings.get reports externalDisable, the client mount gate skips mounting
and the link/open-path/turn-tail takeovers step aside). Live via the
settings/document-updated push (best effort; deployments without the
'remote' service fall back to boot-time evaluation). No new user-facing
setting — the aionui panel card remains the single source of truth.
* feat(editor): merged explorer mode — path input, docked file tree, global name search
New editorExplorer pref (default on, editor card gear): the editor tab gains
a path-input header and a toggleable right-docked tree panel (per-tab state
in tab.meta.treeOpen; file tabs default closed, the seeded path-less home
tab defaults open). Fresh sessions seed that empty editor tab instead of
the explorer tab when merged mode is on; the standalone explorer tab stays
in the + menu unchanged.
- Extract FileTree.tsx from ExplorerView (shared by both hosts)
- Host fs.search route: recursive case-insensitive name search rooted at
the session cwd, budget-capped (200 matches / 100k visited), skips .git,
never descends symlink dirs
- EditorHost reads the pref reactively (boolean snapshot) and keeps the
pre-merge layout verbatim when off
* feat(settings): select row type for declarative settings; editorExplorer as iconed select
- SidebarSettingToggle gains type 'select' with options {value,title,desc,icon}
and multi (default false; stores the picked values as an array in options
order). Any option carrying an icon renders big-icon option cards in the
dropdown and the icon in the closed anchor; icon-less rows are one-line text.
- SelectRow in SideCardSection rides the primitives Menu (portal); single-pick
commits and closes, multi toggles and stays open. Wired for both host-prefs
toggles and plugin-owned pluginToggles via the new onSelectValue seam.
- The editorExplorer merged-mode pref is now an iconed select (merged vs
separate) instead of a switch; the stored value stays boolean.
- Advertise the capability as 'settingSelect' in SIDEBAR_FEATURES.
* feat(editor): unify explorer into the editor surface, resizable tree dock, hoisted toolbar
- One tree implementation, two presentations: the shared TreePanel (global
name search + refresh + FileTree) docks as the editor tab's right panel
and — in merged mode — fills the explorer tab's window; the pref-off
explorer keeps its legacy header shell.
- The docked panel drag-resizes from its left edge (160-480px clamp),
persisted as tab.meta.treeWidth alongside treeOpen.
- The text editor's preview/edit/save toolbar moves into the path-input
header row: FileViewerProps gains internal toolbar fields ('host' mode
skips the viewer's own row, reports EditorToolbarState and registers
EditorToolbarControls); non-merged mode is byte-identical to before.
* feat(editor): single files window with in-place switching; explorer descriptor removed
- editor tab replaces explorer on the + menu (order 10, Files); persisted
explorer tabs migrate to the editor home tab via sanitizeState
- editorExplorer now only controls open behavior: merged = in-place
updateTab, independent = openSidebarFile; both modes share the merged
chrome (path input + hoisted preview/edit/save toolbar + tree toggle)
- file tree context menu gains 'open in new tab' / 'open to the side'
(mintTabId avoids the id safety net; insertLeafAt splits right)
- tree dock resize uses pointer capture (touch-action none, 6px handle)
- defaults: openByDefault off, sidebar width 35%
- file tree clips horizontal overflow (no sideways scroll)
- e2e: mount lane arms the editor-chunk wait before goto and clicks the
tree row's left edge (hover @-reference button sits mid-row); drag lane
expands the panel first (collapsed push writes 0px), waits for the
slide-in to settle, and picks the leftmost col-resize strip; 'Files'
title assertions require draggable to skip the bottom panel's welcome
cards
* feat(editor): split mode turns the path-less window into the standalone explorer
In independent (split) mode a path-less files window renders ONLY the
tree panel (search + FileTree, full-window form) — no path input, no
hoisted toolbar, no tree toggle. File clicks open per-path editor tabs
(full chrome, tree docked closed by default) through openSidebarFile,
unchanged. Merged mode is untouched (chrome + docked tree, in-place
switching). TreePanel's previously reserved 'full' prop now has its
consumer.
- Sidebar.tsx: useLayoutEffect for the layout-push variables so cleanup+setup
land in the same pre-paint frame (no full-width flash on drag release);
applyDrag writes the width only while the panel is open (a closed right
panel no longer squeezes the conversation when dragging the bottom height);
flush the final drag frame before stopDragScheduling on all three handles;
sync centerRect.right on width/corner release so the bottom panel does not
jump.
- index.ts/pty-manager.ts: close a session's UI terminals immediately on
session/disposed (they used to linger until reconnectGraceMs expired).
- sidebar.module.css: no highlight on the resize handles while dragging
(local preference; the corner hover hint stays).
* feat: configurable terminal shell with Windows pwsh auto-probe
Windows previously hardcoded powershell.exe (5.1) in defaultShell(), ignoring PowerShell 7 installs. Resolve the shell in priority order: explicit shell config -> DSH_SIDEBAR_SHELL -> PATH/known-dir probe for pwsh.exe -> inbox 5.1 fallback. The same resolved shell feeds both the UI terminal tabs (PtyManager) and the model-facing terminal_* tools (AgentPtyRegistry).
defaultShell() now accepts injectable platform/env/exists options so the Windows chain is unit-testable on the ubuntu CI, which never executes win32 branches. The probe also checks ProgramW6432 for 32-bit Node, and machines without PowerShell 7 keep the 5.1 fallback.
Refs: #46
* fix: trim resolved SHELL and document ProgramW6432 in resolver contract
- POSIX SHELL is validated with trim() but was returned untrimmed; trailing whitespace could leak into the spawned executable path. Return the trimmed value like the explicit/config and Windows/env branches.
- ShellResolutionOptions.env comment now lists ProgramW6432, which the Windows probe prefers over ProgramFiles on 32-bit Node.
- Test pins the SHELL trimming behavior.
Add a side-card preference 'browserAllowedLoopback' (comma-separated
host or host:port entries) that lets the sidebar browser navigate to
explicitly trusted loopback addresses. Bare hosts (localhost,
127.0.0.1) match every port; host:port entries match exactly.
Allowlisted local addresses additionally get the 'allow-same-origin'
sandbox token in the iframe: local dev servers (Vite etc.) need a real
origin for their module/HMR/fetch pipeline and would otherwise render
blank in the opaque-origin sandbox. The page stays cross-origin to the
GUI and to every other site.
The server-side browser.probe route mirrors the same allowlist so
probed local servers are not rejected as 'local addresses are not
probed'.
Some servers (e.g. aliyun consoles) answer HEAD without the
X-Frame-Options / Content-Security-Policy headers that only their
GET response carries. The embeddability probe then wrongly reported
the site as embeddable, leaving the plain iframe to surface the
browser's misleading "refused to connect" error (the site actually
forbids framing via frame-ancestors).
Retry once as GET when both embed signals are absent (the body is
discarded — only headers matter), mirroring the existing 405/501
HEAD fallback. The client then shows the friendly embed-refusal
panel with an "open in browser" action instead of the misleading
connection error.
- Remove every box-shadow; panel uses the native sidebar fill surface
(--dsw-specific-sidebar-fill) with a hairline border, the collapse
control becomes the native 36px circular rail button, the active tab
uses the native interactive-bg-active fill, and rows/typography follow
the native 34px/14px session-row recipe.
- New 'Side card' settings section in the DSH Settings shell: open-by-
default for new conversations (boolean) and default width share as a
percent of the window (20-60). New sessions seed from these prefs;
existing per-session layouts stay untouched.
- The settings namespace is read/written through the plugin's own fenced
/sidebar routes calling the settings seam in-process: the DSH settings
RPC domain only serves allowlisted namespaces to configuration clients,
so third-party namespaces cannot use connection.api.settings (reads are
silently filtered, writes refused with 'not exposed'). The seam still
persists to the user settings document with revision-guarded writes.
- fs-tree joins listed paths with the platform separator (Windows rows
previously leaked mixed separators)
- media route containment check goes through isWithin(): separator-
tolerant and case-insensitive on win32, so case-mismatched Windows
paths are not misclassified
- relativeTo compares case-insensitively (Windows / case-insensitive
macOS volumes) while keeping the caller's own casing in the output
- tests: platform-conditional path assertions, os.tmpdir() instead of
/tmp, isWithin/relativeTo/resolveSidebarPath win32 coverage
- state: side-store per conversation, injected as a prop instead of a module singleton
- host: schemastery Config (read/media/list/terminal limits) resolved from cordis config
- shape: types/exports/peerDependencies for the web profile + plugin-shape spec
- fix: split divider emits incremental deltas so drags track the cursor 1:1
- docs: README refresh
The close frame was sent on every unmount, so switching conversations (or
refreshing) killed the shell even though the tab was still open; coming
back spawned a fresh terminal. Now the unmount close frame only fires when
the tab is truly gone (checked against the per-session store state), so a
bare unmount hands the process to the host's reconnect grace and the next
connect reattaches the SAME shell with its transcript. Closing a tab
releases the quota immediately: the socket-open path keeps the close
frame, and a new pty.close API covers the socket-down (reconnect loop)
case that previously left a 30s zombie holding the quota.
The host also records the spawn cwd and respawns when a reconnect carries
a different authoritative cwd — the page-load hydrate race can attach the
real session cwd after the first connect fell back to the process cwd, and
a shell in the wrong directory must not linger.
- cwd resolution falls back (attached header → client summary cwd → process
cwd) across the JSON API, media route and terminal upgrade, so explorer/
git/terminal work from first paint instead of 'session ... has no working
directory' while the web client hydrates the session (refresh-only fix).
- terminal client threads the summary cwd into the WS URL and stops the
reconnect loop after three unreasoned failures, showing the close code
with a manual retry; a 1011+reason refusal already showed the reason.
- white screen: persisted state is structurally sanitized on load (malformed
shapes fall back to default) and the restored width is clamped to the
viewport; the layout margin is capped at the viewport; the client entry
wraps every lifecycle step in try/catch and pins a visible diagnostic bar
on load/mount/interception failure instead of failing silently.
- editor: textarea replaced with CodeMirror 6 — line wrapping, per-extension
syntax highlighting (js/ts/jsx/tsx, json, python, html, css, xml, yaml,
sql, java, c/c++, rust, go, php, shell, toml, nginx, dockerfile,
properties), one-dark theme, dirty dot + Ctrl/Cmd+S save kept.
- tests: +9 (cwd fallback chain over the API route, state sanitization,
language mapping); 36 pass, typecheck + build + vm materialize clean.
- Terminals were never destroyed when their tab closed (socket drop only
detached listeners), so the per-session quota (3) filled up and new
terminals failed to mount with 'terminal limit reached'. Now the client
sends a {type:'close'} control frame on tab teardown and the host releases
the pty immediately; a bare socket drop (refresh/switch) keeps the process
for a 30s grace period, and a reconnect within it cancels the pending
close. open() also respawns a process whose shell already exited instead
of reusing a dead handle (input sink).
- The sidebar portal renders inside an error boundary: a render failure now
shows a dismissible error strip with the message (console.error with the
component stack) instead of a silent blank panel.
- Tests: +2 real-pty smoke specs (close releases quota + respawn after
exit; reconnect cancels pending close).
- Root cause of the endless 'disconnected, reconnecting' banner: pnpm
strips the executable bit from node-pty's prebuilt spawn-helper, so every
spawn failed with posix_spawnp failed and the client retried forever.
ensureSpawnHelper() restores 0755 at plugin activation (mirrors
dsh-pty-local's postinstall; works for link installs too). Verified
against the live GUI: terminal now connects and echoes.
- TerminalView stops auto-reconnecting on a server refusal (close 1011 with
a reason): shows the reason plus a manual retry instead of spinning.
- defaultShell() also guards an empty SHELL.
- New sessions open at half the viewport width; width clamping now allows
filling the window, and a header fullscreen button expands the sidebar to
the full viewport (toggle restores half).
- Middle-click on a tab closes it (auxclick button 1, autoscroll
suppressed).
- Tests: +2 (shell fallback, spawn-helper exec bit idempotent).
Host half (src/index.ts): /sidebar/api JSON API (session cwd, fs tree/read/
write, git status/diff/stage/commit/branch/log/show), /sidebar/file media
route, /sidebar/ws/terminal WebSocket over node-pty; routes share the /api
trust fence (Host-header loopback or connection.trustedHosts).
Client half (src/client): portal-mounted right panel with a per-session
split-pane workbench (recursive row/col splits, draggable dividers, tab
drag-move), tab bar with max-width 160px + plus-menu (explorer/git/terminal
multi-open rules), lazy file explorer rooted at the session cwd, editable
text editor (dirty dot, Ctrl/Cmd+S), image + Markdown preview, xterm
terminal with reconnect, and a VSCode-style git panel. Per-session state
persists to localStorage (dsh-sidebar:v1:<sessionId>). Intercepts the chat
turn-tail produced-files row (priority -1 chain entry) to open files in the
sidebar. UI styled with the dsh --dsw-* theme tokens and ui-primitives.
Build: tsdown emits the host lib plus the __ModuleLoader__ client bundle
(externals = module table words; xterm/css inlined). Tests: 19 unit + smoke
specs (pure state/tree/git parsers + real git/fs integration).