dsh-session-menu-plus
Three additions to the DeepSeek Harness sidebar Session list: a Session menu on right-click, a read/unread mark, and Session deletion behind a confirmation.
What it does
- Right-click any Session row in the sidebar and that row's own "⋯" menu opens — no more aiming for the icon.
Mark as unread/Mark as read(first row in the menu) flips a mark for that Session: unread rows carry the same greendonestate dot DSH uses for its own row states, in the row's leading status cell.- The menu gains
Export as Markdown(download icon, right afterCopy Session ID) andDelete session(danger colour, trash icon, with a separator above it). Export as Markdowndownloadssession-<uuid>.mdand reportsSession exported.Delete sessionasks first:Delete this session?, the Session title, andCancel/Delete session.- The outcome arrives as a notice:
Session deletedon success, or one of "still running", "no longer exists", "delete failed". - The only reason a delete is refused is a Session with a running agent. The row you currently have open can be deleted.
- Deletion is thorough: transcript directory, projection cache, spill, workspace membership, archive flag and registry index all go, then
api-session/removedmakes the Client drop the row immediately.
Install
dsh plugin add dsh-session-menu-plus # from the npm registry
dsh plugin add /path/to/dsh-session-menu-plus # or straight from a checkout
The Harness plugin manager can also install either one. Restart Harness afterwards — Client modules are loaded as a bundle.
Export format
One row, one output: a transcript meant to be read.
- Opens with the header block:
# <title>, the cwd / model / preset essentials, then---. - One
## Turn N · <time>section per turn, holding only the prose blocks:**User**,**Assistant**. - Tool calls and their results render as paired
<details>so long output cannot bury the conversation; arguments and results are each clipped to 4000 characters. - A call with no recorded result says
_No result was recorded._instead of inventing one. - Fences pick a backtick run that the content does not already contain, so a fenced block can never close early.
Preconditions:
- Before reading, the live instance is flushed with
sessions.flush, so even a running Session exports everything committed up to that moment. - Reading goes through
sessionPersistence.open(id, 'read')— the same bytes the backend stores; the projection cache is not used. - The text is handed to the browser as
text/markdown.
How it is built
- The export row (Client): a plain built-in
MenuItemButtonthat closes the menu and requests the export. No submenu: the Session menu opens withcloseOnPointerLeave, so a card floating beside it takes the parent away as soon as the pointer moves in, and an absolutely positioned card inside the menu gets clipped by the menu surface. - The delete row (Client):
sidebar.workspaces.session.menu.iteminjects aMenuItemButton(danger colour, trash icon, separator). Selecting it opens aModal; confirming sendsPOST /session-menu-plus/delete; both outcomes use the sharedToast. No bespoke dialog, no edited built-in components. - The unread mark (Client): two seats, no new widgets. The toggle is a
MenuItemButtonin the menu list; the dot is one component insidebar.session.row.leading— the 16px cell DSH already gives its own row-state marks (the schedule plugin's clock mark sits there too) — and it is the sharedStateDotprimitive atstate="done", so colour and geometry are the platform's, not ours. The cell only offers itself while the row's primary state is idle, so the dot never competes with a running or pending indicator. Marks live inlocalStorageunderdsh-session-menu-plus:unreadand follow thestorageevent, so two windows agree; nothing is written to the Host. - Right-click: the
contextmenugesture is forwarded to that row's own "⋯" trigger, so the menu, focus handling, outside dismissal and shortcuts stay the built-in ones. - Host — the delete sequence, entirely through Host services, never by scanning directories:
- Refuse only when
agents.get(id).status === 'running'. readHeader: live instance →sessionQuery.readSession→sessionPersistence.list.- For a live instance,
sessions.flush()thendetachEntered, so nothing keeps appending to files that are about to disappear. sessionProjectionCache.delete(id).workspaceRegistry.unarchiveSession(id)plusdetachSession(id)on every workspace.sessionPersistence.locate(header)gives the transcript path; delete the whole directory when only backend files remain (transcript plussession.lock), otherwise just the transcript.- Remove the matching
spillStoredirectory. - Clear the registry's in-memory index (
headers/sessionPaths/missingSessionPaths/invalidSessionPaths) so a stale list cannot re-materialise the row. emit('api-session/removed'); the Client refreshes its Session list.
- Refuse only when
- Host services are all taken through
ctx.get(); a missing one degrades to a skipped step rather than a hard dependency.webServerandconnectioncome fromctx.inject.
Known limits
- A Session with a running agent is refused; everything else, including the row you have open, is deletable.
- The read/unread mark is this plugin's own and per Client: it is not the platform's
completionUnreadchip (that one is runtime-only and has no setter) and it does not sync across machines. Showing that Session clears its own mark — opening a different one leaves it alone — so onlyMark as unreadever needs a second gesture. - Deleting the open Session leaves the conversation pane on the empty/onboarding state — the transcript directory and projection cache are already gone.
- Deletion does not prune
sessionIds/archivedSessionIds/pinnedSessionIdsfromworkspace.json: the platform exposes no removal API for them. A leftover entry is not rendered and has no effect.
Verification
verification/ holds screenshots from real runs: the right-click menu, the confirmation, the success notice with the row gone, and the refusal when deleting the row that is open.
tests/delete-route.test.js covers the delete route's success / in-use / missing / malformed branches:
node --test tests/
No comments yet. Be the first to write one.