chore: move list recipes and archive recipe to goose server (#4422)

This commit is contained in:
Lifei Zhou
2025-09-01 11:28:28 +10:00
committed by GitHub
parent cded5ae56b
commit ce69b396ce
16 changed files with 1039 additions and 801 deletions
@@ -227,7 +227,7 @@ export default function ProgressiveMessageList({
toolCallNotifications,
isStreamingMessage,
onMessageUpdate,
hasCompactionMarker
hasCompactionMarker,
]);
return (
+84 -154
View File
@@ -1,21 +1,11 @@
import { useState, useEffect } from 'react';
import {
listSavedRecipes,
archiveRecipe,
SavedRecipe,
saveRecipe,
generateRecipeFilename,
convertToLocaleDateString,
} from '../recipe/recipeStorage';
import {
FileText,
Trash2,
Bot,
Calendar,
Globe,
Folder,
AlertCircle,
Download,
} from 'lucide-react';
import { FileText, Trash2, Bot, Calendar, AlertCircle, Download } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import { Card } from './ui/card';
import { Button } from './ui/button';
@@ -24,6 +14,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout';
import { Recipe, decodeRecipe, generateDeepLink } from '../recipe';
import { toastSuccess, toastError } from '../toasts';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { deleteRecipe, RecipeManifestResponse } from '../api';
interface RecipesViewProps {
onLoadRecipe?: (recipe: Recipe) => void;
@@ -31,11 +22,11 @@ interface RecipesViewProps {
// @ts-expect-error until we make onLoadRecipe work for loading recipes in the same window
export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
const [savedRecipes, setSavedRecipes] = useState<SavedRecipe[]>([]);
const [savedRecipes, setSavedRecipes] = useState<RecipeManifestResponse[]>([]);
const [loading, setLoading] = useState(true);
const [showSkeleton, setShowSkeleton] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(null);
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifestResponse | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [showContent, setShowContent] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
@@ -99,8 +90,8 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
setShowSkeleton(true);
setShowContent(false);
setError(null);
const recipes = await listSavedRecipes();
setSavedRecipes(recipes);
const recipeManifestResponses = await listSavedRecipes();
setSavedRecipes(recipeManifestResponses);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load recipes');
console.error('Failed to load saved recipes:', err);
@@ -109,7 +100,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
}
};
const handleLoadRecipe = async (savedRecipe: SavedRecipe) => {
const handleLoadRecipe = async (recipe: Recipe) => {
try {
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
@@ -125,7 +116,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
undefined, // dir
undefined, // version
undefined, // resumeSessionId
savedRecipe.recipe, // recipe config
recipe, // recipe config
undefined // view type
);
// }
@@ -135,15 +126,15 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
}
};
const handleDeleteRecipe = async (savedRecipe: SavedRecipe) => {
const handleDeleteRecipe = async (recipeManifest: RecipeManifestResponse) => {
// TODO: Use Electron's dialog API for confirmation
const result = await window.electron.showMessageBox({
type: 'warning',
buttons: ['Cancel', 'Delete'],
defaultId: 0,
title: 'Delete Recipe',
message: `Are you sure you want to delete "${savedRecipe.name}"?`,
detail: 'Deleted recipes can be restored later.',
message: `Are you sure you want to delete "${recipeManifest.name}"?`,
detail: 'Recipe file will be deleted.',
});
if (result.response !== 1) {
@@ -151,22 +142,25 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
}
try {
await archiveRecipe(savedRecipe.name, savedRecipe.isGlobal);
// Reload the recipes list
await deleteRecipe({ body: { id: recipeManifest.id } });
await loadSavedRecipes();
toastSuccess({
title: recipeManifest.name,
msg: 'Recipe deleted successfully',
});
} catch (err) {
console.error('Failed to archive recipe:', err);
setError(err instanceof Error ? err.message : 'Failed to archive recipe');
console.error('Failed to delete recipe:', err);
setError(err instanceof Error ? err.message : 'Failed to delete recipe');
}
};
const handlePreviewRecipe = async (savedRecipe: SavedRecipe) => {
setSelectedRecipe(savedRecipe);
const handlePreviewRecipe = async (recipeManifest: RecipeManifestResponse) => {
setSelectedRecipe(recipeManifest);
setShowPreview(true);
// Generate deeplink for preview
try {
const deeplink = await generateDeepLink(savedRecipe.recipe);
const deeplink = await generateDeepLink(recipeManifest.recipe);
setPreviewDeeplink(deeplink);
} catch (error) {
console.error('Failed to generate deeplink for preview:', error);
@@ -372,90 +366,65 @@ Parameters you can use:
}
};
// Render a recipe item with error handling
const RecipeItem = ({ savedRecipe }: { savedRecipe: SavedRecipe }) => {
try {
return (
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base truncate max-w-[50vw]">{savedRecipe.recipe.title}</h3>
{savedRecipe.isGlobal ? (
<Globe className="w-4 h-4 text-text-muted flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-text-muted flex-shrink-0" />
)}
</div>
<p className="text-text-muted text-sm mb-2 line-clamp-2">
{savedRecipe.recipe.description}
</p>
<div className="flex items-center text-xs text-text-muted">
<Calendar className="w-3 h-3 mr-1" />
{savedRecipe.lastModified.toLocaleDateString()}
</div>
</div>
// Render a recipe item
const RecipeItem = ({
recipeManifestResponse,
recipeManifestResponse: { recipe, lastModified },
}: {
recipeManifestResponse: RecipeManifestResponse;
}) => (
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base truncate max-w-[50vw]">{recipe.title}</h3>
</div>
<p className="text-text-muted text-sm mb-2 line-clamp-2">{recipe.description}</p>
<div className="flex items-center text-xs text-text-muted">
<Calendar className="w-3 h-3 mr-1" />
{convertToLocaleDateString(lastModified)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
onClick={(e) => {
e.stopPropagation();
handleLoadRecipe(savedRecipe);
}}
size="sm"
className="h-8"
>
<Bot className="w-4 h-4 mr-1" />
Use
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handlePreviewRecipe(savedRecipe);
}}
variant="outline"
size="sm"
className="h-8"
>
<FileText className="w-4 h-4 mr-1" />
Preview
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleDeleteRecipe(savedRecipe);
}}
variant="ghost"
size="sm"
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
} catch (error) {
// Error row showing failed to read file with filename and error details
return (
<Card className="py-2 px-4 mb-2 bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<h3 className="text-base text-red-700 dark:text-red-300">
Failed to read file: {savedRecipe.filename}
</h3>
</div>
<p className="text-red-600 dark:text-red-400 text-sm">
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
</div>
</Card>
);
}
};
<div className="flex items-center gap-2 shrink-0">
<Button
onClick={(e) => {
e.stopPropagation();
handleLoadRecipe(recipe);
}}
size="sm"
className="h-8"
>
<Bot className="w-4 h-4 mr-1" />
Use
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handlePreviewRecipe(recipeManifestResponse);
}}
variant="outline"
size="sm"
className="h-8"
>
<FileText className="w-4 h-4 mr-1" />
Preview
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleDeleteRecipe(recipeManifestResponse);
}}
variant="ghost"
size="sm"
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
// Render skeleton loader for recipe items
const RecipeSkeleton = () => (
@@ -515,10 +484,10 @@ Parameters you can use:
return (
<div className="space-y-2">
{savedRecipes.map((savedRecipe) => (
{savedRecipes.map((recipeManifestResponse: RecipeManifestResponse) => (
<RecipeItem
key={`${savedRecipe.isGlobal ? 'global' : 'local'}-${savedRecipe.name}`}
savedRecipe={savedRecipe}
key={recipeManifestResponse.id}
recipeManifestResponse={recipeManifestResponse}
/>
))}
</div>
@@ -584,9 +553,6 @@ Parameters you can use:
<h3 className="text-xl font-medium text-text-standard">
{selectedRecipe.recipe.title}
</h3>
<p className="text-sm text-text-muted">
{selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'}
</p>
</div>
<button
onClick={() => setShowPreview(false)}
@@ -921,18 +887,6 @@ Parameters you can use:
</div>
</div>
)}
{selectedRecipe.recipe.goosehints && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Goose Hints</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
{selectedRecipe.recipe.goosehints}
</pre>
</div>
</div>
)}
{selectedRecipe.recipe.context && selectedRecipe.recipe.context.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Context</h4>
@@ -949,30 +903,6 @@ Parameters you can use:
</div>
)}
{selectedRecipe.recipe.profile && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Profile</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<span className="text-sm text-text-muted font-mono">
{selectedRecipe.recipe.profile}
</span>
</div>
</div>
)}
{selectedRecipe.recipe.mcps && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">
Max Completion Tokens per Second
</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<span className="text-sm text-text-muted font-mono">
{selectedRecipe.recipe.mcps}
</span>
</div>
</div>
)}
{selectedRecipe.recipe.author && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Author</h4>
@@ -1001,7 +931,7 @@ Parameters you can use:
<Button
onClick={() => {
setShowPreview(false);
handleLoadRecipe(selectedRecipe);
handleLoadRecipe(selectedRecipe.recipe);
}}
variant="default"
>
@@ -19,7 +19,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('Test info message')).toBeInTheDocument();
});
@@ -31,7 +31,7 @@ describe('AlertBox', () => {
const { container } = render(<AlertBox alert={alert} />);
const alertElement = container.querySelector('.bg-\\[\\#cc4b03\\]');
expect(alertElement).toBeInTheDocument();
expect(screen.getByText('Test warning message')).toBeInTheDocument();
});
@@ -44,7 +44,7 @@ describe('AlertBox', () => {
const { container } = render(<AlertBox alert={alert} />);
const alertElement = container.querySelector('.bg-\\[\\#d7040e\\]');
expect(alertElement).toBeInTheDocument();
expect(screen.getByText('Test error message')).toBeInTheDocument();
});
@@ -57,7 +57,7 @@ describe('AlertBox', () => {
const { container } = render(<AlertBox alert={alert} className="custom-class" />);
const alertElement = container.firstChild as HTMLElement;
expect(alertElement).toHaveClass('custom-class');
});
});
@@ -74,13 +74,15 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
// Check progress bar exists
const progressDots = screen.getByText('Context window').parentElement?.parentElement?.querySelectorAll('.h-\\[2px\\]');
const progressDots = screen
.getByText('Context window')
.parentElement?.parentElement?.querySelectorAll('.h-\\[2px\\]');
expect(progressDots).toBeDefined();
});
@@ -95,7 +97,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
@@ -112,7 +114,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
// Use getAllByText since there are multiple "100" elements (current and total)
const hundredElements = screen.getAllByText('100');
expect(hundredElements).toHaveLength(2); // One for current, one for total
@@ -130,7 +132,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('1.5k')).toBeInTheDocument();
expect(screen.getByText('15%')).toBeInTheDocument();
expect(screen.getByText('10k')).toBeInTheDocument();
@@ -147,7 +149,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('150')).toBeInTheDocument();
expect(screen.getByText('150%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
@@ -165,13 +167,13 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('Compact now')).toBeInTheDocument();
});
it('should render compact button with custom icon', () => {
const CompactIcon = () => <span data-testid="compact-icon">📦</span>;
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
@@ -182,14 +184,14 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByTestId('compact-icon')).toBeInTheDocument();
expect(screen.getByText('Compact now')).toBeInTheDocument();
});
it('should call onCompact when compact button is clicked', async () => {
const user = userEvent.setup();
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
@@ -199,16 +201,16 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
const compactButton = screen.getByText('Compact now');
await user.click(compactButton);
expect(mockOnCompact).toHaveBeenCalledTimes(1);
});
it('should prevent event propagation when compact button is clicked', () => {
const mockParentClick = vi.fn();
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
@@ -222,10 +224,10 @@ describe('AlertBox', () => {
<AlertBox alert={alert} />
</div>
);
const compactButton = screen.getByText('Compact now');
fireEvent.click(compactButton);
expect(mockOnCompact).toHaveBeenCalledTimes(1);
expect(mockParentClick).not.toHaveBeenCalled();
});
@@ -240,7 +242,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.queryByText('Compact now')).not.toBeInTheDocument();
});
@@ -253,7 +255,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.queryByText('Compact now')).not.toBeInTheDocument();
});
});
@@ -272,7 +274,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('75')).toBeInTheDocument();
expect(screen.getByText('75%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
@@ -286,9 +288,14 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
// Use a function matcher to handle the whitespace-pre-line rendering
expect(screen.getByText((content) => content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3'))).toBeInTheDocument();
expect(
screen.getByText(
(content) =>
content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3')
)
).toBeInTheDocument();
});
});
@@ -300,7 +307,7 @@ describe('AlertBox', () => {
};
const { container } = render(<AlertBox alert={alert} />);
// Should still render the alert container
const alertElement = container.querySelector('.flex.flex-col.gap-2');
expect(alertElement).toBeInTheDocument();
@@ -317,7 +324,7 @@ describe('AlertBox', () => {
};
render(<AlertBox alert={alert} />);
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
// Progress percentage would be Infinity, but it should still render
@@ -11,7 +11,7 @@ describe('useAlerts', () => {
describe('Initial State', () => {
it('should start with empty alerts array', () => {
const { result } = renderHook(() => useAlerts());
expect(result.current.alerts).toEqual([]);
expect(typeof result.current.addAlert).toBe('function');
expect(typeof result.current.clearAlerts).toBe('function');
@@ -21,33 +21,33 @@ describe('useAlerts', () => {
describe('Adding Alerts', () => {
it('should add a single alert', () => {
const { result } = renderHook(() => useAlerts());
const newAlert = {
type: AlertType.Info,
message: 'Test alert',
};
act(() => {
result.current.addAlert(newAlert);
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0]).toMatchObject(newAlert);
});
it('should add multiple alerts', () => {
const { result } = renderHook(() => useAlerts());
const alert1 = { type: AlertType.Info, message: 'First alert' };
const alert2 = { type: AlertType.Warning, message: 'Second alert' };
const alert3 = { type: AlertType.Error, message: 'Third alert' };
act(() => {
result.current.addAlert(alert1);
result.current.addAlert(alert2);
result.current.addAlert(alert3);
});
expect(result.current.alerts).toHaveLength(3);
expect(result.current.alerts[0]).toMatchObject(alert1);
expect(result.current.alerts[1]).toMatchObject(alert2);
@@ -56,7 +56,7 @@ describe('useAlerts', () => {
it('should add alerts with all optional properties', () => {
const { result } = renderHook(() => useAlerts());
const complexAlert = {
type: AlertType.Info,
message: 'Complex alert',
@@ -66,11 +66,11 @@ describe('useAlerts', () => {
compactIcon: <span>Icon</span>,
autoShow: true,
};
act(() => {
result.current.addAlert(complexAlert);
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0]).toMatchObject(complexAlert);
});
@@ -79,35 +79,35 @@ describe('useAlerts', () => {
describe('Clearing Alerts', () => {
it('should clear all alerts', () => {
const { result } = renderHook(() => useAlerts());
// Add some alerts first
act(() => {
result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' });
result.current.addAlert({ type: AlertType.Warning, message: 'Alert 2' });
result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' });
});
expect(result.current.alerts).toHaveLength(3);
// Clear all alerts
act(() => {
result.current.clearAlerts();
});
expect(result.current.alerts).toHaveLength(0);
expect(result.current.alerts).toEqual([]);
});
it('should handle clearing when no alerts exist', () => {
const { result } = renderHook(() => useAlerts());
expect(result.current.alerts).toHaveLength(0);
// Should not throw error
act(() => {
result.current.clearAlerts();
});
expect(result.current.alerts).toHaveLength(0);
});
});
@@ -115,7 +115,7 @@ describe('useAlerts', () => {
describe('Alert Management Patterns', () => {
it('should handle rapid add and clear operations', () => {
const { result } = renderHook(() => useAlerts());
// Rapid operations
act(() => {
result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' });
@@ -123,25 +123,25 @@ describe('useAlerts', () => {
result.current.clearAlerts();
result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' });
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0].message).toBe('Alert 3');
});
it('should maintain alert order', () => {
const { result } = renderHook(() => useAlerts());
const alerts = [
{ type: AlertType.Info, message: 'First' },
{ type: AlertType.Warning, message: 'Second' },
{ type: AlertType.Error, message: 'Third' },
{ type: AlertType.Info, message: 'Fourth' },
];
act(() => {
alerts.forEach(alert => result.current.addAlert(alert));
alerts.forEach((alert) => result.current.addAlert(alert));
});
expect(result.current.alerts).toHaveLength(4);
alerts.forEach((alert, index) => {
expect(result.current.alerts[index].message).toBe(alert.message);
@@ -150,18 +150,18 @@ describe('useAlerts', () => {
it('should handle duplicate alerts', () => {
const { result } = renderHook(() => useAlerts());
const duplicateAlert = { type: AlertType.Info, message: 'Duplicate alert' };
act(() => {
result.current.addAlert(duplicateAlert);
result.current.addAlert(duplicateAlert);
result.current.addAlert(duplicateAlert);
});
// Should allow duplicates
expect(result.current.alerts).toHaveLength(3);
result.current.alerts.forEach(alert => {
result.current.alerts.forEach((alert) => {
expect(alert.message).toBe('Duplicate alert');
});
});
@@ -170,17 +170,17 @@ describe('useAlerts', () => {
describe('Alert Types', () => {
it('should handle all alert types', () => {
const { result } = renderHook(() => useAlerts());
const alertTypes = [
{ type: AlertType.Info, message: 'Info alert' },
{ type: AlertType.Warning, message: 'Warning alert' },
{ type: AlertType.Error, message: 'Error alert' },
];
act(() => {
alertTypes.forEach(alert => result.current.addAlert(alert));
alertTypes.forEach((alert) => result.current.addAlert(alert));
});
expect(result.current.alerts).toHaveLength(3);
expect(result.current.alerts[0].type).toBe(AlertType.Info);
expect(result.current.alerts[1].type).toBe(AlertType.Warning);
@@ -191,23 +191,23 @@ describe('useAlerts', () => {
describe('Progress Alerts', () => {
it('should handle alerts with progress', () => {
const { result } = renderHook(() => useAlerts());
const progressAlert = {
type: AlertType.Info,
message: 'Loading...',
progress: { current: 25, total: 100 },
};
act(() => {
result.current.addAlert(progressAlert);
});
expect(result.current.alerts[0].progress).toEqual({ current: 25, total: 100 });
});
it('should handle progress updates by replacing alerts', () => {
const { result } = renderHook(() => useAlerts());
// Add initial progress alert
act(() => {
result.current.addAlert({
@@ -216,9 +216,9 @@ describe('useAlerts', () => {
progress: { current: 25, total: 100 },
});
});
expect(result.current.alerts[0].progress?.current).toBe(25);
// Clear and add updated progress
act(() => {
result.current.clearAlerts();
@@ -228,7 +228,7 @@ describe('useAlerts', () => {
progress: { current: 75, total: 100 },
});
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0].progress?.current).toBe(75);
});
@@ -237,7 +237,7 @@ describe('useAlerts', () => {
describe('Compact Button Alerts', () => {
it('should handle alerts with compact functionality', () => {
const { result } = renderHook(() => useAlerts());
const mockOnCompact = vi.fn();
const compactAlert = {
type: AlertType.Info,
@@ -246,11 +246,11 @@ describe('useAlerts', () => {
onCompact: mockOnCompact,
compactIcon: <span>📦</span>,
};
act(() => {
result.current.addAlert(compactAlert);
});
const alert = result.current.alerts[0];
expect(alert.showCompactButton).toBe(true);
expect(alert.onCompact).toBe(mockOnCompact);
@@ -261,32 +261,32 @@ describe('useAlerts', () => {
describe('Auto-show Alerts', () => {
it('should handle autoShow property', () => {
const { result } = renderHook(() => useAlerts());
const autoShowAlert = {
type: AlertType.Error,
message: 'Critical error',
autoShow: true,
};
act(() => {
result.current.addAlert(autoShowAlert);
});
expect(result.current.alerts[0].autoShow).toBe(true);
});
it('should handle alerts without autoShow property', () => {
const { result } = renderHook(() => useAlerts());
const regularAlert = {
type: AlertType.Info,
message: 'Regular alert',
};
act(() => {
result.current.addAlert(regularAlert);
});
expect(result.current.alerts[0].autoShow).toBeUndefined();
});
});
@@ -294,45 +294,45 @@ describe('useAlerts', () => {
describe('Edge Cases', () => {
it('should handle empty message', () => {
const { result } = renderHook(() => useAlerts());
act(() => {
result.current.addAlert({
type: AlertType.Info,
message: '',
});
});
expect(result.current.alerts).toHaveLength(1);
expect(result.current.alerts[0].message).toBe('');
});
it('should handle very long messages', () => {
const { result } = renderHook(() => useAlerts());
const longMessage = 'A'.repeat(1000);
act(() => {
result.current.addAlert({
type: AlertType.Info,
message: longMessage,
});
});
expect(result.current.alerts[0].message).toBe(longMessage);
});
it('should handle special characters in messages', () => {
const { result } = renderHook(() => useAlerts());
const specialMessage = '🚨 Alert with émojis and spëcial chars! @#$%^&*()';
act(() => {
result.current.addAlert({
type: AlertType.Warning,
message: specialMessage,
});
});
expect(result.current.alerts[0].message).toBe(specialMessage);
});
});
@@ -158,449 +158,457 @@ interface SessionListViewProps {
selectedSessionId?: string | null;
}
const SessionListView: React.FC<SessionListViewProps> = React.memo(({ onSelectSession, selectedSessionId }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
const [dateGroups, setDateGroups] = useState<DateGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showSkeleton, setShowSkeleton] = useState(true);
const [showContent, setShowContent] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<{
count: number;
currentIndex: number;
} | null>(null);
const SessionListView: React.FC<SessionListViewProps> = React.memo(
({ onSelectSession, selectedSessionId }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
const [dateGroups, setDateGroups] = useState<DateGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showSkeleton, setShowSkeleton] = useState(true);
const [showContent, setShowContent] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<{
count: number;
currentIndex: number;
} | null>(null);
// Edit modal state
const [showEditModal, setShowEditModal] = useState(false);
const [editingSession, setEditingSession] = useState<Session | null>(null);
// Edit modal state
const [showEditModal, setShowEditModal] = useState(false);
const [editingSession, setEditingSession] = useState<Session | null>(null);
// Search state for debouncing
const [searchTerm, setSearchTerm] = useState('');
const [caseSensitive, setCaseSensitive] = useState(false);
const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce
// Search state for debouncing
const [searchTerm, setSearchTerm] = useState('');
const [caseSensitive, setCaseSensitive] = useState(false);
const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce
const containerRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Track session to element ref
const sessionRefs = useRef<Record<string, HTMLElement>>({});
const setSessionRefs = (itemId: string, element: HTMLDivElement | null) => {
if (element) {
sessionRefs.current[itemId] = element;
} else {
delete sessionRefs.current[itemId];
}
};
const loadSessions = useCallback(async () => {
setIsLoading(true);
setShowSkeleton(true);
setShowContent(false);
setError(null);
try {
const sessions = await fetchSessions();
// Use startTransition to make state updates non-blocking
startTransition(() => {
setSessions(sessions);
setFilteredSessions(sessions);
});
} catch (err) {
console.error('Failed to load sessions:', err);
setError('Failed to load sessions. Please try again later.');
setSessions([]);
setFilteredSessions([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
// Timing logic to prevent flicker between skeleton and content on initial load
useEffect(() => {
if (!isLoading && showSkeleton) {
setShowSkeleton(false);
// Use startTransition for non-blocking content show
startTransition(() => {
setTimeout(() => {
setShowContent(true);
if (isInitialLoad) {
setIsInitialLoad(false);
}
}, 10);
});
}
return () => void 0;
}, [isLoading, showSkeleton, isInitialLoad]);
// Memoize date groups calculation to prevent unnecessary recalculations
const memoizedDateGroups = useMemo(() => {
if (filteredSessions.length > 0) {
return groupSessionsByDate(filteredSessions);
}
return [];
}, [filteredSessions]);
// Update date groups when filtered sessions change
useEffect(() => {
startTransition(() => {
setDateGroups(memoizedDateGroups);
});
}, [memoizedDateGroups]);
// Scroll to the selected session when returning from session history view
useEffect(() => {
if (selectedSessionId) {
const element = sessionRefs.current[selectedSessionId];
// Track session to element ref
const sessionRefs = useRef<Record<string, HTMLElement>>({});
const setSessionRefs = (itemId: string, element: HTMLDivElement | null) => {
if (element) {
element.scrollIntoView({
block: "center"
sessionRefs.current[itemId] = element;
} else {
delete sessionRefs.current[itemId];
}
};
const loadSessions = useCallback(async () => {
setIsLoading(true);
setShowSkeleton(true);
setShowContent(false);
setError(null);
try {
const sessions = await fetchSessions();
// Use startTransition to make state updates non-blocking
startTransition(() => {
setSessions(sessions);
setFilteredSessions(sessions);
});
} catch (err) {
console.error('Failed to load sessions:', err);
setError('Failed to load sessions. Please try again later.');
setSessions([]);
setFilteredSessions([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
// Timing logic to prevent flicker between skeleton and content on initial load
useEffect(() => {
if (!isLoading && showSkeleton) {
setShowSkeleton(false);
// Use startTransition for non-blocking content show
startTransition(() => {
setTimeout(() => {
setShowContent(true);
if (isInitialLoad) {
setIsInitialLoad(false);
}
}, 10);
});
}
}
}, [selectedSessionId, sessions]);
return () => void 0;
}, [isLoading, showSkeleton, isInitialLoad]);
// Debounced search effect - performs actual filtering
useEffect(() => {
if (!debouncedSearchTerm) {
// Memoize date groups calculation to prevent unnecessary recalculations
const memoizedDateGroups = useMemo(() => {
if (filteredSessions.length > 0) {
return groupSessionsByDate(filteredSessions);
}
return [];
}, [filteredSessions]);
// Update date groups when filtered sessions change
useEffect(() => {
startTransition(() => {
setFilteredSessions(sessions);
setSearchResults(null);
setDateGroups(memoizedDateGroups);
});
return;
}
}, [memoizedDateGroups]);
// Use startTransition to make search non-blocking
startTransition(() => {
const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase();
const filtered = sessions.filter((session) => {
const description = session.metadata.description || session.id;
const path = session.path;
const workingDir = session.metadata.working_dir;
if (caseSensitive) {
return (
description.includes(searchTerm) ||
path.includes(searchTerm) ||
workingDir.includes(searchTerm)
);
} else {
return (
description.toLowerCase().includes(searchTerm) ||
path.toLowerCase().includes(searchTerm) ||
workingDir.toLowerCase().includes(searchTerm)
);
// Scroll to the selected session when returning from session history view
useEffect(() => {
if (selectedSessionId) {
const element = sessionRefs.current[selectedSessionId];
if (element) {
element.scrollIntoView({
block: 'center',
});
}
}
}, [selectedSessionId, sessions]);
// Debounced search effect - performs actual filtering
useEffect(() => {
if (!debouncedSearchTerm) {
startTransition(() => {
setFilteredSessions(sessions);
setSearchResults(null);
});
return;
}
// Use startTransition to make search non-blocking
startTransition(() => {
const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase();
const filtered = sessions.filter((session) => {
const description = session.metadata.description || session.id;
const path = session.path;
const workingDir = session.metadata.working_dir;
if (caseSensitive) {
return (
description.includes(searchTerm) ||
path.includes(searchTerm) ||
workingDir.includes(searchTerm)
);
} else {
return (
description.toLowerCase().includes(searchTerm) ||
path.toLowerCase().includes(searchTerm) ||
workingDir.toLowerCase().includes(searchTerm)
);
}
});
setFilteredSessions(filtered);
setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null);
});
}, [debouncedSearchTerm, caseSensitive, sessions]);
setFilteredSessions(filtered);
setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null);
});
}, [debouncedSearchTerm, caseSensitive, sessions]);
// Handle immediate search input (updates search term for debouncing)
const handleSearch = useCallback((term: string, caseSensitive: boolean) => {
setSearchTerm(term);
setCaseSensitive(caseSensitive);
}, []);
// Handle immediate search input (updates search term for debouncing)
const handleSearch = useCallback((term: string, caseSensitive: boolean) => {
setSearchTerm(term);
setCaseSensitive(caseSensitive);
}, []);
// Handle search result navigation
const handleSearchNavigation = (direction: 'next' | 'prev') => {
if (!searchResults || filteredSessions.length === 0) return;
// Handle search result navigation
const handleSearchNavigation = (direction: 'next' | 'prev') => {
if (!searchResults || filteredSessions.length === 0) return;
let newIndex: number;
if (direction === 'next') {
newIndex = (searchResults.currentIndex % filteredSessions.length) + 1;
} else {
newIndex =
searchResults.currentIndex === 1
? filteredSessions.length
: searchResults.currentIndex - 1;
}
let newIndex: number;
if (direction === 'next') {
newIndex = (searchResults.currentIndex % filteredSessions.length) + 1;
} else {
newIndex =
searchResults.currentIndex === 1 ? filteredSessions.length : searchResults.currentIndex - 1;
}
setSearchResults({ ...searchResults, currentIndex: newIndex });
setSearchResults({ ...searchResults, currentIndex: newIndex });
// Find the SearchView's container element
const searchContainer =
containerRef.current?.querySelector<SearchContainerElement>('.search-container');
if (searchContainer?._searchHighlighter) {
// Update the current match in the highlighter
searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true);
}
};
// Find the SearchView's container element
const searchContainer =
containerRef.current?.querySelector<SearchContainerElement>('.search-container');
if (searchContainer?._searchHighlighter) {
// Update the current match in the highlighter
searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true);
}
};
// Handle modal close
const handleModalClose = useCallback(() => {
setShowEditModal(false);
setEditingSession(null);
}, []);
// Handle modal close
const handleModalClose = useCallback(() => {
setShowEditModal(false);
setEditingSession(null);
}, []);
const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => {
// Update state immediately for optimistic UI
setSessions((prevSessions) =>
prevSessions.map((s) =>
s.id === sessionId
? { ...s, metadata: { ...s.metadata, description: newDescription } }
: s
)
);
}, []);
const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => {
// Update state immediately for optimistic UI
setSessions((prevSessions) =>
prevSessions.map((s) =>
s.id === sessionId ? { ...s, metadata: { ...s.metadata, description: newDescription } } : s
)
);
}, []);
const handleEditSession = useCallback((session: Session) => {
setEditingSession(session);
setShowEditModal(true);
}, []);
const handleEditSession = useCallback((session: Session) => {
setEditingSession(session);
setShowEditModal(true);
}, []);
const SessionItem = React.memo(function SessionItem({
session,
onEditClick,
}: {
session: Session;
onEditClick: (session: Session) => void;
}) {
const handleEditClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click
onEditClick(session);
},
[onEditClick, session]
);
const SessionItem = React.memo(function SessionItem({
session,
onEditClick,
}: {
session: Session;
onEditClick: (session: Session) => void;
}) {
const handleEditClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click
onEditClick(session);
},
[onEditClick, session]
);
const handleCardClick = useCallback(() => {
onSelectSession(session.id);
}, [session.id]);
const handleCardClick = useCallback(() => {
onSelectSession(session.id);
}, [session.id]);
return (
<Card
onClick={handleCardClick}
className="session-item h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between relative group"
ref={(el) => setSessionRefs(session.id, el)}
>
<button
onClick={handleEditClick}
className="absolute top-3 right-4 p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
title="Edit session name"
return (
<Card
onClick={handleCardClick}
className="session-item h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between relative group"
ref={(el) => setSessionRefs(session.id, el)}
>
<Edit2 className="w-3 h-3 text-textSubtle hover:text-textStandard" />
</button>
<button
onClick={handleEditClick}
className="absolute top-3 right-4 p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
title="Edit session name"
>
<Edit2 className="w-3 h-3 text-textSubtle hover:text-textStandard" />
</button>
<div className="flex-1">
<h3 className="text-base mb-1 pr-6 break-words">
{session.metadata.description || session.id}
</h3>
<div className="flex-1">
<h3 className="text-base mb-1 pr-6 break-words">
{session.metadata.description || session.id}
</h3>
<div className="flex items-center text-text-muted text-xs mb-1">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
</div>
<div className="flex items-center text-text-muted text-xs mb-1">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{session.metadata.working_dir}</span>
</div>
</div>
<div className="flex items-center justify-between mt-1 pt-2">
<div className="flex items-center space-x-3 text-xs text-text-muted">
<div className="flex items-center">
<MessageSquareText className="w-3 h-3 mr-1" />
<span className="font-mono">{session.metadata.message_count}</span>
<div className="flex items-center text-text-muted text-xs mb-1">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
</div>
{session.metadata.total_tokens !== null && (
<div className="flex items-center text-text-muted text-xs mb-1">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{session.metadata.working_dir}</span>
</div>
</div>
<div className="flex items-center justify-between mt-1 pt-2">
<div className="flex items-center space-x-3 text-xs text-text-muted">
<div className="flex items-center">
<Target className="w-3 h-3 mr-1" />
<span className="font-mono">{session.metadata.total_tokens.toLocaleString()}</span>
<MessageSquareText className="w-3 h-3 mr-1" />
<span className="font-mono">{session.metadata.message_count}</span>
</div>
)}
{session.metadata.total_tokens !== null && (
<div className="flex items-center">
<Target className="w-3 h-3 mr-1" />
<span className="font-mono">
{session.metadata.total_tokens.toLocaleString()}
</span>
</div>
)}
</div>
</div>
</div>
</Card>
);
});
</Card>
);
});
// Render skeleton loader for session items with variations
const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => {
const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2'];
const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24'];
const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8'];
// Render skeleton loader for session items with variations
const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => {
const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2'];
const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24'];
const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8'];
return (
<Card className="session-skeleton h-full py-3 px-4 flex flex-col justify-between">
<div className="flex-1">
<Skeleton className={`h-5 ${titleWidths[variant % titleWidths.length]} mb-2`} />
<div className="flex items-center mb-1">
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center mb-1">
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
<Skeleton className={`h-4 ${pathWidths[variant % pathWidths.length]}`} />
</div>
</div>
<div className="flex items-center justify-between mt-1 pt-2">
<div className="flex items-center space-x-3">
<div className="flex items-center">
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
<Skeleton className="h-4 w-8" />
</div>
<div className="flex items-center">
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
<Skeleton className={`h-4 ${tokenWidths[variant % tokenWidths.length]}`} />
</div>
</div>
</div>
</Card>
);
});
SessionSkeleton.displayName = 'SessionSkeleton';
const renderActualContent = () => {
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full text-text-muted">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<p className="text-lg mb-2">Error Loading Sessions</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={loadSessions} variant="default">
Try Again
</Button>
</div>
);
}
if (sessions.length === 0) {
return (
<div className="flex flex-col justify-center h-full text-text-muted">
<MessageSquareText className="h-12 w-12 mb-4" />
<p className="text-lg mb-2">No chat sessions found</p>
<p className="text-sm">Your chat history will appear here</p>
</div>
);
}
if (dateGroups.length === 0 && searchResults !== null) {
return (
<div className="flex flex-col items-center justify-center h-full text-text-muted mt-4">
<MessageSquareText className="h-12 w-12 mb-4" />
<p className="text-lg mb-2">No matching sessions found</p>
<p className="text-sm">Try adjusting your search terms</p>
</div>
);
}
// For regular rendering in grid layout
return (
<div className="space-y-8">
{dateGroups.map((group) => (
<div key={group.label} className="space-y-4">
<div className="sticky top-0 z-10 bg-background-default/95 backdrop-blur-sm">
<h2 className="text-text-muted">{group.label}</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{group.sessions.map((session) => (
<SessionItem key={session.id} session={session} onEditClick={handleEditSession} />
))}
</div>
</div>
))}
</div>
);
};
return (
<Card className="session-skeleton h-full py-3 px-4 flex flex-col justify-between">
<div className="flex-1">
<Skeleton className={`h-5 ${titleWidths[variant % titleWidths.length]} mb-2`} />
<div className="flex items-center mb-1">
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center mb-1">
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
<Skeleton className={`h-4 ${pathWidths[variant % pathWidths.length]}`} />
</div>
</div>
<div className="flex items-center justify-between mt-1 pt-2">
<div className="flex items-center space-x-3">
<div className="flex items-center">
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
<Skeleton className="h-4 w-8" />
</div>
<div className="flex items-center">
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
<Skeleton className={`h-4 ${tokenWidths[variant % tokenWidths.length]}`} />
</div>
</div>
</div>
</Card>
);
});
SessionSkeleton.displayName = 'SessionSkeleton';
const renderActualContent = () => {
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full text-text-muted">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<p className="text-lg mb-2">Error Loading Sessions</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={loadSessions} variant="default">
Try Again
</Button>
</div>
);
}
if (sessions.length === 0) {
return (
<div className="flex flex-col justify-center h-full text-text-muted">
<MessageSquareText className="h-12 w-12 mb-4" />
<p className="text-lg mb-2">No chat sessions found</p>
<p className="text-sm">Your chat history will appear here</p>
</div>
);
}
if (dateGroups.length === 0 && searchResults !== null) {
return (
<div className="flex flex-col items-center justify-center h-full text-text-muted mt-4">
<MessageSquareText className="h-12 w-12 mb-4" />
<p className="text-lg mb-2">No matching sessions found</p>
<p className="text-sm">Try adjusting your search terms</p>
</div>
);
}
// For regular rendering in grid layout
return (
<div className="space-y-8">
{dateGroups.map((group) => (
<div key={group.label} className="space-y-4">
<div className="sticky top-0 z-10 bg-background-default/95 backdrop-blur-sm">
<h2 className="text-text-muted">{group.label}</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{group.sessions.map((session) => (
<SessionItem key={session.id} session={session} onEditClick={handleEditSession} />
))}
</div>
</div>
))}
</div>
);
};
return (
<>
<MainPanelLayout>
<div className="flex-1 flex flex-col min-h-0">
<div className="bg-background-default px-8 pb-8 pt-16">
<div className="flex flex-col page-transition">
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Chat history</h1>
<>
<MainPanelLayout>
<div className="flex-1 flex flex-col min-h-0">
<div className="bg-background-default px-8 pb-8 pt-16">
<div className="flex flex-col page-transition">
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Chat history</h1>
</div>
<p className="text-sm text-text-muted mb-4">
View and search your past conversations with Goose.
</p>
</div>
<p className="text-sm text-text-muted mb-4">
View and search your past conversations with Goose.
</p>
</div>
</div>
<div className="flex-1 min-h-0 relative px-8">
<ScrollArea className="h-full" data-search-scroll-area>
<div ref={containerRef} className="h-full relative">
<SearchView
onSearch={handleSearch}
onNavigate={handleSearchNavigation}
searchResults={searchResults}
className="relative"
>
{/* Skeleton layer - always rendered but conditionally visible */}
<div
className={`absolute inset-0 transition-opacity duration-300 ${
isLoading || showSkeleton
? 'opacity-100 z-10'
: 'opacity-0 z-0 pointer-events-none'
}`}
<div className="flex-1 min-h-0 relative px-8">
<ScrollArea className="h-full" data-search-scroll-area>
<div ref={containerRef} className="h-full relative">
<SearchView
onSearch={handleSearch}
onNavigate={handleSearchNavigation}
searchResults={searchResults}
className="relative"
>
<div className="space-y-8">
{/* Today section */}
<div className="space-y-4">
<Skeleton className="h-6 w-16" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<SessionSkeleton variant={0} />
<SessionSkeleton variant={1} />
<SessionSkeleton variant={2} />
<SessionSkeleton variant={3} />
<SessionSkeleton variant={0} />
{/* Skeleton layer - always rendered but conditionally visible */}
<div
className={`absolute inset-0 transition-opacity duration-300 ${
isLoading || showSkeleton
? 'opacity-100 z-10'
: 'opacity-0 z-0 pointer-events-none'
}`}
>
<div className="space-y-8">
{/* Today section */}
<div className="space-y-4">
<Skeleton className="h-6 w-16" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<SessionSkeleton variant={0} />
<SessionSkeleton variant={1} />
<SessionSkeleton variant={2} />
<SessionSkeleton variant={3} />
<SessionSkeleton variant={0} />
</div>
</div>
</div>
{/* Yesterday section */}
<div className="space-y-4">
<Skeleton className="h-6 w-20" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<SessionSkeleton variant={1} />
<SessionSkeleton variant={2} />
<SessionSkeleton variant={3} />
<SessionSkeleton variant={0} />
<SessionSkeleton variant={1} />
<SessionSkeleton variant={2} />
{/* Yesterday section */}
<div className="space-y-4">
<Skeleton className="h-6 w-20" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<SessionSkeleton variant={1} />
<SessionSkeleton variant={2} />
<SessionSkeleton variant={3} />
<SessionSkeleton variant={0} />
<SessionSkeleton variant={1} />
<SessionSkeleton variant={2} />
</div>
</div>
</div>
{/* Additional section */}
<div className="space-y-4">
<Skeleton className="h-6 w-24" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<SessionSkeleton variant={3} />
<SessionSkeleton variant={0} />
<SessionSkeleton variant={1} />
{/* Additional section */}
<div className="space-y-4">
<Skeleton className="h-6 w-24" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<SessionSkeleton variant={3} />
<SessionSkeleton variant={0} />
<SessionSkeleton variant={1} />
</div>
</div>
</div>
</div>
</div>
{/* Content layer - always rendered but conditionally visible */}
<div
className={`relative transition-opacity duration-300 ${
showContent ? 'opacity-100 z-10' : 'opacity-0 z-0'
}`}
>
{renderActualContent()}
</div>
</SearchView>
</div>
</ScrollArea>
{/* Content layer - always rendered but conditionally visible */}
<div
className={`relative transition-opacity duration-300 ${
showContent ? 'opacity-100 z-10' : 'opacity-0 z-0'
}`}
>
{renderActualContent()}
</div>
</SearchView>
</div>
</ScrollArea>
</div>
</div>
</div>
</MainPanelLayout>
</MainPanelLayout>
<EditSessionModal
session={editingSession}
isOpen={showEditModal}
onClose={handleModalClose}
onSave={handleModalSave}
/>
</>
);
});
<EditSessionModal
session={editingSession}
isOpen={showEditModal}
onClose={handleModalClose}
onSave={handleModalSave}
/>
</>
);
}
);
SessionListView.displayName = 'SessionListView';