Remember exmpanded state of log entries idiomatically (#547)

This commit is contained in:
Solomon
2025-08-21 14:34:12 +01:00
committed by GitHub
parent b29f8a9f0d
commit 061b461397
8 changed files with 107 additions and 38 deletions

View File

@@ -51,6 +51,7 @@
"react-virtuoso": "^4.13.0",
"react-window": "^1.8.11",
"rfc6902": "^5.1.2",
"zustand": "^4.5.4",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"
},

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import MarkdownRenderer from '@/components/ui/markdown-renderer.tsx';
import {
AlertCircle,
@@ -25,7 +24,7 @@ import FileChangeRenderer from './FileChangeRenderer';
type Props = {
entry: NormalizedEntry;
index: number;
expansionKey: string;
diffDeletable?: boolean;
};
@@ -131,24 +130,15 @@ const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
);
};
function DisplayConversationEntry({ entry, index }: Props) {
const [expandedErrors, setExpandedErrors] = useState<Set<number>>(new Set());
const toggleErrorExpansion = (index: number) => {
setExpandedErrors((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
import { useExpandable } from '@/stores/useExpandableStore';
function DisplayConversationEntry({ entry, expansionKey }: Props) {
const isErrorMessage = entry.entry_type.type === 'error_message';
const isExpanded = expandedErrors.has(index);
const hasMultipleLines = isErrorMessage && entry.content.includes('\n');
const [isExpanded, setIsExpanded] = useExpandable(
`err:${expansionKey}`,
false
);
const fileEdit =
entry.entry_type.type === 'tool_use' &&
@@ -160,12 +150,12 @@ function DisplayConversationEntry({ entry, index }: Props) {
: null;
return (
<div key={index} className="px-4 py-1">
<div className="px-4 py-1">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
{isErrorMessage && hasMultipleLines ? (
<button
onClick={() => toggleErrorExpansion(index)}
onClick={() => setIsExpanded()}
className="transition-colors hover:opacity-70"
>
{getEntryIcon(entry.entry_type)}
@@ -191,7 +181,7 @@ function DisplayConversationEntry({ entry, index }: Props) {
<>
{entry.content.split('\n')[0]}
<button
onClick={() => toggleErrorExpansion(index)}
onClick={() => setIsExpanded()}
className="ml-2 inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<ChevronRight className="h-3 w-3" />
@@ -202,7 +192,7 @@ function DisplayConversationEntry({ entry, index }: Props) {
</div>
{isExpanded && (
<button
onClick={() => toggleErrorExpansion(index)}
onClick={() => setIsExpanded()}
className="flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<ChevronUp className="h-3 w-3" />
@@ -225,13 +215,16 @@ function DisplayConversationEntry({ entry, index }: Props) {
{fileEdit &&
Array.isArray(fileEdit.changes) &&
fileEdit.changes.map((change, idx) => (
<FileChangeRenderer
key={idx}
path={fileEdit.path}
change={change}
/>
))}
fileEdit.changes.map((change, idx) => {
return (
<FileChangeRenderer
key={idx}
path={fileEdit.path}
change={change}
expansionKey={`edit:${expansionKey}:${idx}`}
/>
);
})}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import {
DiffView,
DiffModeEnum,
@@ -17,6 +17,7 @@ type Props = {
path: string;
unifiedDiff: string;
hasLineNumbers: boolean;
expansionKey: string;
};
/**
@@ -55,9 +56,16 @@ function processUnifiedDiff(unifiedDiff: string, hasLineNumbers: boolean) {
};
}
function EditDiffRenderer({ path, unifiedDiff, hasLineNumbers }: Props) {
import { useExpandable } from '@/stores/useExpandableStore';
function EditDiffRenderer({
path,
unifiedDiff,
hasLineNumbers,
expansionKey,
}: Props) {
const { config } = useConfig();
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useExpandable(expansionKey, false);
let theme: 'light' | 'dark' | undefined = 'light';
if (config?.theme === ThemeMode.DARK) {
@@ -86,7 +94,7 @@ function EditDiffRenderer({ path, unifiedDiff, hasLineNumbers }: Props) {
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((e) => !e)}
onClick={() => setExpanded()}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { ThemeMode, type FileChange } from 'shared/types';
import { useConfig } from '@/components/config-provider';
import { Button } from '@/components/ui/button';
@@ -13,10 +12,12 @@ import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import EditDiffRenderer from './EditDiffRenderer';
import FileContentView from './FileContentView';
import '@/styles/diff-style-overrides.css';
import { useExpandable } from '@/stores/useExpandableStore';
type Props = {
path: string;
change: FileChange;
expansionKey: string;
};
function isWrite(
@@ -40,9 +41,9 @@ function isEdit(
return change?.action === 'edit';
}
const FileChangeRenderer = ({ path, change }: Props) => {
const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
const { config } = useConfig();
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useExpandable(expansionKey, false);
let theme: 'light' | 'dark' | undefined = 'light';
if (config?.theme === ThemeMode.DARK) theme = 'dark';
@@ -54,6 +55,7 @@ const FileChangeRenderer = ({ path, change }: Props) => {
path={path}
unifiedDiff={change.unified_diff}
hasLineNumbers={change.has_line_numbers}
expansionKey={expansionKey}
/>
);
}
@@ -121,7 +123,7 @@ const FileChangeRenderer = ({ path, change }: Props) => {
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((e) => !e)}
onClick={() => setExpanded()}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}

View File

@@ -43,7 +43,7 @@ function LogEntryRow({
return (
<DisplayConversationEntry
entry={entry.payload as NormalizedEntry}
index={index}
expansionKey={`${entry.processId}:${index}`}
diffDeletable={false}
/>
);

View File

@@ -160,7 +160,7 @@ function ProcessCard({ process }: ProcessCardProps) {
<DisplayConversationEntry
key={entry.timestamp ?? index}
entry={entry}
index={index}
expansionKey={`${process.id}:${index}`}
diffDeletable={false}
/>
))

View File

@@ -0,0 +1,40 @@
import { create } from 'zustand';
type State = {
expanded: Record<string, boolean>;
setKey: (key: string, value: boolean) => void;
toggleKey: (key: string, fallback?: boolean) => void;
clear: () => void;
};
export const useExpandableStore = create<State>((set) => ({
expanded: {},
setKey: (key, value) =>
set((s) =>
s.expanded[key] === value
? s
: { expanded: { ...s.expanded, [key]: value } }
),
toggleKey: (key, fallback = false) =>
set((s) => {
const next = !(s.expanded[key] ?? fallback);
return { expanded: { ...s.expanded, [key]: next } };
}),
clear: () => set({ expanded: {} }),
}));
export function useExpandable(
key: string,
defaultValue = false
): [boolean, (next?: boolean) => void] {
const expandedValue = useExpandableStore((s) => s.expanded[key]);
const setKey = useExpandableStore((s) => s.setKey);
const toggleKey = useExpandableStore((s) => s.toggleKey);
const set = (next?: boolean) => {
if (typeof next === 'boolean') setKey(key, next);
else toggleKey(key, defaultValue);
};
return [expandedValue ?? defaultValue, set];
}