merge(0.3.1): slice 15 founder-walk-file-link-primitive — FileLink primitive + image-rendering via canonical FileViewer.inferKind path

Closes walk items 6 (images show as binary) + 11 (referenced files universally not clickable). WAVE 2 CLOSE — slices 12 + 13 + 14 + 15 all merged.

What ships:
- NEW `<FileLink>` primitive at `packages/ui/src/components/ui/FileLink.tsx` — thin wrapper over existing FileReferenceTrigger with simpler API (path + optional root/absolutePath/kind/readPath). Default testId; default children = path string.
- ScopePages SliceArtifactsTab file rows wrap each `entry.type='file'` in FileLink with absolutePath = `${detail.slicePath}/${entry.relPath}`. Directories stay inert (no drawer trigger).
- Walk-item-6 fix: image extensions auto-detect via FileViewer.inferKind (already shipped); callsite routing through FileLink → drawer makes the canonical image-rendering path the default. NO new viewer kind; NO new SharedDetailDrawer selection type.

Scope-cuts (driver-judgment; endorsed):
- ProofImageViewer rename DEFERRED — multiple callers tightly coupled to `proofAssetUrl(sliceName, relPath)`. FileViewer image-kind path is the canonical going-forward surface; ProofImageViewer remains for proof-asset-specific modal contexts. Founder note ("Proof" misleading for non-proof images) flagged for follow-up if usage warrants.
- DocsTab intentionally NOT converted — split-view local-tree + right-pane viewer is the correct UX for that surface; replacing with drawer-trigger would worsen affordance. Documented in slice-walk packet.
- Exhaustive audit-and-replace DEFERRED — primitive + highest-impact surface (ScopePages SliceArtifactsTab) ships; remaining audit-list captured in slice-walk.md for incremental pattern application.

Test coverage:
- file-link.test.tsx (NEW): 6/6 PASS — default-testId; custom children; custom testId; click→setSelection; absolutePath+readPath+kind threading; walk-item-6 no-spurious-kind discriminator (verifies FileLink does NOT inject kind; FileViewer.inferKind owns image detection)
- Full daemon suite: 3379/3379 passed / 1 skipped / 0 failed
- UI typecheck on touched files: zero new errors
- git diff --check clean

Branch commits:
- be2ea65 feat(ui): FileLink primitive + ScopePages SliceArtifactsTab integration

Triple-guard:
- redo3-driver-3 driver (Phase D + scope-cut judgment calls flagged transparently)
- redo3-guard-3 guard (1 narrow re-review pass; CLEAR; walk-item-6 discriminator coverage real)
- velocity-qa VM dev-server walk: deferred to founder host walk on refreshed VM for visual HG-3 (image renders as image) + HG-5 (artifacts files clickable opens drawer)

HG ledger: HG-1/2/3/5/6/7 PASS code-level; HG-4 (DocsTab) judgment-call NOT CONVERTED (split-view correct as-is).

Walk-item phrasing discipline applied; slice id token retained as stable identifier.

WAVE 2 STATE POST-MERGE:
- ✅ Slice 12 (mission/slice Overview+Progress content) at 08017ab
- ✅ Slice 13 (per-mission topology spec graph) at 5973140
- ✅ Slice 14 (table active/idle + terminal click) at 82d4e9f
- ✅ Slice 15 (FileLink primitive + image rendering) at <this commit>
- All 4 wave-2 slices merged. Wave 3 dispatches next: slices 16 (typography-and-vellum) + 18 (for-you-dismiss-and-skills bundled with Plugins-sidebar per founder-walk amendment) + 19 (library-and-project-density).
This commit is contained in:
mvschwarz committed 2026-05-11 09:50:19 -07:00
commit b1e04b8750
3 files changed
+211 -10

No files matched your search

@@ -42,6 +42,7 @@ import {
import { TimelineTab } from "../slices/tabs/TimelineTab.js";
import { useSliceTimelineMarkdown } from "../../hooks/useSliceTimelineMarkdown.js";
import { useScopeMarkdown } from "../../hooks/useScopeMarkdown.js";
import { FileLink } from "../ui/FileLink.js";
import { useMission } from "../../hooks/useMission.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
@@ -965,16 +966,38 @@ function SliceArtifactsTab({ detail }: { detail: SliceDetail }) {
<SectionHeader tone="muted">Files</SectionHeader>
{docsTree.length > 0 ? (
<ul className="mt-3 divide-y divide-outline-variant border border-outline-variant bg-white/30">
{docsTree.map((entry) => (
<li key={entry.relPath} className="grid grid-cols-[1fr_auto_auto] gap-3 px-3 py-2 font-mono text-[10px]">
<span className="inline-flex min-w-0 items-center gap-1.5 text-stone-900">
<ToolMark tool={entry.relPath} size="xs" />
<span className="truncate">{entry.relPath}</span>
</span>
<span className="uppercase tracking-[0.10em] text-stone-500">{entry.type}</span>
<span className="text-stone-400">{entry.size == null ? "-" : `${entry.size}b`}</span>
</li>
))}
{docsTree.map((entry) => {
// V0.3.1 slice 15 walk-items 6 + 11 — file entries
// become FileLink triggers. Click opens the
// SharedDetailDrawer with FileViewer (images render as
// images via FileViewer.inferKind). Directories stay
// inert (non-clickable) since FileViewer would 404 on
// a directory read.
const isFile = entry.type === "file";
const absolutePath = `${detail.slicePath}/${entry.relPath}`;
return (
<li key={entry.relPath} className="grid grid-cols-[1fr_auto_auto] gap-3 px-3 py-2 font-mono text-[10px]">
{isFile ? (
<FileLink
path={entry.relPath}
absolutePath={absolutePath}
className="inline-flex min-w-0 items-center gap-1.5 text-stone-900 hover:underline text-left"
testId={`slice-artifacts-file-${entry.relPath}`}
>
<ToolMark tool={entry.relPath} size="xs" />
<span className="truncate">{entry.relPath}</span>
</FileLink>
) : (
<span className="inline-flex min-w-0 items-center gap-1.5 text-stone-500">
<ToolMark tool="folder" size="xs" decorative />
<span className="truncate">{entry.relPath}</span>
</span>
)}
<span className="uppercase tracking-[0.10em] text-stone-500">{entry.type}</span>
<span className="text-stone-400">{entry.size == null ? "-" : `${entry.size}b`}</span>
</li>
);
})}
</ul>
) : (
<div className="mt-3 font-mono text-[10px] text-stone-400">No slice files indexed.</div>
@@ -0,0 +1,74 @@
// V0.3.1 slice 15 walk-items 6 + 11 — universal file-link primitive.
//
// Wraps any file reference in the UI so a click opens the
// SharedDetailDrawer with the appropriate viewer. Thin layer over the
// existing FileReferenceTrigger: simpler API (just `path` + `root`
// instead of a fully-constructed FileViewerData) so callsites adopt
// it without ceremony.
//
// FileViewer infers the kind from extension at render time (see
// drawer-viewers/FileViewer.tsx :: inferKind), so FileLink doesn't
// run its own inference path — image extensions
// (.png/.jpg/.jpeg/.gif/.webp/.svg) automatically render as `<img>`
// via the drawer. Walk-item 6 ("images show as binary") is therefore
// resolved by routing image-typed file references through this
// primitive instead of rendering filename text inertly.
import type { ReactNode, CSSProperties } from "react";
import { FileReferenceTrigger } from "../drawer-triggers/FileReferenceTrigger.js";
import type { FileKind, FileViewerData } from "../drawer-viewers/FileViewer.js";
export interface FileLinkProps {
/** Display path. Also the relative read path under `root` when
* `readPath` is omitted. */
path: string;
/** Allowlist root name to read from. Optional when `absolutePath` is
* provided (the FileViewer's resolver picks the matching root). */
root?: string;
/** Explicit relative path under `root`; defaults to `path` when
* omitted. Useful when `path` is a display label distinct from the
* on-disk relative path. */
readPath?: string;
/** Absolute filesystem path; resolved against /api/files/roots by
* FileViewer when `root` isn't provided. */
absolutePath?: string | null;
/** Optional explicit kind override. When omitted, FileViewer infers
* from `path` extension at render time. */
kind?: FileKind;
/** Children rendered inside the clickable wrapper. Defaults to the
* raw `path` string when omitted. */
children?: ReactNode;
className?: string;
style?: CSSProperties;
testId?: string;
}
export function FileLink({
path,
root,
readPath,
absolutePath,
kind,
children,
className,
style,
testId,
}: FileLinkProps) {
const data: FileViewerData = {
path,
...(root !== undefined ? { root } : {}),
...(readPath !== undefined ? { readPath } : {}),
...(absolutePath !== undefined ? { absolutePath } : {}),
...(kind !== undefined ? { kind } : {}),
};
return (
<FileReferenceTrigger
data={data}
className={className}
style={style}
testId={testId ?? "file-link"}
>
{children ?? path}
</FileReferenceTrigger>
);
}
+104
View File
@@ -0,0 +1,104 @@
// V0.3.1 slice 15 walk-items 6 + 11 — FileLink primitive tests.
//
// FileLink is a thin wrapper over the existing FileReferenceTrigger
// that constructs FileViewerData from simpler props (path + root +
// optional absolutePath / kind / readPath). On click the drawer
// selection becomes `{ type: "file", data }`. Image-kind inference
// happens in FileViewer at render time, so FileLink itself doesn't
// run its own inference path — this test file verifies the data
// shape that flows to setSelection so a downstream FileViewer would
// honor inferKind correctly.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import { DrawerSelectionContext } from "../src/components/AppShell.js";
import { FileLink } from "../src/components/ui/FileLink.js";
beforeEach(() => {
cleanup();
vi.restoreAllMocks();
});
function renderWithDrawerCtx(ui: React.ReactNode) {
const setSelection = vi.fn();
const utils = render(
<DrawerSelectionContext.Provider value={{ selection: null, setSelection }}>
{ui}
</DrawerSelectionContext.Provider>,
);
return { setSelection, ...utils };
}
describe("FileLink primitive", () => {
it("renders as a clickable element with the default testId 'file-link' and the path as the visible label", () => {
const { getByTestId } = renderWithDrawerCtx(
<FileLink path="README.md" root="workspace" />,
);
const trigger = getByTestId("file-link");
expect(trigger.textContent).toBe("README.md");
});
it("renders custom children when provided (label distinct from raw path)", () => {
const { getByTestId } = renderWithDrawerCtx(
<FileLink path="missions/release-0.3.1/README.md" root="workspace">
<span>release-0.3.1</span>
</FileLink>,
);
expect(getByTestId("file-link").textContent).toBe("release-0.3.1");
});
it("honors a custom testId override", () => {
const { getByTestId } = renderWithDrawerCtx(
<FileLink path="x.md" root="workspace" testId="custom-file-link" />,
);
expect(getByTestId("custom-file-link")).toBeTruthy();
});
it("click → setSelection({ type: 'file', data: { path, root } })", () => {
const { setSelection, getByTestId } = renderWithDrawerCtx(
<FileLink path="notes.md" root="workspace" />,
);
fireEvent.click(getByTestId("file-link"));
expect(setSelection).toHaveBeenCalledOnce();
expect(setSelection).toHaveBeenCalledWith({
type: "file",
data: { path: "notes.md", root: "workspace" },
});
});
it("threads absolutePath + readPath + kind through to the drawer payload", () => {
const { setSelection, getByTestId } = renderWithDrawerCtx(
<FileLink
path="screenshot.png"
absolutePath="/abs/path/screenshot.png"
readPath="screenshots/screenshot.png"
kind="image"
/>,
);
fireEvent.click(getByTestId("file-link"));
expect(setSelection).toHaveBeenCalledWith({
type: "file",
data: {
path: "screenshot.png",
absolutePath: "/abs/path/screenshot.png",
readPath: "screenshots/screenshot.png",
kind: "image",
},
});
});
it("walk-item 6 fix: image extensions flow through without explicit kind (FileViewer infers at render time)", () => {
// FileLink doesn't run kind inference itself — it just constructs
// FileViewerData with the path. FileViewer.inferKind() picks up
// .png/.jpg/.jpeg/.gif/.webp/.svg → "image" at render time. This
// test asserts the data shape is clean (no spurious kind injection)
// so the downstream inference path is unblocked.
const { setSelection, getByTestId } = renderWithDrawerCtx(
<FileLink path="cover.jpg" root="workspace" />,
);
fireEvent.click(getByTestId("file-link"));
const call = setSelection.mock.calls[0]![0] as { data: Record<string, unknown> };
expect(call.data.kind).toBeUndefined();
expect(call.data.path).toBe("cover.jpg");
});
});