Alex Netsch 76535ba457 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
2025-08-08 13:53:27 +01:00
2025-08-08 13:53:27 +01:00
2025-10-21 09:45:22 +00:00
2025-10-16 18:28:56 +01:00
2025-10-20 18:02:58 +01:00
2025-09-04 10:52:18 +01:00
2025-08-08 13:53:27 +01:00
2025-08-08 13:53:27 +01:00
2025-07-11 15:57:30 +01:00
2025-10-20 13:24:04 +01:00
2025-07-11 16:31:08 +01:00
2025-10-21 09:45:22 +00:00
2025-06-14 15:14:08 -04:00
2025-10-03 13:48:27 +01:00
2025-06-25 09:27:29 +01:00
2025-06-25 09:27:29 +01:00
2025-07-04 11:11:45 +02:00

Vibe Kanban Logo

Get 10X more out of Claude Code, Gemini CLI, Codex, Amp and other coding agents...

npm Build status Ask DeepWiki

Overview

AI coding agents are increasingly writing the world's code and human engineers now spend the majority of their time planning, reviewing, and orchestrating tasks. Vibe Kanban streamlines this process, enabling you to:

  • Easily switch between different coding agents
  • Orchestrate the execution of multiple coding agents in parallel or in sequence
  • Quickly review work and start dev servers
  • Track the status of tasks that your coding agents are working on
  • Centralise configuration of coding agent MCP configs

You can watch a video overview here.

Installation

Make sure you have authenticated with your favourite coding agent. A full list of supported coding agents can be found in the docs. Then in your terminal run:

npx vibe-kanban

Documentation

Please head to the website for the latest documentation and user guides.

Support

We use GitHub Discussions for feature requests. Please open a discussion to create a feature request. For bugs please open an issue on this repo.

Contributing

We would prefer that ideas and changes are first raised with the core team via GitHub Discussions or Discord, where we can discuss implementation details and alignment with the existing roadmap. Please do not open PRs without first discussing your proposal with the team.

Development

Prerequisites

Additional development tools:

cargo install cargo-watch
cargo install sqlx-cli

Install dependencies:

pnpm i

Running the dev server

pnpm run dev

This will start the backend. A blank DB will be copied from the dev_assets_seed folder.

Building the frontend

To build just the frontend:

cd frontend
pnpm build

Build from source

  1. Run build-npm-package.sh
  2. In the npx-cli folder run npm pack
  3. You can run your build with npx [GENERATED FILE].tgz

Environment Variables

The following environment variables can be configured at build time or runtime:

Variable Type Default Description
GITHUB_CLIENT_ID Build-time Ov23li9bxz3kKfPOIsGm GitHub OAuth app client ID for authentication
POSTHOG_API_KEY Build-time Empty PostHog analytics API key (disables analytics if empty)
POSTHOG_API_ENDPOINT Build-time Empty PostHog analytics endpoint (disables analytics if empty)
BACKEND_PORT Runtime 0 (auto-assign) Backend server port
FRONTEND_PORT Runtime 3000 Frontend development server port
HOST Runtime 127.0.0.1 Backend server host
DISABLE_WORKTREE_ORPHAN_CLEANUP Runtime Not set Disable git worktree cleanup (for debugging)

Build-time variables must be set when running pnpm run build. Runtime variables are read when the application starts.

Custom GitHub OAuth App (Optional)

By default, Vibe Kanban uses Bloop AI's GitHub OAuth app for authentication. To use your own GitHub app for self-hosting or custom branding:

  1. Create a GitHub OAuth App at GitHub Developer Settings
  2. Enable "Device Flow" in the app settings
  3. Set scopes to include user:email,repo
  4. Build with your client ID:
    GITHUB_CLIENT_ID=your_client_id_here pnpm run build
    
Description
Get 10X more out of Claude Code, Codex or any coding agent
https://www.vibekanban.com/
Readme Apache-2.0 48 MiB
Languages
Rust 51.1%
TypeScript 46.6%
CSS 1.1%
JavaScript 0.7%
PLpgSQL 0.2%
Other 0.2%