mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(research): add trace-workbench local session-replay UI
A localhost viewer over persisted session JSONL for agent developers and researchers. Three first-class views — Chat (markdown-rendered surface conversation with a second-level inspector), Trajectory (turn/step tree with scroll-spy over a step-grouped event table, inline annotations), and Waterfall (timing summary + aligned time track that deep-links into Trajectory). Subagent sessions group under their parentSession with spawn links and breadcrumbs; failed tool calls are marked in every view; ?session=&view=&sel= makes any selection a shareable deep link. Motion follows an audited restraint baseline (plans/ documents the audit): one strong ease-out token, enter-only animations, press feedback on pushbuttons, prefers-reduced-motion support. AGENTS.md gains the research/ layout entry; the AGENTS.md word ceiling rises 1370 -> 1375 to fit it (one line, relocation not applicable for a top-level layout entry).
This commit is contained in:
@@ -33,6 +33,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
|
||||
python/ Python SDK and bundled runtime (see python/README.md)
|
||||
examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md)
|
||||
docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md)
|
||||
research/ research prototypes (see research/trace-workbench/README.md)
|
||||
scripts/ repo gates and generators
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.feedback/
|
||||
@@ -0,0 +1,39 @@
|
||||
# DeepSeek Harness Trace Workbench
|
||||
|
||||
Localhost UI backed by real DeepSeek Harness persisted session JSONL files.
|
||||
|
||||
```sh
|
||||
node server.js
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:5173/>.
|
||||
|
||||
Defaults:
|
||||
|
||||
- Reads sessions from `./.sessions` under the current working directory (set `HARNESS_SESSIONS_ROOT` to point elsewhere)
|
||||
- Serves static UI and API from the same localhost origin
|
||||
- Annotations are appended to `.feedback/*.jsonl` next to the server (local data, not committed)
|
||||
|
||||
Useful overrides:
|
||||
|
||||
```sh
|
||||
HARNESS_SESSIONS_ROOT=/path/to/.sessions PORT=5174 node server.js
|
||||
```
|
||||
|
||||
API:
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/sessions`
|
||||
- `GET /api/sessions/:id`
|
||||
|
||||
Current UI capabilities:
|
||||
|
||||
- Reads real session JSONL, not mock data; replay-only (the composer is disabled until live runtime wiring lands).
|
||||
- Three first-class views; the inspector is second-level and belongs to Chat only:
|
||||
- **Chat** — markdown-rendered surface conversation; clicking a message opens the inspector as an inner column (paired Input/Output, metadata, feedback, Plain/JSON/JSONL/YAML formatting) that squeezes the conversation, never overlays it.
|
||||
- **Trajectory** — self-contained: a structure tree (turn → step, with durations, tool summaries and error dots) navigates a step-grouped event table; lifecycle events are absorbed into the tree and sticky group headers instead of appearing as rows. Expanded rows carry Copy JSON, inline annotations (标注) and the raw event.
|
||||
- **Waterfall** — hotspot finder: a summary strip (total / LLM time / tool time / errors / slowest step / tokens) plus an aligned time track; clicking any bar, label or stat jumps to the matching Trajectory row.
|
||||
- Session list groups subagent sessions under their `parentSession`; a spawning tool call links to the sessions it spawned, and a child session shows a breadcrumb back to its parent.
|
||||
- Failed tool calls are marked in every view (red rail + `← error` chip in Trajectory, red bar in Waterfall, `Tool failed` in Chat).
|
||||
- Panes resize by dragging the dividers (clamped; widths persist), and all panes squeeze each other in one layer.
|
||||
- URL carries `?session=&view=&sel=` so any selection is a shareable deep link.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DeepSeek Harness Workbench</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="left-pane">
|
||||
<header class="brand">
|
||||
<div class="mark">DH</div>
|
||||
<div>
|
||||
<p class="overline">Local replay</p>
|
||||
<h1>Harness Workbench</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button id="newSessionButton" class="new-session-button">+ New session</button>
|
||||
|
||||
<section class="session-search-card">
|
||||
<label class="field-label" for="sessionSearch">Sessions</label>
|
||||
<input id="sessionSearch" type="search" placeholder="Search sessions">
|
||||
<div id="sourceLine" class="source-line">Loading persisted sessions...</div>
|
||||
</section>
|
||||
|
||||
<nav id="sessionList" class="session-list" aria-label="Sessions"></nav>
|
||||
</aside>
|
||||
|
||||
<div class="pane-divider" id="dividerLeft" role="separator" aria-orientation="vertical" aria-label="Resize sessions panel"></div>
|
||||
|
||||
<main class="main-pane">
|
||||
<header class="main-switcher">
|
||||
<div class="conversation-title">
|
||||
<span class="replay-pill">replay</span>
|
||||
<strong id="conversationTitle">Loading session...</strong>
|
||||
<span id="sessionCrumb"></span>
|
||||
</div>
|
||||
<div class="segmented large">
|
||||
<button class="active" data-main-view="conversation">Chat</button>
|
||||
<button data-main-view="trajectory">Trajectory</button>
|
||||
<button data-main-view="waterfall">Waterfall</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="conversationView" class="view split-view active">
|
||||
<div class="chat-split" id="chatSplit">
|
||||
<div class="conversation-scroll">
|
||||
<div id="conversation" class="conversation"></div>
|
||||
</div>
|
||||
<div class="pane-divider" id="dividerInspector" role="separator" aria-orientation="vertical" aria-label="Resize inspector"></div>
|
||||
<aside id="detailDrawer" class="inspector" aria-label="Inspector">
|
||||
<header class="details-head">
|
||||
<div class="detail-object">
|
||||
<div id="detailIcon" class="detail-icon">S</div>
|
||||
<div>
|
||||
<h2 id="detailTitle">Selected item</h2>
|
||||
<p id="detailSubtitle">Click a chat message to inspect it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-actions">
|
||||
<button id="jumpFromInspector" title="在 Trajectory 中打开">→ Traj</button>
|
||||
<button id="inspectorClose" aria-label="Close inspector">Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="detail-tabs">
|
||||
<button class="active" data-detail-tab="input">Input</button>
|
||||
<button data-detail-tab="output">Output</button>
|
||||
<button data-detail-tab="metadata">Metadata</button>
|
||||
<button data-detail-tab="feedback">Feedback</button>
|
||||
</div>
|
||||
|
||||
<section id="inputPanel" class="detail-panel active">
|
||||
<div class="format-row">
|
||||
<strong>Input</strong>
|
||||
<select data-format-target="inputText">
|
||||
<option value="plain">Plain</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="jsonl">JSONL</option>
|
||||
<option value="yaml">YAML</option>
|
||||
</select>
|
||||
</div>
|
||||
<pre id="inputText" class="code-block"></pre>
|
||||
</section>
|
||||
|
||||
<section id="outputPanel" class="detail-panel">
|
||||
<div class="format-row">
|
||||
<strong>Output</strong>
|
||||
<select data-format-target="outputText">
|
||||
<option value="plain">Plain</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="jsonl">JSONL</option>
|
||||
<option value="yaml">YAML</option>
|
||||
</select>
|
||||
</div>
|
||||
<pre id="outputText" class="code-block"></pre>
|
||||
</section>
|
||||
|
||||
<section id="metadataPanel" class="detail-panel">
|
||||
<div class="format-row">
|
||||
<strong>Metadata</strong>
|
||||
<select data-format-target="metadataText">
|
||||
<option value="json">JSON</option>
|
||||
<option value="yaml">YAML</option>
|
||||
<option value="plain">Plain</option>
|
||||
</select>
|
||||
</div>
|
||||
<pre id="metadataText" class="code-block tall"></pre>
|
||||
</section>
|
||||
|
||||
<section id="feedbackPanel" class="detail-panel">
|
||||
<div class="feedback-compose-card">
|
||||
<form id="feedbackForm" class="feedback-form">
|
||||
<label class="feedback-author">
|
||||
<span>标注人</span>
|
||||
<input id="feedbackAuthor" type="text" placeholder="your name" value="shentuni">
|
||||
</label>
|
||||
<textarea id="feedbackText" rows="5" placeholder="输入标注内容(Enter 提交,Alt+Enter 换行)"></textarea>
|
||||
<button class="feedback-submit" type="submit">提交标注</button>
|
||||
</form>
|
||||
</div>
|
||||
<section class="feedback-history">
|
||||
<header>
|
||||
<strong>已有反馈</strong>
|
||||
<span>针对当前选中对象</span>
|
||||
</header>
|
||||
<div id="feedbackList" class="feedback-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="trajectoryView" class="view split-view">
|
||||
<div class="traj-split">
|
||||
<nav id="trajTree" class="traj-tree" aria-label="Session structure"></nav>
|
||||
<div class="traj-main">
|
||||
<div class="trajectory-toolbar">
|
||||
<span>Agent trajectory</span>
|
||||
<div>
|
||||
<button id="expandAllTrajectory">Expand all</button>
|
||||
<button id="collapseTrajectory">Collapse</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="trajectory" class="trajectory"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="waterfallView" class="view split-view">
|
||||
<div class="wf-split" id="wfSplit">
|
||||
<div class="waterfall-scroll">
|
||||
<div id="waterfall" class="waterfall"></div>
|
||||
</div>
|
||||
<div class="pane-divider" id="dividerWfInspector" role="separator" aria-orientation="vertical" aria-label="Resize inspector"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="composer" class="composer disabled">
|
||||
<button type="button" class="composer-plus" aria-label="Add context" disabled>+</button>
|
||||
<textarea id="composerInput" rows="1" placeholder="Replay mode — live input is not wired yet" disabled></textarea>
|
||||
<div class="composer-meta">read-only</div>
|
||||
<button type="submit" class="send-button" disabled>↑</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
# 001 — Add easing token and upgrade existing transitions
|
||||
|
||||
- **Status**: DONE
|
||||
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
|
||||
- **Severity**: LOW (foundation for 002–005)
|
||||
- **Category**: Easing & duration / Cohesion & tokens
|
||||
- **Estimated scope**: 1 file (styles.css), ~4 small edits
|
||||
|
||||
## Problem
|
||||
|
||||
No shared easing tokens exist; entrances use weak built-in `ease`:
|
||||
|
||||
```css
|
||||
/* styles.css — current */
|
||||
.toast {
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
}
|
||||
.main-pane {
|
||||
transition: opacity .15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
Built-in `ease` is too weak for deliberate motion; the toast enter feels mushy.
|
||||
|
||||
## Target
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */
|
||||
}
|
||||
.toast {
|
||||
transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
|
||||
}
|
||||
.main-pane {
|
||||
transition: opacity 150ms var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
Keep `.pane-divider::after { transition: background .12s ease }` unchanged — hover/color change correctly uses `ease`.
|
||||
|
||||
## Repo conventions to follow
|
||||
|
||||
- All custom properties live in the single `:root` block at the top of styles.css (e.g. `--blue: #0a64ff;`); add `--ease-out` there.
|
||||
|
||||
## Steps
|
||||
|
||||
1. styles.css `:root`: add `--ease-out: cubic-bezier(0.23, 1, 0.32, 1);`.
|
||||
2. styles.css `.toast`: replace transition with `opacity 200ms var(--ease-out), transform 200ms var(--ease-out)`.
|
||||
3. styles.css `.main-pane`: replace transition with `opacity 150ms var(--ease-out)`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do NOT touch app.js or index.html.
|
||||
- Do NOT change the `.pane-divider` or `.tree-turn-head::before` transitions.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Mechanical**: reload http://127.0.0.1:5173/ — no console errors.
|
||||
- **Feel check**: trigger a toast (submit an annotation); it should decelerate crisply into place instead of easing symmetrically. Switch sessions; the loading fade should feel unchanged or slightly snappier.
|
||||
- **Done when**: token exists and both rules reference it.
|
||||
@@ -0,0 +1,61 @@
|
||||
# 002 — Slide the inspector's content in on open
|
||||
|
||||
- **Status**: DONE
|
||||
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
|
||||
- **Severity**: MEDIUM
|
||||
- **Category**: Missed opportunities
|
||||
- **Estimated scope**: 1 file (styles.css), 1 rule + @starting-style
|
||||
|
||||
## Problem
|
||||
|
||||
The chat/waterfall inspector is a grid column toggled by `.inspector-open`; it appears via `display: none → flex` with zero motion — the panel teleports in with nothing explaining where it came from.
|
||||
|
||||
```css
|
||||
/* styles.css — current */
|
||||
.inspector {
|
||||
display: none;
|
||||
...
|
||||
}
|
||||
.chat-split.inspector-open .inspector,
|
||||
.wf-split.inspector-open .inspector {
|
||||
display: flex;
|
||||
}
|
||||
```
|
||||
|
||||
## Target
|
||||
|
||||
Animate the CONTENT entering (transform+opacity only — never animate the grid track, that's layout). Close stays instant (asymmetric timing: the system's response snaps).
|
||||
|
||||
```css
|
||||
.chat-split.inspector-open .inspector,
|
||||
.wf-split.inspector-open .inspector {
|
||||
display: flex;
|
||||
transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
@starting-style {
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Nested `@starting-style` is supported in Chrome 117+; this app targets local Chrome.)
|
||||
|
||||
## Repo conventions to follow
|
||||
|
||||
- `--ease-out` token from plan 001 (depends on it).
|
||||
|
||||
## Steps
|
||||
|
||||
1. styles.css: extend the `.chat-split.inspector-open .inspector, .wf-split.inspector-open .inspector` rule with the transition, resting transform/opacity, and the nested `@starting-style` block exactly as in Target.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do NOT animate `grid-template-columns`, width, or the divider.
|
||||
- Do NOT add a close animation.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Feel check**: click a chat message — the panel's content slides ~16px leftward while fading in, settling fast. Click Close — it disappears instantly. Rapidly open/close: no restart-from-zero flicker (transitions retarget).
|
||||
- **Done when**: open animates, close is instant, no layout properties in the transition list.
|
||||
@@ -0,0 +1,61 @@
|
||||
# 003 — Fade-slide expanded content into place
|
||||
|
||||
- **Status**: DONE
|
||||
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
|
||||
- **Severity**: MEDIUM
|
||||
- **Category**: Missed opportunities
|
||||
- **Estimated scope**: 1 file (styles.css), 1 keyframe + 3 selectors
|
||||
|
||||
## Problem
|
||||
|
||||
Expanding a trajectory row, a chat activity block, or a tree turn teleports its content into the layout — a jarring change on the most-used disclosure surfaces:
|
||||
|
||||
- `.traj-body` (trajectory expanded row) — inserted by re-render on toggle
|
||||
- `.activity-body` (chat Thinking / Tool use `<details>`)
|
||||
- `.tree-steps` (tree turn `<details>`)
|
||||
|
||||
## Target
|
||||
|
||||
One shared enter animation; exit stays instant (collapse must snap):
|
||||
|
||||
```css
|
||||
@keyframes content-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.traj-body,
|
||||
.chat-activity[open] .activity-body,
|
||||
.tree-turn[open] .tree-steps {
|
||||
animation: content-enter 160ms var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
Keyframes (not transitions) are correct here: each open is a fresh one-shot enter; re-opening restarting from zero is the intended semantic.
|
||||
|
||||
## Repo conventions to follow
|
||||
|
||||
- `--ease-out` token from plan 001 (depends on it).
|
||||
- Place the keyframe near the existing `@keyframes traj-flash` block.
|
||||
|
||||
## Steps
|
||||
|
||||
1. styles.css: add the `content-enter` keyframe next to `traj-flash`.
|
||||
2. styles.css: add the three-selector rule exactly as in Target.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do NOT animate height (no interpolate-size tricks) — opacity+transform only.
|
||||
- Do NOT animate collapse.
|
||||
- Do NOT touch `.md-*`, `.spawn-list`, or inline feedback form styles.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Feel check**: expand a trajectory row — the body settles downward-into-place in ~160ms; collapse is instant. Open a chat Tool use block — same. Expand-all in trajectory: bodies animate once, scrolling stays smooth (animation is per-element, one-shot).
|
||||
- **Done when**: all three surfaces animate on open, none on close.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 004 — Press feedback on pushbuttons
|
||||
|
||||
- **Status**: DONE
|
||||
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
|
||||
- **Severity**: LOW
|
||||
- **Category**: Physicality & origin
|
||||
- **Estimated scope**: 1 file (styles.css), 1 rule
|
||||
|
||||
## Problem
|
||||
|
||||
No pressable element gives press feedback; clicks feel dead. Applies to true pushbuttons only — rows and list items must NOT scale (they're selection surfaces, not buttons).
|
||||
|
||||
## Target
|
||||
|
||||
```css
|
||||
.segmented button:active,
|
||||
.trajectory-toolbar button:active,
|
||||
.drawer-actions button:active,
|
||||
.row-tools button:active,
|
||||
.fb-send:active,
|
||||
.feedback-submit:active,
|
||||
.new-session-button:active,
|
||||
.activity-open-inspector:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.segmented button,
|
||||
.trajectory-toolbar button,
|
||||
.drawer-actions button,
|
||||
.row-tools button,
|
||||
.fb-send,
|
||||
.feedback-submit,
|
||||
.new-session-button {
|
||||
transition: transform 160ms var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
Subtle (0.97), transform-only, 160ms — inside the 100–160ms press-feedback budget on release.
|
||||
|
||||
## Repo conventions to follow
|
||||
|
||||
- `--ease-out` token from plan 001 (depends on it).
|
||||
|
||||
## Steps
|
||||
|
||||
1. styles.css: add both rules near the global `button` styles at the top of the file.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do NOT add `:active` scaling to `.session-row`, `.traj-summary`, `.tree-step`, `.spawn-link`, `.wf-label-col`, `.wf-bar`, or chat summaries.
|
||||
- Do NOT scale below 0.95.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Feel check**: hold the Trajectory segmented button down — it compresses slightly; release — it springs back over ~160ms. Click a session row — no scaling.
|
||||
- **Done when**: pushbuttons compress on press, selection surfaces don't.
|
||||
@@ -0,0 +1,70 @@
|
||||
# 005 — prefers-reduced-motion support
|
||||
|
||||
- **Status**: DONE
|
||||
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
|
||||
- **Severity**: MEDIUM
|
||||
- **Category**: Accessibility
|
||||
- **Estimated scope**: 1 file (styles.css), 1 media query block
|
||||
|
||||
## Problem
|
||||
|
||||
styles.css has zero `prefers-reduced-motion` handling. Movement-based motion (toast slide, inspector slide-in from 002, content slide from 003, press scale from 004) plays regardless of the OS setting.
|
||||
|
||||
## Target
|
||||
|
||||
Reduced motion = drop position/scale changes, KEEP opacity feedback (comprehension aids stay):
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
transform: none;
|
||||
transition: opacity 200ms var(--ease-out);
|
||||
}
|
||||
.chat-split.inspector-open .inspector,
|
||||
.wf-split.inspector-open .inspector {
|
||||
transition: opacity 200ms var(--ease-out);
|
||||
@starting-style {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.traj-body,
|
||||
.chat-activity[open] .activity-body,
|
||||
.tree-turn[open] .tree-steps {
|
||||
animation: none;
|
||||
}
|
||||
.segmented button:active,
|
||||
.trajectory-toolbar button:active,
|
||||
.drawer-actions button:active,
|
||||
.row-tools button:active,
|
||||
.fb-send:active,
|
||||
.feedback-submit:active,
|
||||
.new-session-button:active,
|
||||
.activity-open-inspector:active {
|
||||
transform: none;
|
||||
}
|
||||
.flash {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 3px rgba(10, 100, 255, .45);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note `.flash` keeps a static highlight ring (the jump-target indicator is comprehension, not decoration) — it just stops pulsing. `.toast` keeps its opacity fade.
|
||||
|
||||
## Repo conventions to follow
|
||||
|
||||
- Depends on plans 001–004 (targets their rules). Place the block at the end of styles.css, before the responsive media queries.
|
||||
|
||||
## Steps
|
||||
|
||||
1. styles.css: add the media query block exactly as in Target.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do NOT remove opacity transitions — reduced motion is fewer/gentler, not zero.
|
||||
- Do NOT gate hover color changes (no movement involved).
|
||||
|
||||
## Verification
|
||||
|
||||
- **Feel check**: DevTools → Rendering → emulate `prefers-reduced-motion: reduce`. Toast fades without sliding; inspector fades in place; expanding rows appears instantly; jump-to still shows a static ring.
|
||||
- **Done when**: with reduction on, nothing on screen translates or scales, but state feedback remains visible.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Animation plans
|
||||
|
||||
Written by the `improve-animations` audit (2026-07-18) against the uncommitted working tree.
|
||||
|
||||
| # | Plan | Severity | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| 001 | [Add easing token, upgrade transitions](001-motion-foundation.md) | LOW (foundation) | DONE |
|
||||
| 002 | [Inspector content slide-in](002-inspector-enter.md) | MEDIUM | DONE |
|
||||
| 003 | [Fade-slide expanded content](003-details-enter.md) | MEDIUM | DONE |
|
||||
| 004 | [Press feedback on pushbuttons](004-press-feedback.md) | LOW | DONE |
|
||||
| 005 | [prefers-reduced-motion support](005-reduced-motion.md) | MEDIUM | DONE |
|
||||
|
||||
Execution order: 001 first (defines `--ease-out` used by all others), then 002–004 in any order, 005 last (its selectors target rules created by 002–004).
|
||||
|
||||
Explicit non-findings from the audit (do not "fix" these): instant view switching, instant hover states, the 1.4s flash pulse, instant collapse/close animations — all deliberate restraint for a crisp dashboard.
|
||||
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs')
|
||||
const http = require('node:http')
|
||||
const path = require('node:path')
|
||||
const { URL } = require('node:url')
|
||||
|
||||
const PORT = Number(process.env.PORT || 5173)
|
||||
const HOST = process.env.HOST || '127.0.0.1'
|
||||
const DEFAULT_ROOT = path.join(process.cwd(), '.sessions')
|
||||
const SESSIONS_ROOT = process.env.HARNESS_SESSIONS_ROOT || DEFAULT_ROOT
|
||||
const STATIC_ROOT = __dirname
|
||||
const FEEDBACK_ROOT = process.env.HARNESS_FEEDBACK_ROOT || path.join(STATIC_ROOT, '.feedback')
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
}
|
||||
|
||||
function send(res, status, body, type = 'application/json; charset=utf-8') {
|
||||
const payload = type.startsWith('application/json') ? JSON.stringify(body, null, 2) : body
|
||||
res.writeHead(status, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-store',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
function sendFile(res, full, type) {
|
||||
const body = fs.readFileSync(full)
|
||||
res.writeHead(200, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-store',
|
||||
'content-length': body.length,
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ''
|
||||
req.setEncoding('utf8')
|
||||
req.on('data', chunk => {
|
||||
body += chunk
|
||||
if (body.length > 1024 * 1024) {
|
||||
req.destroy()
|
||||
reject(new Error('request body too large'))
|
||||
}
|
||||
})
|
||||
req.on('end', () => {
|
||||
if (!body.trim()) return resolve({})
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function walkJsonl(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walkJsonl(full, out)
|
||||
else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function readJsonl(file) {
|
||||
const text = fs.readFileSync(file, 'utf8')
|
||||
const rows = []
|
||||
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
rows.push(JSON.parse(line))
|
||||
} catch (error) {
|
||||
rows.push({ type: 'parse/error', seq: index, time: 0, data: { line, error: String(error) } })
|
||||
}
|
||||
}
|
||||
const first = rows[0]
|
||||
const meta = first?.type === 'session'
|
||||
? { ...first, path: file }
|
||||
: { type: 'session', version: 0, id: path.basename(file, '.jsonl'), createdAt: 0, path: file }
|
||||
const events = first?.type === 'session' ? rows.slice(1) : rows
|
||||
return { meta, events, rawText: text }
|
||||
}
|
||||
|
||||
function feedbackFile(sessionId) {
|
||||
return path.join(FEEDBACK_ROOT, `${encodeURIComponent(sessionId)}.feedback.jsonl`)
|
||||
}
|
||||
|
||||
function readFeedback(sessionId) {
|
||||
const file = feedbackFile(sessionId)
|
||||
if (!fs.existsSync(file)) return []
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line, index) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch (error) {
|
||||
return { type: 'feedback/parse-error', seq: index, time: 0, data: { line, error: String(error) } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function appendFeedback(sessionId, data) {
|
||||
fs.mkdirSync(FEEDBACK_ROOT, { recursive: true })
|
||||
const rows = readFeedback(sessionId)
|
||||
const record = {
|
||||
type: 'feedback/add',
|
||||
seq: rows.length,
|
||||
time: Date.now(),
|
||||
data: {
|
||||
sessionId,
|
||||
targetId: String(data.targetId || `session:${sessionId}`),
|
||||
targetTitle: String(data.targetTitle || sessionId),
|
||||
targetKind: String(data.targetKind || 'session'),
|
||||
author: String(data.author || 'anonymous').trim() || 'anonymous',
|
||||
text: String(data.text || '').trim(),
|
||||
},
|
||||
}
|
||||
if (!record.data.text) throw new Error('feedback text is required')
|
||||
fs.appendFileSync(feedbackFile(sessionId), `${JSON.stringify(record)}\n`)
|
||||
return record
|
||||
}
|
||||
|
||||
function textOfContent(content) {
|
||||
if (!Array.isArray(content)) return ''
|
||||
return content.map((block) => {
|
||||
if (block.type === 'text' || block.type === 'reasoning') return block.text || ''
|
||||
if (block.type === 'tool-call') return `[tool-call ${block.name}] ${block.arguments || ''}`
|
||||
if (block.type === 'tool-result') return `[tool-result] ${JSON.stringify(block.content ?? block)}`
|
||||
return JSON.stringify(block)
|
||||
}).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function latestHeader(events) {
|
||||
const headers = events.filter(event => event.type === 'request/header' && event.data?.header)
|
||||
return headers.at(-1)?.data?.header ?? {}
|
||||
}
|
||||
|
||||
function summarize(file) {
|
||||
const { meta, events } = readJsonl(file)
|
||||
const header = latestHeader(events)
|
||||
const last = events.at(-1)
|
||||
const firstUser = events.find(event => event.type === 'user/message')
|
||||
return {
|
||||
id: String(meta.id),
|
||||
parentSession: meta.parentSession ? String(meta.parentSession) : undefined,
|
||||
cwd: meta.cwd,
|
||||
path: file,
|
||||
createdAt: meta.createdAt || events[0]?.time || 0,
|
||||
lastActivity: last?.time || meta.createdAt || 0,
|
||||
eventCount: events.length,
|
||||
turnCount: events.filter(event => event.type === 'turn/start').length,
|
||||
stepCount: events.filter(event => event.type === 'step/start').length,
|
||||
toolCallCount: events.filter(event => event.type === 'tool/call').length,
|
||||
model: header.config?.model,
|
||||
title: textOfContent(firstUser?.data?.content).slice(0, 120) || String(meta.id),
|
||||
}
|
||||
}
|
||||
|
||||
function findSessionFile(id) {
|
||||
const files = walkJsonl(SESSIONS_ROOT)
|
||||
const byName = files.find(file => path.basename(file, '.jsonl') === id)
|
||||
if (byName) return byName
|
||||
return files
|
||||
.map(file => ({ file, summary: summarize(file) }))
|
||||
.find(item => item.summary.id === id)?.file
|
||||
}
|
||||
|
||||
function parseArguments(text) {
|
||||
if (typeof text !== 'string') return text
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode({ id, kind, title, subtitle, startTime, status = 'ok' }) {
|
||||
return {
|
||||
id, kind, title, subtitle, status,
|
||||
startTime, endTime: startTime, startSec: 0, durationSec: 0,
|
||||
children: [], rawEvents: [], detail: {},
|
||||
}
|
||||
}
|
||||
|
||||
function assignTiming(node, zero, fallbackEnd) {
|
||||
const start = Number.isFinite(node.startTime) ? node.startTime : zero
|
||||
const end = Math.max(start, Number.isFinite(node.endTime) ? node.endTime : fallbackEnd)
|
||||
node.startSec = Math.max(0, (start - zero) / 1000)
|
||||
node.durationSec = Math.max(0, (end - start) / 1000)
|
||||
for (const child of node.children) assignTiming(child, zero, fallbackEnd)
|
||||
}
|
||||
|
||||
function buildSession(file) {
|
||||
const { meta, events } = readJsonl(file)
|
||||
const firstTime = events[0]?.time || meta.createdAt || Date.now()
|
||||
const lastTime = events.at(-1)?.time || firstTime
|
||||
const headerEvents = events.filter(event => event.type === 'request/header' && event.data?.header)
|
||||
const header = latestHeader(events)
|
||||
const tools = Array.isArray(header.tools) ? header.tools : []
|
||||
const toolsByName = new Map(tools.map(tool => [tool.name, tool]))
|
||||
|
||||
const root = makeNode({
|
||||
id: `session:${meta.id}`,
|
||||
kind: 'session',
|
||||
title: String(meta.id),
|
||||
subtitle: meta.cwd || path.dirname(file),
|
||||
startTime: firstTime,
|
||||
})
|
||||
root.endTime = lastTime
|
||||
root.rawEvents = events
|
||||
|
||||
const turnMap = new Map()
|
||||
const stepMap = new Map()
|
||||
const toolMap = new Map()
|
||||
const firstChunkByStep = new Map()
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const key = `${event.data?.turn}:${event.data?.step}`
|
||||
if (!firstChunkByStep.has(key)) firstChunkByStep.set(key, event.time)
|
||||
}
|
||||
}
|
||||
|
||||
let currentTurn
|
||||
let currentStep
|
||||
for (const event of events) {
|
||||
if (event.type === 'turn/start') {
|
||||
const turn = event.data.turn
|
||||
const node = makeNode({
|
||||
id: `turn:${turn}`,
|
||||
kind: 'turn',
|
||||
title: `turn ${turn}`,
|
||||
subtitle: event.data.trigger?.kind || 'turn/start',
|
||||
startTime: event.time,
|
||||
})
|
||||
node.rawEvents.push(event)
|
||||
root.children.push(node)
|
||||
turnMap.set(turn, node)
|
||||
currentTurn = node
|
||||
currentStep = undefined
|
||||
} else if (event.type === 'turn/end') {
|
||||
const node = turnMap.get(event.data.turn)
|
||||
if (node) {
|
||||
node.endTime = event.time
|
||||
node.status = event.data.reason?.kind === 'completed' ? 'ok' : 'error'
|
||||
node.rawEvents.push(event)
|
||||
}
|
||||
} else if (event.type === 'step/start') {
|
||||
const turn = turnMap.get(event.data.turn) || currentTurn || root
|
||||
const key = `${event.data.turn}:${event.data.step}`
|
||||
const node = makeNode({
|
||||
id: `step:${key}`,
|
||||
kind: 'step',
|
||||
title: `step ${event.data.step}`,
|
||||
subtitle: `turn ${event.data.turn}`,
|
||||
startTime: event.time,
|
||||
})
|
||||
node.rawEvents.push(event)
|
||||
turn.children.push(node)
|
||||
stepMap.set(key, node)
|
||||
currentStep = node
|
||||
} else if (event.type === 'step/end') {
|
||||
const node = stepMap.get(`${event.data.turn}:${event.data.step}`)
|
||||
if (node) {
|
||||
node.endTime = event.time
|
||||
node.rawEvents.push(event)
|
||||
}
|
||||
} else if (event.type === 'request/header') {
|
||||
const node = currentStep || currentTurn || root
|
||||
node.rawEvents.push(event)
|
||||
node.detail.promptSeq = event.seq
|
||||
} else if (event.type === 'assistant/message') {
|
||||
const key = `${event.data.turn}:${event.data.step}`
|
||||
const step = stepMap.get(key) || currentStep || root
|
||||
const startTime = firstChunkByStep.get(key) ?? event.time
|
||||
const text = textOfContent(event.data.content)
|
||||
const node = makeNode({
|
||||
id: `assistant:${event.seq}`,
|
||||
kind: 'llm',
|
||||
title: 'assistant/message',
|
||||
subtitle: text.slice(0, 96) || 'assembled assistant message',
|
||||
startTime,
|
||||
})
|
||||
node.endTime = event.time
|
||||
node.rawEvents.push(event)
|
||||
node.detail = {
|
||||
input: event.data.usage ? { usage: event.data.usage } : '',
|
||||
output: event.data.content,
|
||||
promptSeq: step.detail.promptSeq,
|
||||
}
|
||||
step.children.push(node)
|
||||
step.rawEvents.push(event)
|
||||
} else if (event.type === 'tool/call') {
|
||||
const key = `${event.data.turn}:${event.data.step}`
|
||||
const step = stepMap.get(key) || currentStep || root
|
||||
const node = makeNode({
|
||||
id: `tool:${event.data.callId}`,
|
||||
kind: 'tool',
|
||||
title: event.data.name,
|
||||
subtitle: event.data.callId,
|
||||
startTime: event.time,
|
||||
})
|
||||
node.rawEvents.push(event)
|
||||
node.detail = {
|
||||
input: parseArguments(event.data.arguments),
|
||||
output: '',
|
||||
promptSeq: step.detail.promptSeq,
|
||||
schema: toolsByName.get(event.data.name),
|
||||
}
|
||||
step.children.push(node)
|
||||
step.rawEvents.push(event)
|
||||
toolMap.set(event.data.callId, node)
|
||||
} else if (event.type === 'tool/result') {
|
||||
const node = toolMap.get(event.data.callId)
|
||||
if (node) {
|
||||
node.endTime = event.time
|
||||
node.status = event.data.isError ? 'error' : 'ok'
|
||||
node.rawEvents.push(event)
|
||||
node.detail.output = {
|
||||
content: event.data.content,
|
||||
isError: event.data.isError,
|
||||
error: event.data.error,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const target = currentStep || currentTurn || root
|
||||
target.rawEvents.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
assignTiming(root, firstTime, lastTime)
|
||||
|
||||
const messages = events
|
||||
.filter(event => ['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message'].includes(event.type))
|
||||
.map(event => ({
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
type: event.type,
|
||||
role: event.type === 'assistant/message' ? 'assistant' : event.type === 'tool/result' ? 'tool' : 'user',
|
||||
content: event.data.content,
|
||||
text: textOfContent(event.data.content),
|
||||
}))
|
||||
|
||||
const stats = {
|
||||
startTime: firstTime,
|
||||
durationSec: Math.max(0, (lastTime - firstTime) / 1000),
|
||||
turns: events.filter(event => event.type === 'turn/start').length,
|
||||
steps: events.filter(event => event.type === 'step/start').length,
|
||||
toolCalls: events.filter(event => event.type === 'tool/call').length,
|
||||
llmMessages: events.filter(event => event.type === 'assistant/message').length,
|
||||
}
|
||||
|
||||
// Serialize the tree with event seq references instead of embedded event
|
||||
// copies: every event is already shipped once in `events`, so nodes carry
|
||||
// `eventSeqs` and the client resolves them through its seq index.
|
||||
const packNode = (node) => {
|
||||
const { rawEvents, children, ...rest } = node
|
||||
return { ...rest, eventSeqs: rawEvents.map(event => event.seq), children: children.map(packNode) }
|
||||
}
|
||||
|
||||
const siblings = listSessions()
|
||||
const sessionId = String(meta.id)
|
||||
const children = siblings.filter(summary => summary.parentSession === sessionId)
|
||||
const parent = meta.parentSession
|
||||
? siblings.find(summary => summary.id === String(meta.parentSession)) ?? { id: String(meta.parentSession) }
|
||||
: undefined
|
||||
|
||||
return {
|
||||
header: { ...meta, path: file },
|
||||
stats,
|
||||
agent: {
|
||||
id: 'A_main',
|
||||
model: header.config?.model,
|
||||
systemPrompt: header.system || '',
|
||||
messagePrefix: header.messagePrefix,
|
||||
tools,
|
||||
headerEvents: headerEvents.length,
|
||||
latestHeader: header,
|
||||
},
|
||||
tree: packNode(root),
|
||||
messages,
|
||||
events,
|
||||
children,
|
||||
parent,
|
||||
feedback: readFeedback(sessionId),
|
||||
}
|
||||
}
|
||||
|
||||
function listSessions() {
|
||||
return walkJsonl(SESSIONS_ROOT)
|
||||
.filter(file => !path.basename(file).startsWith('stdout.'))
|
||||
.map(summarize)
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity)
|
||||
}
|
||||
|
||||
function serveStatic(req, res, pathname) {
|
||||
const target = pathname === '/' ? '/index.html' : pathname
|
||||
const decoded = decodeURIComponent(target)
|
||||
const full = path.normalize(path.join(STATIC_ROOT, decoded))
|
||||
if (full !== STATIC_ROOT && !full.startsWith(STATIC_ROOT + path.sep)) return send(res, 403, 'forbidden', 'text/plain; charset=utf-8')
|
||||
if (!fs.existsSync(full) || fs.statSync(full).isDirectory()) return send(res, 404, 'not found', 'text/plain; charset=utf-8')
|
||||
const ext = path.extname(full)
|
||||
sendFile(res, full, MIME[ext] || 'application/octet-stream')
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`)
|
||||
if (url.pathname === '/api/health') return send(res, 200, { ok: true, root: SESSIONS_ROOT })
|
||||
if (url.pathname === '/api/sessions' && req.method === 'POST') {
|
||||
return send(res, 501, {
|
||||
error: 'New live sessions are not connected yet. This prototype currently replays persisted JSONL sessions.',
|
||||
next: 'Wire this endpoint to the Harness ACP/stdio runtime to create a live session.',
|
||||
})
|
||||
}
|
||||
if (url.pathname === '/api/sessions') return send(res, 200, { root: SESSIONS_ROOT, sessions: listSessions() })
|
||||
if (url.pathname.startsWith('/api/sessions/')) {
|
||||
const suffix = decodeURIComponent(url.pathname.slice('/api/sessions/'.length))
|
||||
if (suffix.endsWith('/feedback')) {
|
||||
const id = suffix.slice(0, -'/feedback'.length)
|
||||
if (req.method === 'GET') return send(res, 200, { feedback: readFeedback(id) })
|
||||
if (req.method === 'POST') return send(res, 200, { feedback: appendFeedback(id, await readJsonBody(req)) })
|
||||
}
|
||||
if (req.method === 'POST' && suffix.endsWith('/messages')) {
|
||||
return send(res, 501, {
|
||||
error: 'Live interaction is not connected yet. This prototype is reading persisted JSONL replay data.',
|
||||
next: 'Wire this endpoint to the Harness ACP/stdio runtime to continue a session.',
|
||||
})
|
||||
}
|
||||
const id = suffix
|
||||
const file = findSessionFile(id)
|
||||
if (!file) return send(res, 404, { error: `session not found: ${id}` })
|
||||
return send(res, 200, buildSession(file))
|
||||
}
|
||||
serveStatic(req, res, url.pathname)
|
||||
} catch (error) {
|
||||
send(res, 500, { error: String(error?.stack || error) })
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Harness Local Workbench listening on http://${HOST}:${PORT}`)
|
||||
console.log(`Reading sessions from ${SESSIONS_ROOT}`)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AGENTS.md": 1370,
|
||||
"AGENTS.md": 1375,
|
||||
"docs/AGENTS.md": 1100,
|
||||
"docs/architecture.md": 1790,
|
||||
"docs/cordis-primer.md": 600,
|
||||
|
||||
Reference in New Issue
Block a user