Skip to content
51 changes: 51 additions & 0 deletions packages/dashboard/src/app/components/EpicRef.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Renders an Epic reference per the dispatch mode the workflow row carries:
*
* - **github mode** (`epicNumber !== null`) → plain `#N` text, byte-for-byte
* what the surfaces rendered before file mode existed. Deliberately not an
* anchor — AC4 of #187 is "no behavior change for github-mode rows".
* - **file mode** (`epicNumber === null`, `epicRef` a slug) → the slug as a
* `file://planning/epics/<slug>.md` link, the on-disk Epic file. No GitHub
* link in file mode (the Epic isn't a GitHub issue).
* - **no Epic** (both null — a recommender / documentation row) → the caller's
* `fallback`, defaulting to the em dash the surfaces already showed.
*
* The `fallback` prop exists because the two callers differ in their pre-#187
* empty rendering (the runner surfaces showed `#—`, others a bare `—`); keeping
* it configurable preserves each surface's exact output.
*/

/**
* Build the `file://` href for an Epic file from its slug. The slug is a single
* path segment, URL-encoded so a malformed value can't break out of the
* `planning/epics/` directory or inject markup into the `href`.
*/
export function epicFileHref(slug: string): string {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file:// href + slug encoding. AC2 names exactly file:// + planning/epics/<slug>.md; the repo's absolute root isn't known client-side, so the path stays repo-relative as specified (yes, file://planning/... parses planning as the URL authority — it's a display affordance, not a guaranteed click-to-open). encodeURIComponent collapses the slug to one safe path segment, so a malformed value (../, quotes, angle brackets) can't traverse out of planning/epics/ or inject markup into the href; a normal kebab-case slug encodes to itself.

return `file://planning/epics/${encodeURIComponent(slug)}.md`;
}

export function EpicRef({
epicNumber,
epicRef,
fallback = "—",
}: {
epicNumber: number | null;
epicRef: string | null;
/** What to render when there's no Epic at all (both ids null). */
fallback?: string;
}) {
if (epicNumber !== null) return <>#{epicNumber}</>;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

github mode renders plain #N, not an anchor — on purpose. The issue Context describes the current rendering as "a numeric link to GitHub", but the surfaces in fact render plain #N text today (no <a> exists). AC4 is the hard constraint — no behavior change for github-mode rows — so adding a GitHub anchor would itself be a behavior change. The literal reading wins: github mode keeps its exact pre-#187 plain-text output; file mode is the only new rendering. (toBe("#42") in the test asserts byte-identical markup.)

// A present *and non-blank* slug is the file-mode signal. A null column is JS
// null; an empty/whitespace value (no real writer produces one today, but a
// future one could) would otherwise render an empty-labelled link to
// `planning/epics/.md`, so treat it as "no Epic" and fall through.
const slug = epicRef?.trim();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blank-slug guard (self-review hardening). A present-but-blank epicRef ("" / whitespace) would otherwise take the file-mode branch and render <a href="file://planning/epics/.md"></a>. No writer produces a blank value today, but guarding on a trimmed-truthy slug encodes the real intent ("a present, non-blank slug is the file-mode signal") and survives a future writer. Trimming also keeps stray whitespace out of the label and href.

if (slug) {
return (
<a className="epic-file-link" href={epicFileHref(slug)}>
{slug}
</a>
);
}
return <>{fallback}</>;
}
3 changes: 2 additions & 1 deletion packages/dashboard/src/app/components/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type { RunnerPanel, SessionEvent } from "../../wire.ts";
import { ago } from "../format.ts";
import { CopyCommand } from "./CopyCommand.tsx";
import { EpicRef } from "./EpicRef.tsx";

/** Events that record a verification gate outcome — pulled out as evidence. */
function isVerificationEvent(type: string): boolean {
Expand Down Expand Up @@ -37,7 +38,7 @@ export function Inspector({
<aside className="inspector" role="dialog" aria-label={`Inspector for ${panel.session}`}>
<div className="inspector-head">
<h3>
#{panel.epic ?? "—"} · {panel.repo}
<EpicRef epicNumber={panel.epic} epicRef={panel.epicRef} fallback="#—" /> · {panel.repo}
</h3>
<button type="button" className="inspector-close" onClick={() => onClose?.()}>
close
Expand Down
5 changes: 3 additions & 2 deletions packages/dashboard/src/app/components/RunnerRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type { RunnerSummary } from "../../wire.ts";
import { ago } from "../format.ts";
import { CopyCommand } from "./CopyCommand.tsx";
import { EpicRef } from "./EpicRef.tsx";

export function RunnerRow({
runner,
Expand All @@ -28,8 +29,8 @@ export function RunnerRow({
className="runner-open"
onClick={() => onOpenInspector?.(runner.session)}
>
#{runner.epic ?? "—"} · {runner.adapter} · {runner.progress} ·{" "}
{ago(runner.lastHeartbeat, now)} ago
<EpicRef epicNumber={runner.epic} epicRef={runner.epicRef} fallback="#—" /> ·{" "}
{runner.adapter} · {runner.progress} · {ago(runner.lastHeartbeat, now)} ago
</button>
{runner.controlledBy === "human" ? <span className="badge human">human</span> : null}
<span className="runner-actions">
Expand Down
2 changes: 1 addition & 1 deletion packages/dashboard/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function bridgeWorkflowsToBus(bus: DashboardEventBus, db: Database): () =
if (!row) return;
bus.broadcastRepo(row.repo, {
type: WORKFLOW_EVENT,
data: { id, repo: row.repo, epic: row.epicNumber, state: row.state },
data: { id, repo: row.repo, epic: row.epicNumber, epicRef: row.epicRef, state: row.state },
});
});
}
5 changes: 4 additions & 1 deletion packages/dashboard/src/db-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type WorkflowRow = {
id: string;
repo: string;
epic_number: number | null;
epic_ref: string | null;
adapter: string;
state: string;
controlled_by: string;
Expand All @@ -94,7 +95,7 @@ type WorkflowRow = {
};

const WORKFLOW_COLUMNS =
"id, repo, epic_number, adapter, state, controlled_by, session_name, transcript_path, worktree_path, current_sub_issue, pr_number, pr_branch, last_heartbeat";
"id, repo, epic_number, epic_ref, adapter, state, controlled_by, session_name, transcript_path, worktree_path, current_sub_issue, pr_number, pr_branch, last_heartbeat";

/** Lifecycle states a workflow has finished in — excluded from "in flight". */
const TERMINAL_STATES = ["completed", "compensated", "failed", "cancelled"] as const;
Expand All @@ -117,6 +118,7 @@ function toRunnerSummary(row: WorkflowRow): RunnerSummary {
session: row.session_name ?? row.id,
workflowId: row.id,
epic: row.epic_number,
epicRef: row.epic_ref,
adapter: row.adapter,
progress: progressOf(row),
state: row.state,
Expand Down Expand Up @@ -437,6 +439,7 @@ export function createDbDeps(opts: DbDepsOptions): DashboardDeps {
workflowId: row.id,
repo: row.repo,
epic: row.epic_number,
epicRef: row.epic_ref,
adapter: row.adapter,
state: row.state,
controlledBy: row.controlled_by === "human" ? "human" : "middle",
Expand Down
14 changes: 14 additions & 0 deletions packages/dashboard/src/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,14 @@ export type RunnerSummary = {
/** The tmux session name — the key for `/api/sessions/:session/*`. */
session: string;
workflowId: string;
/** The github-mode Epic issue number, or null in file mode. */
epic: number | null;
/**
* The canonical Epic reference: a slug in file mode (`epic` is then null), or
* `String(epic)` / null in github mode. Renders as a `file://` link when set
* and `epic` is null; github-mode rows render from `epic`.
*/
epicRef: string | null;
adapter: string;
/** `sub-issue 2/4` or `running`. */
progress: string;
Expand Down Expand Up @@ -104,7 +111,14 @@ export type RunnerPanel = {
session: string;
workflowId: string;
repo: string;
/** The github-mode Epic issue number, or null in file mode. */
epic: number | null;
/**
* The canonical Epic reference: a slug in file mode (`epic` is then null), or
* `String(epic)` / null in github mode. Renders as a `file://` link when set
* and `epic` is null; github-mode rows render from `epic`.
*/
epicRef: string | null;
adapter: string;
state: string;
controlledBy: "middle" | "human";
Expand Down
64 changes: 64 additions & 0 deletions packages/dashboard/test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,70 @@ describe("dashboard JSON API", () => {
expect(detail.nextUp).toEqual([]); // no state gateway wired
});

test("github-mode IN FLIGHT row carries epicRef alongside the numeric epic (#187)", async () => {
// createWorkflowRecord doesn't write epic_ref, so a github-mode row's epicRef
// is null over the wire; the UI keys its numeric render off `epic`, not epicRef.
seedWorkflow(db, {
id: "w1",
repo: "o/alpha",
epicNumber: 7,
state: "running",
sessionName: "sess-7",
});
await start();

const detail = (await (
await fetch(`${base}/api/repos/${encodeURIComponent("o/alpha")}`)
).json()) as RepoDetail;
expect(detail.inFlight[0]).toMatchObject({ session: "sess-7", epic: 7, epicRef: null });
});

test("file-mode IN FLIGHT row surfaces epic_ref as epicRef with a null epic (#187)", async () => {
// The end-to-end read path: a migrated db row with epic_number NULL +
// epic_ref slug → db-deps SELECT → RunnerSummary JSON over a real Bun.serve.
seedWorkflow(db, {
id: "wf-file",
repo: "o/alpha",
epicNumber: null,
epicRef: "rollout-epic-store",
state: "running",
sessionName: "sess-file",
});
await start();

const detail = (await (
await fetch(`${base}/api/repos/${encodeURIComponent("o/alpha")}`)
).json()) as RepoDetail;
expect(detail.inFlight).toHaveLength(1);
expect(detail.inFlight[0]).toMatchObject({
session: "sess-file",
epic: null,
epicRef: "rollout-epic-store",
});
});

test("GET /api/sessions/:session carries epicRef for a file-mode runner (#187)", async () => {
seedWorkflow(db, {
id: "wf-file",
repo: "o/alpha",
epicNumber: null,
epicRef: "rollout-epic-store",
state: "running",
sessionName: "sess-file",
worktreePath: "/wt/alpha-file",
});
await start();

const panel = (await (await fetch(`${base}/api/sessions/sess-file`)).json()) as RunnerPanel;
expect(panel).toMatchObject({
session: "sess-file",
repo: "o/alpha",
epic: null,
epicRef: "rollout-epic-store",
worktreePath: "/wt/alpha-file",
});
});

test("GET /api/repos/:repo 404s an unknown repo", async () => {
await start();
const res = await fetch(`${base}/api/repos/${encodeURIComponent("o/missing")}`);
Expand Down
2 changes: 2 additions & 0 deletions packages/dashboard/test/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ describe("dashboard views (static render)", () => {
session: "mm-alpha-247",
workflowId: "w1",
epic: 247,
epicRef: null,
adapter: "claude",
progress: "sub-issue 2",
state: "running",
Expand Down Expand Up @@ -178,6 +179,7 @@ describe("dashboard views (static render)", () => {
workflowId: "w1",
repo: "o/alpha",
epic: 247,
epicRef: null,
adapter: "claude",
state: "running",
controlledBy: "middle",
Expand Down
122 changes: 122 additions & 0 deletions packages/dashboard/test/epic-ref.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { EpicRef, epicFileHref } from "../src/app/components/EpicRef.tsx";
import { Inspector } from "../src/app/components/Inspector.tsx";
import { RunnerRow } from "../src/app/components/RunnerRow.tsx";
import type { RunnerPanel, RunnerSummary } from "../src/wire.ts";

const render = (el: React.ReactElement) => renderToStaticMarkup(el);

describe("EpicRef", () => {
test("github mode renders plain `#N` text, no anchor (AC4: no behavior change)", () => {
const out = render(<EpicRef epicNumber={42} epicRef={null} />);
expect(out).toBe("#42");
expect(out).not.toContain("<a");
});

test("github mode renders `#N` even if a backfilled epic_ref is also present", () => {
// epic_number wins: a github-mode row may carry epic_ref = String(epic_number).
const out = render(<EpicRef epicNumber={42} epicRef="42" />);
expect(out).toBe("#42");
expect(out).not.toContain("file://");
});

test("file mode renders the slug as a file:// link to the Epic file, no GitHub link", () => {
const out = render(<EpicRef epicNumber={null} epicRef="rollout-epic-store" />);
expect(out).toContain('href="file://planning/epics/rollout-epic-store.md"');
expect(out).toContain(">rollout-epic-store<");
expect(out).not.toContain("github.com");
expect(out).not.toContain("#");
});

test("no-Epic (both null) renders the caller's fallback", () => {
expect(render(<EpicRef epicNumber={null} epicRef={null} />)).toBe("—");
expect(render(<EpicRef epicNumber={null} epicRef={null} fallback="#—" />)).toBe("#—");
});

test("a blank epicRef (empty / whitespace) falls through to the fallback, not an empty link", () => {
expect(render(<EpicRef epicNumber={null} epicRef="" fallback="#—" />)).toBe("#—");
expect(render(<EpicRef epicNumber={null} epicRef=" " fallback="#—" />)).toBe("#—");
});

test("a slug with surrounding whitespace is trimmed in both label and href", () => {
const out = render(<EpicRef epicNumber={null} epicRef=" the-slug " />);
expect(out).toContain('href="file://planning/epics/the-slug.md"');
expect(out).toContain(">the-slug<");
});

test("a slug with URL-unsafe / traversal chars is encoded into one safe path segment", () => {
expect(epicFileHref("a/../b")).toBe("file://planning/epics/a%2F..%2Fb.md");
expect(epicFileHref('x"><script>')).toBe("file://planning/epics/x%22%3E%3Cscript%3E.md");
// A normal kebab-case slug encodes to itself (hyphens are unreserved).
expect(epicFileHref("rollout-epic-store")).toBe("file://planning/epics/rollout-epic-store.md");
});
});

const runner = (over: Partial<RunnerSummary> = {}): RunnerSummary => ({
session: "s1",
workflowId: "w1",
epic: null,
epicRef: null,
adapter: "claude",
progress: "running",
state: "running",
controlledBy: "middle",
lastHeartbeat: null,
attachCommands: { watch: "tmux attach -r -t s1", control: "tmux attach -t s1" },
...over,
});

describe("RunnerRow Epic rendering", () => {
test("file-mode runner shows the slug file:// link", () => {
const out = render(<RunnerRow runner={runner({ epic: null, epicRef: "the-slug" })} />);
expect(out).toContain('href="file://planning/epics/the-slug.md"');
expect(out).toContain(">the-slug<");
});

test("github-mode runner is unchanged (`#7`, no link)", () => {
const out = render(<RunnerRow runner={runner({ epic: 7, epicRef: null })} />);
expect(out).toContain("#7");
expect(out).not.toContain("file://");
});

test("no-Epic runner keeps the `#—` fallback", () => {
const out = render(<RunnerRow runner={runner({ epic: null, epicRef: null })} />);
expect(out).toContain("#—");
});
});

const panel = (over: Partial<RunnerPanel> = {}): RunnerPanel => ({
session: "s1",
workflowId: "w1",
repo: "o/r",
epic: null,
epicRef: null,
adapter: "claude",
state: "running",
controlledBy: "middle",
alive: true,
lastHeartbeat: null,
contextTokens: null,
transcriptPath: null,
worktreePath: null,
prNumber: null,
prBranch: null,
currentSubIssue: null,
attachCommands: { watch: "tmux attach -r -t s1", control: "tmux attach -t s1" },
...over,
});

describe("Inspector Epic rendering", () => {
test("file-mode panel shows the slug file:// link in the header", () => {
const out = render(<Inspector panel={panel({ epicRef: "the-slug" })} events={[]} />);
expect(out).toContain('href="file://planning/epics/the-slug.md"');
expect(out).toContain(">the-slug<");
});

test("github-mode panel is unchanged (`#7`, no link)", () => {
const out = render(<Inspector panel={panel({ epic: 7 })} events={[]} />);
expect(out).toContain("#7");
expect(out).not.toContain("file://");
});
});
5 changes: 5 additions & 0 deletions packages/dashboard/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export type SeedWorkflow = {
id: string;
repo: string;
epicNumber?: number | null;
/** File-mode Epic slug. Set directly (createWorkflowRecord doesn't write it). */
epicRef?: string | null;
adapter?: string;
state?: WorkflowState;
sessionName?: string;
Expand Down Expand Up @@ -89,6 +91,9 @@ export function seedWorkflow(db: Database, w: SeedWorkflow): void {
worktreePath: w.worktreePath,
});
// Columns updateWorkflow doesn't cover — set directly.
if (w.epicRef !== undefined) {
db.run("UPDATE workflows SET epic_ref = ? WHERE id = ?", [w.epicRef, w.id]);
}
if (w.currentSubIssue !== undefined) {
db.run("UPDATE workflows SET current_sub_issue = ? WHERE id = ?", [w.currentSubIssue, w.id]);
}
Expand Down
Loading