Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,666 changes: 4,665 additions & 1 deletion design/mission-feed-composer.pen

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions docs/features/58-runner-crew-detail-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Runner & crew detail redesign

Tracking issue: [#393](https://github.com/yicheng47/runner/issues/393). Status: planned.

## Motivation

Both detail pages are MVP drafts implemented straight from the historical MVP canvas (`design/runner-mvp-design.pen`, frames `ocAFJ` and `CUKjM`) and both bury their primary content under prompt prose.

**Crew detail** (`src/pages/CrewEditor.tsx`, `/crews/:crewId`): the slot roster — who is actually in this crew — renders last, below the Purpose, Default goal, and Team conventions sections. With real prose in those sections the slots land below the fold; the page reads as a text editor with a roster appendix. Each `SlotRow` also packs handle, LEAD badge, runtime select, model-override chip, source-runner attribution, a line-clamped system-prompt preview, and the effective command line into one dense row, so the load-bearing facts (who, which engine, who leads) don't pop.

**Runner detail** (`src/pages/RunnerDetail.tsx`, `/runners/:handle`): the full default system prompt renders as an unbounded `<pre>` dump at the top of the two-thirds column. Any real prompt is hundreds of lines, pushing "Crews using this runner" and the working-dir info far below the fold. The "Chat now" card duplicates the header's Chat now button and exists mostly to display the working directory; identity (display name) floats as a lone paragraph under the breadcrumb.

## Scope

Presentation-only redesign of the two pages. Same data, same commands, no backend or API changes.

Design first, in Pencil, per repo convention: new feature-scoped file (e.g. `design/58-runner-crew-detail.pen`) with one frame per page; review before any code. The MVP canvas stays untouched as the historical record.

Direction to explore in the design pass (not binding until frames are approved):

- **Crew detail**: slots become the hero, directly under the header/toolbar. Prose config (purpose, goal, conventions) demoted to a secondary presentation — collapsed sections, a side column, or a config tab — collapsed by default when content is long. Slot rows restructured so identity and role read at a glance (avatar/handle/LEAD first-class), with runtime/model overrides and command detail tucked behind hover or disclosure affordances. Keep drag-reorder, set-lead, override editing, and remove flows functionally intact.
- **Runner detail**: identity block (handle, display name, runtime, avatar) plus activity as the hero. System prompt shown clamped with expand/collapse instead of a full dump. Consolidate the "Chat now" card into the header action + a Details row for working dir. "Crews using this runner" stays one glance away.

## Non-Goals

- New capabilities on either page (no new fields, no new slot operations).
- Changes to `RunnerEditDrawer`, `AddSlotModal`, or `StartMissionModal` beyond what the new layouts require for visual consistency.
- List pages (`Runners.tsx`, `Crews.tsx`) — separate surfaces, already covered by feature 56.

## Implementation Phases

1. **Design** — Pencil frames for both pages in a feature-scoped `.pen`; iterate with the user until approved.
2. **Crew detail** — reorder sections (slots first), restructure `SlotRow` per the approved frame, keep reorder/lead/override/remove behaviors and their tests green.
3. **Runner detail** — new layout: identity hero, clamped prompt block, card consolidation.

## Verification

- `pnpm exec tsc --noEmit` and `pnpm run lint` clean after each phase.
- Existing Vitest suites (`CrewEditor.test.tsx`, `RunnerRuntimeModelReset.test.tsx`) still pass; extend where row restructuring moves behavior.
- Manual pass with a worst-case fixture: a runner with a multi-hundred-line system prompt, a crew with 5+ slots and long purpose/goal/conventions prose — slots visible without scrolling on the crew page, prompt collapsed by default on the runner page.
1 change: 1 addition & 0 deletions docs/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ links to its tracking issue.
- [53 — Session fork](./53-session-fork.md) — branch a chat into a new session: native full-history fork for claude-code via `--resume <key> --fork-session`, bounded ring-transcript handoff draft for other runtimes; original session untouched.
- [56 — Backend list pagination](./56-backend-list-pagination.md) — Runners/Crews pagination moves into SQL (`page`/`page_size`/`query` with LIMIT/OFFSET and the search filter server-side); the pager becomes one slim row flush under the cards, hidden at a single page, with no half-clipped card above it ([#377](https://github.com/yicheng47/runner/issues/377)).
- [57 — Start Project modal](./57-start-project-modal.md) — replace the bare directory-picker "Add project" flow with a modal mirroring the chat/mission start modals: directory field prefilled from `settings.defaultWorkingDir`, name field defaulting to the directory basename and following it until manually edited ([#383](https://github.com/yicheng47/runner/issues/383)).
- [58 — Runner & crew detail redesign](./58-runner-crew-detail-redesign.md) — Pencil-first redesign of both MVP-draft detail pages: crew detail puts the slot roster above the prose config sections instead of below them, runner detail clamps the system-prompt dump and consolidates redundant cards ([#393](https://github.com/yicheng47/runner/issues/393)).

## Archive

Expand Down
51 changes: 51 additions & 0 deletions docs/impls/0044-chat-style-feed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Chat-style mission feed: Discord-like rows and identicon identity

## Status

Planned. Design: `design/mission-feed-composer.pen` — frame "Feed · chat-style messages" (`rAJ00`), updated "Runners rail" (`LMQ7c`), decision notes (`Rauwg`). Builds on the #392 working-tree fixes already present in `MessageBody.tsx` / `EventFeed.tsx` (list gutter, mission-goal row, spacing). Revives the identicon idea from `docs/features/archive/11-runner-avatar.md`, which was specced but never shipped.

## Problem

The feed renders three different anatomies for what is conversationally the same thing: runner/human messages are bare header+body rows, the mission goal was (until the #392 fix) a bordered mono payload block, and signals are JSON boxes — including the raw `ask_human` signal, which renders its full JSON right above the `human_question` card that presents the same prompt, choices, and attribution properly. Identity is a monospace handle with a single accent color for every runner, so multi-runner missions have nothing for the eye to lock onto, and the rail's busy/idle state lives in a separate dot glyph next to the handle.

## Key Decisions

1. **Discord/Slack row anatomy.** Every conversational event renders as avatar + header (author, context, time) + body. Consecutive message-like events from the same author within a 5-minute window collapse into one group: avatar and header render once, subsequent bodies stack under them. Any non-message block (divider, signal line, ask card) breaks the group. Grouping is a pure function over the filtered event list so it is unit-testable.

2. **`RunnerAvatar` identicon, deterministic from the handle.** 5×5 symmetric pixel grid (columns mirrored) generated from a handle hash, rendered on a raised rounded square — 35px in the feed, 25px in the rail. The same hash picks the runner's hue from a fixed palette of carbon-legible colors (accent green, cyan, violet, orange, …); the hue also colors the handle text wherever it appears. Amber (`warn`) is reserved for the human — "you" always renders amber with a fixed pattern and is never assigned to a runner. The LEAD marker stays a badge, not a color.

3. **Message-like events share one renderer.** `message`, `human_said`, `human_response`, and `mission_goal` all go through the same message-row path. `mission_goal` keeps its small `GOAL` chip in the header (from the #392 fix) and an optional `→ @target`. `mission_start` becomes a thin centered divider (`MISSION STARTED · time`), Discord date-divider style.

4. **Raw `ask_human` signals are hidden.** They are worker→router plumbing fully duplicated by the router's `human_question`; `isHiddenSystemSignal` grows to cover them. The `AskHumanCard` absorbs the identity: it renders inside the asker's avatar row with a `NEEDS YOUR INPUT` chip and the `→ you` chain in the header; card internals (prompt, choice buttons, resolved state) are unchanged.

5. **Remaining signals become one-line rows.** `ask_lead`, `mission_warning`, and unknown types render as a single indented line — zap icon, author handle, `signal · type`, time — with a `payload ▾` disclosure that expands the existing payload rendering (the current JSON box body) inline. `mission_warning` keeps a danger tint on the line so diagnostics still stand out. Answered `ask_lead` lines stay visible; the feed remains a faithful log, no fading.

6. **Rail cards adopt the same identity.** In `RunnersRail`, the avatar (25px) with the runner hue replaces the inline status dot; PTY/runner status moves onto the avatar corner as a presence dot (busy accent, idle dim accent, stopped gray, crashed danger — same priority order as today's `dotClass`). Handle text takes the hue. The rest of the card (LEAD badge, Open-PTY button, status subtitle, `session_key` row) is already aligned between code and the updated design frame and does not change structurally.

## Goals

- One visual grammar for every conversational turn; system rows visibly distinct but quiet.
- A three-runner mission is scannable by color and pattern without reading handles.
- Pure presentation: no event-schema, router, log, or backend changes; archived missions replay identically.

## Non-Goals

- Reactions, replies, hover action toolbars, message editing — Discord look, not Discord features.
- Composer changes (shipped in impl 0042) or `AskHumanCard` flow/button changes.
- Avatar images/uploads or per-runner color configuration; the mapping is deterministic.
- Persisting payload-disclosure open state.

## Implementation Notes

- `src/components/ui/RunnerAvatar.tsx` — new. Props: `seed`, `size`, optional `presence`. Exports `hueForSeed(seed)` for handle coloring; special-cases the human seed to `warn`.
- `src/lib/eventFeed.ts` — add `groupFeedBlocks(events)` returning typed blocks (`divider` | `message-group` | `signal` | `ask-card`); extend the hidden-signal predicate with `ask_human`.
- `src/components/EventFeed.tsx` — render from `groupFeedBlocks`; message-group renderer (avatar column + header + stacked bodies via `MessageBody`); divider renderer; signal one-liner with payload disclosure reusing `renderPayload` as the expanded body.
- `src/components/AskHumanCard.tsx` — header row becomes avatar + chip + chain per design; accepts the asker handle it already receives.
- `src/components/RunnersRail.tsx` — swap dot for `RunnerAvatar` with presence; hue on handle text.
- `src/components/MessageBody.tsx` — no changes beyond the #392 fixes already in the tree.

## Validation

- Vitest: `groupFeedBlocks` — same-author grouping inside/outside the 5-minute window, group break on interleaved signal, `mission_goal`/`human_said` classified message-like, `ask_human` hidden, `mission_start` → divider; `hueForSeed` determinism and human reservation.
- Manual: live 2-slot mission — grouped runner turns, goal row, divider, signal disclosure toggle, ask card with avatar; archived mission replay renders identically; rail presence dot tracks busy/idle/stopped.
- `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
130 changes: 64 additions & 66 deletions src/components/AskHumanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useState } from "react";
import { api } from "../lib/api";
import type { HumanQuestionPayload } from "../lib/types";
import { MessageBody } from "./MessageBody";
import { RunnerAvatar, hueForSeed } from "./ui/RunnerAvatar";

interface AskHumanCardProps {
missionId: string;
Expand Down Expand Up @@ -44,10 +45,7 @@ export function AskHumanCard({
: ["yes", "no"]; // sensible fallback so the card always has buttons

const onBehalf = payload.on_behalf_of;
// Attribution chain: when an `ask_human` carries `on_behalf_of`, the
// worker who originally asked is shown alongside the lead so the human
// sees `*@impl → @architect → you*` (per design frame `Z7Dbo`).
const chain = onBehalf ? `@${onBehalf} → @${asker} → you` : `@${asker} → you`;
const chain = onBehalf ? `@${onBehalf} → @${asker} → you` : "→ you";

const resolved = resolvedChoice != null;

Expand All @@ -70,76 +68,76 @@ export function AskHumanCard({
}

return (
<div className="rounded-lg border border-warn/60 bg-warn/10 p-4">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-[12px] font-semibold text-warn">
needs your input
<div className="flex gap-3 px-4">
<RunnerAvatar seed={asker} size={35} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span
className="truncate font-mono text-[13px] font-semibold"
style={{ color: hueForSeed(asker) }}
>
@{asker}
</span>
<span className="text-[11px] font-mono text-fg-2 truncate">
<span className="shrink-0 rounded bg-warn/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em] text-warn">
Needs your input
</span>
<span className="truncate font-mono text-[11px] text-fg-2">
{chain}
</span>
</div>
<span className="text-[11px] text-fg-3">{formatTs(ts)}</span>
</div>
<div className="mt-2 text-[13px] leading-relaxed text-fg">
{payload.prompt ? (
<MessageBody text={payload.prompt} />
) : (
<span className="text-fg-3">(no prompt)</span>
)}
</div>
{/* Choices stack vertically as full-width buttons. Multi-choice
asks (3–4 long options) used to wrap across rows in a tight
horizontal flex, hard to scan; vertical full-width matches
Pencil node `Z7Dbo` and gives each option its own line.
Primary action (first choice) is the green-filled CTA;
subsequent options are outlined buttons. */}
<div className="mt-3 flex flex-col gap-1.5">
{choices.map((c, idx) => {
const isPrimary = idx === 0;
const isPicked = resolved
? c === resolvedChoice
: pickedChoice === c;
return (
<button
key={c}
type="button"
onClick={() => submit(c)}
disabled={submitting || resolved}
className={
isPrimary
? `flex w-full cursor-pointer items-center rounded-md px-3.5 py-2.5 text-left text-[12px] font-semibold transition-all ${
isPicked
? "bg-accent text-accent-ink"
: "bg-accent text-accent-ink hover:bg-accent/90 hover:shadow-[0_0_0_1px_var(--color-accent)] hover:-translate-y-px"
} disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0 disabled:hover:shadow-none`
: `flex w-full cursor-pointer items-center rounded-md border border-line bg-panel px-3.5 py-2.5 text-left text-[12px] font-medium text-fg transition-all ${
isPicked ? "border-accent text-accent" : ""
} hover:border-fg-3 hover:bg-raised hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-panel disabled:hover:border-line`
}
>
{c}
</button>
);
})}
{resolved ? (
<span className="mt-1 text-[11px] text-fg-3">
answered: <span className="text-fg-2">{resolvedChoice}</span>
<span className="shrink-0 text-[11px] text-fg-3">
{formatTs(ts)}
</span>
) : null}
</div>
<div className="mt-1.5 rounded-lg border border-warn/60 bg-warn/10 p-4">
<div className="text-[13px] leading-relaxed text-fg">
{payload.prompt ? (
<MessageBody text={payload.prompt} />
) : (
<span className="text-fg-3">(no prompt)</span>
)}
</div>
<div className="mt-3 flex flex-col gap-1.5">
{choices.map((c, idx) => {
const isPrimary = idx === 0;
const isPicked = resolved
? c === resolvedChoice
: pickedChoice === c;
return (
<button
key={c}
type="button"
onClick={() => submit(c)}
disabled={submitting || resolved}
className={
isPrimary
? `flex w-full cursor-pointer items-center rounded-md px-3.5 py-2.5 text-left text-[12px] font-semibold transition-all ${
isPicked
? "bg-accent text-accent-ink"
: "bg-accent text-accent-ink hover:bg-accent/90 hover:shadow-[0_0_0_1px_var(--color-accent)] hover:-translate-y-px"
} disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0 disabled:hover:shadow-none`
: `flex w-full cursor-pointer items-center rounded-md border border-line bg-panel px-3.5 py-2.5 text-left text-[12px] font-medium text-fg transition-all ${
isPicked ? "border-accent text-accent" : ""
} hover:border-fg-3 hover:bg-raised hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-panel disabled:hover:border-line`
}
>
{c}
</button>
);
})}
{resolved ? (
<span className="mt-1 text-[11px] text-fg-3">
answered: <span className="text-fg-2">{resolvedChoice}</span>
</span>
) : null}
</div>
</div>
</div>
</div>
);
}

function formatTs(ts: string): string {
// Display the local-time HH:MM:SS slice only — the workspace feed is
// always anchored to "now" so the date is implied.
try {
const d = new Date(ts);
return d.toLocaleTimeString();
} catch {
return ts;
}
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return ts;
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
Loading
Loading