Compare commits

...

1 Commits

Author SHA1 Message Date
Danielle Maywood 9cc5030582 feat(site/AgentsPage): add file diff collapsing with localStorage persistence
Adds collapsible file diffs using @pierre/diffs' built-in collapsed
option. A CollapseChevron component is rendered inside each file
header via renderHeaderPrefix, with the chevron produced internally
by LazyFileDiff so only the toggled file re-renders.

Collapsed state persists across browser refreshes via localStorage,
scoped per chat (remote) or per repo root (local). Clicking a file
in the sidebar or programmatic scroll-to-file auto-expands.
2026-03-25 14:10:44 +00:00
5 changed files with 144 additions and 1 deletions
@@ -189,6 +189,8 @@ interface CommentableDiffViewerProps {
diffStyle: DiffStyle;
/** Ref to the chat message input for inserting comments. */
chatInputRef?: RefObject<ChatMessageInputRef | null>;
/** Optional key for persisting collapsed file state to localStorage. */
storageKey?: string;
/** Scroll to a specific file. */
scrollToFile?: string | null;
/** Called after scrollToFile has been processed. */
@@ -63,6 +63,27 @@ export const SplitView: Story = {
},
};
export const WithCollapsedFile: Story = {
args: {
storageKey: "storybook-collapsed",
},
play: async ({ canvasElement }) => {
await waitFor(() => {
const toggle = canvasElement.querySelector<HTMLButtonElement>(
'[data-testid="collapse-file-toggle"]',
);
expect(toggle).not.toBeNull();
toggle!.click();
});
await waitFor(() => {
const toggle = canvasElement.querySelector<HTMLButtonElement>(
'[data-testid="collapse-file-toggle"]',
);
expect(toggle?.getAttribute("aria-label")).toBe("Expand file");
});
},
};
export const Loading: Story = {
args: {
parsedFiles: [],
@@ -91,6 +91,8 @@ interface DiffViewerProps {
scrollToFile?: string | null;
/** Called after scrollToFile has been processed. */
onScrollToFileComplete?: () => void;
/** Optional key for persisting collapsed file state to localStorage. */
storageKey?: string;
}
// -------------------------------------------------------------------
@@ -116,6 +118,15 @@ const STICKY_HEADER_CSS = [
" background-color: hsl(var(--surface-secondary)) !important;",
" padding-block: 0 !important;",
"}",
// Ensure the header-prefix slot (collapse chevron) renders
// before the CSS-generated change-type letter (A/D/M/R) so
// the order matches in both collapsed and expanded states.
"[data-header-content] > slot[name='header-prefix'] {",
" order: -1;",
" display: flex;",
" align-items: center;",
"}",
].join(" ");
export type DiffStyle = "unified" | "split";
@@ -133,6 +144,38 @@ export function saveDiffStyle(style: DiffStyle): void {
localStorage.setItem(DIFF_STYLE_KEY, style);
}
const COLLAPSED_FILES_KEY_PREFIX = "agents.collapsed-diffs";
function loadCollapsedFiles(storageKey: string): Set<string> {
try {
const raw = localStorage.getItem(
`${COLLAPSED_FILES_KEY_PREFIX}.${storageKey}`,
);
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(
parsed.filter((v): v is string => typeof v === "string"),
);
}
}
} catch {
// Corrupted data — start fresh.
}
return new Set();
}
// NOTE: Each storageKey gets its own localStorage entry and old
// entries are never pruned. For `remote-${chatId}` this grows as
// new chats are created. A future improvement could add LRU
// eviction or cap the number of stored keys.
function saveCollapsedFiles(storageKey: string, files: Set<string>): void {
localStorage.setItem(
`${COLLAPSED_FILES_KEY_PREFIX}.${storageKey}`,
JSON.stringify([...files]),
);
}
/** Width of the file tree sidebar in pixels. */
const FILE_TREE_WIDTH = 300;
@@ -413,6 +456,33 @@ const DiffScrollContainer: FC<{
);
};
// -------------------------------------------------------------------
// Collapse toggle rendered in each file header via renderHeaderPrefix
// -------------------------------------------------------------------
const CollapseChevron: FC<{
collapsed: boolean;
onToggle: () => void;
}> = ({ collapsed, onToggle }) => (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
className="flex items-center border-none bg-transparent cursor-pointer p-0 text-content-secondary hover:text-content-primary outline-none"
aria-label={collapsed ? "Expand file" : "Collapse file"}
data-testid="collapse-file-toggle"
>
<ChevronRightIcon
className={cn(
"size-3.5 shrink-0 transition-transform",
!collapsed && "rotate-90",
)}
/>
</button>
);
// -------------------------------------------------------------------
// Lazy file diff wrapper
// -------------------------------------------------------------------
@@ -429,6 +499,8 @@ const DiffScrollContainer: FC<{
interface LazyFileDiffProps {
fileDiff: FileDiffMetadata;
options: ComponentProps<typeof FileDiff>["options"];
collapsed?: boolean;
onToggleCollapsed?: () => void;
lineAnnotations?: DiffLineAnnotation<string>[];
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
selectedLines?: SelectedLineRange | null;
@@ -437,6 +509,8 @@ interface LazyFileDiffProps {
const LazyFileDiff: FC<LazyFileDiffProps> = ({
fileDiff,
options,
collapsed,
onToggleCollapsed,
lineAnnotations,
renderAnnotation: renderAnnotationProp,
selectedLines,
@@ -465,6 +539,14 @@ const LazyFileDiff: FC<LazyFileDiffProps> = ({
return () => observer.disconnect();
}, [visible]);
const mergedOptions = collapsed ? { ...options, collapsed: true } : options;
const headerPrefix = onToggleCollapsed
? () => (
<CollapseChevron collapsed={!!collapsed} onToggle={onToggleCollapsed} />
)
: undefined;
if (!visible) {
return (
<div
@@ -483,11 +565,12 @@ const LazyFileDiff: FC<LazyFileDiffProps> = ({
return (
<FileDiff
fileDiff={fileDiff}
options={options}
options={mergedOptions}
metrics={VIRTUALIZER_METRICS}
style={DIFFS_FONT_STYLE}
lineAnnotations={lineAnnotations}
renderAnnotation={renderAnnotationProp}
renderHeaderPrefix={headerPrefix}
selectedLines={selectedLines}
/>
);
@@ -511,8 +594,31 @@ export const DiffViewer: FC<DiffViewerProps> = ({
renderAnnotation,
scrollToFile,
onScrollToFileComplete,
storageKey,
}) => {
const theme = useTheme();
// ---------------------------------------------------------------
// Collapsed file state
// ---------------------------------------------------------------
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(() =>
storageKey ? loadCollapsedFiles(storageKey) : new Set(),
);
const toggleFileCollapsed = (fileName: string) => {
setCollapsedFiles((prev) => {
const next = new Set(prev);
if (next.has(fileName)) {
next.delete(fileName);
} else {
next.add(fileName);
}
if (storageKey) {
saveCollapsedFiles(storageKey, next);
}
return next;
});
};
const isDark = theme.palette.mode === "dark";
const diffOptions = (() => {
@@ -738,6 +844,9 @@ export const DiffViewer: FC<DiffViewerProps> = ({
}, [showTree, sortedFiles.length]);
const handleFileClick = (name: string) => {
if (collapsedFiles.has(name)) {
toggleFileCollapsed(name);
}
const el = fileRefs.current.get(name);
if (el) {
el.scrollIntoView({ block: "start" });
@@ -748,8 +857,15 @@ export const DiffViewer: FC<DiffViewerProps> = ({
// Scroll to a file programmatically when the parent sets
// scrollToFile. This enables external navigation (e.g.
// clicking a file reference chip in the chat input).
// The effect should only fire when scrollToFile changes;
// collapsedFiles/toggleFileCollapsed are used incidentally
// to auto-expand the target file.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
useEffect(() => {
if (scrollToFile) {
if (collapsedFiles.has(scrollToFile)) {
toggleFileCollapsed(scrollToFile);
}
const el = fileRefs.current.get(scrollToFile);
if (el) {
el.scrollIntoView({ block: "start", behavior: "smooth" });
@@ -848,6 +964,8 @@ export const DiffViewer: FC<DiffViewerProps> = ({
<LazyFileDiff
fileDiff={fileDiff}
options={perFileOptions?.get(fileDiff.name) ?? fileOptions}
collapsed={collapsedFiles.has(fileDiff.name)}
onToggleCollapsed={() => toggleFileCollapsed(fileDiff.name)}
lineAnnotations={perFileAnnotations?.get(fileDiff.name)}
renderAnnotation={renderAnnotation}
selectedLines={
@@ -38,6 +38,7 @@ export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
emptyMessage="No file changes."
diffStyle={diffStyle}
chatInputRef={chatInputRef}
storageKey={`local-${repo.repo_root}`}
/>
);
};
@@ -197,6 +197,7 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
chatInputRef={chatInputRef}
scrollToFile={scrollTarget}
onScrollToFileComplete={handleScrollComplete}
storageKey={`remote-${chatId}`}
/>
</div>
);