Files
vibe-kanban/frontend/src/components/ui-new/actions/index.ts
Louis Knight-Webb 4a6e556ed2 Simplify WorkspaceSummary card hover UI with opacity fade and single action button (Vibe Kanban) (#2059)
* All changes are complete. Here's a summary of what was changed:

## Summary of Changes

### `WorkspaceSummary.tsx`
1. **Title truncation**: Replaced `truncate` class with CSS mask gradient that fades to transparent (no ellipsis dots)
2. **Simplified hover actions**: Removed pin and archive buttons, keeping only the "more options" (ListIcon) button
3. **Removed sliding animation**: The action button now uses simple opacity transition (`opacity-0 group-hover:opacity-100`) instead of the sliding animation
4. **Removed unused code**: `handleArchive`, `handlePin` functions and `ArchiveIcon` import removed
5. **Removed unused props**: `onArchive` and `onPin` props removed from interface

### `WorkspacesSidebar.tsx`
- Removed `onArchiveWorkspace` and `onPinWorkspace` props from interface and destructuring

### `WorkspacesSidebarContainer.tsx`
- Removed `handleArchiveWorkspace` and `handlePinWorkspace` callbacks
- Removed unused imports (`useCallback`, `useActions`, `Actions`)

All type checks pass. The pin and archive functionality is still accessible through the command bar (more options button).

* Cleanup script changes for workspace 262a1c1d-87ae-40f5-a36d-f43758d18fed

* Done. Changed `ListIcon` to `DotsThreeIcon` with `weight="bold"` for the more options button.

* Done. Changed the icon size from `size-icon-xs` to `size-5` (20px) while keeping the bold weight.

* Done. Added the `ArchiveWorkspace` action to the left side of the navbar in `NavbarActionGroups.left`.
2026-01-15 10:53:12 +00:00

1005 lines
30 KiB
TypeScript

import { forwardRef, createElement } from 'react';
import type { Icon, IconProps } from '@phosphor-icons/react';
import type { NavigateFunction } from 'react-router-dom';
import type { QueryClient } from '@tanstack/react-query';
import type { EditorType, ExecutionProcess, Workspace } from 'shared/types';
import type { DiffViewMode } from '@/stores/useDiffViewStore';
import {
CopyIcon,
PushPinIcon,
ArchiveIcon,
TrashIcon,
PlusIcon,
GearIcon,
ColumnsIcon,
RowsIcon,
TextAlignLeftIcon,
EyeSlashIcon,
SidebarSimpleIcon,
ChatsTeardropIcon,
GitDiffIcon,
TerminalIcon,
SignOutIcon,
CaretDoubleUpIcon,
CaretDoubleDownIcon,
PlayIcon,
PauseIcon,
SpinnerIcon,
GitPullRequestIcon,
GitMergeIcon,
ArrowsClockwiseIcon,
CrosshairIcon,
DesktopIcon,
PencilSimpleIcon,
ArrowUpIcon,
HighlighterIcon,
ListIcon,
MegaphoneIcon,
QuestionIcon,
} from '@phosphor-icons/react';
import { useDiffViewStore } from '@/stores/useDiffViewStore';
import {
useUiPreferencesStore,
RIGHT_MAIN_PANEL_MODES,
} from '@/stores/useUiPreferencesStore';
import { attemptsApi, tasksApi, repoApi } from '@/lib/api';
import { attemptKeys } from '@/hooks/useAttempt';
import { taskKeys } from '@/hooks/useTask';
import { workspaceSummaryKeys } from '@/components/ui-new/hooks/useWorkspaces';
import { ConfirmDialog } from '@/components/ui-new/dialogs/ConfirmDialog';
import { ChangeTargetDialog } from '@/components/ui-new/dialogs/ChangeTargetDialog';
import { RebaseDialog } from '@/components/ui-new/dialogs/RebaseDialog';
import { ResolveConflictsDialog } from '@/components/ui-new/dialogs/ResolveConflictsDialog';
import { RenameWorkspaceDialog } from '@/components/ui-new/dialogs/RenameWorkspaceDialog';
import { CreatePRDialog } from '@/components/dialogs/tasks/CreatePRDialog';
import { getIdeName } from '@/components/ide/IdeIcon';
import { EditorSelectionDialog } from '@/components/dialogs/tasks/EditorSelectionDialog';
import { StartReviewDialog } from '@/components/dialogs/tasks/StartReviewDialog';
import posthog from 'posthog-js';
import { WorkspacesGuideDialog } from '@/components/ui-new/dialogs/WorkspacesGuideDialog';
// Mirrored sidebar icon for right sidebar toggle
const RightSidebarIcon: Icon = forwardRef<SVGSVGElement, IconProps>(
(props, ref) =>
createElement(SidebarSimpleIcon, {
ref,
...props,
style: { transform: 'scaleX(-1)', ...props.style },
})
);
RightSidebarIcon.displayName = 'RightSidebarIcon';
// Special icon types for ContextBar
export type SpecialIconType = 'ide-icon' | 'copy-icon';
export type ActionIcon = Icon | SpecialIconType;
// Workspace type for sidebar (minimal subset needed for workspace selection)
interface SidebarWorkspace {
id: string;
}
// Dev server state type for visibility context
export type DevServerState = 'stopped' | 'starting' | 'running' | 'stopping';
// Context provided to action executors (from React hooks)
export interface ActionExecutorContext {
navigate: NavigateFunction;
queryClient: QueryClient;
selectWorkspace: (workspaceId: string) => void;
activeWorkspaces: SidebarWorkspace[];
currentWorkspaceId: string | null;
containerRef: string | null;
runningDevServers: ExecutionProcess[];
startDevServer: () => void;
stopDevServer: () => void;
}
// Context for evaluating action visibility and state conditions
export interface ActionVisibilityContext {
// Layout state
rightMainPanelMode:
| (typeof RIGHT_MAIN_PANEL_MODES)[keyof typeof RIGHT_MAIN_PANEL_MODES]
| null;
isLeftSidebarVisible: boolean;
isLeftMainPanelVisible: boolean;
isRightSidebarVisible: boolean;
isCreateMode: boolean;
// Workspace state
hasWorkspace: boolean;
workspaceArchived: boolean;
// Diff state
hasDiffs: boolean;
diffViewMode: DiffViewMode;
isAllDiffsExpanded: boolean;
// Dev server state
editorType: EditorType | null;
devServerState: DevServerState;
runningDevServers: ExecutionProcess[];
// Git panel state
hasGitRepos: boolean;
hasMultipleRepos: boolean;
hasOpenPR: boolean;
hasUnpushedCommits: boolean;
// Execution state
isAttemptRunning: boolean;
}
// Base properties shared by all actions
interface ActionBase {
id: string;
label: string | ((workspace?: Workspace) => string);
icon: ActionIcon;
shortcut?: string;
variant?: 'default' | 'destructive';
// Optional visibility condition - if omitted, action is always visible
isVisible?: (ctx: ActionVisibilityContext) => boolean;
// Optional active state - if omitted, action is not active
isActive?: (ctx: ActionVisibilityContext) => boolean;
// Optional enabled state - if omitted, action is enabled
isEnabled?: (ctx: ActionVisibilityContext) => boolean;
// Optional dynamic icon - if omitted, uses static icon property
getIcon?: (ctx: ActionVisibilityContext) => ActionIcon;
// Optional dynamic tooltip - if omitted, uses label
getTooltip?: (ctx: ActionVisibilityContext) => string;
// Optional dynamic label - if omitted, uses static label property
getLabel?: (ctx: ActionVisibilityContext) => string;
}
// Global action (no target needed)
export interface GlobalActionDefinition extends ActionBase {
requiresTarget: false;
execute: (ctx: ActionExecutorContext) => Promise<void> | void;
}
// Workspace action (target required - validated by ActionsContext)
export interface WorkspaceActionDefinition extends ActionBase {
requiresTarget: true;
execute: (
ctx: ActionExecutorContext,
workspaceId: string
) => Promise<void> | void;
}
// Git action (requires workspace + repoId)
export interface GitActionDefinition extends ActionBase {
requiresTarget: 'git';
execute: (
ctx: ActionExecutorContext,
workspaceId: string,
repoId: string
) => Promise<void> | void;
}
// Discriminated union
export type ActionDefinition =
| GlobalActionDefinition
| WorkspaceActionDefinition
| GitActionDefinition;
// Helper to get workspace from query cache or fetch from API
async function getWorkspace(
queryClient: QueryClient,
workspaceId: string
): Promise<Workspace> {
const cached = queryClient.getQueryData<Workspace>(
attemptKeys.byId(workspaceId)
);
if (cached) {
return cached;
}
// Fetch from API if not in cache
return attemptsApi.get(workspaceId);
}
// Helper to invalidate workspace-related queries
function invalidateWorkspaceQueries(
queryClient: QueryClient,
workspaceId: string
) {
queryClient.invalidateQueries({ queryKey: attemptKeys.byId(workspaceId) });
queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });
}
// Helper to find the next workspace to navigate to when removing current workspace
function getNextWorkspaceId(
activeWorkspaces: SidebarWorkspace[],
removingWorkspaceId: string
): string | null {
const currentIndex = activeWorkspaces.findIndex(
(ws) => ws.id === removingWorkspaceId
);
if (currentIndex >= 0 && activeWorkspaces.length > 1) {
const nextWorkspace =
activeWorkspaces[currentIndex + 1] || activeWorkspaces[currentIndex - 1];
return nextWorkspace?.id ?? null;
}
return null;
}
// All application actions
export const Actions = {
// === Workspace Actions ===
DuplicateWorkspace: {
id: 'duplicate-workspace',
label: 'Duplicate',
icon: CopyIcon,
requiresTarget: true,
execute: async (ctx, workspaceId) => {
try {
const firstMessage = await attemptsApi.getFirstUserMessage(workspaceId);
ctx.navigate('/workspaces/create', {
state: { duplicatePrompt: firstMessage },
});
} catch {
// Fallback to creating without the prompt
ctx.navigate('/workspaces/create');
}
},
},
RenameWorkspace: {
id: 'rename-workspace',
label: 'Rename',
icon: PencilSimpleIcon,
requiresTarget: true,
execute: async (ctx, workspaceId) => {
const workspace = await getWorkspace(ctx.queryClient, workspaceId);
await RenameWorkspaceDialog.show({
workspaceId,
currentName: workspace.name || workspace.branch,
});
},
},
PinWorkspace: {
id: 'pin-workspace',
label: (workspace?: Workspace) => (workspace?.pinned ? 'Unpin' : 'Pin'),
icon: PushPinIcon,
requiresTarget: true,
execute: async (ctx, workspaceId) => {
const workspace = await getWorkspace(ctx.queryClient, workspaceId);
await attemptsApi.update(workspaceId, {
pinned: !workspace.pinned,
});
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
},
},
ArchiveWorkspace: {
id: 'archive-workspace',
label: (workspace?: Workspace) =>
workspace?.archived ? 'Unarchive' : 'Archive',
icon: ArchiveIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace,
isActive: (ctx) => ctx.workspaceArchived,
execute: async (ctx, workspaceId) => {
const workspace = await getWorkspace(ctx.queryClient, workspaceId);
const wasArchived = workspace.archived;
// Calculate next workspace before archiving
const nextWorkspaceId = !wasArchived
? getNextWorkspaceId(ctx.activeWorkspaces, workspaceId)
: null;
// Perform the archive/unarchive
await attemptsApi.update(workspaceId, { archived: !wasArchived });
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
// Select next workspace after successful archive
if (!wasArchived && nextWorkspaceId) {
ctx.selectWorkspace(nextWorkspaceId);
}
},
},
DeleteWorkspace: {
id: 'delete-workspace',
label: 'Delete',
icon: TrashIcon,
variant: 'destructive',
requiresTarget: true,
execute: async (ctx, workspaceId) => {
const workspace = await getWorkspace(ctx.queryClient, workspaceId);
const result = await ConfirmDialog.show({
title: 'Delete Workspace',
message:
'Are you sure you want to delete this workspace? This action cannot be undone.',
confirmText: 'Delete',
cancelText: 'Cancel',
variant: 'destructive',
});
if (result === 'confirmed') {
// Calculate next workspace before deleting (only if deleting current)
const isCurrentWorkspace = ctx.currentWorkspaceId === workspaceId;
const nextWorkspaceId = isCurrentWorkspace
? getNextWorkspaceId(ctx.activeWorkspaces, workspaceId)
: null;
await tasksApi.delete(workspace.task_id);
ctx.queryClient.invalidateQueries({ queryKey: taskKeys.all });
ctx.queryClient.invalidateQueries({
queryKey: workspaceSummaryKeys.all,
});
// Navigate away if we deleted the current workspace
if (isCurrentWorkspace) {
if (nextWorkspaceId) {
ctx.selectWorkspace(nextWorkspaceId);
} else {
ctx.navigate('/workspaces/create');
}
}
}
},
},
StartReview: {
id: 'start-review',
label: 'Start Review',
icon: HighlighterIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace,
getTooltip: () => 'Ask the agent to review your changes',
execute: async (_ctx, workspaceId) => {
await StartReviewDialog.show({
workspaceId,
});
},
},
// === Global/Navigation Actions ===
NewWorkspace: {
id: 'new-workspace',
label: 'New Workspace',
icon: PlusIcon,
requiresTarget: false,
execute: (ctx) => {
ctx.navigate('/workspaces/create');
},
},
Settings: {
id: 'settings',
label: 'Settings',
icon: GearIcon,
requiresTarget: false,
execute: (ctx) => {
ctx.navigate('/settings');
},
},
Feedback: {
id: 'feedback',
label: 'Give Feedback',
icon: MegaphoneIcon,
requiresTarget: false,
execute: () => {
posthog.displaySurvey('019bb6e8-3d36-0000-1806-7330cd3c727e');
},
},
WorkspacesGuide: {
id: 'workspaces-guide',
label: 'Workspaces Guide',
icon: QuestionIcon,
requiresTarget: false,
execute: async () => {
await WorkspacesGuideDialog.show();
},
},
OpenCommandBar: {
id: 'open-command-bar',
label: 'Open Command Bar',
icon: ListIcon,
requiresTarget: false,
execute: async () => {
// Dynamic import to avoid circular dependency (pages.ts imports Actions)
const { CommandBarDialog } = await import(
'@/components/ui-new/dialogs/CommandBarDialog'
);
CommandBarDialog.show();
},
},
// === Diff View Actions ===
ToggleDiffViewMode: {
id: 'toggle-diff-view-mode',
label: () =>
useDiffViewStore.getState().mode === 'unified'
? 'Switch to Side-by-Side View'
: 'Switch to Inline View',
icon: ColumnsIcon,
requiresTarget: false,
isVisible: (ctx) =>
ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES,
isActive: (ctx) => ctx.diffViewMode === 'split',
getIcon: (ctx) => (ctx.diffViewMode === 'split' ? ColumnsIcon : RowsIcon),
getTooltip: (ctx) =>
ctx.diffViewMode === 'split' ? 'Inline view' : 'Side-by-side view',
execute: () => {
useDiffViewStore.getState().toggle();
},
},
ToggleIgnoreWhitespace: {
id: 'toggle-ignore-whitespace',
label: () =>
useDiffViewStore.getState().ignoreWhitespace
? 'Show Whitespace Changes'
: 'Ignore Whitespace Changes',
icon: EyeSlashIcon,
requiresTarget: false,
isVisible: (ctx) =>
ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES,
execute: () => {
const store = useDiffViewStore.getState();
store.setIgnoreWhitespace(!store.ignoreWhitespace);
},
},
ToggleWrapLines: {
id: 'toggle-wrap-lines',
label: () =>
useDiffViewStore.getState().wrapText
? 'Disable Line Wrapping'
: 'Enable Line Wrapping',
icon: TextAlignLeftIcon,
requiresTarget: false,
isVisible: (ctx) =>
ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES,
execute: () => {
const store = useDiffViewStore.getState();
store.setWrapText(!store.wrapText);
},
},
// === Layout Panel Actions ===
ToggleLeftSidebar: {
id: 'toggle-left-sidebar',
label: () =>
useUiPreferencesStore.getState().isLeftSidebarVisible
? 'Hide Left Sidebar'
: 'Show Left Sidebar',
icon: SidebarSimpleIcon,
requiresTarget: false,
isActive: (ctx) => ctx.isLeftSidebarVisible,
execute: () => {
useUiPreferencesStore.getState().toggleLeftSidebar();
},
},
ToggleLeftMainPanel: {
id: 'toggle-left-main-panel',
label: () =>
useUiPreferencesStore.getState().isLeftMainPanelVisible
? 'Hide Chat Panel'
: 'Show Chat Panel',
icon: ChatsTeardropIcon,
requiresTarget: false,
isActive: (ctx) => ctx.isLeftMainPanelVisible,
isEnabled: (ctx) =>
!(ctx.isLeftMainPanelVisible && ctx.rightMainPanelMode === null),
execute: () => {
useUiPreferencesStore.getState().toggleLeftMainPanel();
},
},
ToggleRightSidebar: {
id: 'toggle-right-sidebar',
label: () =>
useUiPreferencesStore.getState().isRightSidebarVisible
? 'Hide Right Sidebar'
: 'Show Right Sidebar',
icon: RightSidebarIcon,
requiresTarget: false,
isActive: (ctx) => ctx.isRightSidebarVisible,
execute: () => {
useUiPreferencesStore.getState().toggleRightSidebar();
},
},
ToggleChangesMode: {
id: 'toggle-changes-mode',
label: () =>
useUiPreferencesStore.getState().rightMainPanelMode ===
RIGHT_MAIN_PANEL_MODES.CHANGES
? 'Hide Changes Panel'
: 'Show Changes Panel',
icon: GitDiffIcon,
requiresTarget: false,
isVisible: (ctx) => !ctx.isCreateMode,
isActive: (ctx) =>
ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES,
isEnabled: (ctx) => !ctx.isCreateMode,
execute: () => {
useUiPreferencesStore
.getState()
.toggleRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.CHANGES);
},
},
ToggleLogsMode: {
id: 'toggle-logs-mode',
label: () =>
useUiPreferencesStore.getState().rightMainPanelMode ===
RIGHT_MAIN_PANEL_MODES.LOGS
? 'Hide Logs Panel'
: 'Show Logs Panel',
icon: TerminalIcon,
requiresTarget: false,
isVisible: (ctx) => !ctx.isCreateMode,
isActive: (ctx) => ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS,
isEnabled: (ctx) => !ctx.isCreateMode,
execute: () => {
useUiPreferencesStore
.getState()
.toggleRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS);
},
},
TogglePreviewMode: {
id: 'toggle-preview-mode',
label: () =>
useUiPreferencesStore.getState().rightMainPanelMode ===
RIGHT_MAIN_PANEL_MODES.PREVIEW
? 'Hide Preview Panel'
: 'Show Preview Panel',
icon: DesktopIcon,
requiresTarget: false,
isVisible: (ctx) => !ctx.isCreateMode,
isActive: (ctx) =>
ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW,
isEnabled: (ctx) => !ctx.isCreateMode,
execute: () => {
useUiPreferencesStore
.getState()
.toggleRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.PREVIEW);
},
},
// === Navigation Actions ===
OpenInOldUI: {
id: 'open-in-old-ui',
label: 'Open in Old UI',
icon: SignOutIcon,
requiresTarget: false,
execute: async (ctx) => {
// If no workspace is selected, navigate to root
if (!ctx.currentWorkspaceId) {
ctx.navigate('/');
return;
}
const workspace = await getWorkspace(
ctx.queryClient,
ctx.currentWorkspaceId
);
if (!workspace?.task_id) {
ctx.navigate('/');
return;
}
// Fetch task lazily to get project_id
const task = await tasksApi.getById(workspace.task_id);
if (task?.project_id) {
ctx.navigate(`/projects/${task.project_id}/tasks/${workspace.task_id}`);
} else {
ctx.navigate('/');
}
},
},
// === Diff Actions for Navbar ===
ToggleAllDiffs: {
id: 'toggle-all-diffs',
label: () => {
const { diffPaths } = useDiffViewStore.getState();
const { expanded } = useUiPreferencesStore.getState();
const keys = diffPaths.map((p) => `diff:${p}`);
const isAllExpanded =
keys.length > 0 && keys.every((k) => expanded[k] !== false);
return isAllExpanded ? 'Collapse All Diffs' : 'Expand All Diffs';
},
icon: CaretDoubleUpIcon,
requiresTarget: false,
isVisible: (ctx) =>
ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES,
getIcon: (ctx) =>
ctx.isAllDiffsExpanded ? CaretDoubleUpIcon : CaretDoubleDownIcon,
getTooltip: (ctx) =>
ctx.isAllDiffsExpanded ? 'Collapse all diffs' : 'Expand all diffs',
execute: () => {
const { diffPaths } = useDiffViewStore.getState();
const { expanded, setExpandedAll } = useUiPreferencesStore.getState();
const keys = diffPaths.map((p) => `diff:${p}`);
const isAllExpanded =
keys.length > 0 && keys.every((k) => expanded[k] !== false);
setExpandedAll(keys, !isAllExpanded);
},
},
// === ContextBar Actions ===
OpenInIDE: {
id: 'open-in-ide',
label: 'Open in IDE',
icon: 'ide-icon' as const,
requiresTarget: false,
isVisible: (ctx) => ctx.hasWorkspace,
getTooltip: (ctx) => `Open in ${getIdeName(ctx.editorType)}`,
execute: async (ctx) => {
if (!ctx.currentWorkspaceId) return;
try {
const response = await attemptsApi.openEditor(ctx.currentWorkspaceId, {
editor_type: null,
file_path: null,
});
if (response.url) {
window.open(response.url, '_blank');
}
} catch {
// Show editor selection dialog on failure
EditorSelectionDialog.show({
selectedAttemptId: ctx.currentWorkspaceId,
});
}
},
},
CopyPath: {
id: 'copy-path',
label: 'Copy path',
icon: 'copy-icon' as const,
requiresTarget: false,
isVisible: (ctx) => ctx.hasWorkspace,
execute: async (ctx) => {
if (!ctx.containerRef) return;
await navigator.clipboard.writeText(ctx.containerRef);
},
},
ToggleDevServer: {
id: 'toggle-dev-server',
label: 'Dev Server',
icon: PlayIcon,
requiresTarget: false,
isVisible: (ctx) => ctx.hasWorkspace,
isEnabled: (ctx) =>
ctx.devServerState !== 'starting' && ctx.devServerState !== 'stopping',
getIcon: (ctx) => {
if (
ctx.devServerState === 'starting' ||
ctx.devServerState === 'stopping'
) {
return SpinnerIcon;
}
if (ctx.devServerState === 'running') {
return PauseIcon;
}
return PlayIcon;
},
getTooltip: (ctx) => {
switch (ctx.devServerState) {
case 'starting':
return 'Starting dev server...';
case 'stopping':
return 'Stopping dev server...';
case 'running':
return 'Stop dev server';
default:
return 'Start dev server';
}
},
getLabel: (ctx) =>
ctx.devServerState === 'running' ? 'Stop Dev Server' : 'Start Dev Server',
execute: (ctx) => {
if (ctx.runningDevServers.length > 0) {
ctx.stopDevServer();
} else {
ctx.startDevServer();
// Auto-open preview mode when starting dev server
useUiPreferencesStore
.getState()
.setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.PREVIEW);
}
},
},
// === Git Actions ===
GitCreatePR: {
id: 'git-create-pr',
label: 'Create Pull Request',
icon: GitPullRequestIcon,
requiresTarget: 'git',
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
execute: async (ctx, workspaceId, repoId) => {
const workspace = await getWorkspace(ctx.queryClient, workspaceId);
const task = await tasksApi.getById(workspace.task_id);
const repos = await attemptsApi.getRepos(workspaceId);
const repo = repos.find((r) => r.id === repoId);
const result = await CreatePRDialog.show({
attempt: workspace,
task: {
...task,
has_in_progress_attempt: false,
last_attempt_failed: false,
executor: '',
},
repoId,
targetBranch: repo?.target_branch,
});
if (!result.success && result.error) {
throw new Error(result.error);
}
},
},
GitMerge: {
id: 'git-merge',
label: 'Merge',
icon: GitMergeIcon,
requiresTarget: 'git',
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
execute: async (ctx, workspaceId, repoId) => {
// Check for existing conflicts first
const branchStatus = await attemptsApi.getBranchStatus(workspaceId);
const repoStatus = branchStatus?.find((s) => s.repo_id === repoId);
const hasConflicts =
repoStatus?.is_rebase_in_progress ||
(repoStatus?.conflicted_files?.length ?? 0) > 0;
if (hasConflicts && repoStatus) {
// Show resolve conflicts dialog
const workspace = await getWorkspace(ctx.queryClient, workspaceId);
const result = await ResolveConflictsDialog.show({
workspaceId,
conflictOp: repoStatus.conflict_op ?? 'merge',
sourceBranch: workspace.branch,
targetBranch: repoStatus.target_branch_name,
conflictedFiles: repoStatus.conflicted_files ?? [],
repoName: repoStatus.repo_name,
});
if (result.action === 'resolved') {
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
}
return;
}
// Check if branch is behind - need to rebase first
const commitsBehind = repoStatus?.commits_behind ?? 0;
if (commitsBehind > 0) {
// Prompt user to rebase first
const confirmRebase = await ConfirmDialog.show({
title: 'Rebase Required',
message: `Your branch is ${commitsBehind} commit${commitsBehind === 1 ? '' : 's'} behind the target branch. Would you like to rebase first?`,
confirmText: 'Rebase',
cancelText: 'Cancel',
});
if (confirmRebase === 'confirmed') {
// Trigger the rebase action
const repos = await attemptsApi.getRepos(workspaceId);
const repo = repos.find((r) => r.id === repoId);
if (!repo) throw new Error('Repository not found');
const branches = await repoApi.getBranches(repoId);
await RebaseDialog.show({
attemptId: workspaceId,
repoId,
branches,
initialTargetBranch: repo.target_branch,
});
}
return;
}
const confirmResult = await ConfirmDialog.show({
title: 'Merge Branch',
message:
'Are you sure you want to merge this branch into the target branch?',
confirmText: 'Merge',
cancelText: 'Cancel',
});
if (confirmResult === 'confirmed') {
await attemptsApi.merge(workspaceId, { repo_id: repoId });
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
}
},
},
GitRebase: {
id: 'git-rebase',
label: 'Rebase',
icon: ArrowsClockwiseIcon,
requiresTarget: 'git',
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
execute: async (ctx, workspaceId, repoId) => {
// Check for existing conflicts first
const branchStatus = await attemptsApi.getBranchStatus(workspaceId);
const repoStatus = branchStatus?.find((s) => s.repo_id === repoId);
const hasConflicts =
repoStatus?.is_rebase_in_progress ||
(repoStatus?.conflicted_files?.length ?? 0) > 0;
if (hasConflicts && repoStatus) {
// Show resolve conflicts dialog
const workspace = await getWorkspace(ctx.queryClient, workspaceId);
const result = await ResolveConflictsDialog.show({
workspaceId,
conflictOp: repoStatus.conflict_op ?? 'rebase',
sourceBranch: workspace.branch,
targetBranch: repoStatus.target_branch_name,
conflictedFiles: repoStatus.conflicted_files ?? [],
repoName: repoStatus.repo_name,
});
if (result.action === 'resolved') {
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
}
return;
}
const repos = await attemptsApi.getRepos(workspaceId);
const repo = repos.find((r) => r.id === repoId);
if (!repo) throw new Error('Repository not found');
const branches = await repoApi.getBranches(repoId);
await RebaseDialog.show({
attemptId: workspaceId,
repoId,
branches,
initialTargetBranch: repo.target_branch,
});
},
},
GitChangeTarget: {
id: 'git-change-target',
label: 'Change Target Branch',
icon: CrosshairIcon,
requiresTarget: 'git',
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
execute: async (_ctx, workspaceId, repoId) => {
const branches = await repoApi.getBranches(repoId);
await ChangeTargetDialog.show({
attemptId: workspaceId,
repoId,
branches,
});
},
},
GitPush: {
id: 'git-push',
label: 'Push',
icon: ArrowUpIcon,
requiresTarget: 'git',
isVisible: (ctx) =>
ctx.hasWorkspace &&
ctx.hasGitRepos &&
ctx.hasOpenPR &&
ctx.hasUnpushedCommits,
execute: async (ctx, workspaceId, repoId) => {
const result = await attemptsApi.push(workspaceId, { repo_id: repoId });
if (!result.success) {
if (result.error?.type === 'force_push_required') {
throw new Error(
'Force push required. The remote branch has diverged.'
);
}
throw new Error('Failed to push changes');
}
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
},
},
// === Script Actions ===
RunSetupScript: {
id: 'run-setup-script',
label: 'Run Setup Script',
icon: TerminalIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace,
isEnabled: (ctx) => !ctx.isAttemptRunning,
execute: async (_ctx, workspaceId) => {
const result = await attemptsApi.runSetupScript(workspaceId);
if (!result.success) {
if (result.error?.type === 'no_script_configured') {
throw new Error('No setup script configured for this project');
}
if (result.error?.type === 'process_already_running') {
throw new Error('Cannot run script while another process is running');
}
throw new Error('Failed to run setup script');
}
},
},
RunCleanupScript: {
id: 'run-cleanup-script',
label: 'Run Cleanup Script',
icon: TerminalIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace,
isEnabled: (ctx) => !ctx.isAttemptRunning,
execute: async (_ctx, workspaceId) => {
const result = await attemptsApi.runCleanupScript(workspaceId);
if (!result.success) {
if (result.error?.type === 'no_script_configured') {
throw new Error('No cleanup script configured for this project');
}
if (result.error?.type === 'process_already_running') {
throw new Error('Cannot run script while another process is running');
}
throw new Error('Failed to run cleanup script');
}
},
},
} as const satisfies Record<string, ActionDefinition>;
// Helper to resolve dynamic label
export function resolveLabel(
action: ActionDefinition,
workspace?: Workspace
): string {
return typeof action.label === 'function'
? action.label(workspace)
: action.label;
}
// Divider marker for navbar action groups
export const NavbarDivider = { type: 'divider' } as const;
export type NavbarItem = ActionDefinition | typeof NavbarDivider;
// Navbar action groups define which actions appear in each section
export const NavbarActionGroups = {
left: [Actions.ArchiveWorkspace, Actions.OpenInOldUI] as ActionDefinition[],
right: [
Actions.ToggleDiffViewMode,
Actions.ToggleAllDiffs,
NavbarDivider,
Actions.ToggleLeftSidebar,
Actions.ToggleLeftMainPanel,
Actions.ToggleChangesMode,
Actions.ToggleLogsMode,
Actions.TogglePreviewMode,
Actions.ToggleRightSidebar,
NavbarDivider,
Actions.OpenCommandBar,
Actions.Feedback,
Actions.WorkspacesGuide,
Actions.Settings,
] as NavbarItem[],
};
// Divider marker for context bar action groups
export const ContextBarDivider = { type: 'divider' } as const;
export type ContextBarItem = ActionDefinition | typeof ContextBarDivider;
// ContextBar action groups define which actions appear in each section
export const ContextBarActionGroups = {
primary: [Actions.OpenInIDE, Actions.CopyPath] as ActionDefinition[],
secondary: [
Actions.ToggleDevServer,
Actions.TogglePreviewMode,
Actions.ToggleChangesMode,
] as ActionDefinition[],
};
// Helper to check if an icon is a special type
export function isSpecialIcon(icon: ActionIcon): icon is SpecialIconType {
return icon === 'ide-icon' || icon === 'copy-icon';
}