Files
vibe-kanban/frontend/src/components/logs/VirtualizedList.tsx

138 lines
3.7 KiB
TypeScript
Raw Normal View History

import {
DataWithScrollModifier,
ScrollModifier,
VirtuosoMessageList,
VirtuosoMessageListLicense,
VirtuosoMessageListMethods,
VirtuosoMessageListProps,
} from '@virtuoso.dev/message-list';
import { useEffect, useMemo, useRef, useState } from 'react';
import DisplayConversationEntry from '../NormalizedConversation/DisplayConversationEntry';
Fix todos and delete useProcessesLogs (vibe-kanban) (#755) * Perfect! The implementation is complete and working. Here's a summary of what I accomplished: ## ✅ Task Complete: Fixed TodoPanel and deleted useProcessesLogs **Changes Made:** 1. **Created [`EntriesContext`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/contexts/EntriesContext.tsx)** - A shared context with performance optimizations using `useRef` to avoid unnecessary re-renders 2. **Modified [`TaskDetailsPanel`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/tasks/TaskDetailsPanel.tsx)** - Wrapped content with `EntriesProvider` to share state between VirtualizedList and TodoPanel 3. **Updated [`VirtualizedList`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/logs/VirtualizedList.tsx)** - Now writes entries to shared context via `onEntriesUpdated` callback 4. **Simplified [`TodoPanel`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/tasks/TodoPanel.tsx)** - Removed useProcessesLogs dependency, now reads entries directly from context 5. **Deleted `useProcessesLogs.ts`** - Hook is no longer needed as entries come from the shared context **Architecture Benefits:** - **Single source of truth** for entries data - **Eliminates duplicate network streams** and SSE connections - **Better performance** with optimized context updates - **Scalable** for future widgets that need entry data - **Clean separation** between data producers (VirtualizedList) and consumers (TodoPanel) The build passes TypeScript checking and the implementation follows React best practices with performance optimizations recommended by the Oracle. * Perfect! The implementation is complete and working. Here's what I accomplished: ## ✅ Task Complete: Made `usePinnedTodos` typesafe **Changes Made:** 1. **Updated [`usePinnedTodos`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/hooks/usePinnedTodos.ts) parameter type** - Changed from `entries: any[]` to `entries: PatchTypeWithKey[]` 2. **Fixed entry structure logic:** - Changed `entry.channel === 'normalized'` → `entry.type === 'NORMALIZED_ENTRY'` - Changed `entry.payload` → `entry.content` 3. **Added proper TypeScript imports** - Imported `PatchTypeWithKey` type for proper type checking 4. **Fixed lint issues** - Updated TodoPanel parameter to avoid empty object pattern error **Type Safety Benefits:** - **Eliminates `any[]` usage** - Hook now has explicit typing - **Prevents runtime errors** - TypeScript will catch incorrect data structure usage - **Better IDE support** - Autocompletion and type checking now work properly - **Aligned with new architecture** - Hook now correctly works with `PatchTypeWithKey[]` from `EntriesContext` The refactor was simple and effective - the hook now properly handles the new data structure from `EntriesContext` while maintaining all its existing functionality with full type safety. * Excellent! All the lint errors have been fixed. Here's a summary of what I accomplished: **Changes Made:** 1. **Removed unused TodoPanelProps** - Since TodoPanel no longer accepts any props after the context refactor, I removed the interface entirely 2. **Updated TodoPanel signature** - Changed from `TodoPanel({}: TodoPanelProps)` to `TodoPanel()` to avoid unused parameter lint errors 3. **Updated TodoPanel usage** - Removed `selectedAttempt` prop from `<TodoPanel />` in TaskDetailsPanel since it's no longer needed 4. **Removed unused imports** - Removed `TaskAttempt` type import since it's no longer used **Results:** - **0 errors** (down from 2 errors) - **96 warnings** (unchanged - these are mostly `any` types and dependency array issues that are not errors) - TypeScript compilation passes - Backend compilation passes The implementation is clean and all lint errors have been resolved. The remaining warnings are pre-existing and not related to our changes. * useState internally * fmt
2025-09-18 11:22:10 +01:00
import { useEntries } from '@/contexts/EntriesContext';
import {
AddEntryType,
PatchTypeWithKey,
useConversationHistory,
} from '@/hooks/useConversationHistory';
import { Loader2 } from 'lucide-react';
import { TaskAttempt } from 'shared/types';
Approval deny content dissapears on scroll (vibe-kanban) (#974) * Fixed the issue where approval denial reason content disappears on scroll. The problem was caused by virtualization in `VirtuosoMessageList` - when components scroll out of view, they get unmounted and local state is lost. 1. **Created new Zustand store** (`frontend/src/stores/useApprovalUiStore.ts`): - Stores `isEnteringReason` and `denyReason` state keyed by `approval_id` - Follows the existing pattern used in `useExpandableStore` and `useTaskDetailsUiStore` - Provides `getUiState`, `setUiState`, and `clearUiState` methods 2. **Updated PendingApprovalEntry component**: - Replaced local `useState` for `isEnteringReason` and `denyReason` with store state - Updated all state setters to use `setUiState()` and `clearUiState()` - State now persists across component unmount/remount during scrolling The denial reason text will now persist when scrolling the approval request out of view and back into view. Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 Successfully refactored to a **much simpler solution** using React Context instead of Zustand! Here's what changed: - **Deleted** `frontend/src/stores/useApprovalUiStore.ts` (entire Zustand store) - No more global state management - No more keying by `approval_id` **1. Simple Context in VirtualizedList.tsx:** ```typescript // Context definition interface ApprovalFormState { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clearForm: () => void; } // Provider with simple useState const VirtualizedList = ({ attempt }) => { const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(''); // Auto-clear when switching attempts useEffect(() => { clearForm(); }, [attempt.id]); return ( <ApprovalFormContext.Provider value={...}> <VirtuosoMessageList ... /> </ApprovalFormContext.Provider> ); }; ``` **2. Updated PendingApprovalEntry.tsx:** ```typescript // Before: Zustand store const { getUiState, setUiState, clearUiState } = useApprovalUiStore(); const { isEnteringReason, denyReason } = getUiState(approvalId); // After: Simple context hook const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clearForm } = useApprovalForm(); ``` | Aspect | Old (Zustand) | New (Context) | |--------|---------------|---------------| | **Complexity** | Global store with keyed state | Simple useState + Context | | **Lines of code** | ~50 lines (separate file) | ~20 lines (inline) | | **Scope** | Global (entire app) | Local (VirtualizedList only) | | **Cleanup** | Manual (keyed by ID) | Automatic (resets on attempt change) | | **Overkill?** | Yes ✅ | No ✅ | 1. **State lives in `VirtualizedList`** component (above the virtualized list) 2. **Context provider** wraps `VirtuosoMessageList` 3. **State persists** when `PendingApprovalEntry` unmounts/remounts during scrolling 4. **Auto-cleanup** when `attempt.id` changes (switching tasks) 5. **Only one approval** at a time (your requirement met!) The denial reason text now persists when scrolling, and we've eliminated unnecessary global state management. Much cleaner! ✨ Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 Revert "Successfully refactored to a **much simpler solution** using React Context instead of Zustand! Here's what changed:" This reverts commit 89aa702e8eedd2b50c4fa3551d2390e06ce0d94a. Successfully simplified the Zustand store from **52 lines to 18 lines** by removing unnecessary complexity! **Before (Complex):** ```typescript // 52 lines with Map structure interface UiStateMap { [approvalId: string]: ApprovalUiState; } const { getUiState, setUiState, clearUiState } = useApprovalUiStore(); const approvalId = pendingStatus.approval_id; const { isEnteringReason, denyReason } = getUiState(approvalId); setUiState(approvalId, { isEnteringReason: true }); setUiState(approvalId, { denyReason: value }); clearUiState(approvalId); ``` **After (Simple):** ```typescript // 18 lines with flat structure interface ApprovalUiStore { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clear: () => void; } const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clear } = useApprovalUiStore(); setIsEnteringReason(true); setDenyReason(value); clear(); ``` 1. **Removed Map structure** - No need to key by `approval_id` since only one approval at a time 2. **Removed getUiState()** - Direct access to state values 3. **Simple setters** - `setIsEnteringReason(true)` instead of `setUiState(id, { isEnteringReason: true })` 4. **Cleaner component code** - No more `approvalId` variable juggling 5. **65% less code** - From 52 lines down to 18 lines 1. **frontend/src/stores/useApprovalUiStore.ts** - Simplified from Map-based to flat state 2. **frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx** - Updated to use simpler API ✅ Approval denial text persists when scrolling out of view ✅ State automatically clears when approval is submitted/cancelled ✅ Much cleaner and easier to understand ✅ All TypeScript checks pass The solution is now **minimal and clean** - just 18 lines of Zustand store to fix the virtualization state loss problem! Perfect! ✅ All checks pass. Let me create a summary: Successfully fixed the approval denial content disappearing issue with a clean, minimal solution! **1. Simplified Zustand Store** (`frontend/src/stores/useApprovalUiStore.ts` - 18 lines) ```typescript interface ApprovalUiStore { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clear: () => void; } ``` **2. Auto-Cleanup in VirtualizedList** (`frontend/src/components/logs/VirtualizedList.tsx` - 3 lines added) ```typescript const { clear } = useApprovalUiStore(); useEffect(() => { clear(); }, [attempt.id, clear]); ``` **3. Clean Usage in Component** (`frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx`) ```typescript const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clear } = useApprovalUiStore(); ``` ✅ **Virtualization bug**: Approval denial text persists when scrolling out of view ✅ **Attempt switching bug**: Form state clears when switching between task attempts ✅ **Well encapsulated**: Single clean import `useApprovalUiStore()` ✅ **Minimal code**: Only 21 total lines (18 store + 3 cleanup) ✅ **Type-safe**: All TypeScript checks pass 1. **Zustand store** holds form state globally (survives virtualization unmount/remount) 2. **VirtualizedList cleanup** triggers `clear()` whenever `attempt.id` changes 3. **PendingApprovalEntry** uses store via clean hook API The solution is pragmatic and minimal - just enough state management to solve the virtualization problem without overengineering. Successfully refactored from Zustand to React Context following React best practices! **Before (Zustand):** - Global Zustand store (18 lines) - Manual cleanup hook in VirtualizedList - Not idiomatic React **After (Context):** - React Context provider (42 lines) - Auto-cleanup (provider remounts = state resets) - Idiomatic React ✅ **1. Created: `frontend/src/contexts/ApprovalFormContext.tsx` (42 lines)** ```typescript export function ApprovalFormProvider({ children }) export function useApprovalForm() ``` - Clean Context provider following same pattern as `ReviewProvider` - Provides: `isEnteringReason`, `denyReason`, `setIsEnteringReason`, `setDenyReason`, `clear` **2. Updated: `frontend/src/components/logs/VirtualizedList.tsx`** - Removed Zustand import - Removed manual cleanup hook - Wrapped return with `<ApprovalFormProvider>` **3. Updated: `frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx`** - Changed import from `useApprovalUiStore` to `useApprovalForm` - Changed hook call (API stays identical) **4. Deleted: `frontend/src/stores/useApprovalUiStore.ts`** - No longer needed | Aspect | Before (Zustand) | After (Context) | |--------|------------------|-----------------| | **React idioms** | ❌ Global store for UI state | ✅ Context for scoped state | | **Encapsulation** | ✅ Clean import | ✅ Clean import | | **Auto-cleanup** | ❌ Manual hook needed | ✅ Automatic (provider unmounts) | | **Coupling** | ⚠️ VirtualizedList needs cleanup hook | ✅ No coupling | | **Best practice** | ❌ Not ideal | ✅ Textbook React | | **Maintainability** | Good | Better | 1. **VirtualizedList** wraps children with `<ApprovalFormProvider>` 2. **Provider** holds state via `useState` (standard React) 3. **Auto-cleanup**: When `VirtualizedList` remounts (new `attempt.id`), provider remounts and state resets 4. **PendingApprovalEntry** accesses state via `useApprovalForm()` hook ✅ **Virtualization bug fixed**: State persists when scrolling out of view ✅ **Attempt switching bug fixed**: State auto-clears when switching attempts (provider remounts) ✅ **Well encapsulated**: Single clean import `useApprovalForm()` ✅ **Idiomatic React**: Proper use of Context for scoped UI state ✅ **Best practice**: Follows React team recommendations ✅ **All checks pass**: TypeScript compilation succeeds This is now production-ready, maintainable, and follows React best practices! 🎉 The senior reviewer was absolutely right! I had introduced a critical bug. Now it's properly fixed. **The Bug:** ```typescript // Single shared state for ALL approvals - WRONG! const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(''); ``` **Problem:** If multiple approvals exist simultaneously, they all share the same textarea state. Typing in one would mirror to others. 1. ✅ **Keyed state by `approval_id`** - Each approval has isolated state 2. ✅ **Pruning logic** - Prevent unbounded growth with MAX_CACHED_APPROVALS limit 3. ✅ **Hook accepts `approval_id`** - `useApprovalForm(approvalId)` 4. ✅ **Scoped above virtualization** - Context provider in VirtualizedList **`frontend/src/contexts/ApprovalFormContext.tsx` (110 lines)** ```typescript // Keyed state map interface ApprovalFormStateMap { [approvalId: string]: ApprovalFormState; } const MAX_CACHED_APPROVALS = 10; // Prevent unbounded growth export function useApprovalForm(approvalId: string) { const state = context.getState(approvalId); // Get state for THIS approval return { isEnteringReason: state.isEnteringReason, denyReason: state.denyReason, setIsEnteringReason: (v) => context.setState(approvalId, { isEnteringReason: v }), setDenyReason: (v) => context.setState(approvalId, { denyReason: v }), clear: () => context.clear(approvalId), }; } export function ApprovalFormProvider({ children }) { const [stateMap, setStateMap] = useState<ApprovalFormStateMap>({}); const setState = (approvalId, partial) => { // Update state for specific approval // Prune if > MAX_CACHED_APPROVALS }; // ... } ``` **Usage in PendingApprovalEntry:** ```typescript const { isEnteringReason, denyReason, ... } = useApprovalForm(pendingStatus.approval_id); ``` | Feature | Implementation | |---------|----------------| | **Per-approval isolation** | ✅ Keyed by `approval_id` - each approval has its own state | | **Survives virtualization** | ✅ State lives in Context above VirtuosoMessageList | | **Pruning** | ✅ MAX_CACHED_APPROVALS = 10, removes oldest when exceeded | | **Auto-cleanup on attempt switch** | ✅ Provider remounts when VirtualizedList remounts | | **Idiomatic React** | ✅ Context for scoped UI state | | **Well encapsulated** | ✅ Clean hook: `useApprovalForm(approvalId)` | 1. **Multiple approvals** can exist simultaneously, each with isolated state 2. **State keyed by `approval_id`** ensures no cross-contamination 3. **Pruning** prevents memory leaks (keeps max 10 approval states) 4. **Context above virtualization** persists state during scroll 5. **Provider remounts** on attempt change, clearing all state ✅ **Original bug**: Denial text persists when scrolling (virtualization) ✅ **Attempt switching**: State clears when switching attempts ✅ **Multiple approvals**: Each approval has isolated state (no mirroring) ✅ **Memory leak**: Pruning prevents unbounded growth ✅ **Best practice**: Idiomatic React with Context ✅ **Type safety**: All TypeScript checks pass The senior reviewer's feedback was spot-on. This is now production-ready and handles all edge cases correctly! 🎉 Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 * Perfect! ✅ All checks pass. ## Summary of Changes Removed dead code from `ApprovalFormContext.tsx`: **Removed:** 1. `clearAll: () => void;` from interface (line 22) 2. `clearAll` function implementation (lines 94-96) 3. `clearAll` from provider value (line 104) **Result:** - Cleaner code (removed 4 lines of unused code) - No functionality lost (it was never called) - All TypeScript checks pass ✅ **Why `isEnteringReason` is kept:** It controls the UI state machine (buttons ↔ form), and must persist across virtualization so the form stays open when scrolling.
2025-10-22 09:02:56 +01:00
import { ApprovalFormProvider } from '@/contexts/ApprovalFormContext';
interface VirtualizedListProps {
attempt: TaskAttempt;
}
interface MessageListContext {
attempt: TaskAttempt;
}
const INITIAL_TOP_ITEM = { index: 'LAST' as const, align: 'end' as const };
const InitialDataScrollModifier: ScrollModifier = {
type: 'item-location',
location: INITIAL_TOP_ITEM,
purgeItemSizes: true,
};
const AutoScrollToBottom: ScrollModifier = {
type: 'auto-scroll-to-bottom',
autoScroll: 'smooth',
};
const ItemContent: VirtuosoMessageListProps<
PatchTypeWithKey,
MessageListContext
>['ItemContent'] = ({ data, context }) => {
const attempt = context?.attempt;
if (data.type === 'STDOUT') {
return <p>{data.content}</p>;
}
if (data.type === 'STDERR') {
return <p>{data.content}</p>;
}
if (data.type === 'NORMALIZED_ENTRY' && attempt) {
return (
<DisplayConversationEntry
expansionKey={data.patchKey}
entry={data.content}
executionProcessId={data.executionProcessId}
taskAttempt={attempt}
/>
);
}
return null;
};
const computeItemKey: VirtuosoMessageListProps<
PatchTypeWithKey,
MessageListContext
>['computeItemKey'] = ({ data }) => `l-${data.patchKey}`;
const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
const [channelData, setChannelData] =
useState<DataWithScrollModifier<PatchTypeWithKey> | null>(null);
const [loading, setLoading] = useState(true);
Fix todos and delete useProcessesLogs (vibe-kanban) (#755) * Perfect! The implementation is complete and working. Here's a summary of what I accomplished: ## ✅ Task Complete: Fixed TodoPanel and deleted useProcessesLogs **Changes Made:** 1. **Created [`EntriesContext`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/contexts/EntriesContext.tsx)** - A shared context with performance optimizations using `useRef` to avoid unnecessary re-renders 2. **Modified [`TaskDetailsPanel`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/tasks/TaskDetailsPanel.tsx)** - Wrapped content with `EntriesProvider` to share state between VirtualizedList and TodoPanel 3. **Updated [`VirtualizedList`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/logs/VirtualizedList.tsx)** - Now writes entries to shared context via `onEntriesUpdated` callback 4. **Simplified [`TodoPanel`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/tasks/TodoPanel.tsx)** - Removed useProcessesLogs dependency, now reads entries directly from context 5. **Deleted `useProcessesLogs.ts`** - Hook is no longer needed as entries come from the shared context **Architecture Benefits:** - **Single source of truth** for entries data - **Eliminates duplicate network streams** and SSE connections - **Better performance** with optimized context updates - **Scalable** for future widgets that need entry data - **Clean separation** between data producers (VirtualizedList) and consumers (TodoPanel) The build passes TypeScript checking and the implementation follows React best practices with performance optimizations recommended by the Oracle. * Perfect! The implementation is complete and working. Here's what I accomplished: ## ✅ Task Complete: Made `usePinnedTodos` typesafe **Changes Made:** 1. **Updated [`usePinnedTodos`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/hooks/usePinnedTodos.ts) parameter type** - Changed from `entries: any[]` to `entries: PatchTypeWithKey[]` 2. **Fixed entry structure logic:** - Changed `entry.channel === 'normalized'` → `entry.type === 'NORMALIZED_ENTRY'` - Changed `entry.payload` → `entry.content` 3. **Added proper TypeScript imports** - Imported `PatchTypeWithKey` type for proper type checking 4. **Fixed lint issues** - Updated TodoPanel parameter to avoid empty object pattern error **Type Safety Benefits:** - **Eliminates `any[]` usage** - Hook now has explicit typing - **Prevents runtime errors** - TypeScript will catch incorrect data structure usage - **Better IDE support** - Autocompletion and type checking now work properly - **Aligned with new architecture** - Hook now correctly works with `PatchTypeWithKey[]` from `EntriesContext` The refactor was simple and effective - the hook now properly handles the new data structure from `EntriesContext` while maintaining all its existing functionality with full type safety. * Excellent! All the lint errors have been fixed. Here's a summary of what I accomplished: **Changes Made:** 1. **Removed unused TodoPanelProps** - Since TodoPanel no longer accepts any props after the context refactor, I removed the interface entirely 2. **Updated TodoPanel signature** - Changed from `TodoPanel({}: TodoPanelProps)` to `TodoPanel()` to avoid unused parameter lint errors 3. **Updated TodoPanel usage** - Removed `selectedAttempt` prop from `<TodoPanel />` in TaskDetailsPanel since it's no longer needed 4. **Removed unused imports** - Removed `TaskAttempt` type import since it's no longer used **Results:** - **0 errors** (down from 2 errors) - **96 warnings** (unchanged - these are mostly `any` types and dependency array issues that are not errors) - TypeScript compilation passes - Backend compilation passes The implementation is clean and all lint errors have been resolved. The remaining warnings are pre-existing and not related to our changes. * useState internally * fmt
2025-09-18 11:22:10 +01:00
const { setEntries, reset } = useEntries();
useEffect(() => {
setLoading(true);
setChannelData(null);
Fix todos and delete useProcessesLogs (vibe-kanban) (#755) * Perfect! The implementation is complete and working. Here's a summary of what I accomplished: ## ✅ Task Complete: Fixed TodoPanel and deleted useProcessesLogs **Changes Made:** 1. **Created [`EntriesContext`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/contexts/EntriesContext.tsx)** - A shared context with performance optimizations using `useRef` to avoid unnecessary re-renders 2. **Modified [`TaskDetailsPanel`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/tasks/TaskDetailsPanel.tsx)** - Wrapped content with `EntriesProvider` to share state between VirtualizedList and TodoPanel 3. **Updated [`VirtualizedList`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/logs/VirtualizedList.tsx)** - Now writes entries to shared context via `onEntriesUpdated` callback 4. **Simplified [`TodoPanel`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/tasks/TodoPanel.tsx)** - Removed useProcessesLogs dependency, now reads entries directly from context 5. **Deleted `useProcessesLogs.ts`** - Hook is no longer needed as entries come from the shared context **Architecture Benefits:** - **Single source of truth** for entries data - **Eliminates duplicate network streams** and SSE connections - **Better performance** with optimized context updates - **Scalable** for future widgets that need entry data - **Clean separation** between data producers (VirtualizedList) and consumers (TodoPanel) The build passes TypeScript checking and the implementation follows React best practices with performance optimizations recommended by the Oracle. * Perfect! The implementation is complete and working. Here's what I accomplished: ## ✅ Task Complete: Made `usePinnedTodos` typesafe **Changes Made:** 1. **Updated [`usePinnedTodos`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/hooks/usePinnedTodos.ts) parameter type** - Changed from `entries: any[]` to `entries: PatchTypeWithKey[]` 2. **Fixed entry structure logic:** - Changed `entry.channel === 'normalized'` → `entry.type === 'NORMALIZED_ENTRY'` - Changed `entry.payload` → `entry.content` 3. **Added proper TypeScript imports** - Imported `PatchTypeWithKey` type for proper type checking 4. **Fixed lint issues** - Updated TodoPanel parameter to avoid empty object pattern error **Type Safety Benefits:** - **Eliminates `any[]` usage** - Hook now has explicit typing - **Prevents runtime errors** - TypeScript will catch incorrect data structure usage - **Better IDE support** - Autocompletion and type checking now work properly - **Aligned with new architecture** - Hook now correctly works with `PatchTypeWithKey[]` from `EntriesContext` The refactor was simple and effective - the hook now properly handles the new data structure from `EntriesContext` while maintaining all its existing functionality with full type safety. * Excellent! All the lint errors have been fixed. Here's a summary of what I accomplished: **Changes Made:** 1. **Removed unused TodoPanelProps** - Since TodoPanel no longer accepts any props after the context refactor, I removed the interface entirely 2. **Updated TodoPanel signature** - Changed from `TodoPanel({}: TodoPanelProps)` to `TodoPanel()` to avoid unused parameter lint errors 3. **Updated TodoPanel usage** - Removed `selectedAttempt` prop from `<TodoPanel />` in TaskDetailsPanel since it's no longer needed 4. **Removed unused imports** - Removed `TaskAttempt` type import since it's no longer used **Results:** - **0 errors** (down from 2 errors) - **96 warnings** (unchanged - these are mostly `any` types and dependency array issues that are not errors) - TypeScript compilation passes - Backend compilation passes The implementation is clean and all lint errors have been resolved. The remaining warnings are pre-existing and not related to our changes. * useState internally * fmt
2025-09-18 11:22:10 +01:00
reset();
}, [attempt.id, reset]);
const onEntriesUpdated = (
newEntries: PatchTypeWithKey[],
addType: AddEntryType,
newLoading: boolean
) => {
let scrollModifier: ScrollModifier = InitialDataScrollModifier;
if (addType === 'running' && !loading) {
scrollModifier = AutoScrollToBottom;
}
setChannelData({ data: newEntries, scrollModifier });
setEntries(newEntries);
if (loading) {
setLoading(newLoading);
}
};
useConversationHistory({ attempt, onEntriesUpdated });
const messageListRef = useRef<VirtuosoMessageListMethods | null>(null);
const messageListContext = useMemo(() => ({ attempt }), [attempt]);
return (
Approval deny content dissapears on scroll (vibe-kanban) (#974) * Fixed the issue where approval denial reason content disappears on scroll. The problem was caused by virtualization in `VirtuosoMessageList` - when components scroll out of view, they get unmounted and local state is lost. 1. **Created new Zustand store** (`frontend/src/stores/useApprovalUiStore.ts`): - Stores `isEnteringReason` and `denyReason` state keyed by `approval_id` - Follows the existing pattern used in `useExpandableStore` and `useTaskDetailsUiStore` - Provides `getUiState`, `setUiState`, and `clearUiState` methods 2. **Updated PendingApprovalEntry component**: - Replaced local `useState` for `isEnteringReason` and `denyReason` with store state - Updated all state setters to use `setUiState()` and `clearUiState()` - State now persists across component unmount/remount during scrolling The denial reason text will now persist when scrolling the approval request out of view and back into view. Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 Successfully refactored to a **much simpler solution** using React Context instead of Zustand! Here's what changed: - **Deleted** `frontend/src/stores/useApprovalUiStore.ts` (entire Zustand store) - No more global state management - No more keying by `approval_id` **1. Simple Context in VirtualizedList.tsx:** ```typescript // Context definition interface ApprovalFormState { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clearForm: () => void; } // Provider with simple useState const VirtualizedList = ({ attempt }) => { const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(''); // Auto-clear when switching attempts useEffect(() => { clearForm(); }, [attempt.id]); return ( <ApprovalFormContext.Provider value={...}> <VirtuosoMessageList ... /> </ApprovalFormContext.Provider> ); }; ``` **2. Updated PendingApprovalEntry.tsx:** ```typescript // Before: Zustand store const { getUiState, setUiState, clearUiState } = useApprovalUiStore(); const { isEnteringReason, denyReason } = getUiState(approvalId); // After: Simple context hook const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clearForm } = useApprovalForm(); ``` | Aspect | Old (Zustand) | New (Context) | |--------|---------------|---------------| | **Complexity** | Global store with keyed state | Simple useState + Context | | **Lines of code** | ~50 lines (separate file) | ~20 lines (inline) | | **Scope** | Global (entire app) | Local (VirtualizedList only) | | **Cleanup** | Manual (keyed by ID) | Automatic (resets on attempt change) | | **Overkill?** | Yes ✅ | No ✅ | 1. **State lives in `VirtualizedList`** component (above the virtualized list) 2. **Context provider** wraps `VirtuosoMessageList` 3. **State persists** when `PendingApprovalEntry` unmounts/remounts during scrolling 4. **Auto-cleanup** when `attempt.id` changes (switching tasks) 5. **Only one approval** at a time (your requirement met!) The denial reason text now persists when scrolling, and we've eliminated unnecessary global state management. Much cleaner! ✨ Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 Revert "Successfully refactored to a **much simpler solution** using React Context instead of Zustand! Here's what changed:" This reverts commit 89aa702e8eedd2b50c4fa3551d2390e06ce0d94a. Successfully simplified the Zustand store from **52 lines to 18 lines** by removing unnecessary complexity! **Before (Complex):** ```typescript // 52 lines with Map structure interface UiStateMap { [approvalId: string]: ApprovalUiState; } const { getUiState, setUiState, clearUiState } = useApprovalUiStore(); const approvalId = pendingStatus.approval_id; const { isEnteringReason, denyReason } = getUiState(approvalId); setUiState(approvalId, { isEnteringReason: true }); setUiState(approvalId, { denyReason: value }); clearUiState(approvalId); ``` **After (Simple):** ```typescript // 18 lines with flat structure interface ApprovalUiStore { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clear: () => void; } const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clear } = useApprovalUiStore(); setIsEnteringReason(true); setDenyReason(value); clear(); ``` 1. **Removed Map structure** - No need to key by `approval_id` since only one approval at a time 2. **Removed getUiState()** - Direct access to state values 3. **Simple setters** - `setIsEnteringReason(true)` instead of `setUiState(id, { isEnteringReason: true })` 4. **Cleaner component code** - No more `approvalId` variable juggling 5. **65% less code** - From 52 lines down to 18 lines 1. **frontend/src/stores/useApprovalUiStore.ts** - Simplified from Map-based to flat state 2. **frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx** - Updated to use simpler API ✅ Approval denial text persists when scrolling out of view ✅ State automatically clears when approval is submitted/cancelled ✅ Much cleaner and easier to understand ✅ All TypeScript checks pass The solution is now **minimal and clean** - just 18 lines of Zustand store to fix the virtualization state loss problem! Perfect! ✅ All checks pass. Let me create a summary: Successfully fixed the approval denial content disappearing issue with a clean, minimal solution! **1. Simplified Zustand Store** (`frontend/src/stores/useApprovalUiStore.ts` - 18 lines) ```typescript interface ApprovalUiStore { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clear: () => void; } ``` **2. Auto-Cleanup in VirtualizedList** (`frontend/src/components/logs/VirtualizedList.tsx` - 3 lines added) ```typescript const { clear } = useApprovalUiStore(); useEffect(() => { clear(); }, [attempt.id, clear]); ``` **3. Clean Usage in Component** (`frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx`) ```typescript const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clear } = useApprovalUiStore(); ``` ✅ **Virtualization bug**: Approval denial text persists when scrolling out of view ✅ **Attempt switching bug**: Form state clears when switching between task attempts ✅ **Well encapsulated**: Single clean import `useApprovalUiStore()` ✅ **Minimal code**: Only 21 total lines (18 store + 3 cleanup) ✅ **Type-safe**: All TypeScript checks pass 1. **Zustand store** holds form state globally (survives virtualization unmount/remount) 2. **VirtualizedList cleanup** triggers `clear()` whenever `attempt.id` changes 3. **PendingApprovalEntry** uses store via clean hook API The solution is pragmatic and minimal - just enough state management to solve the virtualization problem without overengineering. Successfully refactored from Zustand to React Context following React best practices! **Before (Zustand):** - Global Zustand store (18 lines) - Manual cleanup hook in VirtualizedList - Not idiomatic React **After (Context):** - React Context provider (42 lines) - Auto-cleanup (provider remounts = state resets) - Idiomatic React ✅ **1. Created: `frontend/src/contexts/ApprovalFormContext.tsx` (42 lines)** ```typescript export function ApprovalFormProvider({ children }) export function useApprovalForm() ``` - Clean Context provider following same pattern as `ReviewProvider` - Provides: `isEnteringReason`, `denyReason`, `setIsEnteringReason`, `setDenyReason`, `clear` **2. Updated: `frontend/src/components/logs/VirtualizedList.tsx`** - Removed Zustand import - Removed manual cleanup hook - Wrapped return with `<ApprovalFormProvider>` **3. Updated: `frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx`** - Changed import from `useApprovalUiStore` to `useApprovalForm` - Changed hook call (API stays identical) **4. Deleted: `frontend/src/stores/useApprovalUiStore.ts`** - No longer needed | Aspect | Before (Zustand) | After (Context) | |--------|------------------|-----------------| | **React idioms** | ❌ Global store for UI state | ✅ Context for scoped state | | **Encapsulation** | ✅ Clean import | ✅ Clean import | | **Auto-cleanup** | ❌ Manual hook needed | ✅ Automatic (provider unmounts) | | **Coupling** | ⚠️ VirtualizedList needs cleanup hook | ✅ No coupling | | **Best practice** | ❌ Not ideal | ✅ Textbook React | | **Maintainability** | Good | Better | 1. **VirtualizedList** wraps children with `<ApprovalFormProvider>` 2. **Provider** holds state via `useState` (standard React) 3. **Auto-cleanup**: When `VirtualizedList` remounts (new `attempt.id`), provider remounts and state resets 4. **PendingApprovalEntry** accesses state via `useApprovalForm()` hook ✅ **Virtualization bug fixed**: State persists when scrolling out of view ✅ **Attempt switching bug fixed**: State auto-clears when switching attempts (provider remounts) ✅ **Well encapsulated**: Single clean import `useApprovalForm()` ✅ **Idiomatic React**: Proper use of Context for scoped UI state ✅ **Best practice**: Follows React team recommendations ✅ **All checks pass**: TypeScript compilation succeeds This is now production-ready, maintainable, and follows React best practices! 🎉 The senior reviewer was absolutely right! I had introduced a critical bug. Now it's properly fixed. **The Bug:** ```typescript // Single shared state for ALL approvals - WRONG! const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(''); ``` **Problem:** If multiple approvals exist simultaneously, they all share the same textarea state. Typing in one would mirror to others. 1. ✅ **Keyed state by `approval_id`** - Each approval has isolated state 2. ✅ **Pruning logic** - Prevent unbounded growth with MAX_CACHED_APPROVALS limit 3. ✅ **Hook accepts `approval_id`** - `useApprovalForm(approvalId)` 4. ✅ **Scoped above virtualization** - Context provider in VirtualizedList **`frontend/src/contexts/ApprovalFormContext.tsx` (110 lines)** ```typescript // Keyed state map interface ApprovalFormStateMap { [approvalId: string]: ApprovalFormState; } const MAX_CACHED_APPROVALS = 10; // Prevent unbounded growth export function useApprovalForm(approvalId: string) { const state = context.getState(approvalId); // Get state for THIS approval return { isEnteringReason: state.isEnteringReason, denyReason: state.denyReason, setIsEnteringReason: (v) => context.setState(approvalId, { isEnteringReason: v }), setDenyReason: (v) => context.setState(approvalId, { denyReason: v }), clear: () => context.clear(approvalId), }; } export function ApprovalFormProvider({ children }) { const [stateMap, setStateMap] = useState<ApprovalFormStateMap>({}); const setState = (approvalId, partial) => { // Update state for specific approval // Prune if > MAX_CACHED_APPROVALS }; // ... } ``` **Usage in PendingApprovalEntry:** ```typescript const { isEnteringReason, denyReason, ... } = useApprovalForm(pendingStatus.approval_id); ``` | Feature | Implementation | |---------|----------------| | **Per-approval isolation** | ✅ Keyed by `approval_id` - each approval has its own state | | **Survives virtualization** | ✅ State lives in Context above VirtuosoMessageList | | **Pruning** | ✅ MAX_CACHED_APPROVALS = 10, removes oldest when exceeded | | **Auto-cleanup on attempt switch** | ✅ Provider remounts when VirtualizedList remounts | | **Idiomatic React** | ✅ Context for scoped UI state | | **Well encapsulated** | ✅ Clean hook: `useApprovalForm(approvalId)` | 1. **Multiple approvals** can exist simultaneously, each with isolated state 2. **State keyed by `approval_id`** ensures no cross-contamination 3. **Pruning** prevents memory leaks (keeps max 10 approval states) 4. **Context above virtualization** persists state during scroll 5. **Provider remounts** on attempt change, clearing all state ✅ **Original bug**: Denial text persists when scrolling (virtualization) ✅ **Attempt switching**: State clears when switching attempts ✅ **Multiple approvals**: Each approval has isolated state (no mirroring) ✅ **Memory leak**: Pruning prevents unbounded growth ✅ **Best practice**: Idiomatic React with Context ✅ **Type safety**: All TypeScript checks pass The senior reviewer's feedback was spot-on. This is now production-ready and handles all edge cases correctly! 🎉 Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 * Perfect! ✅ All checks pass. ## Summary of Changes Removed dead code from `ApprovalFormContext.tsx`: **Removed:** 1. `clearAll: () => void;` from interface (line 22) 2. `clearAll` function implementation (lines 94-96) 3. `clearAll` from provider value (line 104) **Result:** - Cleaner code (removed 4 lines of unused code) - No functionality lost (it was never called) - All TypeScript checks pass ✅ **Why `isEnteringReason` is kept:** It controls the UI state machine (buttons ↔ form), and must persist across virtualization so the form stays open when scrolling.
2025-10-22 09:02:56 +01:00
<ApprovalFormProvider>
<VirtuosoMessageListLicense
licenseKey={import.meta.env.VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY}
>
<VirtuosoMessageList<PatchTypeWithKey, MessageListContext>
ref={messageListRef}
className="flex-1"
data={channelData}
initialLocation={INITIAL_TOP_ITEM}
context={messageListContext}
computeItemKey={computeItemKey}
ItemContent={ItemContent}
Header={() => <div className="h-2"></div>}
Footer={() => <div className="h-2"></div>}
/>
</VirtuosoMessageListLicense>
{loading && (
<div className="float-left top-0 left-0 w-full h-full bg-primary flex flex-col gap-2 justify-center items-center">
<Loader2 className="h-8 w-8 animate-spin" />
<p>Loading History</p>
</div>
)}
Approval deny content dissapears on scroll (vibe-kanban) (#974) * Fixed the issue where approval denial reason content disappears on scroll. The problem was caused by virtualization in `VirtuosoMessageList` - when components scroll out of view, they get unmounted and local state is lost. 1. **Created new Zustand store** (`frontend/src/stores/useApprovalUiStore.ts`): - Stores `isEnteringReason` and `denyReason` state keyed by `approval_id` - Follows the existing pattern used in `useExpandableStore` and `useTaskDetailsUiStore` - Provides `getUiState`, `setUiState`, and `clearUiState` methods 2. **Updated PendingApprovalEntry component**: - Replaced local `useState` for `isEnteringReason` and `denyReason` with store state - Updated all state setters to use `setUiState()` and `clearUiState()` - State now persists across component unmount/remount during scrolling The denial reason text will now persist when scrolling the approval request out of view and back into view. Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 Successfully refactored to a **much simpler solution** using React Context instead of Zustand! Here's what changed: - **Deleted** `frontend/src/stores/useApprovalUiStore.ts` (entire Zustand store) - No more global state management - No more keying by `approval_id` **1. Simple Context in VirtualizedList.tsx:** ```typescript // Context definition interface ApprovalFormState { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clearForm: () => void; } // Provider with simple useState const VirtualizedList = ({ attempt }) => { const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(''); // Auto-clear when switching attempts useEffect(() => { clearForm(); }, [attempt.id]); return ( <ApprovalFormContext.Provider value={...}> <VirtuosoMessageList ... /> </ApprovalFormContext.Provider> ); }; ``` **2. Updated PendingApprovalEntry.tsx:** ```typescript // Before: Zustand store const { getUiState, setUiState, clearUiState } = useApprovalUiStore(); const { isEnteringReason, denyReason } = getUiState(approvalId); // After: Simple context hook const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clearForm } = useApprovalForm(); ``` | Aspect | Old (Zustand) | New (Context) | |--------|---------------|---------------| | **Complexity** | Global store with keyed state | Simple useState + Context | | **Lines of code** | ~50 lines (separate file) | ~20 lines (inline) | | **Scope** | Global (entire app) | Local (VirtualizedList only) | | **Cleanup** | Manual (keyed by ID) | Automatic (resets on attempt change) | | **Overkill?** | Yes ✅ | No ✅ | 1. **State lives in `VirtualizedList`** component (above the virtualized list) 2. **Context provider** wraps `VirtuosoMessageList` 3. **State persists** when `PendingApprovalEntry` unmounts/remounts during scrolling 4. **Auto-cleanup** when `attempt.id` changes (switching tasks) 5. **Only one approval** at a time (your requirement met!) The denial reason text now persists when scrolling, and we've eliminated unnecessary global state management. Much cleaner! ✨ Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 Revert "Successfully refactored to a **much simpler solution** using React Context instead of Zustand! Here's what changed:" This reverts commit 89aa702e8eedd2b50c4fa3551d2390e06ce0d94a. Successfully simplified the Zustand store from **52 lines to 18 lines** by removing unnecessary complexity! **Before (Complex):** ```typescript // 52 lines with Map structure interface UiStateMap { [approvalId: string]: ApprovalUiState; } const { getUiState, setUiState, clearUiState } = useApprovalUiStore(); const approvalId = pendingStatus.approval_id; const { isEnteringReason, denyReason } = getUiState(approvalId); setUiState(approvalId, { isEnteringReason: true }); setUiState(approvalId, { denyReason: value }); clearUiState(approvalId); ``` **After (Simple):** ```typescript // 18 lines with flat structure interface ApprovalUiStore { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clear: () => void; } const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clear } = useApprovalUiStore(); setIsEnteringReason(true); setDenyReason(value); clear(); ``` 1. **Removed Map structure** - No need to key by `approval_id` since only one approval at a time 2. **Removed getUiState()** - Direct access to state values 3. **Simple setters** - `setIsEnteringReason(true)` instead of `setUiState(id, { isEnteringReason: true })` 4. **Cleaner component code** - No more `approvalId` variable juggling 5. **65% less code** - From 52 lines down to 18 lines 1. **frontend/src/stores/useApprovalUiStore.ts** - Simplified from Map-based to flat state 2. **frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx** - Updated to use simpler API ✅ Approval denial text persists when scrolling out of view ✅ State automatically clears when approval is submitted/cancelled ✅ Much cleaner and easier to understand ✅ All TypeScript checks pass The solution is now **minimal and clean** - just 18 lines of Zustand store to fix the virtualization state loss problem! Perfect! ✅ All checks pass. Let me create a summary: Successfully fixed the approval denial content disappearing issue with a clean, minimal solution! **1. Simplified Zustand Store** (`frontend/src/stores/useApprovalUiStore.ts` - 18 lines) ```typescript interface ApprovalUiStore { isEnteringReason: boolean; denyReason: string; setIsEnteringReason: (value: boolean) => void; setDenyReason: (value: string) => void; clear: () => void; } ``` **2. Auto-Cleanup in VirtualizedList** (`frontend/src/components/logs/VirtualizedList.tsx` - 3 lines added) ```typescript const { clear } = useApprovalUiStore(); useEffect(() => { clear(); }, [attempt.id, clear]); ``` **3. Clean Usage in Component** (`frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx`) ```typescript const { isEnteringReason, denyReason, setIsEnteringReason, setDenyReason, clear } = useApprovalUiStore(); ``` ✅ **Virtualization bug**: Approval denial text persists when scrolling out of view ✅ **Attempt switching bug**: Form state clears when switching between task attempts ✅ **Well encapsulated**: Single clean import `useApprovalUiStore()` ✅ **Minimal code**: Only 21 total lines (18 store + 3 cleanup) ✅ **Type-safe**: All TypeScript checks pass 1. **Zustand store** holds form state globally (survives virtualization unmount/remount) 2. **VirtualizedList cleanup** triggers `clear()` whenever `attempt.id` changes 3. **PendingApprovalEntry** uses store via clean hook API The solution is pragmatic and minimal - just enough state management to solve the virtualization problem without overengineering. Successfully refactored from Zustand to React Context following React best practices! **Before (Zustand):** - Global Zustand store (18 lines) - Manual cleanup hook in VirtualizedList - Not idiomatic React **After (Context):** - React Context provider (42 lines) - Auto-cleanup (provider remounts = state resets) - Idiomatic React ✅ **1. Created: `frontend/src/contexts/ApprovalFormContext.tsx` (42 lines)** ```typescript export function ApprovalFormProvider({ children }) export function useApprovalForm() ``` - Clean Context provider following same pattern as `ReviewProvider` - Provides: `isEnteringReason`, `denyReason`, `setIsEnteringReason`, `setDenyReason`, `clear` **2. Updated: `frontend/src/components/logs/VirtualizedList.tsx`** - Removed Zustand import - Removed manual cleanup hook - Wrapped return with `<ApprovalFormProvider>` **3. Updated: `frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx`** - Changed import from `useApprovalUiStore` to `useApprovalForm` - Changed hook call (API stays identical) **4. Deleted: `frontend/src/stores/useApprovalUiStore.ts`** - No longer needed | Aspect | Before (Zustand) | After (Context) | |--------|------------------|-----------------| | **React idioms** | ❌ Global store for UI state | ✅ Context for scoped state | | **Encapsulation** | ✅ Clean import | ✅ Clean import | | **Auto-cleanup** | ❌ Manual hook needed | ✅ Automatic (provider unmounts) | | **Coupling** | ⚠️ VirtualizedList needs cleanup hook | ✅ No coupling | | **Best practice** | ❌ Not ideal | ✅ Textbook React | | **Maintainability** | Good | Better | 1. **VirtualizedList** wraps children with `<ApprovalFormProvider>` 2. **Provider** holds state via `useState` (standard React) 3. **Auto-cleanup**: When `VirtualizedList` remounts (new `attempt.id`), provider remounts and state resets 4. **PendingApprovalEntry** accesses state via `useApprovalForm()` hook ✅ **Virtualization bug fixed**: State persists when scrolling out of view ✅ **Attempt switching bug fixed**: State auto-clears when switching attempts (provider remounts) ✅ **Well encapsulated**: Single clean import `useApprovalForm()` ✅ **Idiomatic React**: Proper use of Context for scoped UI state ✅ **Best practice**: Follows React team recommendations ✅ **All checks pass**: TypeScript compilation succeeds This is now production-ready, maintainable, and follows React best practices! 🎉 The senior reviewer was absolutely right! I had introduced a critical bug. Now it's properly fixed. **The Bug:** ```typescript // Single shared state for ALL approvals - WRONG! const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(''); ``` **Problem:** If multiple approvals exist simultaneously, they all share the same textarea state. Typing in one would mirror to others. 1. ✅ **Keyed state by `approval_id`** - Each approval has isolated state 2. ✅ **Pruning logic** - Prevent unbounded growth with MAX_CACHED_APPROVALS limit 3. ✅ **Hook accepts `approval_id`** - `useApprovalForm(approvalId)` 4. ✅ **Scoped above virtualization** - Context provider in VirtualizedList **`frontend/src/contexts/ApprovalFormContext.tsx` (110 lines)** ```typescript // Keyed state map interface ApprovalFormStateMap { [approvalId: string]: ApprovalFormState; } const MAX_CACHED_APPROVALS = 10; // Prevent unbounded growth export function useApprovalForm(approvalId: string) { const state = context.getState(approvalId); // Get state for THIS approval return { isEnteringReason: state.isEnteringReason, denyReason: state.denyReason, setIsEnteringReason: (v) => context.setState(approvalId, { isEnteringReason: v }), setDenyReason: (v) => context.setState(approvalId, { denyReason: v }), clear: () => context.clear(approvalId), }; } export function ApprovalFormProvider({ children }) { const [stateMap, setStateMap] = useState<ApprovalFormStateMap>({}); const setState = (approvalId, partial) => { // Update state for specific approval // Prune if > MAX_CACHED_APPROVALS }; // ... } ``` **Usage in PendingApprovalEntry:** ```typescript const { isEnteringReason, denyReason, ... } = useApprovalForm(pendingStatus.approval_id); ``` | Feature | Implementation | |---------|----------------| | **Per-approval isolation** | ✅ Keyed by `approval_id` - each approval has its own state | | **Survives virtualization** | ✅ State lives in Context above VirtuosoMessageList | | **Pruning** | ✅ MAX_CACHED_APPROVALS = 10, removes oldest when exceeded | | **Auto-cleanup on attempt switch** | ✅ Provider remounts when VirtualizedList remounts | | **Idiomatic React** | ✅ Context for scoped UI state | | **Well encapsulated** | ✅ Clean hook: `useApprovalForm(approvalId)` | 1. **Multiple approvals** can exist simultaneously, each with isolated state 2. **State keyed by `approval_id`** ensures no cross-contamination 3. **Pruning** prevents memory leaks (keeps max 10 approval states) 4. **Context above virtualization** persists state during scroll 5. **Provider remounts** on attempt change, clearing all state ✅ **Original bug**: Denial text persists when scrolling (virtualization) ✅ **Attempt switching**: State clears when switching attempts ✅ **Multiple approvals**: Each approval has isolated state (no mirroring) ✅ **Memory leak**: Pruning prevents unbounded growth ✅ **Best practice**: Idiomatic React with Context ✅ **Type safety**: All TypeScript checks pass The senior reviewer's feedback was spot-on. This is now production-ready and handles all edge cases correctly! 🎉 Cleanup script changes for task attempt 90e3068a-0293-470b-a1c2-afb6b26ac071 * Perfect! ✅ All checks pass. ## Summary of Changes Removed dead code from `ApprovalFormContext.tsx`: **Removed:** 1. `clearAll: () => void;` from interface (line 22) 2. `clearAll` function implementation (lines 94-96) 3. `clearAll` from provider value (line 104) **Result:** - Cleaner code (removed 4 lines of unused code) - No functionality lost (it was never called) - All TypeScript checks pass ✅ **Why `isEnteringReason` is kept:** It controls the UI state machine (buttons ↔ form), and must persist across virtualization so the form stays open when scrolling.
2025-10-22 09:02:56 +01:00
</ApprovalFormProvider>
);
};
export default VirtualizedList;