# dsh-ui-turn-rail

> **🌐 Language / 语言：** [**English**](README.md) · [简体中文](README.zh.md)

Turn progress rail plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web GUI: a sticky dot strip pinned to the conversation scrollport, one dot per loaded turn, with per-turn question summaries, one-click deep history loading, and summaries for AI-option picks.

**GitHub topics**: `dsh-plugin` · `deepseek-harness`

---

## Features

- **Always-visible rail.** The dot strip is sticky to the conversation scrollport, so it stays in view while the transcript scrolls — even in very long conversations.
- **One dot per turn whose question is loaded**, plus dashed placeholders for turns whose question is still outside the window. AI replies and reasoning never count as a loaded question: a turn whose question has not been paged in yet stays a placeholder.
- **Question summaries.** Hover a dot to see that turn's first question as a one-line preview. Both typed input (`user` nodes) and picked AI options (`steering` nodes) produce summaries.
- **One-click deep-history load.** Click a placeholder and the plugin pages every older batch until the target turn's question is actually in the window (a page boundary can land a turn's first node while its question sits in an older page), then jumps straight to that question row.
- **Click a loaded dot** to scroll the transcript to that turn's question.
- **Scrollable dot window.** The rail shows about ten dots at a time with internal scrolling; the active turn's dot auto-follows into view as the reader moves.

## Security

- **Presentation only.** The rail renders facts already present in the session snapshot. It never produces a model-visible input, never writes the session log, makes no network requests, and touches no credentials.
- **Model Experience**: nothing reaches a model request; token effect none; KV-cache effect none.
- **No new privileges.** The host instrumentation (below) adds one read-only slot to the chat view; it introduces no new RPCs, permissions, or secrets handling.
- **Data lives in the host.** All derivation (turn order, node→turn mapping, summaries) runs against the client-side `ChatSnapshot`; the rail holds no session state of its own.

## Requirements

A DeepSeek Harness checkout (or the published `@deepseek-ai/dsh-*` packages) with the **host instrumentation** applied — the rail registers into the `conversation.chat.rail` seat that ui-conversation's chat view must declare (see below). Without the seat, the plugin loads but renders nothing (the seat renders `fallback: null`).

## Installation

The plugin supports two installation routes. **Neither automates the host instrumentation** — that is a source-level patch to the harness checkout (see [Host instrumentation](#host-instrumentation)) and must be applied and rebuilt regardless of how the package is installed.

### Route 1 — bundle install via `dsh plugin` (recommended)

This repository is a [DSH bundle](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md): its `package.json` declares `dsh.bundle.patch` → `./cordis.patch.yml`, so the profile plugin manager installs it as a patch layer and the patch inserts the plugin into the web profile's browser roster.

```sh
# From the git repository (no npm publish needed):
dsh plugin --profile web add @huanghanheng/dsh-ui-turn-rail

# Or, once published to npm (shorter spec):
dsh plugin --profile web add @huanghanheng/dsh-ui-turn-rail
```

`dsh plugin` runs `pnpm add` in the profile directory and reconciles `dsh.profile.bundles` automatically: a dependency whose manifest declares `dsh.bundle.patch` joins the layer stack.

**Then apply the host instrumentation** to the checkout the profile runs from — the seat must exist for the rail to render. A ready-made patch ships in this repository, applied in one command (then rebuild the web client):

```sh
scripts/apply-instrumentation.sh /path/to/harness-checkout
(cd /path/to/harness-checkout && pnpm run build)
```

### Route 2 — manual npm install

The package publishes to npm with build artifacts committed (`lib/`), so it installs like any package:

```sh
# In the profile directory the app runs from (or the app itself):
npm install @huanghanheng/dsh-ui-turn-rail
# or: pnpm add @huanghanheng/dsh-ui-turn-rail
```

A manual `npm install` does **not** add the bundle layer automatically — declare it in the profile manifest so the patch inserts the plugin row:

```json
// $DSH_HOME/profiles/web/package.json
{
  "dependencies": { "@huanghanheng/dsh-ui-turn-rail": "^0.1.0" },
  "dsh": { "profile": { "bundles": ["@huanghanheng/dsh-ui-turn-rail"] } }
}
```

Or simply run `dsh plugin --profile web add @huanghanheng/dsh-ui-turn-rail`, which performs the install and the bundle-layer reconciliation for you (equivalent to Route 1).

**Then apply the host instrumentation and rebuild**, exactly as in Route 1.

## Host instrumentation (cannot be packaged)

The seat declaration lives in ui-conversation's slot contract, and the jump/paging gestures must operate ChatView's private scroll anchoring — neither can be delivered as a plugin, so `dsh plugin add` cannot automate it. The repository ships the exact change as `patches/chat-rail-seat.patch` (against harness master `b150a551b8`); apply it with `scripts/apply-instrumentation.sh` or `git apply --3way`, then rebuild. If your checkout has drifted, hand-apply the edits below. These are the exact changes:

### `packages/client/ui-conversation/src/client/contract/slots.ts`

1. SlotMap entry:
   ```ts
   'conversation.chat.rail': { kind: 'single'; scope: 'session'; owner: ChatTurnRailOwnerProps }
   ```
2. Owner currency type:
   ```ts
   export interface ChatTurnRailOwnerProps {
     currentTurn: number | undefined
     loadingOlder: boolean
     hasMore: boolean
     onJump: (key: string) => void
     onLoadOlder: () => Promise<void>
   }
   ```
3. Add `'conversation.chat.rail'` to the `PropsRenderSlots` union in `ChatViewSlotProps`.
4. Widen the injected paging contract to return the promise:
   ```ts
   loadOlder: () => Promise<void>
   ```

### `packages/client/ui-conversation/src/client/apply.ts`

Declare the seat in the chat view entry's children:
```ts
children: {
  'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
  'conversation.message.images': { kind: 'single', scope: 'session' },
  'conversation.chat.rail': { kind: 'single', scope: 'session' },
},
```

### `packages/client/ui-conversation/src/client/chat/ChatView.tsx`

1. Node key → turn map for scroll-position attribution:
   ```ts
   const chat = useSession(s => s.chat)
   const turnByKey = useMemo(() => {
     const map = new Map<string, number>()
     for (const turn of chat.timeline.turnOrder) {
       for (const key of chat.locations.getTurn(turn)) map.set(key, turn)
     }
     return map
   }, [chat])
   ```
2. Track `currentTurn` in the scroll handlers (set it from `turnOf(anchorKey)` or the latest turn when pinned to the bottom).
3. `loadOlderAnchored` returns the paging promise:
   ```ts
   const loadOlderAnchored = (): Promise<void> => {
     // ...existing paging-anchor logic...
     return loadOlder()
   }
   ```
4. Render the seat with the owner currency:
   ```tsx
   {renderSlot('conversation.chat.rail', {
     currentTurn,
     loadingOlder,
     hasMore,
     onJump: jumpTo,
     onLoadOlder: loadOlderAnchored,
   }, { fallback: null })}
   ```

## Development

```sh
pnpm install
pnpm vitest run tests/        # unit tests (model + component, jsdom)
pnpm bundle                   # tsdown client bundle (needs the harness's clientBundle preset)
```

The tests cover the turn model (tick derivation, typed/AI-option summaries, placeholders) and the component (jump, one-click paging past page boundaries, active-dot follow, window scroll).

## License

MIT
