Files
vibe-kanban/frontend/src/components/ui-new/primitives/CommandBar.tsx
Louis Knight-Webb 6e3421c820 Fix CommandBar input and navigation issues with state machine refactor (Vibe Kanban) (#1885)
* Done! I've removed all 7 `shortcut` properties from the action definitions in `index.ts`:

- `NewWorkspace`: removed `shortcut: 'N'`
- `Settings`: removed `shortcut: ','`
- `ToggleSidebar`: removed `shortcut: '['`
- `ToggleGitPanel`: removed `shortcut: ']'`
- `ToggleChangesMode`: removed `shortcut: 'C'`
- `ToggleLogsMode`: removed `shortcut: 'L'`
- `TogglePreviewMode`: removed `shortcut: 'P'`

The `ActionDefinition` type (and its base `ActionBase` interface) were left unchanged as requested.

* fix tailwind nto auto creating styles

* I've successfully completed all the changes. Here's a summary of what was done:

## Summary of Changes

### 1. `ActionExecutorContext` (index.ts)
- Made all properties non-optional
- Removed `gitRepoId` from context (now passed as action parameter)
- Properties now use `| null` for values that may not exist

### 2. New `GitActionDefinition` Type (index.ts)
- Added new action type with `requiresTarget: 'git'`
- Git actions now explicitly require `repoId` as a parameter

### 3. Git Actions Updated (index.ts)
- `GitCreatePR`, `GitMerge`, `GitRebase`, `GitChangeTarget` now use `requiresTarget: 'git'`
- Each action's `execute` function takes `repoId` as third parameter
- Simplified implementations (no more fallback repo fetching logic)

### 4. `ActionsContext` Provider (ActionsContext.tsx)
- Now calls `useDevServer()` to get dev server state
- Provides all required context properties
- `executeAction` handles three action types: global, workspace, and git
- Removed `contextOverride` pattern

### 5. `ContextBarContainer` (ContextBarContainer.tsx)
- Simplified significantly - no longer builds extended context
- Uses executor context directly from provider
- Removed `containerPath` prop

### 6. `ActionVisibilityContext` (index.ts + useActionVisibility.ts)
- Made `editorType`, `devServerState`, `runningDevServerId` required
- Visibility hook now provides all values from hooks

### 7. Updated Call Sites
- `WorkspacesLayout`: Git actions now pass `repoId` as third parameter
- `WorkspacesMain`: Removed `containerPath` prop from ContextBarContainer

* Cleanup script changes for workspace e961e311-6db4-41bf-83a1-ef8dd96eb497

* All changes are complete and type checks pass. Here's a summary of the changes made:

## Summary of Changes

### 1. `ActionExecutorContext` - Made all properties non-optional
- Removed `?` from all properties
- Removed `gitRepoId` (moved to per-action parameter)
- Changed nullable properties to use `| null` type

### 2. New `GitActionDefinition` type
- Added new action type with `requiresTarget: 'git'`
- Takes `repoId` as explicit third parameter

### 3. Git actions updated
- `GitCreatePR`, `GitMerge`, `GitRebase`, `GitChangeTarget` now use `requiresTarget: 'git'`
- Each action's `execute` receives `repoId` as parameter

### 4. `ActionsContext` provider centralized
- Now calls `useDevServer()` to provide dev server state
- Provides all required context properties from hooks
- `executeAction` handles three action types: global, workspace, git
- Removed `contextOverride` pattern

### 5. `ActionVisibilityContext` - Made dev server properties required
- `editorType`, `devServerState`, `runningDevServerId` now required
- `useActionVisibilityContext` hook provides all values

### 6. `ContextBarContainer` simplified
- Removed extended context creation
- Uses executor context directly from provider

### 7. Provider order fix
- Moved `ExecutionProcessesProvider` up to `NewDesignScope`
- Required because `useDevServer` depends on `ExecutionProcessesContext`

### Files Modified:
- `vibe-kanban/frontend/src/components/ui-new/actions/index.ts`
- `vibe-kanban/frontend/src/components/ui-new/actions/useActionVisibility.ts`
- `vibe-kanban/frontend/src/contexts/ActionsContext.tsx`
- `vibe-kanban/frontend/src/components/ui-new/containers/ContextBarContainer.tsx`
- `vibe-kanban/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx`
- `vibe-kanban/frontend/src/components/ui-new/views/WorkspacesMain.tsx`
- `vibe-kanban/frontend/src/components/ui-new/scope/NewDesignScope.tsx`

* The implementation is complete. Let me summarize what was changed:

## Summary of Changes

### `pages.ts`
- Added `'selectRepo'` to `PageId` type for dynamic repo selection page
- Created `StaticPageId` type (excludes `selectRepo`) for static page records
- Added `RepoItem` interface for repo selection items
- Extended `ResolvedGroupItem` union type to include `{ type: 'repo'; repo: RepoItem }`
- Updated `Pages` record and `getPageActions` to use `StaticPageId`

### `CommandBarDialog.tsx`
- Added `repos` from `useWorkspaceContext()`
- Added `pendingGitAction` state to track git actions awaiting repo selection
- Reset `pendingGitAction` when dialog opens/closes
- Modified `getPageWithItems` to generate dynamic `selectRepo` page with repo list
- Updated `handleSelect` to:
  - Execute git actions immediately with single repo
  - Navigate to `selectRepo` page when multiple repos exist
  - Execute pending git action when user selects a repo
- Clear `pendingGitAction` when navigating back from `selectRepo` page

### `CommandBar.tsx`
- Added `FolderIcon` import
- Added rendering for `'repo'` item type with folder icon

## Behavior

1. **Single repo**: When user selects a git action (e.g., "Create Pull Request") and the workspace has only one git repo, the action executes immediately with that repo's ID.

2. **Multiple repos**: When workspace has multiple repos, selecting a git action navigates to a "Select Repository" page where the user can choose which repo to use, then the action executes with the selected repo.

3. **Back navigation**: Pressing back from the repo selection page clears the pending action and returns to the previous page.

* Done! The `CommandBarDialog` has been refactored to use a state machine pattern with `useReducer`. Here's a summary of the changes:

## Summary of Changes

**State Machine Types (lines 38-67):**
- `CommandBarState`: Discriminated union with `browsing` and `selectingRepo` states
- `CommandBarEvent`: `RESET`, `SEARCH_CHANGE`, `GO_BACK`, `SELECT_ITEM`
- `CommandBarEffect`: Side effects returned from reducer (`none` or `execute`)

**Reducer (lines 73-201):**
- Pure function returning `[newState, effect]` tuple
- Handles all state transitions based on current state and event
- Git action flow: single repo → execute, multiple repos → transition to `selectingRepo`

**Component Changes:**
- Replaced 4 `useState` calls with single `useReducer`
- `currentPage` is now derived from state (line 249-250)
- `handleSelect` computes effect from reducer, dispatches event, then handles effect (lines 456-475)
- `goBack` and `handleSearchChange` simply dispatch events (lines 446-453)

**Benefits:**
- All state transitions in one place (the reducer)
- State transitions are pure and testable
- Clear separation between state updates and side effects
- Impossible states are unrepresentable (e.g., can't have `pendingAction` while `browsing`)

* Done! Here's the final result:

## Summary

**Before:** 520 lines in 1 file (after initial state machine refactor)
**After:** 487 lines across 4 files

| File | Lines | Purpose |
|------|-------|---------|
| `CommandBarDialog.tsx` | 101 | Thin orchestration (down from 520) |
| `useCommandBarState.ts` | 177 | State machine hook |
| `useResolvedPage.ts` | 150 | Page building logic |
| `injectSearchMatches.ts` | 59 | DRY search helper |

**Key wins:**
- Main component: **520 → 101 lines** (80% reduction)
- Search injection code: **100 → 59 lines** (4× duplication eliminated)
- Each file has single responsibility
- State machine is testable in isolation
- Page building logic is reusable

* Cleanup script changes for workspace e961e311-6db4-41bf-83a1-ef8dd96eb497

* Done! Here are the final results:

## Final Summary

| File | Before | After | Reduction |
|------|--------|-------|-----------|
| `CommandBarDialog.tsx` | 101 | 106 | +5 (linter formatting) |
| `useCommandBarState.ts` | 177 | 95 | **-82 (46%)** |
| `useResolvedPage.ts` | 150 | 95 | **-55 (37%)** |
| `injectSearchMatches.ts` | 59 | 42 | **-17 (29%)** |
| **Total** | **487** | **338** | **-149 (31%)** |

**Original single file:** 398 lines
**Final 4 files:** 338 lines (15% less code overall)

Key wins:
- Main component is now just 106 lines of thin orchestration
- State machine is flattened (if statements instead of nested switch/case)
- Page resolution is simplified with reused types
- Search injection uses reduce() instead of for-loop
- All files pass type check

* Cleanup script changes for workspace e961e311-6db4-41bf-83a1-ef8dd96eb497

* I've fixed both issues with the CommandBar. Here's a summary of what was wrong and how I fixed it:

## Root Causes

### Issue 1: Nested `<Command>` Components
The `CommandDialog` component was wrapping children in a `<Command>` component, and `CommandBar` also rendered its own `<Command>`. This nested cmdk context caused conflicts with state management.

**Fix:** Removed the `<Command>` wrapper from `CommandDialog` in `Command.tsx:26-44`, and moved the styling classes to `CommandBar`'s `<Command>` component.

### Issue 2: Unstable `dispatch` Function Causing Infinite RESET Loop
In `useCommandBarState.ts`, the `dispatch` function had `[state, repoCount]` in its `useCallback` dependency array. This caused `dispatch` to be recreated on every state change.

In `CommandBarDialog.tsx`, the `useEffect` that resets state when the modal opens had `dispatch` in its dependency array:
```tsx
useEffect(() => {
  if (modal.visible) {
    dispatch({ type: 'RESET', page });
  }
}, [modal.visible, page, dispatch]); // dispatch changes → useEffect runs → RESET
```

This created an infinite loop:
1. User types → state updates → `dispatch` recreated
2. `dispatch` changed → `useEffect` runs → `RESET` dispatched
3. State resets to empty → repeat

**Fix:** Made `dispatch` stable by using refs instead of state in dependencies:
```tsx
const stateRef = useRef<CommandBarState>(browsing(initialPage));
const repoCountRef = useRef(repoCount);

const dispatch = useCallback(
  (event: CommandBarEvent): CommandBarEffect => {
    const [, effect] = reducer(stateRef.current, event, repoCountRef.current);
    rawDispatch(event);
    return effect;
  },
  [] // No dependencies - uses refs for current values
);
```

## Files Changed
1. `vibe-kanban/frontend/src/components/ui-new/primitives/Command.tsx` - Removed nested `<Command>` wrapper from `CommandDialog`
2. `vibe-kanban/frontend/src/components/ui-new/primitives/CommandBar.tsx` - Added styling classes that were on the removed wrapper
3. `vibe-kanban/frontend/src/components/ui-new/dialogs/commandBar/useCommandBarState.ts` - Made `dispatch` stable using refs
2026-01-09 16:25:42 +00:00

154 lines
5.1 KiB
TypeScript

import { CaretLeftIcon, CopyIcon, FolderIcon } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
} from './Command';
import type { ActionDefinition, ActionIcon } from '../actions';
import { isSpecialIcon } from '../actions';
import type { ResolvedGroup, ResolvedGroupItem } from '../actions/pages';
import { IdeIcon } from '@/components/ide/IdeIcon';
/**
* Render an action icon, handling special icon types
*/
function ActionItemIcon({ icon }: { icon: ActionIcon }) {
if (isSpecialIcon(icon)) {
if (icon === 'ide-icon') {
return <IdeIcon className="h-4 w-4" />;
}
if (icon === 'copy-icon') {
return <CopyIcon className="h-4 w-4" weight="regular" />;
}
}
// Regular phosphor icon
const IconComponent = icon;
return <IconComponent className="h-4 w-4" weight="regular" />;
}
// Resolved page structure with pre-processed groups
interface ResolvedCommandBarPage {
id: string;
title?: string;
groups: ResolvedGroup[];
}
interface CommandBarProps {
// Resolved page with groups already processed
page: ResolvedCommandBarPage;
// Whether back navigation is available
canGoBack: boolean;
// Called when user clicks back
onGoBack: () => void;
// Called when user selects an item (action or page)
onSelect: (item: ResolvedGroupItem) => void;
// Get resolved label for an action
getLabel: (action: ActionDefinition) => string;
// Controlled search value
search: string;
// Called when search changes
onSearchChange: (search: string) => void;
}
export function CommandBar({
page,
canGoBack,
onGoBack,
onSelect,
getLabel,
search,
onSearchChange,
}: CommandBarProps) {
const { t } = useTranslation('common');
return (
<Command
className="rounded-sm border border-border [&_[cmdk-group-heading]]:px-base [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-low [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-half [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-base [&_[cmdk-item]]:py-half"
loop
filter={(value, search) => {
// Always show the back option
if (value === '__back__') return 1;
// Default filtering for other items
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
>
<div className="flex items-center border-b border-border">
<CommandInput
placeholder={page.title || t('commandBar.defaultPlaceholder')}
value={search}
onValueChange={onSearchChange}
/>
</div>
<CommandList>
<CommandEmpty>{t('commandBar.noResults')}</CommandEmpty>
{canGoBack && !search && (
<CommandGroup>
<CommandItem value="__back__" onSelect={onGoBack}>
<CaretLeftIcon className="h-4 w-4" weight="bold" />
<span>{t('commandBar.back')}</span>
</CommandItem>
</CommandGroup>
)}
{/* Render groups directly - order is explicit from page definition */}
{page.groups.map((group) => (
<CommandGroup key={group.label} heading={group.label}>
{group.items.map((item) => {
if (item.type === 'page') {
const IconComponent = item.icon;
return (
<CommandItem
key={item.pageId}
value={item.pageId}
onSelect={() => onSelect(item)}
>
<IconComponent className="h-4 w-4" weight="regular" />
<span>{item.label}</span>
</CommandItem>
);
} else if (item.type === 'repo') {
return (
<CommandItem
key={item.repo.id}
value={`${item.repo.id} ${item.repo.display_name}`}
onSelect={() => onSelect(item)}
>
<FolderIcon className="h-4 w-4" weight="regular" />
<span>{item.repo.display_name}</span>
</CommandItem>
);
} else if (item.type === 'action') {
const label = getLabel(item.action);
return (
<CommandItem
key={item.action.id}
value={`${item.action.id} ${label}`}
onSelect={() => onSelect(item)}
className={
item.action.variant === 'destructive'
? 'text-error'
: undefined
}
>
<ActionItemIcon icon={item.action.icon} />
<span>{label}</span>
{item.action.shortcut && (
<CommandShortcut>{item.action.shortcut}</CommandShortcut>
)}
</CommandItem>
);
}
return null;
})}
</CommandGroup>
))}
</CommandList>
</Command>
);
}