Skip to content

Commit 613de30

Browse files
authored
feat(studio): make Files tab mobile-friendly (#1262)
* feat(studio): make Files tab mobile-friendly - FileTree: w-64 → w-full md:w-64 (full-width on mobile) - FilesTab: add mobile panel switching with floating toggle button - On mobile, shows one panel at a time (tree or content viewer) - Desktop layout unchanged (side-by-side) * perf(studio): make startup instant — async git sync + cached git runs - syncProjects() now fires in background instead of blocking startup - listGitRuns() results cached in-memory for 60s to avoid repeated expensive git ls-tree + git cat-file --batch on every API request - startup drops from ~30s to <1s even with missing project paths
1 parent ff46bdb commit 613de30

4 files changed

Lines changed: 76 additions & 7 deletions

File tree

apps/cli/src/commands/results/remote.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,31 @@ import {
2424
listResultFilesFromRunsDir,
2525
} from '../inspect/utils.js';
2626

27+
28+
// ── In-memory TTL cache for listGitRuns ────────────────────────────
29+
// Avoids repeated expensive git ls-tree + git cat-file --batch operations
30+
// on every API request. Cache key is repoDir, TTL is 60 seconds.
31+
const gitRunsCache = new Map<string, { data: any; expiresAt: number }>();
32+
const GIT_RUNS_CACHE_TTL_MS = 60_000;
33+
34+
function cachedListGitRuns(repoDir: string) {
35+
const now = Date.now();
36+
const cached = gitRunsCache.get(repoDir);
37+
if (cached && cached.expiresAt > now) {
38+
return cached.data;
39+
}
40+
const promise = listGitRuns(repoDir);
41+
gitRunsCache.set(repoDir, { data: promise, expiresAt: now + GIT_RUNS_CACHE_TTL_MS });
42+
// Evict stale entry once the promise settles so a fresh fetch replaces it
43+
promise.catch(() => {}).finally(() => {
44+
const entry = gitRunsCache.get(repoDir);
45+
if (entry && entry.expiresAt <= Date.now()) {
46+
gitRunsCache.delete(repoDir);
47+
}
48+
});
49+
return promise;
50+
}
51+
2752
export type RunSource = 'local' | 'remote';
2853

2954
export interface SourcedResultFileMeta extends ResultFileMeta {
@@ -129,7 +154,7 @@ export async function getRemoteResultsStatus(cwd: string): Promise<RemoteResults
129154
let runCount = 0;
130155
if (config && status.available) {
131156
try {
132-
runCount = (await listGitRuns(config.path)).length;
157+
runCount = (await cachedListGitRuns(config.path)).length;
133158
} catch {
134159
runCount = listResultFilesFromRunsDir(resolveResultsRepoRunsDir(config)).length;
135160
}
@@ -187,7 +212,7 @@ export async function listMergedResultFiles(
187212
let remoteRuns: SourcedResultFileMeta[] = [];
188213
if (config.mode === 'github') {
189214
try {
190-
const gitRuns = await listGitRuns(config.path);
215+
const gitRuns = await cachedListGitRuns(config.path);
191216
remoteRuns = gitRuns.map((r) => ({
192217
filename: encodeRemoteRunId(r.run_id),
193218
raw_filename: r.run_id,

apps/cli/src/commands/results/serve.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1607,7 +1607,9 @@ export const resultsServeCommand = command({
16071607

16081608
// ── Project sync preflight ───────────────────────────────────────
16091609
// Clone or pull any project entries that declare a source.
1610-
await syncProjects(registry.projects);
1610+
// Non-blocking: fire-and-forget so startup is instant even when some
1611+
// project paths are missing or slow (e.g. /tmp paths that timeout).
1612+
syncProjects(registry.projects).catch((err) => console.error("Background project sync failed:", err));
16111613

16121614
try {
16131615
let results: EvaluationResult[] = [];

apps/studio/src/components/EvalDetail.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ function FilesTab({
264264
const files = filesData?.files ?? [];
265265

266266
const [selectedPath, setSelectedPath] = useState<string | null>(null);
267+
const [mobileShowTree, setMobileShowTree] = useState(false);
267268

268269
const effectivePath = selectedPath ?? (files.length > 0 ? findFirstFile(files) : null);
269270

@@ -284,11 +285,52 @@ function FilesTab({
284285
const displayLanguage = effectivePath ? (fileContentData?.language ?? 'plaintext') : 'plaintext';
285286

286287
return (
287-
<div className="flex h-full min-h-[400px] gap-4">
288-
<FileTree files={files} selectedPath={effectivePath} onSelect={setSelectedPath} />
289-
<div className="flex-1">
288+
<div className="relative flex h-full min-h-[400px] gap-4">
289+
{/* FileTree panel — desktop: side-by-side, mobile: full-width slide-over */}
290+
<div
291+
className={`${
292+
mobileShowTree ? 'block' : 'hidden'
293+
} md:block w-full md:w-auto`}
294+
>
295+
<FileTree
296+
files={files}
297+
selectedPath={effectivePath}
298+
onSelect={(path) => {
299+
setSelectedPath(path);
300+
// On mobile, auto-switch to content viewer after selecting a file
301+
setMobileShowTree(false);
302+
}}
303+
/>
304+
</div>
305+
306+
{/* MonacoViewer panel — desktop: side-by-side, mobile: full-width */}
307+
<div
308+
className={`${
309+
!mobileShowTree ? 'block' : 'hidden'
310+
} md:block flex-1 h-full`}
311+
>
290312
<MonacoViewer value={displayValue} language={displayLanguage} height="100%" />
291313
</div>
314+
315+
{/* Mobile toggle button — floating bottom-right */}
316+
<button
317+
type="button"
318+
onClick={() => setMobileShowTree(!mobileShowTree)}
319+
className="md:hidden fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-full bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-200 shadow-lg border border-gray-700 hover:bg-gray-700 active:bg-gray-600 transition-colors"
320+
aria-label={mobileShowTree ? 'Switch to file content viewer' : 'Switch to file tree'}
321+
>
322+
{mobileShowTree ? (
323+
<>
324+
<span>📄</span>
325+
<span>Content</span>
326+
</>
327+
) : (
328+
<>
329+
<span>📁</span>
330+
<span>Files</span>
331+
</>
332+
)}
333+
</button>
292334
</div>
293335
);
294336
}

apps/studio/src/components/FileTree.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export function FileTree({ files, selectedPath, onSelect }: FileTreeProps) {
131131
};
132132

133133
return (
134-
<div className="w-64 overflow-y-auto rounded-lg border border-gray-800 bg-gray-900 py-2">
134+
<div className="w-full md:w-64 overflow-y-auto rounded-lg border border-gray-800 bg-gray-900 py-2">
135135
{files.length === 0 && <p className="px-4 py-2 text-sm text-gray-500">No files.</p>}
136136
{files.map((node) => (
137137
<TreeNode

0 commit comments

Comments
 (0)