Back to home

omdsh-plugins

omdsh-base

The session-mode system for the DeepSeek Harness web GUI: the segment registry every mode plugin registers into, the switch that renders them, and the sidebar dots that colour their conversations

Stars
0
Language
TypeScript
Created
Aug 16, 2026
Updated
Aug 16, 2026

Introduction

omdsh-base

English | 中文

The session-mode system for the DeepSeek Harness web GUI: the segment registry every mode plugin registers into, the switch that renders them, and the sidebar dots that colour their conversations.

It invents no mode of its own. Chat arrives with omdsh-justchat, Code with omdsh-code, and neither is more native here than the other. The one posture this package does contribute is the one that was already on screen: Work, the harness's own conversation column, offered as a segment so that a switch has somewhere to switch back to. It appears only when it is needed — see The baseline posture.

What it adds

SurfaceWhere it comes from
The sessionModes servicectx.provide, the door every mode plugin registers its segment through
The mode switchAn entry in shell.overlay, ui-layout's frame-wide floating layer, centred on the conversation column and auto-hidden when nobody reaches for it
The Work segment — the harness's own columnregisterBaseline, a registration this package makes for itself and withdraws the moment a mode plugin brings that posture
A leading dot on every sidebar row, in its mode's coloursession-dots.ts — painted onto the shipped rows rather than rendered, driven by the registry's tone and owns
New Session reaching the mode holding the column firstAn override of workspaces.startSession that offers the request to the active segment, and announces it when no segment takes it
sessionModes.column — what the column is actually showingThe active segment's own scope when it declares one, the selected conversation otherwise

Nothing in the harness is modified. The one slot it registers into is a published seat, the override is a prototype method shadowed by an own property, and withdrawing the row hands both straight back.

It registers no settings namespace either, and that is deliberate rather than an omission. There is nothing about a segment registry a person configures — which posture holds the column is derived per tab, and which postures exist at all is a property of the profile — so its card in the plugin hub carries no form.

Why this is its own package

It used to be part of Chat mode, and that was a layering inversion with a concrete cost.

A mode plugin needs the registry. If the registry ships inside one particular mode, every other mode depends on that mode's whole package — its managed workspace, its agent preset, its dock note — to contribute a pill to a switch. Wanting Code mode without Chat mode had no answer, and a profile assembled that way did not merely lose a segment: omdsh-code's browser half sat pending on a service nobody composed, and the client boot audit failed the entire page over it.

Splitting the seat from the postures makes the dependency honest. Every mode plugin depends on this package, this package depends on no mode, and "which postures exist" becomes a property of the profile rather than of whichever mode happened to own the registry.

The baseline posture

A switch needs somewhere to switch back to. A profile composed with this package and omdsh-code and nothing else used to get a control with a single Code pill on it — one segment, no way out of the terminal, and every conversation in the sidebar unmarked because nothing claimed them. The missing posture was never a plugin's to contribute: it is the harness's own conversation column, the screen that is already there.

So this package registers it, under the same name omdsh-justchat uses for it — Work — with that plugin's words, colour and glyph, so a person cannot tell which package their switch got it from. Pressing it takes the column back and shows whatever is selected; it starts nothing, remembers nothing, and needs nothing from the profile.

It stands only while it is needed, and both halves of that matter:

  • Nothing else registered, no switch at all. The baseline alone is not rendered, because a control with one segment cannot switch — which keeps the promise that a profile with the mode system and no mode plugin shows nothing.
  • A contributor claiming the everything-else posture takes its place. omdsh-justchat's Work is this posture with a memory of the conversation you left in it, so its segment is the one that renders; the baseline steps aside for any registration that declares fallback or takes its id, and comes straight back if that plugin unloads. Two everything-else segments would put one conversation under two dots.

Its active flag is derived rather than written: it holds the column exactly while it stands and no contributor has taken it. That is what makes a mode plugin crashing, unloading, or simply going inactive hand the column back with nothing left to notice.

The contract

A mode plugin registers one segment and answers for itself:

// Never in a top-level `inject` — see rule 9 of the conventions.
ctx.inject(['sessionModes'], (mctx) => {
  const modes = mctx.get('sessionModes') as SessionModes | undefined
  if (modes === undefined) return

  mctx.effect(() => modes.register({
    id: 'code',
    order: 20,
    label: t('mode.code'),          // already in the reader's language
    hint: t('mode.code.hint'),
    tone: 'var(--dsw-alias-state-error-primary)',
    icon: createElement(IconCodeOutline16, { size: 14 }),
    owns: isCodeSessionId,          // "is this conversation mine?"
    available: true,
    enter: () => { /* the navigation a press performs */ },
    newSession: (workspaceId) => { /* true when this mode started one */ },
  }))
})

SessionModes is a type, and it comes from @omdsh-plugins/omdsh-base/client. Import it with import type and never as a value: a cross-plugin VALUE import either inlines a second copy of this package's runtime into your bundle or asks the shell's frozen module table for a specifier it cannot answer, and the client bundle purity gate fails the build over it. That is why the service is resolved by the string 'sessionModes' above rather than by this package's exported SESSION_MODES constant — a service name is a wire name shared with a runtime, not a symbol shared with a package, and both mode plugins in this collection spell it out for themselves for exactly that reason.

Four things are worth knowing before writing one.

Exactly one segment is active, and the registry enforces it rather than trusting contributors to agree: marking one active clears the rest. That is also how a contributor learns it lost the column — it watches its own entry go false and takes its surface down.

Copy arrives localized. The switch renders label, hint and unavailableHint as given; it owns two words of its own (switch.aria, and the fallback shown when a mode is unavailable and named no reason) and knows no mode's name. Re-update the segment on locale/change.

A press is a navigation, never a state write. enter is called and the segment is expected to make the world true — by opening a conversation, starting one, or taking the column — after which whatever derives its active flag reports it. Nothing here remembers a mode across a reload; there is no node half, deliberately, so there is no stored posture two tabs can disagree about.

owns is asked per conversation, in display order, and the first claim wins; a segment marked fallback takes whatever no one claims. That is what lets the sidebar mark a whole list by mode with nothing about any particular mode in the code that paints it. A classifier that throws declines that conversation rather than taking the browsing region down.

What the column is showing is not what is selected

sessions.current answers "which conversation is SELECTED", and that is the same question as "what is on screen" only while every mode's column is the web conversation. Code mode's is a terminal, and it deliberately never selects the conversation that terminal drives — a selected conversation is one the web host would resume, on a log another process owns. So the selection stays on whatever was open behind the terminal.

A surface beside the column that read the selection was therefore silently wrong rather than missing: omdsh-sidepanel's file tree sat beside a terminal in one project describing another. column is the honest answer:

// A mode whose column is not the web conversation declares what it shows.
modes.register({ id: 'code', /* … */, scope: controller.scope })

// Everything beside the column follows that, not the selection.
const scope = modes.column.getSnapshot()   // { sessionId, cwd }

Read only while the segment is active, on purpose: a contributor's scope is usually live whether or not it holds the column (Code mode goes on deriving what it WOULD show), and reporting that would describe a screen nobody is looking at. A mode that declares none — Chat and Work, whose column IS the web conversation — is reported as the selection, so a consumer needs one path rather than two.

Clicking a conversation is a request to see it

The selection cannot carry that request on its own, and the gap is a bug a person finds in a minute: enter Code mode from a working conversation — which leaves that conversation selected, because a terminal is not it — then click its row in the sidebar. The runtime selects what is already selected, nothing changes, nothing publishes, and the terminal goes on covering the conversation the click was asking for. The switch was the only way back, which is not what a sidebar row means.

So sessions.open is wrapped here, and every open hands the column to the mode that shows that conversation (showConversation) whether or not the selection moves. A mode that declares its own scope is skipped: it takes such a click before this wrapper does — omdsh-code shows the terminal for its own ids — and reaching here means it declined.

New Session belongs to the mode it was pressed in

"Another conversation like this one" means different things in different postures: the shipped modes want the frame's blank session, and a posture whose column is a terminal wants something the frame has never heard of. So the request is offered to the active segment first (newSession), and only reaches the frame when no segment takes it.

A segment that declines gives the column up with the request, which is the right default — the user asked for a conversation and the frame is about to show one.

The passthrough is announced (onNewSession) because that gesture is the one navigation with nothing to derive from: startSession REUSES a workspace's existing blank conversation, so pressing New Session while already on it moves no selection, changes no list, and publishes no store. A mode deriving its own flag from where the user is would go on reporting the conversation the request was asking to leave. Asking is the whole fact.

Install

dsh plugin --profile web add @omdsh-plugins/omdsh-base
dsh plugin --profile web add @omdsh-plugins/omdsh-justchat   # Chat, and a Work that remembers
dsh plugin --profile web add @omdsh-plugins/omdsh-code       # Code

Each line is optional after the first, and the switch says what was composed: this package alone shows nothing, with omdsh-code it shows Work · Code, with omdsh-justchat Chat · Work, and with both Chat · Work · Code.

Order is a readability preference, not a requirement: mode plugins resolve sessionModes by name on a restricted fiber, so one composed before this package waits rather than fails.

Or from a checkout, which is what an unpublished build wants. lib/ must exist before dsh web runs — the loader imports lib/index.js directly, and a path-installed package never has its prepare run, so nothing builds it for you:

pnpm install && pnpm run build
dsh plugin --profile web add "$PWD"

Rebuild after every source change, for the same reason.

Remove it the same way:

dsh plugin --profile web remove @omdsh-plugins/omdsh-base

Every mode plugin then goes quiet on its own — segments and switch gone, each plugin's other surfaces still standing. That is what a restricted fiber buys, and it is the difference between "the modes are off" and a dead page.

Commands

pnpm install
pnpm run build       # tsc emits lib/types, tsdown bundles the browser half
pnpm run typecheck   # package sources, then the specs
pnpm run test        # vitest

Which harness this package compiles against is a switch:

pnpm run harness:npm                             # the committed state: the pinned release
pnpm run harness:local ../../deepseek-harness    # a sibling checkout, for development
pnpm run check:harness-pin                       # fails while any dependency is linked

Only the registry state may be committed. A link: specifier is resolved against the manifest that declares it, so a committed one bakes one machine's directory layout into the package — and pnpm does not fail loudly when it is wrong: it creates a dangling symlink, reports a successful install, and the build dies later with TS2307 on every harness import. check:harness-pin exists to catch that before a commit.

Known limitations

  • The switch borrows its seat. shell.overlay spans the sidebar and details panel too, so the pill centres itself on the box carrying data-conversation-scroll and hides when the pointer is not near it. A column that does not carry that attribute loses the anchor, and the switch snaps to the centre of the whole frame.
  • Dots are painted, not rendered. The sidebar's rows belong to the harness, so their mode marks are written onto the DOM through a MutationObserver rather than composed into it. A row shape the harness changes is a selector this package has to follow.
  • The dots read a private structure. A row's conversation is taken from its React props, which is not a published contract; a future browser or a future React that changes that shape leaves rows unmarked rather than mismarked, and the switch's glyphs are unaffected. The alternative — matching on the row's title — is worse than nothing, because untitled sessions in one project share a title.
  • Search results carry no dot. The dots are painted on the browsing rows; a search result is a two-line stack that already names its workspace.
  • No mode survives a reload. The active posture is derived per tab from where the current conversation lives, and each contributor derives its own. Opening the app always lands wherever the current conversation says, never where you last were.
  • The baseline Work remembers nothing. Pressing it hands the column back and shows whatever is selected — it does not return you to the last working conversation, and on a fresh tab with nothing selected it lands on the workspace picker. That memory is omdsh-justchat's Work, which is why its segment replaces this one when the plugin is composed.