Skip to content

Commit ec6aad3

Browse files
dastratakosclaude
andauthored
feat(PLA-106): SPA chapter UI + HTTP-backed useViewState hook (#11)
* feat(PLA-106): SPA chapter UI + HTTP-backed useViewState hook Wires the SPA against the chapters and view-state APIs from PLA-116/PLA-117 so viewed/checked state persists in SQLite (no localStorage). Layered structure mirrors hosted's route file boundaries: - routes/pull-request-layout.tsx (= hosted's _app.\$orgSlug.\$repo.pull.\$number.tsx) owns PR_TAB, tabs, TabLink, ErrorState, and the orchestrator. Renders ChaptersIndexPage on the active tab. - routes/chapters-index-page.tsx (= hosted's _app.\$orgSlug.\$repo.pull.\$number.index.tsx) owns ChapterLoadingSkeleton, FileCollapsible, ChapterEntry, ChaptersList, and the exported ChaptersIndexPage. - App.tsx is the hash-routing entry: useHashRunId → renders PullRequestLayout, or 'no run selected' fallback. Vendored from packages/ui (radix wrappers + cn): progress, collapsible, skeleton, button, separator. From components/chapter: file-view-row with status optional (chapter API has no per-file status today; the leading icon is omitted instead of faking 'modified'). useViewState hook uses TanStack Query with snapshot-and-patch optimistic writes. Split into useViewStateData (read-only, memoized return) for components that only need is*-style helpers, plus useViewState that adds the four mutations on top — sharing one query key so PullRequestLayout and ChaptersList agree on viewed counts without an extra fetch. queryKey is captured in MutationContext so a runId change mid-mutation can't roll back to the wrong cache entry. Shared wire-format Zod schemas in src/wire/{chapters,view-state}.ts are the source of truth for the chapters and view-state response shapes. Both the CLI server and the SPA import them via @wire/*; the SPA parses fetch responses through them at the network boundary so schema drift surfaces as a query error rather than a runtime crash. useHashRunId uses useSyncExternalStore (matching use-local-storage.ts) and is covered by 5 vitest cases under happy-dom. Tests: 65 total (53 CLI, 12 SPA). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: forward value to ProgressPrimitive.Root for a11y Bugbot caught the shadcn template bug: Progress destructured `value` into the Indicator's inline style but never forwarded it to the Root, so Radix could not set aria-valuenow/aria-valuemax/data-state. Assistive tech read every progress bar as indeterminate. Same issue as shadcn-ui/ui#8771. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rename src/wire/ → src/types/ to match hosted's @stage/types Aligns the shared schema directory with hosted's package naming so a future merge keeps the import sites identical across products. Where hosted writes \`import { Chapter } from "@stage/types"\` against its @stage/types workspace package, stage-cli now writes the same import against a vite/tsconfig/vitest path alias pointing at src/types/. If we ever extract a real package, the alias drops out and the import statements stay the same. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: split use-view-state.test.tsx to honor 200-line cap TESTING.md caps test files at 200 lines; the combined file was 333. Split by behavior group: - web/src/lib/__tests__/fixtures.tsx (new): shared FetchScript, gate, installFetch, makeWrapper helpers. Sibling of the test files so the cross-file scope rule (mock budget is per file) stays obvious. - use-view-state-reads.test.tsx: hydration, idempotent mark, runId scope (3 cases, ~80 lines). - use-view-state-writes.test.tsx: chapter mark/unmark, key-change round-trip, optimistic visibility, rollback (4 cases, ~120 lines). Same 7 cases, same coverage, same single external boundary mocked (fetch). All 4 files under 200 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rename @stage/types → @cli/types, dedupe alias via vite-tsconfig-paths Two cleanup items from the alias-shape audit: 1. Rename the alias: `@stage/types` looked like a scoped npm package and borrowed a namespace we don't own. `@cli/types` is clearly local. 2. Add vite-tsconfig-paths so the alias is declared once in web/tsconfig.json and picked up by both vite (web build) and vitest (tests). Drops the duplicate manual aliases from web/vite.config.ts and vitest.config.ts. 5 import sites updated. The CLI side never used the alias (it imports src/types/* via relative paths), so tsdown is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(api): project DB rows through mapper, slim wire schema Mirrors hosted's chapter-mapping pattern (packages/api/src/lib/ chapter-mapping.ts) so the wire format is decoupled from the DB schema. Previously `/api/runs/:runId/chapters` dumped raw Drizzle rows at clients, exposing chapterIndex/runId/createdAt/updatedAt and full denormalized key_change rows. Now src/routes/runs.ts projects through mapChapter / mapKeyChange / mapRun before serializing. Wire shape (src/types/chapters.ts) is now: - ChapterRun: { id } - Chapter: { id, externalId, order, title, summary, hunkRefs, keyChanges } - KeyChange: { id, externalId, content, lineRefs } Drops from the wire surface: createdAt, updatedAt, chapterIndex (now `order`), runId, chapterId on key changes, and run-row fields the SPA doesn't need today (repoRoot, scopeKind, baseSha/headSha/mergeBaseSha, generatedAt, workingTreeRef). The DB can grow new columns or rename internal fields without breaking clients; we expose more on the wire when the SPA needs more. The shared types now describe the same conceptual chapter at both the input boundary (src/schema.ts) and the wire boundary (src/types/ chapters.ts). The remaining differences (input is strict and uses `.min(1)`; wire is non-strict and trusts validated rows) reflect the real semantic split between the two boundaries. Updated runs.routes.test.ts to assert the new `order` field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 862dcaf commit ec6aad3

28 files changed

Lines changed: 1818 additions & 161 deletions

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,14 @@
5959
"@biomejs/biome": "^2.3.10",
6060
"@pierre/diffs": "^1.0.11",
6161
"@radix-ui/react-checkbox": "^1.3.3",
62+
"@radix-ui/react-collapsible": "^1.1.12",
63+
"@radix-ui/react-progress": "^1.1.8",
64+
"@radix-ui/react-separator": "^1.1.8",
65+
"@radix-ui/react-slot": "^1.2.4",
6266
"@radix-ui/react-tooltip": "^1.2.8",
6367
"@tailwindcss/vite": "^4.1.18",
68+
"@tanstack/react-query": "^5.100.7",
69+
"@testing-library/react": "^16.3.2",
6470
"@types/better-sqlite3": "^7.6.13",
6571
"@types/node": "^25.6.0",
6672
"@types/react": "^19.2.5",
@@ -69,6 +75,7 @@
6975
"class-variance-authority": "^0.7.1",
7076
"clsx": "^2.1.1",
7177
"drizzle-kit": "^0.31.10",
78+
"happy-dom": "^20.9.0",
7279
"husky": "^9.1.7",
7380
"lint-staged": "^16.2.7",
7481
"lucide-react": "^0.562.0",
@@ -80,6 +87,7 @@
8087
"tw-animate-css": "^1.4.0",
8188
"typescript": "^5.6.3",
8289
"vite": "^7.3.1",
90+
"vite-tsconfig-paths": "^6.1.1",
8391
"vitest": "^4.1.5"
8492
},
8593
"dependencies": {

pnpm-lock.yaml

Lines changed: 353 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/runs.routes.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,18 @@ describe("runs API", () => {
102102
const body = res.body as {
103103
run: { id: string };
104104
chapters: Array<{
105-
chapterIndex: number;
105+
order: number;
106106
title: string;
107107
keyChanges: Array<{ content: string }>;
108108
}>;
109109
};
110110
expect(body.run.id).toBe(runId);
111111
expect(body.chapters).toHaveLength(2);
112-
expect(body.chapters[0]?.chapterIndex).toBe(1);
112+
expect(body.chapters[0]?.order).toBe(1);
113113
expect(body.chapters[0]?.title).toBe("First");
114114
expect(body.chapters[0]?.keyChanges).toHaveLength(1);
115115
expect(body.chapters[0]?.keyChanges[0]).toMatchObject({ content: "Question?" });
116-
expect(body.chapters[1]?.chapterIndex).toBe(2);
116+
expect(body.chapters[1]?.order).toBe(2);
117117
expect(body.chapters[1]?.keyChanges).toHaveLength(0);
118118
});
119119

src/routes/runs.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,42 @@ import { asc, eq, inArray } from "drizzle-orm";
22
import type { StageDb } from "../db/client.js";
33
import { chapter, chapterRun, keyChange } from "../db/schema/index.js";
44
import type { Route } from "../server.js";
5+
import type { Chapter, ChapterRun, KeyChange } from "../types/chapters.js";
56
import { writeJson } from "./json.js";
67

8+
type ChapterRow = typeof chapter.$inferSelect;
9+
type ChapterRunRow = typeof chapterRun.$inferSelect;
10+
type KeyChangeRow = typeof keyChange.$inferSelect;
11+
12+
// Project DB rows into the public wire shape. Keeps DB-only fields
13+
// (`runId`, `chapterIndex`, `createdAt`, `updatedAt`, the denormalized
14+
// `keyChanges` string array) out of the API surface so the wire format can
15+
// evolve independently of the schema. Mirrors hosted's mapChapterRow pattern.
16+
function mapKeyChange(kc: KeyChangeRow): KeyChange {
17+
return {
18+
id: kc.id,
19+
externalId: kc.externalId,
20+
content: kc.content,
21+
lineRefs: kc.lineRefs,
22+
};
23+
}
24+
25+
function mapChapter(ch: ChapterRow, kcs: KeyChangeRow[]): Chapter {
26+
return {
27+
id: ch.id,
28+
externalId: ch.externalId,
29+
order: ch.chapterIndex,
30+
title: ch.title,
31+
summary: ch.summary,
32+
hunkRefs: ch.hunkRefs,
33+
keyChanges: kcs.map(mapKeyChange),
34+
};
35+
}
36+
37+
function mapRun(run: ChapterRunRow): ChapterRun {
38+
return { id: run.id };
39+
}
40+
741
export function runRoutes(db: StageDb): Route[] {
842
return [
943
{
@@ -35,22 +69,17 @@ export function runRoutes(db: StageDb): Route[] {
3569
? db.select().from(keyChange).where(inArray(keyChange.chapterId, chapterIds)).all()
3670
: [];
3771

38-
const byChapter = new Map<string, typeof keyChanges>();
72+
const byChapter = new Map<string, KeyChangeRow[]>();
3973
for (const kc of keyChanges) {
4074
const list = byChapter.get(kc.chapterId);
4175
if (list) list.push(kc);
4276
else byChapter.set(kc.chapterId, [kc]);
4377
}
4478

45-
// Drop the denormalized `keyChanges` content array from the chapter row — the API
46-
// surface returns full key_change rows under the same key. Keeping both would let
47-
// them drift.
48-
const nested = chapters.map(({ keyChanges: _denormalized, ...rest }) => ({
49-
...rest,
50-
keyChanges: byChapter.get(rest.id) ?? [],
51-
}));
52-
53-
writeJson(res, 200, { run, chapters: nested });
79+
writeJson(res, 200, {
80+
run: mapRun(run),
81+
chapters: chapters.map((ch) => mapChapter(ch, byChapter.get(ch.id) ?? [])),
82+
});
5483
},
5584
},
5685
];

src/types/chapters.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { z } from "zod";
2+
import { hunkReferenceSchema, lineRefSchema } from "../schema.js";
3+
4+
export const HunkRefSchema = hunkReferenceSchema;
5+
export type HunkRef = z.infer<typeof HunkRefSchema>;
6+
7+
export const LineRefSchema = lineRefSchema;
8+
export type LineRef = z.infer<typeof LineRefSchema>;
9+
10+
// Non-strict (vs. z.strictObject in src/schema.ts) so the server can add fields
11+
// the SPA doesn't yet read without rejecting the whole response.
12+
export const KeyChangeSchema = z.object({
13+
id: z.string(),
14+
externalId: z.string(),
15+
content: z.string(),
16+
lineRefs: z.array(LineRefSchema),
17+
});
18+
export type KeyChange = z.infer<typeof KeyChangeSchema>;
19+
20+
export const ChapterSchema = z.object({
21+
id: z.string(),
22+
externalId: z.string(),
23+
order: z.number().int(),
24+
title: z.string(),
25+
summary: z.string(),
26+
hunkRefs: z.array(HunkRefSchema),
27+
keyChanges: z.array(KeyChangeSchema),
28+
});
29+
export type Chapter = z.infer<typeof ChapterSchema>;
30+
31+
export const ChapterRunSchema = z.object({
32+
id: z.string(),
33+
});
34+
export type ChapterRun = z.infer<typeof ChapterRunSchema>;
35+
36+
export const ChaptersResponseSchema = z.object({
37+
run: ChapterRunSchema,
38+
chapters: z.array(ChapterSchema),
39+
});
40+
export type ChaptersResponse = z.infer<typeof ChaptersResponseSchema>;

src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./chapters.js";
2+
export * from "./view-state.js";

src/types/view-state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { z } from "zod";
2+
3+
export const ViewStateSchema = z.object({
4+
chapterIds: z.array(z.string()),
5+
keyChangeIds: z.array(z.string()),
6+
});
7+
export type ViewState = z.infer<typeof ViewStateSchema>;

vitest.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
import tsconfigPaths from "vite-tsconfig-paths";
4+
import { defineConfig } from "vitest/config";
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
8+
export default defineConfig({
9+
// SPA modules under test resolve `@/*` and `@cli/types/*` from web/tsconfig.json.
10+
// CLI tests don't use either alias, so pointing the plugin at web/'s tsconfig
11+
// covers the only consumer.
12+
plugins: [tsconfigPaths({ projects: [path.resolve(__dirname, "web", "tsconfig.json")] })],
13+
});

web/src/App.tsx

Lines changed: 12 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,22 @@
1-
import { useCallback, useMemo, useState } from "react";
2-
import { FileHeader, PierreDiffViewer } from "@/components/chapter";
3-
import {
4-
type AnnotatedLineRef,
5-
DIFF_SIDE,
6-
FILE_STATUS,
7-
type LineRef,
8-
type PullRequestFile,
9-
} from "@/lib/diff-types";
10-
import { DiffSettingsProvider } from "@/lib/use-diff-settings";
11-
12-
const SAMPLE_PATCH = `diff --git a/src/greet.ts b/src/greet.ts
13-
index 1111111..2222222 100644
14-
--- a/src/greet.ts
15-
+++ b/src/greet.ts
16-
@@ -1,3 +1,6 @@
17-
-export function greet(name: string) {
18-
- return "Hello, " + name + "!";
19-
+export function greet(name: string): string {
20-
+ if (!name) {
21-
+ throw new Error("name is required");
22-
+ }
23-
+ return \`Hello, \${name}!\`;
24-
}
25-
`;
26-
27-
const SAMPLE_FILE: PullRequestFile = {
28-
path: "src/greet.ts",
29-
filename: "greet.ts",
30-
status: FILE_STATUS.MODIFIED,
31-
additions: 5,
32-
deletions: 2,
33-
hunks: [],
34-
patch: SAMPLE_PATCH,
35-
};
36-
37-
const KEY_CHANGE_ID = "kc-1";
38-
39-
const FIXTURE_LINE_REFS: AnnotatedLineRef[] = [
40-
{
41-
keyChangeId: KEY_CHANGE_ID,
42-
filePath: SAMPLE_FILE.path,
43-
side: DIFF_SIDE.ADDITIONS,
44-
startLine: 2,
45-
endLine: 4,
46-
},
47-
];
48-
49-
const ALL_LINE_REFS_BY_FILE: Map<string, AnnotatedLineRef[]> = new Map([
50-
[SAMPLE_FILE.path, FIXTURE_LINE_REFS],
51-
]);
52-
53-
function ChapterFixture() {
54-
const [isCollapsed, setIsCollapsed] = useState(false);
55-
const [isExpanded, setIsExpanded] = useState(false);
56-
const [isViewed, setIsViewed] = useState(false);
57-
const [checkedKeyChangeIds, setCheckedKeyChangeIds] = useState<Set<string>>(new Set());
58-
const [focusedKeyChangeId, setFocusedKeyChangeId] = useState<string | null>(null);
59-
60-
const focusedLineRefsByFile = useMemo<Map<string, LineRef[]> | null>(() => {
61-
if (focusedKeyChangeId !== KEY_CHANGE_ID) return null;
62-
return new Map([
63-
[
64-
SAMPLE_FILE.path,
65-
FIXTURE_LINE_REFS.map(
66-
(ref): LineRef => ({
67-
filePath: ref.filePath,
68-
side: ref.side,
69-
startLine: ref.startLine,
70-
endLine: ref.endLine,
71-
}),
72-
),
73-
],
74-
]);
75-
}, [focusedKeyChangeId]);
76-
77-
const isKeyChangeChecked = useCallback(
78-
(id: string) => checkedKeyChangeIds.has(id),
79-
[checkedKeyChangeIds],
80-
);
81-
82-
const onMarkKeyChangeChecked = useCallback((id: string) => {
83-
setCheckedKeyChangeIds((prev) => {
84-
if (prev.has(id)) return prev;
85-
const next = new Set(prev);
86-
next.add(id);
87-
return next;
88-
});
89-
}, []);
90-
91-
const onUnmarkKeyChangeChecked = useCallback((id: string) => {
92-
setCheckedKeyChangeIds((prev) => {
93-
if (!prev.has(id)) return prev;
94-
const next = new Set(prev);
95-
next.delete(id);
96-
return next;
97-
});
98-
}, []);
99-
100-
const onFocusKeyChange = useCallback((id: string | null) => setFocusedKeyChangeId(id), []);
1+
import { useHashRunId } from "@/lib/use-hash-run-id";
2+
import { PullRequestLayout } from "@/routes/pull-request-layout";
1013

4+
function NoRunSelected() {
1025
return (
103-
<div className="min-h-screen bg-background p-6 text-foreground">
104-
<div className="mx-auto max-w-4xl space-y-4">
105-
<h1 className="font-semibold text-2xl">Chapter UI fixture</h1>
106-
<p className="text-muted-foreground text-sm">
107-
Hand-crafted prop data exercising the vendored chapter components.
6+
<div className="flex min-h-screen items-center justify-center bg-background p-6 text-foreground">
7+
<div className="max-w-md text-center">
8+
<h1 className="font-semibold text-lg">No run selected</h1>
9+
<p className="mt-2 text-muted-foreground text-sm">
10+
The URL is missing a <code>#/runs/&lt;runId&gt;</code> hash. Open the app via{" "}
11+
<code>stage-cli show &lt;path&gt;</code>.
10812
</p>
109-
<div>
110-
<FileHeader
111-
file={SAMPLE_FILE}
112-
isCollapsed={isCollapsed}
113-
isExpanded={isExpanded}
114-
isViewed={isViewed}
115-
onToggle={() => setIsCollapsed((prev) => !prev)}
116-
onToggleAll={() => setIsCollapsed((prev) => !prev)}
117-
onToggleExpand={() => setIsExpanded((prev) => !prev)}
118-
onComment={() => {}}
119-
onToggleViewed={() => setIsViewed((prev) => !prev)}
120-
/>
121-
{!isCollapsed && (
122-
<PierreDiffViewer
123-
patch={SAMPLE_PATCH}
124-
filePath={SAMPLE_FILE.path}
125-
expandUnchanged={isExpanded}
126-
allLineRefsByFile={ALL_LINE_REFS_BY_FILE}
127-
focusedLineRefsByFile={focusedLineRefsByFile}
128-
focusedKeyChangeId={focusedKeyChangeId}
129-
isKeyChangeChecked={isKeyChangeChecked}
130-
onMarkKeyChangeChecked={onMarkKeyChangeChecked}
131-
onUnmarkKeyChangeChecked={onUnmarkKeyChangeChecked}
132-
onFocusKeyChange={onFocusKeyChange}
133-
/>
134-
)}
135-
</div>
13613
</div>
13714
</div>
13815
);
13916
}
14017

14118
export function App() {
142-
return (
143-
<DiffSettingsProvider>
144-
<ChapterFixture />
145-
</DiffSettingsProvider>
146-
);
19+
const runId = useHashRunId();
20+
if (!runId) return <NoRunSelected />;
21+
return <PullRequestLayout runId={runId} />;
14722
}

0 commit comments

Comments
 (0)