* 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.
3.7 KiB
3.7 KiB