-
Notifications
You must be signed in to change notification settings - Fork 1
feat(dashboard): file-mode Epic display (epic_ref + file:// links) #199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fa0f0ee
fc1bc2b
1912003
defd0bf
c3c3cfd
8d02df3
2b2ed28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| 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}</>; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. github mode renders plain |
||
| // 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(); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blank-slug guard (self-review hardening). A present-but-blank |
||
| if (slug) { | ||
| return ( | ||
| <a className="epic-file-link" href={epicFileHref(slug)}> | ||
| {slug} | ||
| </a> | ||
| ); | ||
| } | ||
| return <>{fallback}</>; | ||
| } | ||
| 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://"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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/...parsesplanningas the URL authority — it's a display affordance, not a guaranteed click-to-open).encodeURIComponentcollapses the slug to one safe path segment, so a malformed value (../, quotes, angle brackets) can't traverse out ofplanning/epics/or inject markup into thehref; a normal kebab-case slug encodes to itself.