From 4f052bc50974c66ca8aac58cb69d6836e460873f Mon Sep 17 00:00:00 2001 From: Josh Black Date: Thu, 10 Sep 2026 10:22:42 -0500 Subject: [PATCH 1/4] feat: add a file explorer to trial results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a49f4691-6bee-4479-98d8-adf6e8f60d38 --- website/README.md | 13 + .../app/components/FileExplorer.module.css | 49 ++++ website/src/app/components/FileExplorer.tsx | 90 ++++++ website/src/app/components/RunDetailsPage.tsx | 16 +- website/src/artifacts.ts | 39 +++ website/src/run-details.ts | 42 +-- website/src/runs.ts | 2 + website/src/workspace-files.test.ts | 272 ++++++++++++++++++ website/src/workspace-files.ts | 150 ++++++++++ 9 files changed, 635 insertions(+), 38 deletions(-) create mode 100644 website/src/app/components/FileExplorer.module.css create mode 100644 website/src/app/components/FileExplorer.tsx create mode 100644 website/src/artifacts.ts create mode 100644 website/src/workspace-files.test.ts create mode 100644 website/src/workspace-files.ts diff --git a/website/README.md b/website/README.md index e8a13ae..87d3aef 100644 --- a/website/README.md +++ b/website/README.md @@ -36,3 +36,16 @@ model and treatment. Each judge shows its score, scoring criteria, rationale, and file-backed findings with code snippets. Scores use the judge's configured scale, not a shared pass/fail threshold. Judge errors and missing results are shown separately from scored results. + +Each trial's **Code** tab shows the saved `artifacts.workspaceDirectory` as an +expandable file tree with read-only UTF-8 text previews. This is the final saved +workspace, including starter files, rather than a diff of the agent's changes. +Workspaces are read at build time, so the explorer also works in the static export. +Missing workspaces have an unavailable message. + +Dependency, build, and Git directories (`node_modules`, `.next`, `.turbo`, `dist`, +and `.git`) are omitted. Symbolic links and binary files cannot be previewed. +Previews are limited to 256 KiB per file and 2 MiB per workspace; the tree is limited +to 2,000 entries and 50 directory levels. Limits are indicated in the explorer. +Review workspace contents before publishing a result bundle, as previewable files +are included in the website. diff --git a/website/src/app/components/FileExplorer.module.css b/website/src/app/components/FileExplorer.module.css new file mode 100644 index 0000000..82cd9db --- /dev/null +++ b/website/src/app/components/FileExplorer.module.css @@ -0,0 +1,49 @@ +.explorer { + display: grid; + grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); + border: 1px solid var(--borderColor-default); + border-radius: var(--borderRadius-medium); + overflow: hidden; +} + +.tree { + max-height: 600px; + overflow: auto; + padding: var(--base-size-8); + border-right: 1px solid var(--borderColor-default); +} + +.preview { + min-width: 0; +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--base-size-16); + padding: var(--base-size-12); + background: var(--bgColor-muted); + border-bottom: 1px solid var(--borderColor-default); +} + +.code { + margin: 0; + padding: var(--base-size-16); + max-height: 550px; + overflow: auto; + font-size: var(--text-body-size-medium); + tab-size: 2; +} + +@media (max-width: 767px) { + .explorer { + grid-template-columns: minmax(0, 1fr); + } + + .tree { + max-height: 240px; + border-right: 0; + border-bottom: 1px solid var(--borderColor-default); + } +} diff --git a/website/src/app/components/FileExplorer.tsx b/website/src/app/components/FileExplorer.tsx new file mode 100644 index 0000000..bd227f5 --- /dev/null +++ b/website/src/app/components/FileExplorer.tsx @@ -0,0 +1,90 @@ +'use client' + +import {FileIcon} from '@primer/octicons-react' +import {TreeView} from '@primer/react' +import {useId, useState} from 'react' +import type {WorkspaceEntry, WorkspaceFile, WorkspaceFiles} from '../../workspace-files' +import styles from './FileExplorer.module.css' + +function FileExplorer({workspace}: {workspace: WorkspaceFiles}) { + const [selectedFile, setSelectedFile] = useState(null) + const id = useId() + + if (workspace.type === 'unavailable') { + return

{workspace.reason}

+ } + + function renderEntry(entry: WorkspaceEntry) { + if (entry.type === 'directory') { + return ( + + + + + {entry.name} + {entry.children.map(renderEntry)} + + ) + } + + return ( + { + setSelectedFile(entry) + }} + > + + + + {entry.name} + + ) + } + + if (workspace.entries.length === 0) { + return

No files are available in the generated workspace.

+ } + + return ( +
+ {workspace.truncated ? ( +

The file tree is limited to 2,000 entries and 50 directory levels.

+ ) : null} +
+
+ {workspace.entries.map(renderEntry)} +
+
+ {selectedFile ? ( + <> +
+

{selectedFile.path}

+ + {selectedFile.size.toLocaleString('en-US')} bytes + +
+ {selectedFile.preview.type === 'text' ? ( + selectedFile.preview.content.length > 0 ? ( +
+                    {selectedFile.preview.content}
+                  
+ ) : ( +

This file is empty.

+ ) + ) : ( +

{selectedFile.preview.reason}

+ )} + + ) : ( +

Select a file to view its contents.

+ )} +
+
+
+ ) +} + +export {FileExplorer} diff --git a/website/src/app/components/RunDetailsPage.tsx b/website/src/app/components/RunDetailsPage.tsx index 4f8c469..50a4dc4 100644 --- a/website/src/app/components/RunDetailsPage.tsx +++ b/website/src/app/components/RunDetailsPage.tsx @@ -8,6 +8,7 @@ import Link from 'next/link' import Image from 'next/image' import {useState} from 'react' import {JudgeResults} from './JudgeResults' +import {FileExplorer} from './FileExplorer' type RunResult = RunDetails['results'][number] @@ -115,7 +116,7 @@ function UiWalkthrough({scenarioId, walkthrough}: {scenarioId: string; walkthrou return

No UI walkthrough was recorded.

} -type ResultTab = 'walkthrough' | 'tests' | 'judges' | 'transcript' +type ResultTab = 'walkthrough' | 'tests' | 'judges' | 'transcript' | 'code' function ResultTabs({index, result}: {index: number; result: RunResult}) { const [selectedTab, setSelectedTab] = useState('walkthrough') @@ -124,6 +125,7 @@ function ResultTabs({index, result}: {index: number; result: RunResult}) { tests: `result-${index}-tests-tab`, judges: `result-${index}-judges-tab`, transcript: `result-${index}-transcript-tab`, + code: `result-${index}-code-tab`, } const panelId = `result-${index}-${selectedTab}-panel` @@ -177,6 +179,17 @@ function ResultTabs({index, result}: {index: number; result: RunResult}) { > Transcript + { + event.preventDefault() + setSelectedTab('code') + }} + > + Code +
{selectedTab === 'walkthrough' ? ( @@ -219,6 +232,7 @@ function ResultTabs({index, result}: {index: number; result: RunResult}) {
) : null} + {selectedTab === 'code' ? : null} ) diff --git a/website/src/artifacts.ts b/website/src/artifacts.ts new file mode 100644 index 0000000..95d5ef0 --- /dev/null +++ b/website/src/artifacts.ts @@ -0,0 +1,39 @@ +import path from 'node:path' + +const LEGACY_ARTIFACTS_DIRECTORY = path.resolve(process.cwd(), '..', 'artifacts') + +function isWithinDirectory(directory: string, filepath: string): boolean { + const relativePath = path.relative(directory, filepath) + return relativePath !== '..' && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath) +} + +function getArtifactCandidates(artifactPath: string, runDirectory: string): Array { + const runArtifactsDirectory = path.join(runDirectory, 'artifacts') + + if (!path.isAbsolute(artifactPath)) { + const candidate = path.resolve(runDirectory, artifactPath) + return isWithinDirectory(runArtifactsDirectory, candidate) ? [candidate] : [] + } + + if (isWithinDirectory(LEGACY_ARTIFACTS_DIRECTORY, artifactPath)) { + return [artifactPath] + } + + const segments = artifactPath.split(/[\\/]+/) + const artifactsIndex = segments.lastIndexOf('artifacts') + if (artifactsIndex === -1) { + return [] + } + + const artifactSegments = segments.slice(artifactsIndex + 1) + return [ + path.join(runArtifactsDirectory, ...artifactSegments), + path.join(LEGACY_ARTIFACTS_DIRECTORY, ...artifactSegments), + ].filter(candidate => { + return ( + isWithinDirectory(runArtifactsDirectory, candidate) || isWithinDirectory(LEGACY_ARTIFACTS_DIRECTORY, candidate) + ) + }) +} + +export {getArtifactCandidates, isWithinDirectory, LEGACY_ARTIFACTS_DIRECTORY} diff --git a/website/src/run-details.ts b/website/src/run-details.ts index 3d438a0..1dc1c58 100644 --- a/website/src/run-details.ts +++ b/website/src/run-details.ts @@ -2,9 +2,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import type {RunOutput, RunOutputResult} from './runs' import type {BenchmarkRun} from './benchmark-results' - -const REPOSITORY_ROOT = path.resolve(process.cwd(), '..') -const LEGACY_ARTIFACTS_DIRECTORY = path.join(REPOSITORY_ROOT, 'artifacts') +import {getArtifactCandidates} from './artifacts' +import {getWorkspaceFiles, type WorkspaceFiles} from './workspace-files' type LogMessage = RunOutputResult['assistant']['logs'][number] type Walkthrough = RunOutputResult['walkthrough'] @@ -47,6 +46,7 @@ type RunResult = { transcript: Array walkthrough: WalkthroughDataUrl judges: Array + workspace: WorkspaceFiles } type RunDetails = { @@ -183,40 +183,6 @@ function createTranscript(logs: Array): Array { }) } -function isWithinDirectory(directory: string, filepath: string): boolean { - const relativePath = path.relative(directory, filepath) - return relativePath !== '..' && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath) -} - -function getArtifactCandidates(artifactPath: string, runDirectory: string): Array { - const runArtifactsDirectory = path.join(runDirectory, 'artifacts') - - if (!path.isAbsolute(artifactPath)) { - const candidate = path.resolve(runDirectory, artifactPath) - return isWithinDirectory(runArtifactsDirectory, candidate) ? [candidate] : [] - } - - if (isWithinDirectory(LEGACY_ARTIFACTS_DIRECTORY, artifactPath)) { - return [artifactPath] - } - - const segments = artifactPath.split(/[\\/]+/) - const artifactsIndex = segments.lastIndexOf('artifacts') - if (artifactsIndex === -1) { - return [] - } - - const artifactSegments = segments.slice(artifactsIndex + 1) - return [ - path.join(runArtifactsDirectory, ...artifactSegments), - path.join(LEGACY_ARTIFACTS_DIRECTORY, ...artifactSegments), - ].filter(candidate => { - return ( - isWithinDirectory(runArtifactsDirectory, candidate) || isWithinDirectory(LEGACY_ARTIFACTS_DIRECTORY, candidate) - ) - }) -} - async function getArtifactDataUrl( artifactPath: string | undefined, mimeType: string, @@ -327,6 +293,7 @@ async function createExperimentRunDetails(date: string, output: RunOutput, runDi } }), walkthrough: await getWalkthroughDataUrls(result.walkthrough, runDirectory), + workspace: await getWorkspaceFiles(result.workspaceDirectory, runDirectory), transcript: createTranscript(result.assistant.logs), judges: createJudgeDetails(result.judges), } @@ -390,6 +357,7 @@ async function createBenchmarkRunDetails(run: BenchmarkRun): Promise }) }), walkthrough: await getWalkthroughDataUrls(trial.walkthrough, run.directory), + workspace: await getWorkspaceFiles(trial.artifacts.workspaceDirectory, run.directory), transcript: createTranscript( sessions.flatMap(session => { return session.messages diff --git a/website/src/runs.ts b/website/src/runs.ts index 748b548..588510b 100644 --- a/website/src/runs.ts +++ b/website/src/runs.ts @@ -38,6 +38,7 @@ type RunOutputResult = { } walkthrough: ExperimentOutputTrial['walkthrough'] judges: ExperimentOutputTrial['judges'] + workspaceDirectory?: string } type RunOutput = { @@ -226,6 +227,7 @@ function normalizeOutput(output: ExperimentOutput): RunOutput { }, walkthrough: trial.walkthrough, judges: trial.judges, + workspaceDirectory: trial.artifacts.workspaceDirectory, } }) diff --git a/website/src/workspace-files.test.ts b/website/src/workspace-files.test.ts new file mode 100644 index 0000000..567596f --- /dev/null +++ b/website/src/workspace-files.test.ts @@ -0,0 +1,272 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {getWorkspaceFiles, type WorkspaceEntry, type WorkspaceFile} from './workspace-files' +import {getArtifactCandidates} from './artifacts' +import {createExperimentRunDetails} from './run-details' +import type {RunOutputResult} from './runs' + +let directory: string +let workspace: string + +beforeEach(async () => { + const temporaryDirectory = path.resolve('.agents/tmp') + await fs.mkdir(temporaryDirectory, {recursive: true}) + directory = await fs.mkdtemp(path.join(temporaryDirectory, 'workspace-files-')) + workspace = path.join(directory, 'artifacts/trial/workspace') + await fs.mkdir(workspace, {recursive: true}) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(directory, {recursive: true, force: true}) +}) + +async function writeFile(name: string, content: string | Buffer) { + const filepath = path.join(workspace, name) + await fs.mkdir(path.dirname(filepath), {recursive: true}) + await fs.writeFile(filepath, content) +} + +async function loadEntries(): Promise> { + const result = await getWorkspaceFiles('artifacts/trial/workspace', directory) + expect(result.type).toBe('available') + if (result.type !== 'available') { + throw new Error(result.reason) + } + return result.entries +} + +function getFile(entries: Array, name: string): WorkspaceFile { + const file = entries.find(entry => { + return entry.name === name + }) + if (file?.type !== 'file') { + throw new Error(`File ${name} not found`) + } + return file +} + +test('loads nested files with directories first, natural sorting, and exact text', async () => { + const content = '\n\tconst greeting = "hello"\n' + await writeFile('src/app/page.tsx', content) + await writeFile('file10.txt', 'ten') + await writeFile('file2.txt', 'two') + await writeFile('.gitignore', 'node_modules\n') + await writeFile('empty.txt', '') + const entries = await loadEntries() + expect( + entries.map(entry => { + return entry.name + }), + ).toEqual(['src', '.gitignore', 'empty.txt', 'file2.txt', 'file10.txt']) + expect(entries[0]).toMatchObject({ + type: 'directory', + path: 'src', + children: [ + { + type: 'directory', + path: 'src/app', + children: [ + { + type: 'file', + path: 'src/app/page.tsx', + size: Buffer.byteLength(content), + preview: {type: 'text', content}, + }, + ], + }, + ], + }) + expect(getFile(entries, 'empty.txt').preview).toEqual({type: 'text', content: ''}) +}) + +test('omits dependency, build, and Git directories at every level', async () => { + for (const name of ['node_modules', '.next', '.turbo', 'dist', '.git']) { + await writeFile(`${name}/ignored.txt`, 'ignored') + await writeFile(`src/${name}/ignored.txt`, 'ignored') + } + expect(await loadEntries()).toEqual([{type: 'directory', name: 'src', path: 'src', children: []}]) +}) + +test('reports missing and empty workspaces separately', async () => { + expect(await getWorkspaceFiles(undefined, directory)).toMatchObject({type: 'unavailable'}) + expect(await getWorkspaceFiles('artifacts/missing/workspace', directory)).toMatchObject({type: 'unavailable'}) + expect(await getWorkspaceFiles('artifacts/trial/workspace', directory)).toEqual({ + type: 'available', + entries: [], + truncated: false, + }) +}) + +test('resolves relocated absolute artifact paths', async () => { + await writeFile('README.md', 'portable') + const result = await getWorkspaceFiles('/old/runner/artifacts/trial/workspace', directory) + expect(result).toMatchObject({ + type: 'available', + entries: [{name: 'README.md', preview: {type: 'text', content: 'portable'}}], + }) +}) + +test('rejects paths outside artifact roots and sibling paths with matching prefixes', async () => { + expect(getArtifactCandidates('../outside', directory)).toEqual([]) + expect(getArtifactCandidates('artifacts/../../outside', directory)).toEqual([]) + expect(getArtifactCandidates('artifacts-other/workspace', directory)).toEqual([]) + expect(await getWorkspaceFiles('../outside', directory)).toMatchObject({type: 'unavailable'}) +}) + +test('does not follow workspace symlinks outside the artifact root', async () => { + const outside = path.join(directory, 'outside') + await fs.mkdir(outside) + await fs.writeFile(path.join(outside, 'private.txt'), 'not part of the bundle') + await fs.symlink(outside, path.join(directory, 'artifacts/linked')) + expect(await getWorkspaceFiles('artifacts/linked', directory)).toEqual({ + type: 'unavailable', + reason: 'The generated workspace points outside the result artifacts.', + }) +}) + +test('does not follow a symlinked artifacts directory', async () => { + await fs.rename(path.join(directory, 'artifacts'), path.join(directory, 'outside')) + await fs.symlink(path.join(directory, 'outside'), path.join(directory, 'artifacts')) + expect(await getWorkspaceFiles('artifacts/trial/workspace', directory)).toMatchObject({ + type: 'unavailable', + reason: 'The generated workspace points outside the result artifacts.', + }) +}) + +test('does not follow file links, directory links, or symlink loops', async () => { + await fs.writeFile(path.join(directory, 'private.txt'), 'not part of the workspace') + await fs.symlink(path.join(directory, 'private.txt'), path.join(workspace, 'linked.txt')) + await fs.symlink(workspace, path.join(workspace, 'loop')) + const entries = await loadEntries() + expect(entries).toHaveLength(2) + for (const entry of entries) { + expect(entry).toMatchObject({type: 'file', preview: {type: 'unavailable'}}) + expect(JSON.stringify(entry)).not.toContain('not part of the workspace') + } +}) + +test('identifies binary and non-UTF-8 files without corrupting previews', async () => { + await writeFile('binary.png', Buffer.from([0, 1, 2])) + await writeFile('invalid.txt', Buffer.from([255, 254, 253])) + for (const entry of await loadEntries()) { + expect(entry).toMatchObject({ + preview: {type: 'unavailable', reason: 'Binary files cannot be previewed.'}, + }) + } +}) + +test('enforces the exact per-file byte limit', async () => { + await writeFile('allowed.txt', 'a'.repeat(256 * 1024)) + await writeFile('large.txt', 'a'.repeat(256 * 1024 + 1)) + const entries = await loadEntries() + expect(getFile(entries, 'allowed.txt').preview.type).toBe('text') + expect(getFile(entries, 'large.txt').preview).toEqual({ + type: 'unavailable', + reason: 'This file exceeds the 256 KiB preview limit.', + }) +}) + +test('enforces the total workspace preview byte limit', async () => { + for (let index = 0; index < 9; index++) { + await writeFile(`${index}.txt`, 'a'.repeat(256 * 1024)) + } + const entries = await loadEntries() + expect( + entries.filter(entry => { + return entry.type === 'file' && entry.preview.type === 'text' + }), + ).toHaveLength(8) + expect(getFile(entries, '8.txt').preview).toEqual({ + type: 'unavailable', + reason: 'The 2 MiB workspace preview limit has been reached.', + }) +}) + +test('limits tree entries and reports truncation', async () => { + await Promise.all( + Array.from({length: 2001}, async (_, index) => { + await writeFile(`${index}.txt`, '') + }), + ) + const result = await getWorkspaceFiles('artifacts/trial/workspace', directory) + expect(result).toMatchObject({type: 'available', truncated: true}) + if (result.type === 'available') { + expect(result.entries).toHaveLength(2000) + } +}) + +test('limits deeply nested directories', async () => { + await writeFile(`${'nested/'.repeat(51)}file.txt`, 'too deep') + expect(await getWorkspaceFiles('artifacts/trial/workspace', directory)).toMatchObject({ + type: 'available', + truncated: true, + }) +}) + +test('reports a file used as a workspace', async () => { + await writeFile('file.txt', 'text') + expect(await getWorkspaceFiles('artifacts/trial/workspace/file.txt', directory)).toMatchObject({ + type: 'unavailable', + reason: 'The generated workspace is not a directory.', + }) +}) + +test('propagates unexpected filesystem errors instead of reporting missing files', async () => { + vi.spyOn(fs, 'readdir').mockRejectedValueOnce(new Error('Permission denied')) + await expect(getWorkspaceFiles('artifacts/trial/workspace', directory)).rejects.toThrow('Permission denied') +}) + +test('includes generated files in experiment run details without changing existing details', async () => { + await writeFile('index.ts', 'export const generated = true\n') + const result: RunOutputResult = { + id: 'trial', + treatmentId: 'control', + model: 'gpt-5.6-sol', + reasoningEffort: 'medium', + scenarioId: 'scenario', + workspaceDirectory: 'artifacts/trial/workspace', + assistant: { + logs: [], + turns: 1, + outputTokens: 10, + premiumRequests: 1, + totalApiDurationMs: 10, + sessionDurationMs: 20, + tools: {}, + }, + testResults: { + numTotalTests: 0, + numPassedTests: 0, + numFailedTests: 0, + numPendingTests: 0, + numTodoTests: 0, + success: true, + testResults: [], + tests: [], + }, + walkthrough: {type: 'Unavailable'}, + judges: [], + } + const details = await createExperimentRunDetails( + '2026-09-10', + { + experiment: {id: 'experiment', models: []}, + scenarios: [], + treatments: [{id: 'control', config: {name: 'Control'}}], + results: [result], + }, + directory, + ) + expect(details.results[0]).toMatchObject({ + id: 'trial', + treatment: 'Control', + walkthrough: {type: 'Unavailable'}, + transcript: [], + workspace: { + type: 'available', + entries: [{path: 'index.ts', preview: {type: 'text', content: 'export const generated = true\n'}}], + }, + }) +}) diff --git a/website/src/workspace-files.ts b/website/src/workspace-files.ts new file mode 100644 index 0000000..9da635b --- /dev/null +++ b/website/src/workspace-files.ts @@ -0,0 +1,150 @@ +import fs from 'node:fs/promises' +import {isUtf8} from 'node:buffer' +import path from 'node:path' +import {getArtifactCandidates, isWithinDirectory, LEGACY_ARTIFACTS_DIRECTORY} from './artifacts' + +const MAX_FILE_BYTES = 256 * 1024 +const MAX_WORKSPACE_BYTES = 2 * 1024 * 1024 +const MAX_ENTRIES = 2000 +const MAX_DEPTH = 50 +const EXCLUDED_DIRECTORIES = new Set(['.git', '.next', '.turbo', 'node_modules', 'dist']) + +type WorkspaceFile = { + type: 'file' + name: string + path: string + size: number + preview: {type: 'text'; content: string} | {type: 'unavailable'; reason: string} +} + +type WorkspaceEntry = + | WorkspaceFile + | { + type: 'directory' + name: string + path: string + children: Array + } + +type WorkspaceFiles = + {type: 'unavailable'; reason: string} | {type: 'available'; entries: Array; truncated: boolean} + +async function readWorkspace(directory: string): Promise { + let entryCount = 0 + let previewBytes = 0 + let truncated = false + + async function readDirectory(relativePath: string, depth: number): Promise> { + if (depth >= MAX_DEPTH) { + truncated = true + return [] + } + + const entries = await fs.readdir(path.join(directory, relativePath), {withFileTypes: true}) + const sortedEntries = entries + .filter(entry => { + return !EXCLUDED_DIRECTORIES.has(entry.name) + }) + .sort((first, second) => { + return ( + Number(second.isDirectory()) - Number(first.isDirectory()) || + first.name.localeCompare(second.name, 'en', {numeric: true}) + ) + }) + const result: Array = [] + + for (const entry of sortedEntries) { + if (entryCount >= MAX_ENTRIES) { + truncated = true + break + } + entryCount++ + const entryPath = relativePath ? `${relativePath}/${entry.name}` : entry.name + + if (entry.isDirectory()) { + result.push({ + type: 'directory', + name: entry.name, + path: entryPath, + children: await readDirectory(entryPath, depth + 1), + }) + continue + } + + const file: WorkspaceFile = { + type: 'file', + name: entry.name, + path: entryPath, + size: 0, + preview: {type: 'unavailable', reason: 'Symbolic links and special files are not previewed.'}, + } + result.push(file) + if (!entry.isFile()) { + continue + } + + const filepath = path.join(directory, entryPath) + const stats = await fs.stat(filepath) + file.size = stats.size + if (stats.size > MAX_FILE_BYTES) { + file.preview = {type: 'unavailable', reason: 'This file exceeds the 256 KiB preview limit.'} + continue + } + if (previewBytes + stats.size > MAX_WORKSPACE_BYTES) { + file.preview = {type: 'unavailable', reason: 'The 2 MiB workspace preview limit has been reached.'} + continue + } + + const contents = await fs.readFile(filepath) + if (contents.includes(0) || !isUtf8(contents)) { + file.preview = {type: 'unavailable', reason: 'Binary files cannot be previewed.'} + continue + } + previewBytes += contents.byteLength + file.preview = {type: 'text', content: contents.toString('utf8')} + } + + return result + } + + const entries = await readDirectory('', 0) + return {type: 'available', entries, truncated} +} + +async function getWorkspaceFiles( + workspaceDirectory: string | undefined, + runDirectory: string, +): Promise { + if (!workspaceDirectory) { + return {type: 'unavailable', reason: 'No generated workspace was recorded for this trial.'} + } + + for (const candidate of getArtifactCandidates(workspaceDirectory, runDirectory)) { + let realDirectory: string + try { + realDirectory = await fs.realpath(candidate) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + continue + } + throw error + } + + const artifactRoot = isWithinDirectory(path.join(runDirectory, 'artifacts'), candidate) + ? path.join(await fs.realpath(runDirectory), 'artifacts') + : path.join(await fs.realpath(path.dirname(LEGACY_ARTIFACTS_DIRECTORY)), 'artifacts') + if (!isWithinDirectory(artifactRoot, realDirectory)) { + return {type: 'unavailable', reason: 'The generated workspace points outside the result artifacts.'} + } + + if (!(await fs.stat(realDirectory)).isDirectory()) { + return {type: 'unavailable', reason: 'The generated workspace is not a directory.'} + } + return readWorkspace(realDirectory) + } + + return {type: 'unavailable', reason: 'The generated workspace is missing from this result bundle.'} +} + +export {getWorkspaceFiles} +export type {WorkspaceEntry, WorkspaceFile, WorkspaceFiles} From 310b12213527b0419316f46949dd3a7316727d94 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Thu, 10 Sep 2026 10:47:06 -0500 Subject: [PATCH 2/4] fix: address file explorer review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a49f4691-6bee-4479-98d8-adf6e8f60d38 --- website/src/workspace-files.test.ts | 54 +++++++++++++++++++++++------ website/src/workspace-files.ts | 11 +++--- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/website/src/workspace-files.test.ts b/website/src/workspace-files.test.ts index 567596f..bac1ed8 100644 --- a/website/src/workspace-files.test.ts +++ b/website/src/workspace-files.test.ts @@ -89,6 +89,24 @@ test('omits dependency, build, and Git directories at every level', async () => expect(await loadEntries()).toEqual([{type: 'directory', name: 'src', path: 'src', children: []}]) }) +test('preserves regular files named like excluded directories at every level', async () => { + const names = ['node_modules', '.next', '.turbo', 'dist', '.git'] + for (const name of names) { + await writeFile(name, `root ${name}`) + await writeFile(`src/${name}`, `nested ${name}`) + } + + const entries = await loadEntries() + const src = entries[0] + if (src.type !== 'directory') { + throw new Error('Expected the src directory') + } + for (const name of names) { + expect(getFile(entries, name).preview).toEqual({type: 'text', content: `root ${name}`}) + expect(getFile(src.children, name).preview).toEqual({type: 'text', content: `nested ${name}`}) + } +}) + test('reports missing and empty workspaces separately', async () => { expect(await getWorkspaceFiles(undefined, directory)).toMatchObject({type: 'unavailable'}) expect(await getWorkspaceFiles('artifacts/missing/workspace', directory)).toMatchObject({type: 'unavailable'}) @@ -185,11 +203,9 @@ test('enforces the total workspace preview byte limit', async () => { }) test('limits tree entries and reports truncation', async () => { - await Promise.all( - Array.from({length: 2001}, async (_, index) => { - await writeFile(`${index}.txt`, '') - }), - ) + for (let index = 0; index < 2001; index++) { + await writeFile(`${index}.txt`, '') + } const result = await getWorkspaceFiles('artifacts/trial/workspace', directory) expect(result).toMatchObject({type: 'available', truncated: true}) if (result.type === 'available') { @@ -197,12 +213,28 @@ test('limits tree entries and reports truncation', async () => { } }) -test('limits deeply nested directories', async () => { - await writeFile(`${'nested/'.repeat(51)}file.txt`, 'too deep') - expect(await getWorkspaceFiles('artifacts/trial/workspace', directory)).toMatchObject({ - type: 'available', - truncated: true, - }) +test.each([0, 49, 50, 51])('enforces the 50-level limit for a file at depth %i', async depth => { + await writeFile(`${'nested/'.repeat(depth)}file.txt`, 'file contents') + const result = await getWorkspaceFiles('artifacts/trial/workspace', directory) + if (result.type !== 'available') { + throw new Error(result.reason) + } + expect(result.truncated).toBe(depth > 50) + + let entries = result.entries + for (let level = 0; level < Math.min(depth, 50); level++) { + expect(entries).toHaveLength(1) + const entry = entries[0] + if (entry.type !== 'directory') { + throw new Error(`Expected a directory at level ${level + 1}`) + } + entries = entry.children + } + if (depth <= 50) { + expect(getFile(entries, 'file.txt').preview).toEqual({type: 'text', content: 'file contents'}) + } else { + expect(entries).toEqual([]) + } }) test('reports a file used as a workspace', async () => { diff --git a/website/src/workspace-files.ts b/website/src/workspace-files.ts index 9da635b..f0f8e13 100644 --- a/website/src/workspace-files.ts +++ b/website/src/workspace-files.ts @@ -35,15 +35,10 @@ async function readWorkspace(directory: string): Promise { let truncated = false async function readDirectory(relativePath: string, depth: number): Promise> { - if (depth >= MAX_DEPTH) { - truncated = true - return [] - } - const entries = await fs.readdir(path.join(directory, relativePath), {withFileTypes: true}) const sortedEntries = entries .filter(entry => { - return !EXCLUDED_DIRECTORIES.has(entry.name) + return !entry.isDirectory() || !EXCLUDED_DIRECTORIES.has(entry.name) }) .sort((first, second) => { return ( @@ -54,6 +49,10 @@ async function readWorkspace(directory: string): Promise { const result: Array = [] for (const entry of sortedEntries) { + if (entry.isDirectory() && depth >= MAX_DEPTH) { + truncated = true + continue + } if (entryCount >= MAX_ENTRIES) { truncated = true break From f72e351b1a12ef1e5da4071ba117a6eb67c4bf24 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Thu, 10 Sep 2026 11:08:30 -0500 Subject: [PATCH 3/4] feat: highlight trial workspace files with Shiki Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a49f4691-6bee-4479-98d8-adf6e8f60d38 --- pnpm-lock.yaml | 345 ++++++++++++++ website/README.md | 6 + website/package.json | 4 +- .../app/components/FileExplorer.module.css | 23 + website/src/app/components/FileExplorer.tsx | 20 +- .../src/app/components/FilePreview.test.tsx | 98 ++++ website/src/app/components/FilePreview.tsx | 82 ++++ website/src/app/components/RunDetailsPage.tsx | 437 +----------------- website/src/app/components/RunDetailsView.tsx | 432 +++++++++++++++++ website/src/workspace-files.ts | 17 +- 10 files changed, 1023 insertions(+), 441 deletions(-) create mode 100644 website/src/app/components/FilePreview.test.tsx create mode 100644 website/src/app/components/FilePreview.tsx create mode 100644 website/src/app/components/RunDetailsView.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f8d9cf..8cc742b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -529,6 +529,12 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + server-only: + specifier: ^0.0.1 + version: 0.0.1 + shiki: + specifier: ^4.4.3 + version: 4.4.3 dependenciesMeta: '@primer/agent-eval': injected: true @@ -1328,6 +1334,37 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} + engines: {node: '>=20'} + + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + engines: {node: '>=20'} + + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} + engines: {node: '>=20'} + + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1483,12 +1520,18 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -1512,6 +1555,9 @@ packages: '@types/tar-stream@3.1.4': resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.65.0': resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1630,6 +1676,9 @@ packages: resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -1995,10 +2044,19 @@ packages: caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -2026,6 +2084,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2093,10 +2154,17 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + docker-modem@5.0.7: resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} engines: {node: '>= 8.0'} @@ -2507,6 +2575,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -2516,6 +2590,9 @@ packages: hsluv@1.0.1: resolution: {integrity: sha512-zCaFTiDqBLQjCCFBu0qg7z9ASYPd+Bxx2GDCVZJsnehjK80S+jByqhuFz0pCd2Aw3FSKr18AWbRlwnKR0YdizQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + human-id@4.2.0: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true @@ -2899,9 +2976,27 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + memfs@4.68.1: resolution: {integrity: sha512-OD+IDRUvIxu3QHL+nFm9gdyugInD27FDJ+sl4B5QgomPHXMlbw+GP918P8VNKu2FkNlVeqBkpzkwROpamVifRw==} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -3010,6 +3105,12 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3126,6 +3227,9 @@ packages: process-warning@5.1.0: resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@7.6.5: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} @@ -3186,6 +3290,15 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -3276,6 +3389,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3309,6 +3425,10 @@ packages: resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} + engines: {node: '>=20'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -3338,6 +3458,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -3385,6 +3508,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3482,6 +3608,9 @@ packages: peerDependencies: tslib: '2' + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -3543,6 +3672,21 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3555,6 +3699,12 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.2.2: resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3709,6 +3859,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -4379,6 +4532,46 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@shikijs/core@4.4.3': + dependencies: + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/primitive@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/types@4.4.3': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.23': @@ -4502,10 +4695,18 @@ snapshots: '@types/estree@1.0.9': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -4535,6 +4736,8 @@ snapshots: dependencies: '@types/node': 26.4.0 + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4717,6 +4920,8 @@ snapshots: '@typescript-eslint/types': 8.67.0 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.4.0': {} + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -5030,8 +5235,14 @@ snapshots: caniuse-lite@1.0.30001809: {} + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chownr@1.1.4: {} client-only@0.0.1: {} @@ -5054,6 +5265,8 @@ snapshots: colorette@2.0.20: {} + comma-separated-tokens@2.0.3: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -5118,8 +5331,14 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + docker-modem@5.0.7: dependencies: debug: 4.4.3 @@ -5646,6 +5865,24 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + help-me@5.0.0: {} history@5.3.0: @@ -5654,6 +5891,8 @@ snapshots: hsluv@1.0.1: {} + html-void-elements@3.0.0: {} + human-id@4.2.0: {} hyperdyperid@1.2.0: {} @@ -5970,6 +6209,18 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + memfs@4.68.1: dependencies: '@jsonjoy.com/fs-core': 4.68.1(tslib@2.8.1) @@ -5987,6 +6238,23 @@ snapshots: tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -6097,6 +6365,14 @@ snapshots: dependencies: wrappy: 1.0.2 + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6223,6 +6499,8 @@ snapshots: process-warning@5.1.0: {} + property-information@7.2.0: {} + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -6293,6 +6571,16 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.9 @@ -6394,6 +6682,8 @@ snapshots: semver@7.8.5: {} + server-only@0.0.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -6458,6 +6748,17 @@ snapshots: shell-quote@1.10.0: {} + shiki@4.4.3: + dependencies: + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -6496,6 +6797,8 @@ snapshots: source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + split-ca@1.0.1: {} split2@4.2.0: {} @@ -6566,6 +6869,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -6667,6 +6975,8 @@ snapshots: dependencies: tslib: 2.8.1 + trim-lines@3.0.1: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -6752,6 +7062,29 @@ snapshots: undici-types@8.3.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: browserslist: 4.28.7 @@ -6764,6 +7097,16 @@ snapshots: util-deprecate@1.0.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 @@ -6922,3 +7265,5 @@ snapshots: '@yuku-parser/binding-win32-x64': 0.8.7 zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/website/README.md b/website/README.md index 87d3aef..b52cd48 100644 --- a/website/README.md +++ b/website/README.md @@ -43,6 +43,12 @@ workspace, including starter files, rather than a diff of the agent's changes. Workspaces are read at build time, so the explorer also works in the static export. Missing workspaces have an unavailable message. +Recognized file types use Shiki syntax highlighting with GitHub light and dark +themes that follow the website's color mode. Unknown file types remain plain text. +The results page renders previews in Server Components and passes them into the +interactive file explorer, keeping Shiki and its language grammars out of the +browser bundle. Highlighting also runs at build time for the static export. + Dependency, build, and Git directories (`node_modules`, `.next`, `.turbo`, `dist`, and `.git`) are omitted. Symbolic links and binary files cannot be previewed. Previews are limited to 256 KiB per file and 2 MiB per workspace; the tree is limited diff --git a/website/package.json b/website/package.json index 64c44ac..7298098 100644 --- a/website/package.json +++ b/website/package.json @@ -16,7 +16,9 @@ "@primer/tailwind-config": "^1.0.0", "next": "^16.3.1", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "server-only": "^0.0.1", + "shiki": "^4.4.3" }, "dependenciesMeta": { "@primer/agent-eval": { diff --git a/website/src/app/components/FileExplorer.module.css b/website/src/app/components/FileExplorer.module.css index 82cd9db..3ec3c77 100644 --- a/website/src/app/components/FileExplorer.module.css +++ b/website/src/app/components/FileExplorer.module.css @@ -36,6 +36,29 @@ tab-size: 2; } +.code span { + color: var(--shiki-light); + font-style: var(--shiki-light-font-style); + font-weight: var(--shiki-light-font-weight); + text-decoration: var(--shiki-light-text-decoration); +} + +:global([data-color-mode='dark']) .code span { + color: var(--shiki-dark); + font-style: var(--shiki-dark-font-style); + font-weight: var(--shiki-dark-font-weight); + text-decoration: var(--shiki-dark-text-decoration); +} + +@media (prefers-color-scheme: dark) { + :global([data-color-mode='auto']) .code span { + color: var(--shiki-dark); + font-style: var(--shiki-dark-font-style); + font-weight: var(--shiki-dark-font-weight); + text-decoration: var(--shiki-dark-text-decoration); + } +} + @media (max-width: 767px) { .explorer { grid-template-columns: minmax(0, 1fr); diff --git a/website/src/app/components/FileExplorer.tsx b/website/src/app/components/FileExplorer.tsx index bd227f5..f73ed64 100644 --- a/website/src/app/components/FileExplorer.tsx +++ b/website/src/app/components/FileExplorer.tsx @@ -2,19 +2,19 @@ import {FileIcon} from '@primer/octicons-react' import {TreeView} from '@primer/react' -import {useId, useState} from 'react' +import {useId, useState, type ReactNode} from 'react' import type {WorkspaceEntry, WorkspaceFile, WorkspaceFiles} from '../../workspace-files' import styles from './FileExplorer.module.css' -function FileExplorer({workspace}: {workspace: WorkspaceFiles}) { - const [selectedFile, setSelectedFile] = useState(null) +function FileExplorer({workspace}: {workspace: WorkspaceFiles}) { + const [selectedFile, setSelectedFile] = useState | null>(null) const id = useId() if (workspace.type === 'unavailable') { return

{workspace.reason}

} - function renderEntry(entry: WorkspaceEntry) { + function renderEntry(entry: WorkspaceEntry) { if (entry.type === 'directory') { return ( @@ -66,17 +66,7 @@ function FileExplorer({workspace}: {workspace: WorkspaceFiles}) { {selectedFile.size.toLocaleString('en-US')} bytes - {selectedFile.preview.type === 'text' ? ( - selectedFile.preview.content.length > 0 ? ( -
-                    {selectedFile.preview.content}
-                  
- ) : ( -

This file is empty.

- ) - ) : ( -

{selectedFile.preview.reason}

- )} + {selectedFile.preview} ) : (

Select a file to view its contents.

diff --git a/website/src/app/components/FilePreview.test.tsx b/website/src/app/components/FilePreview.test.tsx new file mode 100644 index 0000000..157a14e --- /dev/null +++ b/website/src/app/components/FilePreview.test.tsx @@ -0,0 +1,98 @@ +import {renderToStaticMarkup} from 'react-dom/server' +import {expect, test, vi} from 'vitest' +import type {WorkspaceFile} from '../../workspace-files' +import {FilePreview, getFileLanguage} from './FilePreview' + +vi.mock('server-only', () => { + return {} +}) + +function file(filepath: string, content: string): WorkspaceFile { + return { + type: 'file', + path: filepath, + name: filepath.split('/').at(-1) ?? filepath, + size: Buffer.byteLength(content), + preview: {type: 'text', content}, + } +} + +test.each([ + ['src/App.tsx', 'tsx'], + ['src/index.ts', 'typescript'], + ['src/types.d.ts', 'typescript'], + ['src/index.mts', 'typescript'], + ['src/App.jsx', 'jsx'], + ['next.config.mjs', 'javascript'], + ['index.cjs', 'javascript'], + ['package.json', 'json'], + ['styles.CSS', 'css'], + ['README.md', 'markdown'], + ['workflow.yml', 'yaml'], + ['script.py', 'python'], + ['script.sh', 'shellscript'], + ['Dockerfile', 'docker'], + ['Dockerfile.dev', 'docker'], + ['Makefile', 'make'], + ['.env.local', 'dotenv'], + ['LICENSE', 'text'], + ['file.unknown', 'text'], +])('detects the language for %s', (filepath, language) => { + expect(getFileLanguage(filepath)).toBe(language) +}) + +test.each([ + ['src/App.tsx', 'export default function App() {\n return \n}\n'], + ['package.json', '{\n "name": "generated-app"\n}\n'], + ['styles.css', 'button {\n color: red;\n}\n'], +])('renders %s as highlighted server markup with both themes', async (filepath, content) => { + const html = renderToStaticMarkup(await FilePreview({file: file(filepath, content)})) + expect(html).toContain('--shiki-light:') + expect(html).toContain('--shiki-dark:') + expect(html).toContain(`aria-label="${filepath}"`) + expect(html).toContain('tabindex="0"') + expect(html.replace(/<[^>]*>/g, '')).toBe(renderToStaticMarkup(<>{content})) +}) + +test.each([ + '\n\n\tconst greeting = "hello"\n\n', + '\r\n\tconst greeting = "hello"\r\n\r\n', + '\nconst first = 1\r\nconst second = 2\n', + 'const greeting = "hello"', + 'const greeting = "こんにちは 👋"\n', +])('preserves source text, whitespace, and line endings: %j', async content => { + const html = renderToStaticMarkup(await FilePreview({file: file('index.ts', content)})) + expect(html.replace(/<[^>]*>/g, '')).toBe(renderToStaticMarkup(<>{content})) +}) + +test('escapes file contents rather than rendering generated HTML', async () => { + const content = '' + const html = renderToStaticMarkup(await FilePreview({file: file('index.html', content)})) + expect(html).not.toContain('\nplain text\n' + const html = renderToStaticMarkup(await FilePreview({file: file('notes.unknown', content)})) + expect(html).not.toContain(']*>/g, '')).toBe(renderToStaticMarkup(<>{content})) +}) + +test('preserves empty and unavailable preview messages', async () => { + const empty = renderToStaticMarkup(await FilePreview({file: file('empty.ts', '')})) + expect(empty).toContain('This file is empty.') + const unavailable = renderToStaticMarkup( + await FilePreview({ + file: { + ...file('binary.png', ''), + preview: {type: 'unavailable', reason: 'Binary files cannot be previewed.'}, + }, + }), + ) + expect(unavailable).toContain('Binary files cannot be previewed.') + expect(unavailable).not.toContain('() +for (const language of bundledLanguagesInfo) { + languages.set(language.id, language.id) + for (const alias of language.aliases ?? []) { + languages.set(alias, language.id) + } +} + +function isBundledLanguage(language: string): language is BundledLanguage { + return Object.hasOwn(bundledLanguages, language) +} + +function getFileLanguage(filepath: string): BundledLanguage | 'text' { + const name = path.posix.basename(filepath).toLowerCase() + if (name === 'dockerfile' || name.startsWith('dockerfile.')) { + return 'docker' + } + if (name === 'makefile' || name === 'gnumakefile') { + return 'make' + } + if (name === '.env' || name.startsWith('.env.')) { + return 'dotenv' + } + const language = languages.get(path.posix.extname(name).slice(1)) + return language && isBundledLanguage(language) ? language : 'text' +} + +async function FilePreview({file}: {file: WorkspaceFile}) { + if (file.preview.type === 'unavailable') { + return

{file.preview.reason}

+ } + + const content = file.preview.content + if (content.length === 0) { + return

This file is empty.

+ } + + const language = getFileLanguage(file.path) + if (language === 'text') { + return ( +
+        {content}
+      
+ ) + } + + const {tokens} = await codeToTokens(content, { + lang: language, + themes: {light: 'github-light-default', dark: 'github-dark-default'}, + defaultColor: false, + }) + let previousEnd = 0 + const highlighted = tokens.flat().map((token, index) => { + // Token offsets let us retain the original line endings and blank lines. + const gap = content.slice(previousEnd, token.offset) + previousEnd = token.offset + token.content.length + return ( + + {gap} + {token.content} + + ) + }) + + return ( +
+      
+        {highlighted}
+        {content.slice(previousEnd)}
+      
+    
+ ) +} + +export {FilePreview, getFileLanguage} diff --git a/website/src/app/components/RunDetailsPage.tsx b/website/src/app/components/RunDetailsPage.tsx index 50a4dc4..4552ad4 100644 --- a/website/src/app/components/RunDetailsPage.tsx +++ b/website/src/app/components/RunDetailsPage.tsx @@ -1,427 +1,28 @@ -'use client' +import 'server-only' +import type {ReactNode} from 'react' +import type {RunDetails} from '../../run-details' +import type {WorkspaceEntry} from '../../workspace-files' +import {FilePreview} from './FilePreview' +import {RunDetailsView, type RunDetailsViewProps} from './RunDetailsView' -import {CheckCircleFillIcon, CopilotIcon, PersonIcon, XCircleFillIcon} from '@primer/octicons-react' -import {Breadcrumbs, FormControl, Select, Stack, UnderlineNav} from '@primer/react' -import type {RunDetails, TranscriptEntry, WalkthroughDataUrl} from '../../run-details' -import type {Route} from 'next' -import Link from 'next/link' -import Image from 'next/image' -import {useState} from 'react' -import {JudgeResults} from './JudgeResults' -import {FileExplorer} from './FileExplorer' - -type RunResult = RunDetails['results'][number] - -type ScenarioResultGroup = { - scenarioId: string - results: [RunResult, ...Array] -} - -function formatDuration(milliseconds: number): string { - if (milliseconds < 1000) { - return `${milliseconds} ms` - } - - return `${(milliseconds / 1000).toFixed(1)} s` -} - -function Transcript({entries}: {entries: Array}) { - if (entries.length === 0) { - return

No transcript messages were recorded.

- } - - return ( -
    - {entries.map(entry => { - const isUser = entry.label === 'User' - const isAssistant = entry.label === 'Assistant' - - return ( -
  1. - - {isUser ? : } - -
    -
    - {entry.label} - {entry.timestamp ? ( - - ) : null} -
    -
    -                {entry.content}
    -              
    -
    -
  2. - ) - })} -
- ) -} - -function BrowserScreenshot({alt, source}: {alt: string; source: string}) { - return ( -
- - {alt} -
- ) -} - -function UiWalkthrough({scenarioId, walkthrough}: {scenarioId: string; walkthrough: WalkthroughDataUrl}) { - if (walkthrough.type === 'Video') { - return ( -