Load file drawer content from file roots

This commit is contained in:
mvschwarz committed 2026-05-07 04:12:00 -07:00
1 parent 4ad5078224
commit 7284cda909
8 files changed
+380 -14

No files matched your search

@@ -1,13 +1,57 @@
import type { ReactNode } from "react";
import type { AgentSpecReview } from "../hooks/useSpecReview.js";
import { WorkflowCodePreview } from "./WorkflowScaffold.js";
import { FileReferenceTrigger } from "./drawer-triggers/FileReferenceTrigger.js";
interface AgentSpecDisplayProps {
review?: AgentSpecReview | null;
yaml: string;
testIdPrefix?: string;
sourcePath?: string | null;
}
export function AgentSpecDisplay({ review, yaml, testIdPrefix = "agent" }: AgentSpecDisplayProps) {
function resolveSpecRelativePath(sourcePath: string | null | undefined, filePath: string): string | null {
if (!sourcePath || !filePath) return null;
if (filePath.startsWith("/")) return filePath;
const sourceDir = sourcePath.replace(/\/[^/]*$/, "");
const parts = `${sourceDir}/${filePath}`.split("/");
const resolved: string[] = [];
for (const part of parts) {
if (!part || part === ".") continue;
if (part === "..") {
resolved.pop();
continue;
}
resolved.push(part);
}
return `/${resolved.join("/")}`;
}
function FileChip({
label,
sourcePath,
testId,
children,
}: {
label: string;
sourcePath?: string | null;
testId: string;
children: ReactNode;
}) {
const absolutePath = resolveSpecRelativePath(sourcePath, label);
if (!absolutePath) return <>{children}</>;
return (
<FileReferenceTrigger
data={{ path: label, absolutePath }}
testId={testId}
className="inline-block text-left"
>
{children}
</FileReferenceTrigger>
);
}
export function AgentSpecDisplay({ review, yaml, testIdPrefix = "agent", sourcePath }: AgentSpecDisplayProps) {
const profiles = review?.profiles ?? [];
const resources = review?.resources ?? { skills: [], guidance: [], hooks: [], subagents: [] };
const startup = review?.startup ?? { files: [], actions: [] };
@@ -45,7 +89,16 @@ export function AgentSpecDisplay({ review, yaml, testIdPrefix = "agent" }: Agent
<div>
<span className="text-stone-500">Guidance:</span>{" "}
{resources.guidance.map((g, i) => (
<span key={i} className="inline-block bg-stone-100 px-1.5 py-0.5 mr-1 mb-0.5">{g}</span>
<FileChip
key={`${g}-${i}`}
label={g}
sourcePath={sourcePath}
testId={`${testIdPrefix}-guidance-file-trigger-${g}`}
>
<span className="inline-block bg-stone-100 px-1.5 py-0.5 mr-1 mb-0.5 underline decoration-dotted decoration-stone-400">
{g}
</span>
</FileChip>
))}
</div>
)}
@@ -66,7 +119,14 @@ export function AgentSpecDisplay({ review, yaml, testIdPrefix = "agent" }: Agent
<div className="font-mono text-[9px] text-stone-500 uppercase mb-1">Files</div>
{startup.files.map((f, i) => (
<div key={i} className="font-mono text-[10px]">
{f.path} {f.required && <span className="text-red-500 text-[8px]">REQUIRED</span>}
<FileChip
label={f.path}
sourcePath={sourcePath}
testId={`${testIdPrefix}-startup-file-trigger-${f.path}`}
>
<span className="underline decoration-dotted decoration-stone-400">{f.path}</span>
</FileChip>{" "}
{f.required && <span className="text-red-500 text-[8px]">REQUIRED</span>}
</div>
))}
</div>
+8 -3
View File
@@ -87,7 +87,12 @@ function LibraryAgentReviewPage({ review }: { review: LibraryAgentReview }) {
<WorkflowSummaryCard label="Skills" value={resources.skills.length} testId="lib-agent-skills" />
</WorkflowSummaryGrid>
<AgentSpecDisplay review={review} yaml={review.raw} testIdPrefix="lib-agent" />
<AgentSpecDisplay
review={review}
yaml={review.raw}
testIdPrefix="lib-agent"
sourcePath={review.sourcePath}
/>
</div>
</WorkspacePage>
);
@@ -639,7 +644,7 @@ function ContextPackReviewBody({
className={`font-mono text-[10px] ${missing ? "text-red-700" : "text-stone-800"}`}
>
<FileReferenceTrigger
data={{ path: f.path }}
data={{ path: f.path, absolutePath: f.absolutePath }}
testId={`lib-pack-file-trigger-${f.path}`}
className="block w-full px-3 py-2 text-left hover:bg-stone-100/60 transition-colors"
>
@@ -906,7 +911,7 @@ function AgentImageReviewBody({
data-testid={`lib-image-file-${f.path}`}
>
<FileReferenceTrigger
data={{ path: f.path }}
data={{ path: f.path, absolutePath: f.absolutePath }}
testId={`lib-image-file-trigger-${f.path}`}
className="block w-full px-3 py-2 text-left hover:bg-stone-100/60 transition-colors"
>
@@ -101,7 +101,12 @@ function AgentSpecSection({ data }: { data: NodeDetailData }) {
) : !review || review.kind !== "agent" ? (
<div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>
) : (
<AgentSpecDisplay review={review as AgentSpecReview} yaml={review.raw} testIdPrefix="live-agent" />
<AgentSpecDisplay
review={review as AgentSpecReview}
yaml={review.raw}
testIdPrefix="live-agent"
sourcePath={review.sourcePath}
/>
)}
</div>
);
@@ -460,7 +465,7 @@ function StartupTab({ rigId, logicalId, data }: { rigId: string; logicalId: stri
data-testid={`live-startup-file-${f.path}`}
>
<FileReferenceTrigger
data={{ path: f.path }}
data={{ path: f.path, absolutePath: f.absolutePath }}
testId={`live-startup-file-trigger-${f.path}`}
className="block w-full px-3 py-2 text-left hover:bg-stone-100/60 transition-colors font-mono text-[10px]"
>
@@ -2,17 +2,36 @@
//
// Renders markdown / text / YAML / JSON / image / binary file refs.
import { useMemo } from "react";
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
import {
fileAssetUrl,
useFilesRead,
useFilesRoots,
type AllowlistRoot,
} from "../../hooks/useFiles.js";
export type FileKind = "markdown" | "text" | "yaml" | "json" | "image" | "binary";
export interface FileViewerData {
/** Display path shown in the drawer. Also used as the relative read path when `root` is set. */
path: string;
kind?: FileKind;
content?: string;
imageUrl?: string;
/** Existing /api/files allowlist root name. */
root?: string;
/** Optional explicit path under `root`; falls back to `path` when omitted. */
readPath?: string;
/** Absolute file path; resolved against /api/files/roots before reading. */
absolutePath?: string | null;
}
interface ResolvedReadTarget {
root: string;
path: string;
}
function inferKind(path: string): FileKind {
@@ -25,9 +44,66 @@ function inferKind(path: string): FileKind {
return "text";
}
export function FileViewer({ path, kind, content, imageUrl }: FileViewerData) {
const resolvedKind = kind ?? inferKind(path);
function normalizeAbsolutePath(path: string): string {
return path.replaceAll("\\", "/").replace(/\/+$/, "");
}
function resolveFromAbsolutePath(
roots: AllowlistRoot[] | undefined,
absolutePath: string | null | undefined,
): ResolvedReadTarget | null {
if (!roots || !absolutePath) return null;
const normalizedFile = normalizeAbsolutePath(absolutePath);
const sortedRoots = [...roots].sort((a, b) => b.path.length - a.path.length);
for (const root of sortedRoots) {
const normalizedRoot = normalizeAbsolutePath(root.path);
if (normalizedFile === normalizedRoot) return null;
const prefix = `${normalizedRoot}/`;
if (!normalizedFile.startsWith(prefix)) continue;
const relativePath = normalizedFile.slice(prefix.length);
if (!relativePath || relativePath.includes("../")) return null;
return { root: root.name, path: relativePath };
}
return null;
}
function useResolvedReadTarget(data: FileViewerData): {
rootsLoading: boolean;
target: ResolvedReadTarget | null;
hasFetchIntent: boolean;
} {
const explicitTarget = data.root
? { root: data.root, path: data.readPath ?? data.path }
: null;
const needsRootResolution = !explicitTarget && !!data.absolutePath;
const roots = useFilesRoots();
const absoluteTarget = useMemo(() => {
if (!needsRootResolution) return null;
const rootData = roots.data;
if (!rootData || "unavailable" in rootData) return null;
return resolveFromAbsolutePath(rootData.roots, data.absolutePath);
}, [data.absolutePath, needsRootResolution, roots.data]);
return {
rootsLoading: needsRootResolution && roots.isLoading,
target: explicitTarget ?? absoluteTarget,
hasFetchIntent: !!explicitTarget || !!data.absolutePath,
};
}
function FileViewerBody({
path,
resolvedKind,
content,
imageUrl,
target,
}: {
path: string;
resolvedKind: FileKind;
content?: string;
imageUrl?: string;
target?: ResolvedReadTarget | null;
}) {
if (!content && !imageUrl && resolvedKind !== "binary") {
return (
<EmptyState
@@ -44,6 +120,11 @@ export function FileViewer({ path, kind, content, imageUrl }: FileViewerData) {
<header className="px-4 py-3 border-b border-outline-variant">
<SectionHeader tone="muted">File</SectionHeader>
<h3 className="mt-1 font-mono text-xs text-stone-900 break-all">{path}</h3>
{target && (
<div data-testid="file-viewer-root-path" className="mt-1 font-mono text-[9px] text-stone-500 break-all">
{target.root}/{target.path}
</div>
)}
</header>
<div className="flex-1 min-h-0 overflow-y-auto">
{resolvedKind === "markdown" && content ? (
@@ -80,3 +161,73 @@ export function FileViewer({ path, kind, content, imageUrl }: FileViewerData) {
</div>
);
}
function FileViewerWithFetch(data: FileViewerData) {
const { path, kind, content, imageUrl } = data;
const resolvedKind = kind ?? inferKind(path);
const { rootsLoading, target, hasFetchIntent } = useResolvedReadTarget(data);
const read = useFilesRead(target?.root ?? null, target?.path ?? null);
const fetchedContent = read.data?.content;
const fetchedImageUrl = target && resolvedKind === "image"
? fileAssetUrl(target.root, target.path)
: undefined;
const resolvedContent = content ?? fetchedContent;
const resolvedImageUrl = imageUrl ?? fetchedImageUrl;
if (!content && !imageUrl && hasFetchIntent && (rootsLoading || read.isLoading)) {
return (
<EmptyState
label="LOADING"
description={`Loading ${path}...`}
variant="card"
testId="file-viewer-empty"
/>
);
}
if (!content && !imageUrl && hasFetchIntent && !target) {
return (
<EmptyState
label="FILE UNAVAILABLE"
description={`No configured file root contains ${path}.`}
variant="card"
testId="file-viewer-error"
/>
);
}
if (!content && !imageUrl && read.isError) {
return (
<EmptyState
label="FILE UNAVAILABLE"
description={(read.error as Error)?.message ?? `Could not load ${path}.`}
variant="card"
testId="file-viewer-error"
/>
);
}
return (
<FileViewerBody
path={path}
resolvedKind={resolvedKind}
content={resolvedContent}
imageUrl={resolvedImageUrl}
target={target}
/>
);
}
export function FileViewer(data: FileViewerData) {
if (!data.root && !data.absolutePath) {
return (
<FileViewerBody
path={data.path}
resolvedKind={data.kind ?? inferKind(data.path)}
content={data.content}
imageUrl={data.imageUrl}
/>
);
}
return <FileViewerWithFetch {...data} />;
}
+7 -1
View File
@@ -53,7 +53,13 @@ export interface NodeDetailData {
resolvedSpecName: string | null;
resolvedSpecVersion: string | null;
cwd: string | null;
startupFiles: Array<{ path: string; deliveryHint: string; required: boolean }>;
startupFiles: Array<{
path: string;
deliveryHint: string;
required: boolean;
absolutePath?: string | null;
ownerRoot?: string | null;
}>;
startupActions: Array<{ type: string; value: string }>;
recentEvents: Array<{ type: string; createdAt: string }>;
infrastructureStartupCommand: string | null;
+81 -1
View File
@@ -18,7 +18,9 @@
// guard lives in test/node-selection-migration.test.tsx.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import { render, fireEvent, cleanup, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import {
DrawerSelectionContext,
@@ -34,8 +36,20 @@ import { SharedDetailDrawer } from "../src/components/SharedDetailDrawer.js";
beforeEach(() => {
cleanup();
vi.restoreAllMocks();
});
function renderWithQuery(ui: ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
);
}
// ---------------------------------------------------------------------------
// Viewers — render-without-crash with canonical-shape props
// ---------------------------------------------------------------------------
@@ -83,6 +97,72 @@ describe("Drawer viewers (P4-1) render with canonical props", () => {
expect(getByTestId("file-viewer-empty")).toBeTruthy();
});
it("FileViewer reads drawer content from an explicit /api/files root + path", async () => {
const fetchMock = vi.fn(async (url: string) => {
expect(url).toBe("/api/files/read?root=workspace&path=docs%2Frole.md");
return {
ok: true,
json: async () => ({
root: "workspace",
path: "docs/role.md",
absolutePath: "/workspace/docs/role.md",
content: "# Role\nLoaded from files API.",
mtime: "2026-05-07T00:00:00.000Z",
contentHash: "hash",
size: 29,
truncated: false,
truncatedAtBytes: null,
totalBytes: 29,
}),
};
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderWithQuery(
<FileViewer path="role.md" kind="markdown" root="workspace" readPath="docs/role.md" />,
);
expect(await screen.findByTestId("file-viewer")).toBeTruthy();
expect(screen.getByText(/Loaded from files API/)).toBeTruthy();
expect(screen.getByTestId("file-viewer-root-path").textContent).toBe("workspace/docs/role.md");
});
it("FileViewer resolves an absolute file path against /api/files roots", async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url === "/api/files/roots") {
return {
ok: true,
json: async () => ({ roots: [{ name: "workspace", path: "/workspace" }] }),
};
}
expect(url).toBe("/api/files/read?root=workspace&path=agents%2Frole.md");
return {
ok: true,
json: async () => ({
root: "workspace",
path: "agents/role.md",
absolutePath: "/workspace/agents/role.md",
content: "# Agent Role",
mtime: "2026-05-07T00:00:00.000Z",
contentHash: "hash",
size: 12,
truncated: false,
truncatedAtBytes: null,
totalBytes: 12,
}),
};
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderWithQuery(
<FileViewer path="role.md" kind="markdown" absolutePath="/workspace/agents/role.md" />,
);
expect(await screen.findByTestId("file-viewer")).toBeTruthy();
expect(screen.getByText(/Agent Role/)).toBeTruthy();
expect(screen.getByTestId("file-viewer-root-path").textContent).toBe("workspace/agents/role.md");
});
it("SubSpecPreview renders header + manifest excerpt; no Link when entryId omitted", () => {
const { getByTestId, queryByTestId } = render(
<SubSpecPreview
+37 -1
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import { LiveNodeDetails } from "../src/components/LiveNodeDetails.js";
import { DrawerSelectionContext, type DrawerSelection } from "../src/components/AppShell.js";
const mockFetch = vi.fn();
@@ -12,7 +13,12 @@ const NODE_DETAIL = {
tmuxAttachCommand: "tmux attach -t dev-impl@test-rig", resumeCommand: null,
latestError: null, model: "opus", agentRef: "local:agents/impl", profile: "default",
resolvedSpecName: "impl", resolvedSpecVersion: "1.0.0", cwd: "/workspace",
startupFiles: [{ path: "role.md", deliveryHint: "guidance_merge", required: true }],
startupFiles: [{
path: "role.md",
deliveryHint: "guidance_merge",
required: true,
absolutePath: "/workspace/specs/agents/impl/guidance/role.md",
}],
startupActions: [], recentEvents: [],
infrastructureStartupCommand: null,
binding: { tmuxSession: "dev-impl@test-rig" },
@@ -77,6 +83,19 @@ describe("LiveNodeDetails", () => {
);
}
function renderDetailsWithDrawerSelection(setSelection: (sel: DrawerSelection) => void, logicalId = "dev.impl") {
return render(
createTestRouter({
component: () => (
<DrawerSelectionContext.Provider value={{ selection: null, setSelection }}>
<LiveNodeDetails rigId="rig-1" logicalId={logicalId} />
</DrawerSelectionContext.Provider>
),
path: "/test",
}),
);
}
it("renders with identity section showing peers and edges", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
@@ -142,6 +161,23 @@ describe("LiveNodeDetails", () => {
});
});
it("startup file trigger includes resolved file provenance for drawer loading", async () => {
const setSelection = vi.fn();
mockNodeDetail(NODE_DETAIL);
renderDetailsWithDrawerSelection(setSelection as (sel: DrawerSelection) => void);
fireEvent.click(await screen.findByTestId("live-tab-startup"));
fireEvent.click(await screen.findByTestId("live-startup-file-trigger-role.md"));
expect(setSelection).toHaveBeenCalledWith({
type: "file",
data: {
path: "role.md",
absolutePath: "/workspace/specs/agents/impl/guidance/role.md",
},
});
});
it("transcript and terminal tabs own their named content", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
+24 -1
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { RigSpecDisplay } from "../src/components/RigSpecDisplay.js";
import { AgentSpecDisplay } from "../src/components/AgentSpecDisplay.js";
import { DrawerSelectionContext, type DrawerSelection } from "../src/components/AppShell.js";
import type { RigSpecReview, AgentSpecReview } from "../src/hooks/useSpecReview.js";
afterEach(() => cleanup());
@@ -159,6 +160,28 @@ describe("AgentSpecDisplay", () => {
expect(screen.getByText("code-style.md")).toBeDefined();
});
it("wraps guidance resources in file triggers when sourcePath is available", () => {
const setSelection = vi.fn();
render(
<DrawerSelectionContext.Provider value={{ selection: null, setSelection: setSelection as (sel: DrawerSelection) => void }}>
<AgentSpecDisplay
review={AGENT_REVIEW}
yaml="name: test"
sourcePath="/workspace/specs/agents/test-agent/agent.yaml"
/>
</DrawerSelectionContext.Provider>,
);
fireEvent.click(screen.getByTestId("agent-guidance-file-trigger-code-style.md"));
expect(setSelection).toHaveBeenCalledWith({
type: "file",
data: {
path: "code-style.md",
absolutePath: "/workspace/specs/agents/test-agent/code-style.md",
},
});
});
it("renders startup section with files and actions", () => {
render(<AgentSpecDisplay review={AGENT_REVIEW} yaml="name: test" />);