Remember exmpanded state of log entries idiomatically (#547)
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -43,7 +43,7 @@ function LogEntryRow({
|
||||
return (
|
||||
<DisplayConversationEntry
|
||||
entry={entry.payload as NormalizedEntry}
|
||||
index={index}
|
||||
expansionKey={`${entry.processId}:${index}`}
|
||||
diffDeletable={false}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -160,7 +160,7 @@ function ProcessCard({ process }: ProcessCardProps) {
|
||||
<DisplayConversationEntry
|
||||
key={entry.timestamp ?? index}
|
||||
entry={entry}
|
||||
index={index}
|
||||
expansionKey={`${process.id}:${index}`}
|
||||
diffDeletable={false}
|
||||
/>
|
||||
))
|
||||
|
||||
40
frontend/src/stores/useExpandableStore.ts
Normal file
40
frontend/src/stores/useExpandableStore.ts
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user