omdsh-sidepanel
English | 中文
Two side panels for the DeepSeek Harness web GUI: a file explorer down the right edge, and a terminal along the bottom of the conversation. Both belong to Work, and in Chat mode neither exists.
What it adds
| Surface | Where it comes from |
|---|---|
| Two switches in the session header's utility row | conversation.session.header.utilities, with an understudy on shell.overlay holding the row's corner while the header is away |
| The right file panel, and the one file it previews | shell.overlay, plus a margin written onto #root so the column is beside the app rather than over it |
| The bottom terminal panel | The same overlay layer, plus a margin on the parent of [data-slot="conversation"], so only the conversation column is lifted |
Four routes under /omdsh-sidepanel |
webServer — one directory level, one file, one image, and the shell's socket |
The panel.files and panel.terminal commands |
Handed to omdsh-shortcuts' shortcut service on a restricted fiber; this package binds no key of its own |
The harness ships a three-column frame — session list, conversation, details — and no place to look at a file or run a command. This adds the two, and stops there. The right panel lists the conversation's own directory and shows one file at a time. The bottom panel is a shell in that same directory. There is no editor, no git surface, no browser, no tab strip: a panel that is easy to ignore is worth more here than one that is a second application.
Both switches live in the session header's utility row, beside the controls the harness already puts there — and they stay on that one spot. While a conversation is still blank the harness clears the whole header for the hero, so the pair holds the row's own corner from the frame's overlay instead: the header's own padding, measured off it, and the row's height, which is the same pixels the switches occupy once the header comes back. What the understudy waits on is the header seat reporting itself, not a second copy of the harness's rule for when it hides — and the switches sit last in the row on purpose, since anything rendering outside them would move them the moment the header took over.
A key for each panel
Neither panel binds a key here. Both are handed over as commands — panel.files and panel.terminal — and which chord reaches one, or whether any does, belongs to omdsh-shortcuts: one settings form beside every other key, rather than a constant in whichever plugin happens to own the behaviour. That plugin ships CmdOrCtrl+Shift+E and Ctrl+` for these two, and the switches teach whatever the document actually says — the chord rides in the tooltip, re-read on every revision, so a rebinding reaches it with no reload.
With no keybinding layer composed there is nothing to hand them to. The registration rides a restricted fiber, and without the service that fiber is never entered; the tooltips then name the panels without claiming a key this composition could not deliver. The two switches in the header are the whole entry point either way, which is why shortcut is not in this package's own inject list: a plugin nobody installed must cost a chord, not the panels. See shortcut.ts.
Copying a name or a path
One menu, two ways in, one rule:
- the panel header's copy button acts on what the header names — the project folder while the tree is showing, the open file while a preview is;
- right-click acts on the row you point at, file or folder.
Rows themselves gain no chrome, which is what keeps a 320px column readable. Feedback rides the menu: the pressed row turns into 已复制 (or 复制失败, when the browser refuses the clipboard) and the menu closes a beat later — no toast, no status strip, and no way for a copy to happen without saying so. Hovering a row already shows its full path in the tooltip, so the menu is for taking a path somewhere, not for reading one.
The Chat rule
In Chat mode there are no panels, no collapsed rail, and no switches — the right and bottom edges of the screen show nothing at all. A chat has no project directory to browse and nothing to run in, so the honest surface is no surface.
Which mode a conversation is in is derived, never stored: a session is a chat exactly when the host-managed Chat workspace accounts it, the same fact omdsh-justchat derives its own switch from. That is what keeps the two plugins independent in both directions — this one imports nothing from that one, and a deployment that never installed it simply has no workspace by that name, so every session is Work and the panels are always available.
Which conversation that question is asked about is not the selected one. These panels sit beside the conversation column, and a mode whose column is not the web conversation — omdsh-code's terminal — deliberately never selects what it shows. So the panels follow sessionModes.column, the scope omdsh-base publishes for exactly this, on a restricted fiber: with no mode system composed the tracker answers with the selection, which is what this plugin read directly before the modes existed. See column.ts.
How it occupies the layout
The panels are fixed-position surfaces on shell.overlay, the frame-wide floating layer ui-layout publishes. Floating alone would be a curtain over the conversation, so the app shell is given matching margins and the page gives up exactly the space they take:
- the right panel narrows
#root, so it gets a full-height column beside every part of the app; - the bottom panel lifts only the conversation column, so the harness's own session list keeps its full height beneath it.
Two published anchors carry the whole coupling — #root and [data-slot="conversation"], whose parent is the frame's center column. Neither is a class name or a DOM shape, and absent either one the push is skipped and the panels merely float. Unloading the plugin removes every margin it wrote.
Each panel's inner edge is an 8px grip with a hover pill — the same affordance the harness frame gives its own columns. Drag it to resize; double-press it to return the panel to its shipped size. Sizes are remembered across reloads in localStorage, under omdsh-sidepanel.panels — per browser rather than per profile, and not a setting: this plugin registers no settings namespace at all, so its card in the plugin hub is a name with nothing to fill in. The double-press is the way back out of a size you no longer want.
The double-press is detected from the grip's own pointer events rather than left to the platform's dblclick: the grip cancels pointerdown (that is what stops a drag from selecting text through the app) and takes pointer capture, and both put the compatibility click sequence on ground the specs leave to the implementation. Two stationary presses, within 400ms and 6px of each other, is the same gesture with none of that doubt — and a press that moved closes the window, so "drag, then press" never resets what the drag just set.
Nothing here is a harness change: every slot is a seat the harness publishes, every registration goes through slots.inject(), and removing the plugin's row removes every surface with it.
The host half
Neither panel could be served by what the harness already exposes. host.listDirectory lists directories only (it backs the workspace picker), no API reads a file, and the ctx.terminals seam is owner-fenced to a live Agent — a person's own shell is not an agent's. So the node half adds four endpoints under one prefix and nothing else:
| Route | What it answers |
|---|---|
GET /omdsh-sidepanel/tree |
one directory level, directories first |
GET /omdsh-sidepanel/file |
one file classified, and read when it is text |
GET /omdsh-sidepanel/raw |
one image, as bytes an <img> can point at |
WS /omdsh-sidepanel/terminal |
the shell |
Every one of them is conversation-scoped and fenced twice. Where: a request names a session, the session's working directory comes from the host store, and a path that does not resolve under it is refused out-of-scope — a panel that could walk to ~/.ssh would be a filesystem browser wearing a side panel's clothes. Who: the same browser-trust check the /api gateway applies (Host-header loopback or a configured authority, plus same-origin browser markers), because a route that reads files and hands out a shell must be exactly as reachable as /api and no more.
The fence resolves paths logically, so a symlink inside the workspace can still point outside it. That is deliberate: the panel is scoped to the directory the agent already operates in, for the person who owns both, and the fence exists to stop a stray query rather than to sandbox a user from their own machine.
The terminal
One shell per conversation, over node-pty, with xterm.js in the panel.
It outlives its socket on purpose. Switching conversations, collapsing the panel, and reloading the page all drop the connection and none of them mean "kill my shell" — so the process stays, its output keeps accumulating into a bounded transcript, and the next connection replays that transcript before going live. Coming back lands mid-command rather than at a fresh prompt. What does end a shell is exit, the reconnect grace running out, the conversation's directory moving out from under it, or the plugin unloading.
The wire is almost nothing. Server to client: raw terminal text. Client to server: raw text is keystrokes, except a frame beginning with NUL, which is a control message. That prefix is the whole reason the two stay unambiguous — typing {"type":"resize"} at your shell has to reach your shell.
When the workspace is on a server
The host half asks one question before it reads anything: is this conversation's directory really on this machine? omdsh-remdev publishes remdev, which answers it — and when the answer is a remote workspace, the directory level, the preview, the image, and the shell all come from over there instead. Same routes, same JSON, same panel: that plugin answers in this one's own currency, so nothing in the components ever learns the difference.
With no remote-development plugin composed the answer is undefined, and every read takes the local branch it always took. That is not a degraded mode; it is the mode this plugin shipped in. The service is asked for by name on each request rather than captured at activation — it can load after this plugin and can come and go under HMR — and neither package imports the other in either direction: the face is described structurally.
Install
dsh plugin --profile web add @omdsh-plugins/omdsh-sidepanel
The command installs the package into the profile and appends it to dsh.profile.bundles; the cordis.patch.yml here is the one row that mounts. The harness tree stays exactly as it shipped.
Or from a checkout beside yours, which is what an unpublished build wants:
pnpm install && pnpm run build
dsh plugin --profile web add link:../omdsh-plugins/omdsh-sidepanel
A link: dependency is NOT built on install, which is what the build ahead of it is for.
Remove it the same way:
dsh plugin --profile web remove @omdsh-plugins/omdsh-sidepanel
Both halves go, and every margin this plugin wrote is handed back with them.
No plugin in this collection is a precondition, and each absence costs only itself: with no omdsh-shortcuts the two switches still open the panels and simply claim no chord; with no omdsh-base the panels follow the selected conversation, which is what they read before the modes existed; with no omdsh-justchat no workspace is named Chat, so every session is Work and the panels are always available; with no omdsh-remdev every read takes the local branch it always took. The row itself activates only where the services it injects exist — webServer, sessions, webRuntime. On a surface that composes no web server (the TUI, headless) it simply never activates, which is correct: there is no browser to serve.
Commands
pnpm install
pnpm run build # tsc emits lib/types, tsdown bundles both halves
pnpm run typecheck # sources and tests
pnpm run test # vitest
pnpm run build produces three artifacts: lib/index.js and lib/invariant.js (the node half, imported by the loader) and lib/client.js (the browser half, a closure-factory bundle the shell fetches outside any module graph). xterm rides inside lib/client.js rather than behind a chunk route, which keeps the terminal one plain artifact.
The committed manifest pins the published harness. To build against a sibling checkout instead:
pnpm run harness:local ../../deepseek-harness # that checkout must be built first
pnpm install
pnpm run harness:npm # before committing — a link: is one machine's layout
pnpm run check:harness-pin
Known limitations
- The tree is not watched. The refresh button in the panel header is the whole reconciliation story.
- The preview is a preview. Text as text, images inline, everything else named rather than rendered; no syntax highlighting, no editing, no saving.
- Terminals are process-local. They do not survive a harness restart, and a session opened in two browser tabs shares one shell.
node-ptyis a native dependency. It ships prebuilt binaries for the common platforms; a platform without one compiles on install.
No comments yet. Be the first to write one.