Tree cards + file viewer UX: Code shortcut, labels, line numbers

- Cards: labeled Changes/Logs/Code actions (Terminal renamed), GitHub-mark
  icon link at the card edge; width 240->264 so the row fits; no phantom
  selection outline or pointer cursor (selection state was write-only and
  is removed; RF nodes stay selectable so pointer-events keep the buttons
  alive, visuals suppressed in CSS)
- Card Code button opens the project-level Code tab pinned to that
  experiment's branch (CodeTabDef.sessionId nullable); chat-header Code
  button removed along with the session-tab opener it fed
- Changes/Code headers: BRANCH/COMMIT/SOURCE micro-labels; shared GitHubMark
  (lucide has no brand icons) used by pill, cards, and code-tab link
- File viewer: sticky line-number gutter (1:1 with unwrapped rows) and a
  slimmer header
- ui/dist rebuilt
This commit is contained in:
Daniel Kim
2026-07-16 20:33:14 -07:00
parent fc4bd1aee9
commit 45f0b1186e
12 changed files with 283 additions and 232 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
html { background: #ffffff; }
@media (prefers-color-scheme: dark) { html { background: #0e0c0c; } }
</style>
<script type="module" crossorigin src="/assets/index-VwwcTCRX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hCeZGmkM.css">
<script type="module" crossorigin src="/assets/index-BDvcb1RI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHqNmNnI.css">
</head>
<body>
<div id="root"></div>
+20 -42
View File
@@ -79,7 +79,9 @@ interface PlanViewDef {
* `code` flag (the other tab kinds discriminate on `view`/`path`/`kind`). */
interface CodeTabDef {
code: true;
sessionId: string;
/** Owning chat session, or null for the project-level tab (opened from an
* experiment card's Code shortcut — browses branches via the hub clone). */
sessionId: string | null;
/** Source to browse: "" = live session worktree, else a branch name. */
sel: string;
/** Whether `sel` was picked by the user — a seeded default silently falls
@@ -89,14 +91,6 @@ interface CodeTabDef {
toggled: ReadonlySet<string>;
}
/** Branch of the experiment owning the project's most recent run — the code
* the user most likely wants to browse — or "" (live worktree) without runs. */
function defaultCodeSelection(experiments: Experiment[], runs: Run[]): string {
if (runs.length === 0) return "";
const latest = runs.reduce((a, b) => (b.createdAt > a.createdAt ? b : a));
return experiments.find((e) => e.id === latest.experimentId)?.branchName ?? "";
}
// Map a path an agent reported to a repo-relative one — keeping the session
// id when it points into a per-session worktree, so the file is read from the
// right checkout. Relative paths name files in the click context's checkout
@@ -175,7 +169,6 @@ export default function App() {
const [runs, setRuns] = useState<Run[]>([]);
const [files, setFiles] = useState<ProjectFiles | null>(null);
const [view, setView] = useState<"tree" | "table">("tree");
const [selectedExpId, setSelectedExpId] = useState<string | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
// Right-panel tab strip: the pinned Experiments tab plus a closable tab per
// opened experiment view / project file. Views are single-purpose, so the
@@ -236,7 +229,6 @@ export default function App() {
setExperiments([]);
setRuns([]);
setFiles(null);
setSelectedExpId(null);
setSelectedRunId(null);
setExpTabs([]);
setFileTabs([]);
@@ -274,7 +266,6 @@ export default function App() {
// Open an experiment view as a right-panel tab (creating it if needed) and
// focus it.
const openExperimentTab = useCallback((id: string, view: ExperimentView = "changes") => {
setSelectedExpId(id);
const tab = { id, view };
setExpTabs((prev) => (prev.some((t) => sameExpTab(t, tab)) ? prev : [...prev, tab]));
setRightTab(tab);
@@ -350,34 +341,29 @@ export default function App() {
[planTabs, rightTab],
);
// Open the code browser for a chat session — live worktree or experiment
// branches — one tab per session (deduped on sessionId), mirroring the
// file/plan tab handlers. A new tab's source defaults to the latest-run
// experiment's branch; reopening keeps the existing tab's state.
const openCodeTab = useCallback(
(sessionId: string) => {
const existing = codeTabs.find((t) => t.sessionId === sessionId);
const tab: CodeTabDef = existing ?? {
code: true,
sessionId,
sel: defaultCodeSelection(experiments, runs),
selPicked: false,
toggled: new Set<string>(),
};
// Self-guarding updater: a double invocation must not push duplicates.
// Card shortcut: browse a specific experiment branch in the project-level
// code tab (sessionId null), pinning the pick so it never falls back.
const openCodeTabForBranch = useCallback(
(branch: string) => {
const existing = codeTabs.find((t) => t.sessionId === null);
const tab: CodeTabDef = existing
? { ...existing, sel: branch, selPicked: true }
: { code: true, sessionId: null, sel: branch, selPicked: true, toggled: new Set<string>() };
setCodeTabs((prev) =>
prev.some((t) => t.sessionId === sessionId) ? prev : [...prev, tab],
prev.some((t) => t.sessionId === null)
? prev.map((t) => (t.sessionId === null ? tab : t))
: [...prev, tab],
);
setRightTab(tab);
setPanelOpen(true);
},
[codeTabs, experiments, runs],
[codeTabs],
);
// Source/expansion changes persist on the tab def, not in CodeTab state —
// the component unmounts whenever another right-pane tab fronts it.
const updateCodeTab = useCallback(
(sessionId: string, patch: Partial<Omit<CodeTabDef, "code" | "sessionId">>) => {
(sessionId: string | null, patch: Partial<Omit<CodeTabDef, "code" | "sessionId">>) => {
setCodeTabs((prev) => prev.map((t) => (t.sessionId === sessionId ? { ...t, ...patch } : t)));
},
[],
@@ -526,7 +512,6 @@ export default function App() {
}}
onOpenFile={openFileTab}
onOpenPlan={openPlanTab}
onOpenCode={openCodeTab}
>
{mainView === "files" ? (
(() => {
@@ -573,10 +558,7 @@ export default function App() {
<GitBranch size={12} style={{ flexShrink: 0 }} />
)
}
onSelect={() => {
setRightTab(t);
setSelectedExpId(t.id);
}}
onSelect={() => setRightTab(t)}
onClose={() => closeExperimentTab(t)}
/>
);
@@ -603,7 +585,7 @@ export default function App() {
))}
{codeTabs.map((t) => (
<ClosableTab
key={`code:${t.sessionId}`}
key={`code:${t.sessionId ?? "@project"}`}
active={codeTab !== null && codeTab.sessionId === t.sessionId}
label="Code"
icon={<FolderTree size={12} style={{ flexShrink: 0 }} />}
@@ -659,12 +641,8 @@ export default function App() {
experiments={experiments}
runs={runs}
project={activeProject}
selectedId={selectedExpId}
onSelect={(id) => {
setSelectedRunId(null);
setSelectedExpId(id);
}}
onOpenView={openExperimentTab}
onOpenCodeBranch={openCodeTabForBranch}
/>
)
) : (
@@ -715,7 +693,7 @@ export default function App() {
const def = codeTabs.find((t) => t.sessionId === codeTab.sessionId) ?? codeTab;
return (
<CodeTab
key={`code:${def.sessionId}`}
key={`code:${def.sessionId ?? "@project"}`}
projectId={projectId}
sessionId={def.sessionId}
project={activeProject}
+11 -2
View File
@@ -1,6 +1,15 @@
import { ExternalLink } from "lucide-react";
import { githubBranchUrl } from "../api";
/** GitHub mark (lucide dropped brand icons). fill=currentColor so it inherits
* hover styling like lucide siblings. */
export function GitHubMark({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}
/** A branch name as a pill linking to its GitHub tree view (files-pill
* styling). */
export function BranchPill({
@@ -21,7 +30,7 @@ export function BranchPill({
title={`Open ${branch} on GitHub`}
>
<code>{branch}</code>
<ExternalLink size={12} />
<GitHubMark size={12} />
</a>
);
}
-15
View File
@@ -4,7 +4,6 @@ import {
CornerDownLeft,
FlaskConical,
FolderOpen,
FolderTree,
MoreHorizontal,
PanelLeft,
Plus,
@@ -749,7 +748,6 @@ export function ChatPanel({
onTogglePanel,
onOpenFile,
onOpenPlan,
onOpenCode,
children,
}: {
projectId: string;
@@ -773,9 +771,6 @@ export function ChatPanel({
onOpenFile?: (path: string, sessionId?: string) => void;
/** Open a plan's markdown as a right-pane tab (plan strip / plan cards). */
onOpenPlan?: (plan: string, sessionId: string, promptId: string) => void;
/** Open the code browser (live session worktree / experiment branches) as
* a right-pane tab for the active session. */
onOpenCode?: (sessionId: string) => void;
/** Middle-pane content when a settings section is active (the SettingsView). */
children?: React.ReactNode;
}) {
@@ -1277,16 +1272,6 @@ export function ChatPanel({
>
{activeSession ? activeSession.title?.trim() || "Untitled" : "New session"}
</div>
{onOpenCode && activeId && (
<button
className="icon-btn"
title="Code"
aria-label="Code"
onClick={() => onOpenCode(activeId)}
>
<FolderTree size={15} />
</button>
)}
<button
className={`icon-btn ${panelOpen ? "active" : ""}`}
title="Experiments"
+9 -5
View File
@@ -180,7 +180,8 @@ export function CodeTab({
}: {
projectId: string;
/** Chat session whose worktree the live view browses. */
sessionId: string;
/** null: project-level tab (card shortcut) — no live-worktree source. */
sessionId: string | null;
/** Owning project — supplies owner/repo for the GitHub branch link. */
project: Project;
/** Project experiments — one selectable branch entry each (deduped). */
@@ -218,7 +219,7 @@ export function CodeTab({
const id = ++reqId.current;
inFlight.current = true;
if (showSpinner) setLoading(true);
getCodeTree(projectId, sel ? { ref: sel } : { sessionId })
getCodeTree(projectId, sel ? { ref: sel } : { sessionId: sessionId ?? undefined })
.then((d) => {
if (id !== reqId.current) return;
setData(d);
@@ -301,13 +302,16 @@ export function CodeTab({
return (
<div className="code-tab">
<div className="code-tab-header">
<span className="ctl-label">Source</span>
<select
className="input sm code-tab-select"
value={sel}
onChange={(e) => onSelChange(e.target.value, true)}
title="Source to browse"
>
<option value="">live — session worktree</option>
<option value="">
{sessionId ? "live — session worktree" : "project clone"}
</option>
{branchOptions.map((o) => (
<option key={o.branch} value={o.branch}>
{o.label}
@@ -335,7 +339,7 @@ export function CodeTab({
{loading ? <span className="spinner" /> : <RotateCw size={13} />}
</button>
</div>
{!sel && data?.root === "clone" && (
{sessionId !== null && !sel && data?.root === "clone" && (
<div className="code-tab-note">
This session's worktree isn't available — showing the project clone.
</div>
@@ -355,7 +359,7 @@ export function CodeTab({
depth={0}
toggled={toggled}
onToggle={toggle}
onOpenFile={(path) => onOpenFile(path, sessionId, sel || undefined)}
onOpenFile={(path) => onOpenFile(path, sessionId ?? undefined, sel || undefined)}
/>
</div>
)}
+2
View File
@@ -281,6 +281,7 @@ function ChangesView({ experiment, project }: { experiment: Experiment; project:
<div className="drawer-body">
<div className="drawer-section">
<div className="changes-branch">
<span className="ctl-label">Branch</span>
<BranchPill
owner={project.githubOwner}
repo={project.githubRepo}
@@ -298,6 +299,7 @@ function ChangesView({ experiment, project }: { experiment: Experiment; project:
) : (
<>
<div className="commit-picker">
<span className="ctl-label">Commit</span>
{selection === UNCOMMITTED && <span className="uncommitted-dot" />}
<select
className="input sm"
+15 -3
View File
@@ -64,6 +64,13 @@ export function FileViewer({
[data, path],
);
// One number per source line; a trailing newline ends a line, it doesn't
// start an empty one.
const lineCount =
data && !data.notFound && data.content
? data.content.split("\n").length - (data.content.endsWith("\n") ? 1 : 0)
: 0;
const notFoundCopy = (d: ProjectFile) => {
if (gitRef) return `File not found on branch ${gitRef}.`;
if (sessionId && d.root === "clone")
@@ -124,9 +131,14 @@ export function FileViewer({
/>
</div>
) : (
<pre className="file-view-code">
<code>{rendered}</code>
</pre>
<div className="file-view-codewrap">
<pre className="file-view-gutter" aria-hidden="true">
{Array.from({ length: lineCount }, (_, i) => i + 1).join("\n")}
</pre>
<pre className="file-view-code">
<code>{rendered}</code>
</pre>
</div>
)}
{data.truncated && (
<div className="file-view-note">File truncated — showing the first 512 KB.</div>
+24 -17
View File
@@ -8,13 +8,14 @@ import {
type Node,
type NodeProps,
} from "@xyflow/react";
import { ExternalLink, GitBranch, Terminal } from "lucide-react";
import { FolderTree, GitBranch, Terminal } from "lucide-react";
import { GitHubMark } from "./BranchPill";
import { memo, useMemo } from "react";
import { githubBranchUrl, timeAgo, type Experiment, type Project, type Run } from "../api";
import type { ExperimentView } from "./DetailDrawer";
import { StatusBadge } from "./StatusBadge";
const NODE_W = 240;
const NODE_W = 264;
const NODE_H = 132;
const GAP_X = 44;
const GAP_Y = 72;
@@ -25,10 +26,10 @@ type ExpNodeData = {
latestRun: Run | null;
runs: Run[]; // oldest → newest
isBaseline: boolean;
selected: boolean;
githubOwner: string;
githubRepo: string;
onOpenView: (id: string, view: ExperimentView) => void;
onOpenCodeBranch: (branch: string) => void;
};
type ExpFlowNode = Node<ExpNodeData, "exp">;
@@ -71,13 +72,13 @@ function runSquareClass(status: string): string {
}
const ExpNode = memo(function ExpNode({ data }: NodeProps<ExpFlowNode>) {
const { exp, latestRun, runs, isBaseline, selected, githubOwner, githubRepo, onOpenView } = data;
const { exp, latestRun, runs, isBaseline, githubOwner, githubRepo, onOpenView, onOpenCodeBranch } = data;
const status = latestRun?.status;
const live = status === "running" || status === "starting";
const kind = isBaseline ? "BASELINE" : live ? "RUNNING" : "EXPERIMENT";
const squares = runs.slice(-MAX_SQUARES);
return (
<div className={`exp-node ${selected ? "selected" : ""} ${live ? "live" : ""}`}>
<div className={`exp-node ${live ? "live" : ""}`}>
<Handle type="target" position={Position.Top} />
<div className="node-eyebrow">
<span>{kind}</span>
@@ -120,19 +121,28 @@ const ExpNode = memo(function ExpNode({ data }: NodeProps<ExpFlowNode>) {
onClick={() => onOpenView(exp.id, "terminal")}
>
<Terminal size={13} />
Terminal
Logs
</button>
)}
<a
<button
className="node-action"
title={`Browse code on ${exp.branchName}`}
onClick={() => onOpenCodeBranch(exp.branchName)}
>
<FolderTree size={13} />
Code
</button>
{/* Icon-only: labeled actions + the link overflow the card's fixed width. */}
<a
className="node-action node-action-ext"
title={`Open ${exp.branchName} on GitHub`}
aria-label={`Open ${exp.branchName} on GitHub`}
href={githubBranchUrl(githubOwner, githubRepo, exp.branchName)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={13} />
GitHub
<GitHubMark size={13} />
</a>
</div>
<Handle type="source" position={Position.Bottom} />
@@ -151,18 +161,17 @@ export function TreeView({
experiments,
runs,
project,
selectedId,
onSelect,
onOpenView,
onOpenCodeBranch,
}: {
experiments: Experiment[];
runs: Run[];
/** Owning project — supplies owner/repo for the GitHub branch links. */
project: Project;
selectedId: string | null;
onSelect: (id: string | null) => void;
/** Open an experiment view as a right-pane tab (card shortcut buttons). */
onOpenView: (id: string, view: ExperimentView) => void;
/** Browse an experiment branch's code in the project-level Code tab. */
onOpenCodeBranch: (branch: string) => void;
}) {
const { nodes, edges } = useMemo(() => {
const runsByExp = new Map<string, Run[]>();
@@ -188,10 +197,10 @@ export function TreeView({
latestRun: expRuns[expRuns.length - 1] ?? null,
runs: expRuns,
isBaseline: !node.exp.parentExperimentId,
selected: node.exp.id === selectedId,
githubOwner: project.githubOwner,
githubRepo: project.githubRepo,
onOpenView,
onOpenCodeBranch,
},
});
if (node.children.length === 0) return;
@@ -218,7 +227,7 @@ export function TreeView({
rx += w + GAP_X;
}
return { nodes, edges };
}, [experiments, runs, selectedId, onOpenView, project.githubOwner, project.githubRepo]);
}, [experiments, runs, onOpenView, onOpenCodeBranch, project.githubOwner, project.githubRepo]);
if (experiments.length === 0) {
return (
@@ -240,8 +249,6 @@ export function TreeView({
minZoom={0.15}
fitView
fitViewOptions={{ padding: 0.25, maxZoom: 1 }}
onNodeClick={(_, node) => onSelect(node.id)}
onPaneClick={() => onSelect(null)}
>
<Background variant={BackgroundVariant.Dots} color="var(--dots-strong)" gap={28} size={1.6} />
</ReactFlow>
+60 -9
View File
@@ -1411,12 +1411,25 @@ textarea::placeholder {
/* ---- tree nodes (graph cards on the dotted canvas) ---- */
/* Cards must stay `selectable` (ReactFlow sets pointer-events:none on nodes
* that are neither draggable nor selectable, which would kill the action
* buttons) — but selection itself means nothing here, so suppress its
* pointer cursor and click outline. Specificity beats RF's stylesheet. */
.react-flow__node.react-flow__node-exp.selectable {
cursor: default;
}
.react-flow__node.react-flow__node-exp.selectable.selected,
.react-flow__node.react-flow__node-exp.selectable:focus,
.react-flow__node.react-flow__node-exp.selectable:focus-visible {
outline: none;
box-shadow: none;
}
.exp-node {
width: 240px;
/* Keep in sync with NODE_W in TreeView.tsx (layout math uses it). */
width: 264px;
border: 1px solid var(--border);
background: var(--base);
padding: 10px 12px;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
font-size: 12.5px;
transition: box-shadow 120ms ease;
@@ -1424,10 +1437,6 @@ textarea::placeholder {
.exp-node:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.exp-node.selected {
border-color: var(--text);
box-shadow: 0 0 0 1px var(--text);
}
.exp-node.live {
border-color: var(--accent-teal);
box-shadow: 0 2px 12px rgba(32, 154, 132, 0.2);
@@ -1505,13 +1514,14 @@ textarea::placeholder {
border-top: 1px solid var(--border-variant);
display: flex;
align-items: center;
gap: 4px;
gap: 3px;
}
.exp-node .node-action {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
/* Tight: Changes + Terminal + Code + the GitHub icon share one fixed-width row. */
padding: 3px 6px;
font-size: 10.5px;
font-weight: 700;
color: var(--subtext);
@@ -1523,6 +1533,11 @@ textarea::placeholder {
color: var(--text);
background: var(--surface);
}
/* External GitHub shortcut: icon-only, pinned to the card's far edge. */
.exp-node .node-action-ext {
margin-left: auto;
padding: 3px 5px;
}
/* run-history squares (RUNS strip on node cards) */
.run-squares {
@@ -1874,6 +1889,8 @@ textarea::placeholder {
/* commit picker (Changes tab) */
.changes-branch {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.commit-picker {
@@ -1881,6 +1898,17 @@ textarea::placeholder {
align-items: center;
gap: 8px;
}
/* Tiny caption naming what a header control is (Branch / Commit / Source) —
* two adjacent unlabeled monospace pickers are indistinguishable. */
.ctl-label {
flex-shrink: 0;
font-family: var(--mono);
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.commit-picker select {
flex: 1;
min-width: 0;
@@ -3361,7 +3389,7 @@ button.files-pill-link:hover {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
padding: 4px 12px;
border-bottom: 1px solid var(--border-variant);
color: var(--subtext);
flex-shrink: 0;
@@ -3398,6 +3426,29 @@ button.files-pill-link:hover {
overflow: auto;
background: var(--base);
}
/* Gutter + code share one row so line numbers track content rows 1:1 — the
code never soft-wraps (min-width: max-content), so line N is row N. */
.file-view-codewrap {
display: flex;
align-items: flex-start;
min-width: max-content;
}
.file-view-gutter {
margin: 0;
padding: 14px 10px 14px 14px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
text-align: right;
color: var(--muted);
user-select: none;
/* Stay visible while the code scrolls horizontally. */
position: sticky;
left: 0;
background: var(--base);
border-right: 1px solid var(--border-variant);
flex-shrink: 0;
}
.file-view-code {
margin: 0;
padding: 14px 16px;