From 6e3421c820ecf270a5ad9590d849f73d9656f809 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Fri, 9 Jan 2026 16:25:42 +0000 Subject: [PATCH] Fix CommandBar input and navigation issues with state machine refactor (Vibe Kanban) (#1885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `` Components The `CommandDialog` component was wrapping children in a `` component, and `CommandBar` also rendered its own ``. This nested cmdk context caused conflicts with state management. **Fix:** Removed the `` wrapper from `CommandDialog` in `Command.tsx:26-44`, and moved the styling classes to `CommandBar`'s `` 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(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 `` 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 --- .../src/components/ui-new/actions/index.ts | 120 +++---- .../src/components/ui-new/actions/pages.ts | 19 +- .../ui-new/actions/useActionVisibility.ts | 24 +- .../ui-new/containers/ContextBarContainer.tsx | 51 +-- .../ui-new/containers/WorkspacesLayout.tsx | 6 +- .../ui-new/dialogs/CommandBarDialog.tsx | 310 +++--------------- .../dialogs/commandBar/injectSearchMatches.ts | 51 +++ .../dialogs/commandBar/useCommandBarState.ts | 145 ++++++++ .../dialogs/commandBar/useResolvedPage.ts | 105 ++++++ .../components/ui-new/primitives/Command.tsx | 4 +- .../ui-new/primitives/CommandBar.tsx | 15 +- .../ui-new/scope/NewDesignScope.tsx | 28 +- .../ui-new/views/WorkspacesMain.tsx | 5 +- frontend/src/contexts/ActionsContext.tsx | 48 ++- frontend/src/utils/TruncatePath.tsx | 33 +- 15 files changed, 523 insertions(+), 441 deletions(-) create mode 100644 frontend/src/components/ui-new/dialogs/commandBar/injectSearchMatches.ts create mode 100644 frontend/src/components/ui-new/dialogs/commandBar/useCommandBarState.ts create mode 100644 frontend/src/components/ui-new/dialogs/commandBar/useResolvedPage.ts diff --git a/frontend/src/components/ui-new/actions/index.ts b/frontend/src/components/ui-new/actions/index.ts index 0a3f6e76..5d76efef 100644 --- a/frontend/src/components/ui-new/actions/index.ts +++ b/frontend/src/components/ui-new/actions/index.ts @@ -62,20 +62,13 @@ export type DevServerState = 'stopped' | 'starting' | 'running' | 'stopping'; export interface ActionExecutorContext { navigate: NavigateFunction; queryClient: QueryClient; - // Optional workspace selection context (for archive action) - selectWorkspace?: (workspaceId: string) => void; - activeWorkspaces?: SidebarWorkspace[]; - // Current workspace ID (for actions that optionally use workspace context) - currentWorkspaceId?: string; - - // ContextBar-specific state (optional, only set in ContextBar context) - containerRef?: string; // For copy path (workspace.container_ref) - runningDevServerId?: string; // For stopping dev server - startDevServer?: () => void; // For starting dev server with mutation tracking - stopDevServer?: () => void; // For stopping dev server with mutation tracking - - // Git-specific state (optional, only set when clicking from a specific RepoCard) - gitRepoId?: string; + selectWorkspace: (workspaceId: string) => void; + activeWorkspaces: SidebarWorkspace[]; + currentWorkspaceId: string | null; + containerRef: string | null; + runningDevServerId: string | null; + startDevServer: () => void; + stopDevServer: () => void; } // Context for evaluating action visibility and state conditions @@ -98,10 +91,10 @@ export interface ActionVisibilityContext { diffViewMode: DiffViewMode; isAllDiffsExpanded: boolean; - // ContextBar-specific state (optional) - editorType?: EditorType | null; - devServerState?: DevServerState; - runningDevServerId?: string; + // Dev server state + editorType: EditorType | null; + devServerState: DevServerState; + runningDevServerId: string | null; // Git panel state hasGitRepos: boolean; @@ -144,10 +137,21 @@ export interface WorkspaceActionDefinition extends ActionBase { ) => Promise | void; } +// Git action (requires workspace + repoId) +export interface GitActionDefinition extends ActionBase { + requiresTarget: 'git'; + execute: ( + ctx: ActionExecutorContext, + workspaceId: string, + repoId: string + ) => Promise | void; +} + // Discriminated union export type ActionDefinition = | GlobalActionDefinition - | WorkspaceActionDefinition; + | WorkspaceActionDefinition + | GitActionDefinition; // Helper to get workspace from query cache function getWorkspaceFromCache( @@ -233,9 +237,9 @@ export const Actions = { const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId); const wasArchived = workspace.archived; - // Calculate next workspace before archiving (if we have the context) + // Calculate next workspace before archiving let nextWorkspaceId: string | null = null; - if (!wasArchived && ctx.selectWorkspace && ctx.activeWorkspaces) { + if (!wasArchived) { const currentIndex = ctx.activeWorkspaces.findIndex( (ws) => ws.id === workspaceId ); @@ -252,7 +256,7 @@ export const Actions = { invalidateWorkspaceQueries(ctx.queryClient, workspaceId); // Select next workspace after successful archive - if (!wasArchived && nextWorkspaceId && ctx.selectWorkspace) { + if (!wasArchived && nextWorkspaceId) { ctx.selectWorkspace(nextWorkspaceId); } }, @@ -289,7 +293,6 @@ export const Actions = { id: 'new-workspace', label: 'New Workspace', icon: PlusIcon, - shortcut: 'N', requiresTarget: false, execute: (ctx) => { ctx.navigate('/workspaces/create'); @@ -300,7 +303,6 @@ export const Actions = { id: 'settings', label: 'Settings', icon: GearIcon, - shortcut: ',', requiresTarget: false, execute: (ctx) => { ctx.navigate('/settings'); @@ -364,7 +366,6 @@ export const Actions = { ? 'Hide Sidebar' : 'Show Sidebar', icon: SidebarSimpleIcon, - shortcut: '[', requiresTarget: false, isActive: (ctx) => ctx.isSidebarVisible, execute: () => { @@ -394,7 +395,6 @@ export const Actions = { ? 'Hide Git Panel' : 'Show Git Panel', icon: SidebarSimpleIcon, - shortcut: ']', requiresTarget: false, isActive: (ctx) => ctx.isGitPanelVisible, execute: () => { @@ -409,7 +409,6 @@ export const Actions = { ? 'Hide Changes Panel' : 'Show Changes Panel', icon: GitDiffIcon, - shortcut: 'C', requiresTarget: false, isVisible: (ctx) => !ctx.isCreateMode, isActive: (ctx) => ctx.isChangesMode, @@ -426,7 +425,6 @@ export const Actions = { ? 'Hide Logs Panel' : 'Show Logs Panel', icon: TerminalIcon, - shortcut: 'L', requiresTarget: false, isVisible: (ctx) => !ctx.isCreateMode, isActive: (ctx) => ctx.isLogsMode, @@ -443,7 +441,6 @@ export const Actions = { ? 'Hide Preview Panel' : 'Show Preview Panel', icon: DesktopIcon, - shortcut: 'P', requiresTarget: false, isVisible: (ctx) => !ctx.isCreateMode, isActive: (ctx) => ctx.isPreviewMode, @@ -589,9 +586,9 @@ export const Actions = { getLabel: (ctx) => ctx.devServerState === 'running' ? 'Stop Dev Server' : 'Start Dev Server', execute: (ctx) => { - if (ctx.runningDevServerId && ctx.stopDevServer) { + if (ctx.runningDevServerId) { ctx.stopDevServer(); - } else if (ctx.startDevServer) { + } else { ctx.startDevServer(); } }, @@ -602,18 +599,9 @@ export const Actions = { id: 'git-create-pr', label: 'Create Pull Request', icon: GitPullRequestIcon, - requiresTarget: true, + requiresTarget: 'git', isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos, - execute: async (ctx, workspaceId) => { - // Get repoId - either from context (RepoCard click) or fetch repos - let repoId = ctx.gitRepoId; - if (!repoId) { - const repos = await attemptsApi.getRepos(workspaceId); - if (repos.length === 0) throw new Error('No repositories found'); - if (repos.length > 1) throw new Error('Please select a repository'); - repoId = repos[0].id; - } - + execute: async (ctx, workspaceId, repoId) => { const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId); const task = await tasksApi.getById(workspace.task_id); @@ -638,18 +626,9 @@ export const Actions = { id: 'git-merge', label: 'Merge', icon: GitMergeIcon, - requiresTarget: true, + requiresTarget: 'git', isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos, - execute: async (ctx, workspaceId) => { - // Get repoId - either from context (RepoCard click) or fetch repos - let repoId = ctx.gitRepoId; - if (!repoId) { - const repos = await attemptsApi.getRepos(workspaceId); - if (repos.length === 0) throw new Error('No repositories found'); - if (repos.length > 1) throw new Error('Please select a repository'); - repoId = repos[0].id; - } - + execute: async (ctx, workspaceId, repoId) => { const confirmResult = await ConfirmDialog.show({ title: 'Merge Branch', message: @@ -669,27 +648,17 @@ export const Actions = { id: 'git-rebase', label: 'Rebase', icon: ArrowsClockwiseIcon, - requiresTarget: true, + requiresTarget: 'git', isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos, - execute: async (ctx, workspaceId) => { - // Fetch repos to get target_branch info + execute: async (_ctx, workspaceId, repoId) => { const repos = await attemptsApi.getRepos(workspaceId); - if (repos.length === 0) throw new Error('No repositories found'); + const repo = repos.find((r) => r.id === repoId); + if (!repo) throw new Error('Repository not found'); - // Get repo - either from context (RepoCard click) or use first repo - let repo = ctx.gitRepoId - ? repos.find((r) => r.id === ctx.gitRepoId) - : repos[0]; - - if (!repo) { - if (repos.length > 1) throw new Error('Please select a repository'); - repo = repos[0]; - } - - const branches = await repoApi.getBranches(repo.id); + const branches = await repoApi.getBranches(repoId); await RebaseDialog.show({ attemptId: workspaceId, - repoId: repo.id, + repoId, branches, initialTargetBranch: repo.target_branch, }); @@ -700,18 +669,9 @@ export const Actions = { id: 'git-change-target', label: 'Change Target Branch', icon: CrosshairIcon, - requiresTarget: true, + requiresTarget: 'git', isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos, - execute: async (ctx, workspaceId) => { - // Get repoId - either from context (RepoCard click) or fetch repos - let repoId = ctx.gitRepoId; - if (!repoId) { - const repos = await attemptsApi.getRepos(workspaceId); - if (repos.length === 0) throw new Error('No repositories found'); - if (repos.length > 1) throw new Error('Please select a repository'); - repoId = repos[0].id; - } - + execute: async (_ctx, workspaceId, repoId) => { const branches = await repoApi.getBranches(repoId); await ChangeTargetDialog.show({ attemptId: workspaceId, diff --git a/frontend/src/components/ui-new/actions/pages.ts b/frontend/src/components/ui-new/actions/pages.ts index 49c24305..9b7eb637 100644 --- a/frontend/src/components/ui-new/actions/pages.ts +++ b/frontend/src/components/ui-new/actions/pages.ts @@ -8,7 +8,8 @@ export type PageId = | 'workspaceActions' | 'diffOptions' | 'viewOptions' - | 'gitActions'; + | 'gitActions' + | 'selectRepo'; // Dynamic page for repo selection (not in Pages record) // Items that can appear inside a group export type CommandBarGroupItem = @@ -26,10 +27,17 @@ export interface CommandBarGroup { // Top-level items in a page are groups export type CommandBarItem = CommandBarGroup; +// Repo item for dynamic repo selection page +export interface RepoItem { + id: string; + display_name: string; +} + // Resolved types (after childPages expansion) export type ResolvedGroupItem = | { type: 'action'; action: ActionDefinition } - | { type: 'page'; pageId: PageId; label: string; icon: Icon }; + | { type: 'page'; pageId: PageId; label: string; icon: Icon } + | { type: 'repo'; repo: RepoItem }; export interface ResolvedGroup { label: string; @@ -46,7 +54,10 @@ export interface CommandBarPage { isVisible?: (ctx: ActionVisibilityContext) => boolean; } -export const Pages: Record = { +// Static page IDs (excludes dynamic pages like selectRepo) +export type StaticPageId = Exclude; + +export const Pages: Record = { // Root page - shown when opening via CMD+K root: { id: 'root', @@ -164,7 +175,7 @@ export const Pages: Record = { }; // Get all actions from a specific page -export function getPageActions(pageId: PageId): ActionDefinition[] { +export function getPageActions(pageId: StaticPageId): ActionDefinition[] { const page = Pages[pageId]; const actions: ActionDefinition[] = []; diff --git a/frontend/src/components/ui-new/actions/useActionVisibility.ts b/frontend/src/components/ui-new/actions/useActionVisibility.ts index 6550faed..ef1dd1e1 100644 --- a/frontend/src/components/ui-new/actions/useActionVisibility.ts +++ b/frontend/src/components/ui-new/actions/useActionVisibility.ts @@ -3,11 +3,14 @@ import { useLayoutStore } from '@/stores/useLayoutStore'; import { useDiffViewStore, useDiffViewMode } from '@/stores/useDiffViewStore'; import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import { useUserSystem } from '@/components/ConfigProvider'; +import { useDevServer } from '@/hooks/useDevServer'; import type { Workspace } from 'shared/types'; import type { ActionVisibilityContext, ActionDefinition, ActionIcon, + DevServerState, } from './index'; import { resolveLabel } from './index'; import type { CommandBarPage } from './pages'; @@ -19,10 +22,13 @@ import type { CommandBarPage } from './pages'; */ export function useActionVisibilityContext(): ActionVisibilityContext { const layout = useLayoutStore(); - const { workspace, isCreateMode, repos } = useWorkspaceContext(); + const { workspace, workspaceId, isCreateMode, repos } = useWorkspaceContext(); const diffPaths = useDiffViewStore((s) => s.diffPaths); const diffViewMode = useDiffViewMode(); const expanded = useUiPreferencesStore((s) => s.expanded); + const { config } = useUserSystem(); + const { isStarting, isStopping, runningDevServer } = + useDevServer(workspaceId); return useMemo(() => { // Compute isAllDiffsExpanded @@ -30,6 +36,15 @@ export function useActionVisibilityContext(): ActionVisibilityContext { const isAllDiffsExpanded = diffKeys.length > 0 && diffKeys.every((k) => expanded[k] !== false); + // Compute dev server state + const devServerState: DevServerState = isStarting + ? 'starting' + : isStopping + ? 'stopping' + : runningDevServer + ? 'running' + : 'stopped'; + return { isChangesMode: layout.isChangesMode, isLogsMode: layout.isLogsMode, @@ -43,6 +58,9 @@ export function useActionVisibilityContext(): ActionVisibilityContext { hasDiffs: diffPaths.length > 0, diffViewMode, isAllDiffsExpanded, + editorType: config?.editor?.editor_type ?? null, + devServerState, + runningDevServerId: runningDevServer?.id ?? null, hasGitRepos: repos.length > 0, hasMultipleRepos: repos.length > 1, }; @@ -59,6 +77,10 @@ export function useActionVisibilityContext(): ActionVisibilityContext { diffPaths, diffViewMode, expanded, + config?.editor?.editor_type, + isStarting, + isStopping, + runningDevServer, ]); } diff --git a/frontend/src/components/ui-new/containers/ContextBarContainer.tsx b/frontend/src/components/ui-new/containers/ContextBarContainer.tsx index 4742f67e..6809107e 100644 --- a/frontend/src/components/ui-new/containers/ContextBarContainer.tsx +++ b/frontend/src/components/ui-new/containers/ContextBarContainer.tsx @@ -1,15 +1,12 @@ import { useMemo, useCallback, type RefObject } from 'react'; import { useActions } from '@/contexts/ActionsContext'; -import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; import { useUserSystem } from '@/components/ConfigProvider'; -import { useDevServer } from '@/hooks/useDevServer'; import { ContextBar } from '../primitives/ContextBar'; import { ContextBarActionGroups, type ActionDefinition, type ActionVisibilityContext, type ContextBarItem, - type DevServerState, } from '../actions'; import { useActionVisibilityContext, @@ -60,62 +57,26 @@ function filterContextBarItems( export interface ContextBarContainerProps { containerRef: RefObject; - containerPath?: string; // workspace.container_ref for copy path } export function ContextBarContainer({ containerRef, - containerPath, }: ContextBarContainerProps) { const { executorContext } = useActions(); - const { workspaceId } = useWorkspaceContext(); const { config } = useUserSystem(); const editorType = config?.editor?.editor_type ?? null; - // Dev server state from hook (uses workspaceId from context) - const { start, stop, isStarting, isStopping, runningDevServer } = - useDevServer(workspaceId); + // Get visibility context (now includes dev server state) + const actionCtx = useActionVisibilityContext(); - // Compute dev server state - const devServerState: DevServerState = useMemo(() => { - if (isStarting) return 'starting'; - if (isStopping) return 'stopping'; - if (runningDevServer) return 'running'; - return 'stopped'; - }, [isStarting, isStopping, runningDevServer]); - - // Build extended visibility context - const baseCtx = useActionVisibilityContext(); - const actionCtx = useMemo( - () => ({ - ...baseCtx, - editorType, - devServerState, - runningDevServerId: runningDevServer?.id, - }), - [baseCtx, editorType, devServerState, runningDevServer?.id] - ); - - // Build extended executor context with ContextBar-specific data - const extendedExecutorCtx = useMemo( - () => ({ - ...executorContext, - containerRef: containerPath, - runningDevServerId: runningDevServer?.id, - startDevServer: start, - stopDevServer: stop, - }), - [executorContext, containerPath, runningDevServer?.id, start, stop] - ); - - // Action handler - pass extended context + // Action handler - use executor context directly from provider const handleExecuteAction = useCallback( async (action: ActionDefinition) => { - if (!action.requiresTarget) { - await action.execute(extendedExecutorCtx); + if (action.requiresTarget === false) { + await action.execute(executorContext); } }, - [extendedExecutorCtx] + [executorContext] ); // Filter visible actions diff --git a/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx b/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx index 26cb9d00..c6a2686e 100644 --- a/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx +++ b/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx @@ -105,10 +105,8 @@ function GitPanelContainer({ const actionDef = actionMap[action]; if (!actionDef) return; - // Execute action with pre-selected repoId - await executeAction(actionDef, selectedWorkspace.id, { - gitRepoId: repoId, - }); + // Execute git action with workspaceId and repoId + await executeAction(actionDef, selectedWorkspace.id, repoId); }, [selectedWorkspace, executeAction] ); diff --git a/frontend/src/components/ui-new/dialogs/CommandBarDialog.tsx b/frontend/src/components/ui-new/dialogs/CommandBarDialog.tsx index e6ef590c..fa307fc5 100644 --- a/frontend/src/components/ui-new/dialogs/CommandBarDialog.tsx +++ b/frontend/src/components/ui-new/dialogs/CommandBarDialog.tsx @@ -1,12 +1,6 @@ -import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { useRef, useEffect, useCallback } from 'react'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { useQueryClient } from '@tanstack/react-query'; -import { - StackIcon, - SlidersIcon, - SquaresFourIcon, - GitBranchIcon, -} from '@phosphor-icons/react'; import type { Workspace } from 'shared/types'; import { defineModal } from '@/lib/modals'; import { CommandDialog } from '@/components/ui-new/primitives/Command'; @@ -14,34 +8,16 @@ import { CommandBar } from '@/components/ui-new/primitives/CommandBar'; import { useActions } from '@/contexts/ActionsContext'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; import { attemptKeys } from '@/hooks/useAttempt'; -import { type ActionDefinition } from '@/components/ui-new/actions'; -import { - Pages, - getPageActions, - type PageId, - type CommandBarGroup, - type CommandBarGroupItem, - type ResolvedGroup, - type ResolvedGroupItem, +import type { + PageId, + ResolvedGroupItem, } from '@/components/ui-new/actions/pages'; -import { resolveLabel } from '@/components/ui-new/actions'; -import { - useActionVisibilityContext, - isActionVisible, - isPageVisible, -} from '@/components/ui-new/actions/useActionVisibility'; - -// Resolved page structure passed to CommandBar -interface ResolvedCommandBarPage { - id: string; - title?: string; - groups: ResolvedGroup[]; -} +import { useActionVisibilityContext } from '@/components/ui-new/actions/useActionVisibility'; +import { useCommandBarState } from './commandBar/useCommandBarState'; +import { useResolvedPage } from './commandBar/useResolvedPage'; export interface CommandBarDialogProps { - // Page to show (defaults to 'root') page?: PageId; - // Optional workspaceId for workspace actions workspaceId?: string; } @@ -51,276 +27,74 @@ const CommandBarDialogImpl = NiceModal.create( const previousFocusRef = useRef(null); const queryClient = useQueryClient(); const { executeAction, getLabel } = useActions(); - const { workspaceId: contextWorkspaceId } = useWorkspaceContext(); - - // Use prop workspaceId if provided, otherwise fall back to context - const effectiveWorkspaceId = workspaceId ?? contextWorkspaceId; - - // Get visibility context for filtering actions + const { workspaceId: contextWorkspaceId, repos } = useWorkspaceContext(); const visibilityContext = useActionVisibilityContext(); - // Page navigation state (lifted from CommandBar) - const [currentPage, setCurrentPage] = useState(page); - const [pageStack, setPageStack] = useState([]); - // Search state - cleared when page changes - const [search, setSearch] = useState(''); - - // Reset page state when dialog opens - useEffect(() => { - if (modal.visible) { - setCurrentPage(page); - setPageStack([]); - setSearch(''); - } - }, [modal.visible, page]); - - // Clear search when navigating to a new page - useEffect(() => { - setSearch(''); - }, [currentPage]); - - // Get workspace from cache for label resolution + const effectiveWorkspaceId = workspaceId ?? contextWorkspaceId; const workspace = effectiveWorkspaceId ? queryClient.getQueryData( attemptKeys.byId(effectiveWorkspaceId) ) : undefined; - // Build resolved page by processing childPages markers within groups - // When searching on root page, also include actions from nested pages - const getPageWithItems = useMemo(() => { - return (pageId: PageId, searchQuery: string): ResolvedCommandBarPage => { - const basePage = Pages[pageId]; + // State machine + const { state, currentPage, canGoBack, dispatch } = useCommandBarState( + page, + repos.length + ); - // Process each group, expanding childPages markers within - const resolvedGroups: ResolvedGroup[] = basePage.items - .map((group: CommandBarGroup): ResolvedGroup | null => { - const resolvedItems = group.items.flatMap( - (item: CommandBarGroupItem): ResolvedGroupItem[] => { - if (item.type === 'childPages') { - const childPage = Pages[item.id]; - // Check page visibility condition - if (!isPageVisible(childPage, visibilityContext)) { - return []; - } - // Get icon based on page type - const pageIcons: Record = { - root: SquaresFourIcon, - workspaceActions: StackIcon, - diffOptions: SlidersIcon, - viewOptions: SquaresFourIcon, - gitActions: GitBranchIcon, - }; - return [ - { - type: 'page' as const, - pageId: item.id, - label: childPage.title ?? item.id, - icon: pageIcons[item.id], - }, - ]; - } - // For action items, filter by visibility condition - if (item.type === 'action') { - if (!isActionVisible(item.action, visibilityContext)) { - return []; - } - } - // action or page items pass through - return [item]; - } - ); - - // Return null for empty groups (will be filtered out) - if (resolvedItems.length === 0) { - return null; - } - - return { - label: group.label, - items: resolvedItems, - }; - }) - .filter((group): group is ResolvedGroup => group !== null); - - // When searching on root page, inject matching actions from nested pages - if (pageId === 'root' && searchQuery.trim()) { - const searchLower = searchQuery.toLowerCase(); - - // Inject workspace actions if workspace is available - if (visibilityContext.hasWorkspace) { - const workspaceActions = getPageActions('workspaceActions'); - const matchingWorkspaceActions = workspaceActions - .filter((action) => isActionVisible(action, visibilityContext)) - .filter((action) => { - const label = resolveLabel(action, workspace); - return ( - label.toLowerCase().includes(searchLower) || - action.id.toLowerCase().includes(searchLower) - ); - }); - - if (matchingWorkspaceActions.length > 0) { - resolvedGroups.push({ - label: Pages.workspaceActions.title || 'Workspace Actions', - items: matchingWorkspaceActions.map((action) => ({ - type: 'action' as const, - action, - })), - }); - } - } - - // Inject diff options (filtered by visibility) - const diffActions = getPageActions('diffOptions'); - const matchingDiffActions = diffActions - .filter((action) => isActionVisible(action, visibilityContext)) - .filter((action) => { - const label = resolveLabel(action, workspace); - return ( - label.toLowerCase().includes(searchLower) || - action.id.toLowerCase().includes(searchLower) - ); - }); - - if (matchingDiffActions.length > 0) { - resolvedGroups.push({ - label: Pages.diffOptions.title || 'Diff Options', - items: matchingDiffActions.map((action) => ({ - type: 'action' as const, - action, - })), - }); - } - - // Inject view options (filtered by visibility) - const viewActions = getPageActions('viewOptions'); - const matchingViewActions = viewActions - .filter((action) => isActionVisible(action, visibilityContext)) - .filter((action) => { - const label = resolveLabel(action, workspace); - return ( - label.toLowerCase().includes(searchLower) || - action.id.toLowerCase().includes(searchLower) - ); - }); - - if (matchingViewActions.length > 0) { - resolvedGroups.push({ - label: Pages.viewOptions.title || 'View Options', - items: matchingViewActions.map((action) => ({ - type: 'action' as const, - action, - })), - }); - } - - // Inject git actions if workspace has git repos - if (visibilityContext.hasGitRepos) { - const gitActions = getPageActions('gitActions'); - const matchingGitActions = gitActions - .filter((action) => isActionVisible(action, visibilityContext)) - .filter((action) => { - const label = resolveLabel(action, workspace); - return ( - label.toLowerCase().includes(searchLower) || - action.id.toLowerCase().includes(searchLower) - ); - }); - - if (matchingGitActions.length > 0) { - resolvedGroups.push({ - label: Pages.gitActions.title || 'Git Actions', - items: matchingGitActions.map((action) => ({ - type: 'action' as const, - action, - })), - }); - } - } - } - - return { - id: basePage.id, - title: basePage.title, - groups: resolvedGroups, - }; - }; - }, [visibilityContext, workspace]); - - // Store the previously focused element when dialog opens + // Reset state and capture focus when dialog opens useEffect(() => { if (modal.visible) { + dispatch({ type: 'RESET', page }); previousFocusRef.current = document.activeElement as HTMLElement; } - }, [modal.visible]); + }, [modal.visible, page, dispatch]); - // Navigate to another page - const navigateToPage = useCallback( - (pageId: PageId) => { - setPageStack((prev) => [...prev, currentPage]); - setCurrentPage(pageId); - }, - [currentPage] + // Resolve current page to renderable data + const resolvedPage = useResolvedPage( + currentPage, + state.search, + visibilityContext, + workspace, + repos ); - // Go back to previous page - const goBack = useCallback(() => { - const prevPage = pageStack[pageStack.length - 1]; - if (prevPage) { - setPageStack((prev) => prev.slice(0, -1)); - setCurrentPage(prevPage); - } - }, [pageStack]); - - // Handle item selection + // Handle item selection with side effects const handleSelect = useCallback( - async (item: ResolvedGroupItem) => { - if (item.type === 'page') { - navigateToPage(item.pageId); - } else if (item.type === 'action') { + (item: ResolvedGroupItem) => { + const effect = dispatch({ type: 'SELECT_ITEM', item }); + + if (effect.type === 'execute') { modal.hide(); - await executeAction(item.action, effectiveWorkspaceId); + const repoId = + effect.repoId === '__single__' ? repos[0]?.id : effect.repoId; + executeAction(effect.action, effectiveWorkspaceId, repoId); } }, - [navigateToPage, modal, executeAction, effectiveWorkspaceId] + [dispatch, modal, executeAction, effectiveWorkspaceId, repos] ); - // Get label for an action (with visibility context for dynamic labels) - const getLabelForAction = useCallback( - (action: ActionDefinition) => - getLabel(action, workspace, visibilityContext), - [getLabel, workspace, visibilityContext] - ); - - const handleOpenChange = (open: boolean) => { - if (!open) { - modal.hide(); - } - }; - - // Restore focus to previously focused element when dialog closes - const handleCloseAutoFocus = (event: Event) => { + // Restore focus when dialog closes + const handleCloseAutoFocus = useCallback((event: Event) => { event.preventDefault(); previousFocusRef.current?.focus(); - }; - - const canGoBack = pageStack.length > 0; + }, []); return ( !open && modal.hide()} onCloseAutoFocus={handleCloseAutoFocus} > dispatch({ type: 'GO_BACK' })} onSelect={handleSelect} - getLabel={getLabelForAction} - search={search} - onSearchChange={setSearch} + getLabel={(action) => getLabel(action, workspace, visibilityContext)} + search={state.search} + onSearchChange={(query) => dispatch({ type: 'SEARCH_CHANGE', query })} /> ); diff --git a/frontend/src/components/ui-new/dialogs/commandBar/injectSearchMatches.ts b/frontend/src/components/ui-new/dialogs/commandBar/injectSearchMatches.ts new file mode 100644 index 00000000..a27aa251 --- /dev/null +++ b/frontend/src/components/ui-new/dialogs/commandBar/injectSearchMatches.ts @@ -0,0 +1,51 @@ +import type { Workspace } from 'shared/types'; +import { + Pages, + getPageActions, + type StaticPageId, + type ResolvedGroup, +} from '@/components/ui-new/actions/pages'; +import { + resolveLabel, + type ActionVisibilityContext, +} from '@/components/ui-new/actions'; +import { isActionVisible } from '@/components/ui-new/actions/useActionVisibility'; + +const INJECTABLE_PAGES: Array<{ + id: StaticPageId; + condition: (ctx: ActionVisibilityContext) => boolean; +}> = [ + { id: 'workspaceActions', condition: (ctx) => ctx.hasWorkspace }, + { id: 'diffOptions', condition: () => true }, + { id: 'viewOptions', condition: () => true }, + { id: 'gitActions', condition: (ctx) => ctx.hasGitRepos }, +]; + +export function injectSearchMatches( + searchQuery: string, + ctx: ActionVisibilityContext, + workspace: Workspace | undefined +): ResolvedGroup[] { + const searchLower = searchQuery.toLowerCase(); + + return INJECTABLE_PAGES.reduce( + (groups, { id, condition }) => { + if (!condition(ctx)) return groups; + + const items = getPageActions(id) + .filter((a) => isActionVisible(a, ctx)) + .filter((a) => { + const label = resolveLabel(a, workspace); + return ( + label.toLowerCase().includes(searchLower) || + a.id.toLowerCase().includes(searchLower) + ); + }) + .map((action) => ({ type: 'action' as const, action })); + + if (items.length) groups.push({ label: Pages[id].title || id, items }); + return groups; + }, + [] + ); +} diff --git a/frontend/src/components/ui-new/dialogs/commandBar/useCommandBarState.ts b/frontend/src/components/ui-new/dialogs/commandBar/useCommandBarState.ts new file mode 100644 index 00000000..d6a9ed84 --- /dev/null +++ b/frontend/src/components/ui-new/dialogs/commandBar/useCommandBarState.ts @@ -0,0 +1,145 @@ +import { useReducer, useCallback, useRef } from 'react'; +import type { + PageId, + ResolvedGroupItem, +} from '@/components/ui-new/actions/pages'; +import type { + ActionDefinition, + GitActionDefinition, +} from '@/components/ui-new/actions'; + +export type CommandBarState = + | { status: 'browsing'; page: PageId; stack: PageId[]; search: string } + | { + status: 'selectingRepo'; + stack: PageId[]; + search: string; + pendingAction: GitActionDefinition; + }; + +export type CommandBarEvent = + | { type: 'RESET'; page: PageId } + | { type: 'SEARCH_CHANGE'; query: string } + | { type: 'GO_BACK' } + | { type: 'SELECT_ITEM'; item: ResolvedGroupItem }; + +export type CommandBarEffect = + | { type: 'none' } + | { type: 'execute'; action: ActionDefinition; repoId?: string }; + +const browsing = (page: PageId, stack: PageId[] = []): CommandBarState => ({ + status: 'browsing', + page, + stack, + search: '', +}); + +const noEffect: CommandBarEffect = { type: 'none' }; + +function reducer( + state: CommandBarState, + event: CommandBarEvent, + repoCount: number +): [CommandBarState, CommandBarEffect] { + if (event.type === 'RESET') { + return [browsing(event.page), noEffect]; + } + if (event.type === 'SEARCH_CHANGE') { + return [{ ...state, search: event.query }, noEffect]; + } + if (event.type === 'GO_BACK') { + const prevPage = state.stack[state.stack.length - 1]; + if (state.status === 'browsing' && !prevPage) return [state, noEffect]; + return [browsing(prevPage ?? 'root', state.stack.slice(0, -1)), noEffect]; + } + + if (event.type === 'SELECT_ITEM') { + if (state.status === 'selectingRepo' && event.item.type === 'repo') { + return [ + browsing('root'), + { + type: 'execute', + action: state.pendingAction, + repoId: event.item.repo.id, + }, + ]; + } + + if (state.status === 'browsing') { + const { item } = event; + if (item.type === 'page') { + return [ + { + ...state, + page: item.pageId, + stack: [...state.stack, state.page], + search: '', + }, + noEffect, + ]; + } + if (item.type === 'action') { + if (item.action.requiresTarget === 'git') { + if (repoCount === 1) { + return [ + state, + { type: 'execute', action: item.action, repoId: '__single__' }, + ]; + } + if (repoCount > 1) { + return [ + { + status: 'selectingRepo', + stack: [...state.stack, state.page], + search: '', + pendingAction: item.action as GitActionDefinition, + }, + noEffect, + ]; + } + } + return [state, { type: 'execute', action: item.action }]; + } + } + } + + return [state, noEffect]; +} + +export function useCommandBarState(initialPage: PageId, repoCount: number) { + // Use refs to avoid stale closures and keep dispatch stable + const stateRef = useRef(browsing(initialPage)); + const repoCountRef = useRef(repoCount); + repoCountRef.current = repoCount; + + const [state, rawDispatch] = useReducer( + (s: CommandBarState, e: CommandBarEvent) => { + const [newState] = reducer(s, e, repoCountRef.current); + stateRef.current = newState; + return newState; + }, + browsing(initialPage) + ); + + // Keep stateRef in sync + stateRef.current = state; + + // Stable dispatch that doesn't change on every render + 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 + ); + + return { + state, + currentPage: (state.status === 'selectingRepo' + ? 'selectRepo' + : state.page) as PageId, + canGoBack: state.stack.length > 0, + dispatch, + }; +} diff --git a/frontend/src/components/ui-new/dialogs/commandBar/useResolvedPage.ts b/frontend/src/components/ui-new/dialogs/commandBar/useResolvedPage.ts new file mode 100644 index 00000000..81d41aa6 --- /dev/null +++ b/frontend/src/components/ui-new/dialogs/commandBar/useResolvedPage.ts @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; +import { + StackIcon, + SlidersIcon, + SquaresFourIcon, + GitBranchIcon, +} from '@phosphor-icons/react'; +import type { Workspace } from 'shared/types'; +import { + Pages, + type PageId, + type StaticPageId, + type CommandBarGroupItem, + type ResolvedGroup, + type ResolvedGroupItem, + type RepoItem, +} from '@/components/ui-new/actions/pages'; +import type { ActionVisibilityContext } from '@/components/ui-new/actions'; +import { + isActionVisible, + isPageVisible, +} from '@/components/ui-new/actions/useActionVisibility'; +import { injectSearchMatches } from './injectSearchMatches'; + +export interface ResolvedCommandBarPage { + id: string; + title?: string; + groups: ResolvedGroup[]; +} + +const PAGE_ICONS = { + root: SquaresFourIcon, + workspaceActions: StackIcon, + diffOptions: SlidersIcon, + viewOptions: SquaresFourIcon, + gitActions: GitBranchIcon, +} as const satisfies Record; + +function expandGroupItems( + items: CommandBarGroupItem[], + ctx: ActionVisibilityContext +): ResolvedGroupItem[] { + return items.flatMap((item) => { + if (item.type === 'childPages') { + const page = Pages[item.id as StaticPageId]; + if (!isPageVisible(page, ctx)) return []; + return [ + { + type: 'page' as const, + pageId: item.id, + label: page.title ?? item.id, + icon: PAGE_ICONS[item.id as StaticPageId], + }, + ]; + } + if (item.type === 'action' && !isActionVisible(item.action, ctx)) return []; + return [item]; + }); +} + +function buildPageGroups( + pageId: StaticPageId, + ctx: ActionVisibilityContext +): ResolvedGroup[] { + return Pages[pageId].items + .map((group) => { + const items = expandGroupItems(group.items, ctx); + return items.length ? { label: group.label, items } : null; + }) + .filter((g): g is ResolvedGroup => g !== null); +} + +export function useResolvedPage( + pageId: PageId, + search: string, + ctx: ActionVisibilityContext, + workspace: Workspace | undefined, + repos: RepoItem[] +): ResolvedCommandBarPage { + return useMemo(() => { + if (pageId === 'selectRepo') { + return { + id: 'selectRepo', + title: 'Select Repository', + groups: [ + { + label: 'Repositories', + items: repos.map((r) => ({ type: 'repo' as const, repo: r })), + }, + ], + }; + } + + const groups = buildPageGroups(pageId as StaticPageId, ctx); + if (pageId === 'root' && search.trim()) { + groups.push(...injectSearchMatches(search, ctx, workspace)); + } + + return { + id: Pages[pageId as StaticPageId].id, + title: Pages[pageId as StaticPageId].title, + groups, + }; + }, [pageId, search, ctx, workspace, repos]); +} diff --git a/frontend/src/components/ui-new/primitives/Command.tsx b/frontend/src/components/ui-new/primitives/Command.tsx index 2f33f210..66714a46 100644 --- a/frontend/src/components/ui-new/primitives/Command.tsx +++ b/frontend/src/components/ui-new/primitives/Command.tsx @@ -35,9 +35,7 @@ function CommandDialog({ hideCloseButton onCloseAutoFocus={onCloseAutoFocus} > - - {children} - + {children} ); diff --git a/frontend/src/components/ui-new/primitives/CommandBar.tsx b/frontend/src/components/ui-new/primitives/CommandBar.tsx index 7092d033..8b54ea30 100644 --- a/frontend/src/components/ui-new/primitives/CommandBar.tsx +++ b/frontend/src/components/ui-new/primitives/CommandBar.tsx @@ -1,4 +1,4 @@ -import { CaretLeftIcon, CopyIcon } from '@phosphor-icons/react'; +import { CaretLeftIcon, CopyIcon, FolderIcon } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { Command, @@ -68,7 +68,7 @@ export function CommandBar({ return ( { // Always show the back option @@ -111,6 +111,17 @@ export function CommandBar({ {item.label} ); + } else if (item.type === 'repo') { + return ( + onSelect(item)} + > + + {item.repo.display_name} + + ); } else if (item.type === 'action') { const label = getLabel(item.action); return ( diff --git a/frontend/src/components/ui-new/scope/NewDesignScope.tsx b/frontend/src/components/ui-new/scope/NewDesignScope.tsx index f85f1111..cd24ee16 100644 --- a/frontend/src/components/ui-new/scope/NewDesignScope.tsx +++ b/frontend/src/components/ui-new/scope/NewDesignScope.tsx @@ -1,7 +1,11 @@ import { ReactNode, useRef } from 'react'; import { PortalContainerContext } from '@/contexts/PortalContainerContext'; -import { WorkspaceProvider } from '@/contexts/WorkspaceContext'; +import { + WorkspaceProvider, + useWorkspaceContext, +} from '@/contexts/WorkspaceContext'; import { ActionsProvider } from '@/contexts/ActionsContext'; +import { ExecutionProcessesProvider } from '@/contexts/ExecutionProcessesContext'; import NiceModal from '@ebay/nice-modal-react'; import '@/styles/new/index.css'; @@ -9,15 +13,31 @@ interface NewDesignScopeProps { children: ReactNode; } +// Wrapper component to get workspaceId from context for ExecutionProcessesProvider +function ExecutionProcessesProviderWrapper({ + children, +}: { + children: ReactNode; +}) { + const { workspaceId } = useWorkspaceContext(); + return ( + + {children} + + ); +} + export function NewDesignScope({ children }: NewDesignScopeProps) { const ref = useRef(null); return (
- - {children} - + + + {children} + +
diff --git a/frontend/src/components/ui-new/views/WorkspacesMain.tsx b/frontend/src/components/ui-new/views/WorkspacesMain.tsx index cf24ee97..d63c0e90 100644 --- a/frontend/src/components/ui-new/views/WorkspacesMain.tsx +++ b/frontend/src/components/ui-new/views/WorkspacesMain.tsx @@ -103,10 +103,7 @@ export function WorkspacesMain({ {/* Context Bar - floating toolbar */} {workspaceWithSession && ( - + )} ); diff --git a/frontend/src/contexts/ActionsContext.tsx b/frontend/src/contexts/ActionsContext.tsx index 0428222b..7088c313 100644 --- a/frontend/src/contexts/ActionsContext.tsx +++ b/frontend/src/contexts/ActionsContext.tsx @@ -17,13 +17,14 @@ import { } from '@/components/ui-new/actions'; import { getActionLabel } from '@/components/ui-new/actions/useActionVisibility'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import { useDevServer } from '@/hooks/useDevServer'; interface ActionsContextValue { - // Execute an action with optional workspaceId and context override + // Execute an action with optional workspaceId and repoId (for git actions) executeAction: ( action: ActionDefinition, workspaceId?: string, - contextOverride?: Partial + repoId?: string ) => Promise; // Get resolved label for an action (supports dynamic labels via visibility context) @@ -47,9 +48,12 @@ export function ActionsProvider({ children }: ActionsProviderProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); // Get workspace context (ActionsProvider is nested inside WorkspaceProvider) - const { selectWorkspace, activeWorkspaces, workspaceId } = + const { selectWorkspace, activeWorkspaces, workspaceId, workspace } = useWorkspaceContext(); + // Get dev server state + const { start, stop, runningDevServer } = useDevServer(workspaceId); + // Build executor context from hooks const executorContext = useMemo( () => ({ @@ -57,9 +61,23 @@ export function ActionsProvider({ children }: ActionsProviderProps) { queryClient, selectWorkspace, activeWorkspaces, - currentWorkspaceId: workspaceId, + currentWorkspaceId: workspaceId ?? null, + containerRef: workspace?.container_ref ?? null, + runningDevServerId: runningDevServer?.id ?? null, + startDevServer: start, + stopDevServer: stop, }), - [navigate, queryClient, selectWorkspace, activeWorkspaces, workspaceId] + [ + navigate, + queryClient, + selectWorkspace, + activeWorkspaces, + workspaceId, + workspace?.container_ref, + runningDevServer?.id, + start, + stop, + ] ); // Main action executor with centralized target validation and error handling @@ -67,23 +85,25 @@ export function ActionsProvider({ children }: ActionsProviderProps) { async ( action: ActionDefinition, workspaceId?: string, - contextOverride?: Partial + repoId?: string ): Promise => { try { - // Merge context with any overrides (e.g., gitRepoId from GitPanelContainer) - const ctx = contextOverride - ? { ...executorContext, ...contextOverride } - : executorContext; - - if (action.requiresTarget) { + if (action.requiresTarget === 'git') { + if (!workspaceId || !repoId) { + throw new Error( + `Action "${action.id}" requires both workspace and repository` + ); + } + await action.execute(executorContext, workspaceId, repoId); + } else if (action.requiresTarget === true) { if (!workspaceId) { throw new Error( `Action "${action.id}" requires a workspace target` ); } - await action.execute(ctx, workspaceId); + await action.execute(executorContext, workspaceId); } else { - await action.execute(ctx); + await action.execute(executorContext); } } catch (error) { // Show error to user via alert dialog diff --git a/frontend/src/utils/TruncatePath.tsx b/frontend/src/utils/TruncatePath.tsx index ef681cd6..b1213c69 100644 --- a/frontend/src/utils/TruncatePath.tsx +++ b/frontend/src/utils/TruncatePath.tsx @@ -1,7 +1,5 @@ import { cn } from '@/lib/utils'; -const ELIPSIS_N = 6; - export function DisplayTruncatedPath({ path }: { path: string }) { const isWindows = path.includes('\\'); const parts = isWindows ? path.split('\\') : path.split('/'); @@ -9,16 +7,17 @@ export function DisplayTruncatedPath({ path }: { path: string }) { return (
- {[...Array(ELIPSIS_N)].map((_, i) => ( -
- ... -
- ))} + + + + + + + + + + + {parts.reverse().map((part, index) => ( {isWindows ? '\\' : '/'} @@ -29,3 +28,13 @@ export function DisplayTruncatedPath({ path }: { path: string }) {
); } + +const ElipsisComponent = ({ className }: { className: string }) => { + return ( +
+ ... +
+ ); +};