feat(ui): FileLink primitive + slice artifacts file references clickable

V0.3.1 slice 15 walk-items 6 + 11 from the 2026-05-11 synthesis.

(1) New FileLink primitive at packages/ui/src/components/ui/FileLink.tsx:
- Thin wrapper over the existing FileReferenceTrigger with a simpler
  API (just `path` + optional `root` / `absolutePath` / `kind` /
  `readPath` instead of a fully-constructed FileViewerData).
- Default `testId="file-link"`; `children` falls back to `path`.
- FileViewer's existing inferKind() handles image-extension auto-
  detection at render time (drawer-viewers/FileViewer.tsx:44 covers
  .png/.jpg/.jpeg/.gif/.webp/.svg → "image" → renders <img>). Walk-
  item 6 ("images show as binary") is therefore resolved by routing
  image-typed file references through this primitive instead of
  rendering inert filename text — no new viewer kind or drawer
  selection type needed.

(2) ScopePages slice-artifacts file list (highest-impact replacement):
- SliceArtifactsTab now wraps each FILE entry in <FileLink> with
  the slice's absolutePath threaded through. Directory entries stay
  inert (no read-as-file).
- Click on a screenshot/image now opens the drawer with the image
  rendered correctly; click on a markdown / text / yaml file opens
  with MarkdownViewer / plain text accordingly.

Scope cuts (endorsed by orch at pre-work ACK):
- ProofImageViewer rename deferred — multiple callers tightly coupled
  to proofAssetUrl(sliceName, relPath); the FileViewer image-kind path
  is the canonical going-forward surface. Flagged in driver ACK for
  follow-up.
- Exhaustive audit-and-replace deferred — slice ships the primitive +
  highest-impact surface; remaining call sites (DocsTab in-tab viewer
  is actually well-shaped today; other potential sites identified via
  pre-work audit) iterate as founder-walk surfaces additional gaps.

Tests:
- file-link.test.tsx (NEW): 6 tests — render with default testId +
  path-as-label; custom children; custom testId; click →
  setSelection({ type: "file", data }); absolutePath + readPath +
  kind threaded through; walk-item-6 fix (no spurious kind injection
  so downstream inferKind path is unblocked).
- Full daemon suite: 3379 passed / 1 skipped / 0 failed.
- UI typecheck on touched files: zero new errors.
- git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 09:46:13 -07:00
1 parent 82d4e9f8c5
commit be2ea657a1
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");
});
});