From bfdd48b7ad17053c221d5aa8241f523d06efa7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 16 Feb 2026 22:54:28 +0000 Subject: [PATCH] feat: command panel (#235) --- apps/packages/ui/src/command.tsx | 5 +- apps/web/app/layout.tsx | 11 +- apps/web/components/command-panel.tsx | 327 ++++++++++ .../editors/tiptap/tiptap-plan-editor.tsx | 1 + apps/web/components/global-commands.tsx | 134 +++++ apps/web/components/homepage-commands.tsx | 77 +++ apps/web/components/kanban-board.tsx | 2 + apps/web/components/onboarding-dialog.tsx | 84 ++- apps/web/components/review/review-dialog.tsx | 3 - apps/web/components/review/review-top-bar.tsx | 3 - apps/web/components/session-commands.tsx | 191 ++++++ .../web/components/task/chat/tiptap-input.tsx | 2 +- .../task/dockview-desktop-layout.tsx | 6 - .../components/task/task-changes-panel.tsx | 8 - .../web/components/task/task-page-content.tsx | 15 + apps/web/components/task/task-top-bar.tsx | 2 - apps/web/components/toast-provider.tsx | 69 ++- apps/web/components/vcs-split-button.tsx | 558 ++++-------------- apps/web/components/vcs/vcs-dialogs.tsx | 293 +++++++++ apps/web/hooks/use-git-with-feedback.ts | 48 ++ apps/web/hooks/use-register-commands.ts | 19 + apps/web/lib/commands/command-registry.tsx | 91 +++ apps/web/lib/commands/types.ts | 19 + apps/web/lib/keyboard/constants.ts | 8 + 24 files changed, 1503 insertions(+), 473 deletions(-) create mode 100644 apps/web/components/command-panel.tsx create mode 100644 apps/web/components/global-commands.tsx create mode 100644 apps/web/components/homepage-commands.tsx create mode 100644 apps/web/components/session-commands.tsx create mode 100644 apps/web/components/vcs/vcs-dialogs.tsx create mode 100644 apps/web/hooks/use-git-with-feedback.ts create mode 100644 apps/web/hooks/use-register-commands.ts create mode 100644 apps/web/lib/commands/command-registry.tsx create mode 100644 apps/web/lib/commands/types.ts diff --git a/apps/packages/ui/src/command.tsx b/apps/packages/ui/src/command.tsx index 497a6ebce4..f76889a49e 100644 --- a/apps/packages/ui/src/command.tsx +++ b/apps/packages/ui/src/command.tsx @@ -39,12 +39,14 @@ function CommandDialog({ children, className, showCloseButton = false, + overlayClassName, ...props }: React.ComponentProps & { title?: string description?: string className?: string showCloseButton?: boolean + overlayClassName?: string }) { return ( @@ -53,8 +55,9 @@ function CommandDialog({ {description} {children} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f855a29d4a..dbc2e18d19 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,6 +5,9 @@ import { StateProvider } from "@/components/state-provider"; import { WebSocketConnector } from "@/components/ws-connector"; import { ToastProvider } from "@/components/toast-provider"; import { TooltipProvider } from "@kandev/ui/tooltip"; +import { CommandRegistryProvider } from "@/lib/commands/command-registry"; +import { CommandPanel } from "@/components/command-panel"; +import { GlobalCommands } from "@/components/global-commands"; export const metadata: Metadata = { title: "Kandev - AI Kanban", @@ -66,8 +69,12 @@ export default function RootLayout({ - - {children} + + + + + {children} + diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx new file mode 100644 index 0000000000..206c8ed6bd --- /dev/null +++ b/apps/web/components/command-panel.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { formatDistanceToNow } from 'date-fns'; +import { + IconArrowRight, + IconLoader2, + IconSearch, +} from '@tabler/icons-react'; +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandShortcut, +} from '@kandev/ui/command'; +import { Kbd, KbdGroup } from '@kandev/ui/kbd'; +import { Badge } from '@kandev/ui/badge'; +import { useCommands, useCommandPanelOpen } from '@/lib/commands/command-registry'; +import type { CommandPanelMode, CommandItem as CommandItemType } from '@/lib/commands/types'; +import { SHORTCUTS } from '@/lib/keyboard/constants'; +import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'; +import { formatShortcut } from '@/lib/keyboard/utils'; +import { useAppStore } from '@/components/state-provider'; +import { useToast } from '@/components/toast-provider'; +import { listTasksByWorkspace } from '@/lib/api'; +import { linkToSession } from '@/lib/links'; +import type { Task } from '@/lib/types/http'; + +const ARCHIVED_STATES = new Set(['COMPLETED', 'CANCELLED', 'FAILED']); + +function CommandItemRow({ cmd, onSelect }: { cmd: CommandItemType; onSelect: (cmd: CommandItemType) => void }) { + return ( + onSelect(cmd)} + > + {cmd.icon && {cmd.icon}} + {cmd.label} + {cmd.shortcut && {formatShortcut(cmd.shortcut)}} + {cmd.enterMode && ( + + + + )} + + ); +} + +export function CommandPanel() { + const { open, setOpen } = useCommandPanelOpen(); + const commands = useCommands(); + const router = useRouter(); + const { toast } = useToast(); + const [mode, setMode] = useState('commands'); + const [search, setSearch] = useState(''); + const [taskResults, setTaskResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const debounceRef = useRef | null>(null); + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const kanbanSteps = useAppStore((state) => state.kanban.steps); + + // Map step IDs to step names for display + const stepMap = useMemo(() => { + const map = new Map(); + for (const step of kanbanSteps) { + map.set(step.id, { name: step.title, color: step.color }); + } + return map; + }, [kanbanSteps]); + + // Toggle panel with Cmd+K, Cmd+P, or Cmd+Shift+P + const openRef = useRef(open); + openRef.current = open; + const toggle = useCallback(() => setOpen(!openRef.current), [setOpen]); + useKeyboardShortcut(SHORTCUTS.SEARCH, toggle); + useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL, toggle); + useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, toggle); + + // Reset state when panel closes + useEffect(() => { + if (!open) { + // Delay reset to avoid flash during close animation + const t = setTimeout(() => { + setMode('commands'); + setSearch(''); + setTaskResults([]); + }, 200); + return () => clearTimeout(t); + } + }, [open]); + + // Debounced task search with AbortController to prevent stale results + const abortRef = useRef(null); + useEffect(() => { + if (mode !== 'search-tasks') return; + if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current?.abort(); + + if (!search.trim()) { + setTaskResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + debounceRef.current = setTimeout(async () => { + if (!workspaceId) { + setIsSearching(false); + return; + } + const controller = new AbortController(); + abortRef.current = controller; + try { + const res = await listTasksByWorkspace( + workspaceId, + { query: search.trim(), page: 1, pageSize: 10 }, + { init: { signal: controller.signal } } + ); + if (!controller.signal.aborted) { + setTaskResults(res.tasks ?? []); + } + } catch { + if (!controller.signal.aborted) { + setTaskResults([]); + } + } finally { + if (!controller.signal.aborted) { + setIsSearching(false); + } + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current?.abort(); + }; + }, [search, mode, workspaceId]); + + // Group commands by group field, sorted by best (lowest) priority in each group + const grouped = useMemo(() => { + const map = new Map(); + for (const cmd of commands) { + const existing = map.get(cmd.group) ?? []; + existing.push(cmd); + map.set(cmd.group, existing); + } + // Sort groups by the minimum priority of their items + const entries = Array.from(map.entries()).sort(([, a], [, b]) => { + const minA = Math.min(...a.map((c) => c.priority ?? 100)); + const minB = Math.min(...b.map((c) => c.priority ?? 100)); + return minA - minB; + }); + return entries; + }, [commands]); + + const handleSelect = useCallback( + (cmd: CommandItemType) => { + if (cmd.enterMode) { + setMode(cmd.enterMode); + setSearch(''); + return; + } + if (cmd.action) { + setOpen(false); + cmd.action(); + } + }, + [setOpen] + ); + + const handleTaskSelect = useCallback( + (task: Task) => { + setOpen(false); + if (task.primary_session_id) { + router.push(linkToSession(task.primary_session_id)); + } else { + toast({ title: 'This task has no active session', variant: 'default' }); + } + }, + [setOpen, router, toast] + ); + + // Handle backspace on empty input to go back to commands mode + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (mode !== 'commands' && e.key === 'Backspace' && !search) { + e.preventDefault(); + setMode('commands'); + setSearch(''); + } + }, + [mode, search] + ); + + return ( + + +
+ {mode === 'search-tasks' && ( + + )} + +
+ + + {mode === 'commands' && ( + <> + No commands found. + {search.trim() ? ( + // Flat list when searching — lets cmdk rank by relevance globally + + {commands.map((cmd) => ( + + ))} + + ) : ( + // Grouped by priority when browsing + grouped.map(([group, items]) => ( + + {items.map((cmd) => ( + + ))} + + )) + )} + + )} + + {mode === 'search-tasks' && ( + <> + {isSearching && ( +
+ +
+ )} + {!isSearching && search.trim() && taskResults.length === 0 && ( + No tasks found. + )} + {!isSearching && !search.trim() && ( + Type to search tasks... + )} + {!isSearching && taskResults.length > 0 && ( + + {taskResults.map((task) => { + const isArchived = ARCHIVED_STATES.has(task.state); + const step = stepMap.get(task.workflow_step_id); + return ( + handleTaskSelect(task)} + className={isArchived ? 'opacity-60' : ''} + > +
+
+ + {task.title} + {step && ( + + {step.name} + + )} + {isArchived && ( + + {task.state} + + )} +
+ {task.updated_at && ( +
+ {formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })} +
+ )} +
+
+ ); + })} +
+ )} + + )} +
+ + {/* Footer bar */} +
+ + ↑ + ↓ + Navigate + + + ↵ + {mode === 'search-tasks' ? 'Open' : 'Select'} + + {mode === 'search-tasks' && ( + + ⌫ + Back + + )} + + esc + Close + +
+
+
+ ); +} diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx index 2a8e988f27..63f107ac00 100644 --- a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx @@ -62,6 +62,7 @@ export function TipTapPlanEditor({ useEffect(() => { onCommentClickRef.current = onCommentClick; }, [onCommentClick]); const editor = useEditor({ + immediatelyRender: false, extensions: [ StarterKit.configure({ codeBlock: false, // We use CodeBlockLowlight instead diff --git a/apps/web/components/global-commands.tsx b/apps/web/components/global-commands.tsx new file mode 100644 index 0000000000..8d620d8ccc --- /dev/null +++ b/apps/web/components/global-commands.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTheme } from 'next-themes'; +import { + IconSearch, + IconHome, + IconList, + IconSettings, + IconChartBar, + IconSun, + IconMoon, + IconRobot, + IconCpu, + IconServer, + IconFolder, + IconMessageCircle, +} from '@tabler/icons-react'; +import { useRegisterCommands } from '@/hooks/use-register-commands'; +import type { CommandItem } from '@/lib/commands/types'; + +export function GlobalCommands() { + const router = useRouter(); + const { resolvedTheme, setTheme } = useTheme(); + + const commands = useMemo( + () => [ + // Search + { + id: 'search-tasks', + label: 'Search Tasks', + group: 'Search', + icon: , + keywords: ['find', 'search', 'task'], + enterMode: 'search-tasks', + priority: 50, + }, + + // Navigation + { + id: 'nav-home', + label: 'Go to Home', + group: 'Navigation', + icon: , + keywords: ['home', 'kanban', 'board'], + action: () => router.push('/'), + }, + { + id: 'nav-tasks', + label: 'Go to All Tasks', + group: 'Navigation', + icon: , + keywords: ['tasks', 'list', 'all'], + action: () => router.push('/tasks'), + }, + { + id: 'nav-settings', + label: 'Go to Settings', + group: 'Navigation', + icon: , + keywords: ['settings', 'preferences', 'config'], + action: () => router.push('/settings/general'), + }, + { + id: 'nav-stats', + label: 'Go to Stats', + group: 'Navigation', + icon: , + keywords: ['stats', 'analytics', 'metrics'], + action: () => router.push('/stats'), + }, + + // Preferences + { + id: 'pref-theme', + label: resolvedTheme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode', + group: 'Preferences', + icon: resolvedTheme === 'dark' + ? + : , + keywords: ['theme', 'dark', 'light', 'mode'], + action: () => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'), + }, + + // Settings sub-pages + { + id: 'settings-agents', + label: 'Agents Settings', + group: 'Settings', + icon: , + keywords: ['agents', 'ai', 'claude'], + action: () => router.push('/settings/agents'), + }, + { + id: 'settings-executors', + label: 'Executors Settings', + group: 'Settings', + icon: , + keywords: ['executors', 'compute', 'run'], + action: () => router.push('/settings/executors'), + }, + { + id: 'settings-environments', + label: 'Environments Settings', + group: 'Settings', + icon: , + keywords: ['environments', 'env', 'variables'], + action: () => router.push('/settings/environments'), + }, + { + id: 'settings-workspace', + label: 'Workspace Settings', + group: 'Settings', + icon: , + keywords: ['workspace', 'project'], + action: () => router.push('/settings/workspace'), + }, + { + id: 'settings-prompts', + label: 'Prompts Settings', + group: 'Settings', + icon: , + keywords: ['prompts', 'templates', 'message'], + action: () => router.push('/settings/prompts'), + }, + ], + [router, resolvedTheme, setTheme] + ); + + useRegisterCommands(commands); + + return null; +} diff --git a/apps/web/components/homepage-commands.tsx b/apps/web/components/homepage-commands.tsx new file mode 100644 index 0000000000..3b95223685 --- /dev/null +++ b/apps/web/components/homepage-commands.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { + IconLayoutKanban, + IconGitBranch, + IconList, + IconPlus, +} from '@tabler/icons-react'; +import { useRegisterCommands } from '@/hooks/use-register-commands'; +import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings'; +import { SHORTCUTS } from '@/lib/keyboard/constants'; +import { linkToTasks } from '@/lib/links'; +import type { CommandItem } from '@/lib/commands/types'; + +type HomepageCommandsProps = { + onCreateTask: () => void; +}; + +export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { + const router = useRouter(); + const { onViewModeChange } = useKanbanDisplaySettings(); + + const commands = useMemo( + () => [ + { + id: 'task-create', + label: 'Create New Task', + group: 'Tasks', + icon: , + shortcut: SHORTCUTS.NEW_TASK, + keywords: ['new', 'create', 'task', 'add'], + action: onCreateTask, + priority: 0, + }, + { + id: 'view-kanban', + label: 'Switch to Kanban View', + group: 'View', + icon: , + keywords: ['kanban', 'board', 'view'], + priority: 0, + action: () => { + router.push('/'); + onViewModeChange(''); + }, + }, + { + id: 'view-pipeline', + label: 'Switch to Pipeline View', + group: 'View', + icon: , + keywords: ['pipeline', 'graph', 'view'], + priority: 0, + action: () => { + router.push('/'); + onViewModeChange('graph2'); + }, + }, + { + id: 'view-list', + label: 'Switch to List View', + group: 'View', + icon: , + keywords: ['list', 'table', 'view'], + priority: 0, + action: () => router.push(linkToTasks()), + }, + ], + [onCreateTask, router, onViewModeChange] + ); + + useRegisterCommands(commands); + + return null; +} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 5a295e5b85..3494f9c4fe 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -13,6 +13,7 @@ import { KanbanHeader } from './kanban/kanban-header'; import { useKanbanData, useKanbanActions, useKanbanNavigation } from '@/hooks/domains/kanban'; import { useAllWorkflowSnapshots } from '@/hooks/domains/kanban/use-all-workflow-snapshots'; import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint'; +import { HomepageCommands } from './homepage-commands'; import { getWebSocketClient } from '@/lib/ws/connection'; import { linkToSession } from '@/lib/links'; import { @@ -202,6 +203,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} return (
+ {step === 0 && 'These AI coding agents were discovered on your system.'} {step === 1 && 'Agents can run in different runtime environments.'} {step === 2 && 'Workflows define the steps and automation for your tasks.'} + {step === 3 && 'Quick access to actions from anywhere with a keyboard shortcut.'} @@ -197,6 +206,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) { )} {step === 1 && } {step === 2 && } + {step === 3 && }
{/* Progress dots */} @@ -393,6 +403,78 @@ function StepWorkflows({ templates, loading }: { templates: WorkflowTemplate[]; ); } +const COMMAND_PANEL_PREVIEW_ITEMS = [ + { icon: IconSearch, label: 'Search Tasks', trailing: '→' }, + { icon: IconHome, label: 'Go to Home' }, + { icon: IconGitCommit, label: 'Commit Changes' }, + { icon: IconArrowDown, label: 'Pull' }, + { icon: IconTerminal2, label: 'Add Terminal Panel' }, +]; + +function StepCommandPanel() { + return ( +
+ {/* Mock command panel preview */} +
+ {/* Search input */} +
+ + Type a command... +
+ {/* Sample commands */} +
+ {COMMAND_PANEL_PREVIEW_ITEMS.map((item) => { + const Icon = item.icon; + return ( +
+ + {item.label} + {item.trailing && ( + {item.trailing} + )} +
+ ); + })} +
+ {/* Footer */} +
+ + ↑ + ↓ + Navigate + + + ↵ + Select + + + esc + Close + +
+
+ + {/* Shortcut hint */} +
+ Press + + + K + + to open it anytime +
+ +

+ Navigate between pages, search tasks, trigger git operations, and manage panels — all + without leaving the keyboard. Context-aware commands appear based on the active page. +

+
+ ); +} + function TemplateCard({ template, isDefault }: { template: WorkflowTemplate; isDefault?: boolean }) { const steps = (template.default_steps ?? []) .slice() diff --git a/apps/web/components/review/review-dialog.tsx b/apps/web/components/review/review-dialog.tsx index f771113f98..c391a357ff 100644 --- a/apps/web/components/review/review-dialog.tsx +++ b/apps/web/components/review/review-dialog.tsx @@ -20,7 +20,6 @@ type ReviewDialogProps = { onOpenChange: (open: boolean) => void; sessionId: string; baseBranch?: string; - taskTitle?: string; onSendComments: (comments: DiffComment[]) => void; onOpenFile?: (filePath: string) => void; gitStatusFiles: Record | null; @@ -33,7 +32,6 @@ export const ReviewDialog = memo(function ReviewDialog({ onOpenChange, sessionId, baseBranch, - taskTitle, onSendComments, onOpenFile, gitStatusFiles, @@ -221,7 +219,6 @@ export const ReviewDialog = memo(function ReviewDialog({ totalCount={totalCount} commentCount={totalCommentCount} baseBranch={baseBranch} - taskTitle={taskTitle} splitView={splitView} onToggleSplitView={handleToggleSplitView} wordWrap={wordWrap} diff --git a/apps/web/components/review/review-top-bar.tsx b/apps/web/components/review/review-top-bar.tsx index 251de1c061..3f91a34b48 100644 --- a/apps/web/components/review/review-top-bar.tsx +++ b/apps/web/components/review/review-top-bar.tsx @@ -30,7 +30,6 @@ type ReviewTopBarProps = { totalCount: number; commentCount: number; baseBranch?: string; - taskTitle?: string; splitView: boolean; onToggleSplitView: (split: boolean) => void; wordWrap: boolean; @@ -47,7 +46,6 @@ export const ReviewTopBar = memo(function ReviewTopBar({ totalCount, commentCount, baseBranch, - taskTitle, splitView, onToggleSplitView, wordWrap, @@ -180,7 +178,6 @@ export const ReviewTopBar = memo(function ReviewTopBar({ {/* Close button */} diff --git a/apps/web/components/session-commands.tsx b/apps/web/components/session-commands.tsx new file mode 100644 index 0000000000..775b476349 --- /dev/null +++ b/apps/web/components/session-commands.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { + IconPlayerStop, + IconFileTextSpark, + IconGitCommit, + IconArrowUp, + IconArrowDown, + IconGitPullRequest, + IconGitBranch, + IconGitMerge, + IconBrowser, + IconTerminal2, + IconFileText, + IconFileDiff, +} from '@tabler/icons-react'; +import { useRegisterCommands } from '@/hooks/use-register-commands'; +import { useGitOperations } from '@/hooks/use-git-operations'; +import { useGitWithFeedback } from '@/hooks/use-git-with-feedback'; +import { usePanelActions } from '@/hooks/use-panel-actions'; +import { useVcsDialogs } from '@/components/vcs/vcs-dialogs'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import type { CommandItem } from '@/lib/commands/types'; + +type SessionCommandsProps = { + sessionId: string | null; + baseBranch?: string; + isAgentRunning?: boolean; + hasWorktree?: boolean; +}; + +export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWorktree }: SessionCommandsProps) { + const git = useGitOperations(sessionId); + const panels = usePanelActions(); + const { openCommitDialog, openPRDialog } = useVcsDialogs(); + + const cancelTurn = useCallback(async () => { + if (!sessionId) return; + const client = getWebSocketClient(); + if (!client) return; + try { + await client.request('agent.cancel', { session_id: sessionId }, 15000); + } catch (error) { + console.error('Failed to cancel agent turn:', error); + } + }, [sessionId]); + + const gitWithFeedback = useGitWithFeedback(); + + const runGitWithFeedback = useCallback( + async ( + operation: () => Promise<{ success: boolean; output: string; error?: string }>, + operationName: string + ) => { + panels.addChanges(); + await gitWithFeedback(operation, operationName); + }, + [panels, gitWithFeedback] + ); + + const commands = useMemo(() => { + if (!sessionId) return []; + + const PAGE_PRIORITY = 0; + const items: CommandItem[] = []; + + // Session — conditional + if (isAgentRunning) { + items.push({ + id: 'session-cancel', + label: 'Cancel Turn', + group: 'Session', + icon: , + keywords: ['cancel', 'stop', 'turn'], + action: cancelTurn, + }); + } + + items.push({ + id: 'session-plan-mode', + label: 'Toggle Plan Mode', + group: 'Session', + icon: , + keywords: ['plan', 'mode', 'toggle'], + action: () => panels.addPlan(), + }); + + // Git — only when worktree is set up + if (hasWorktree) { + items.push( + { + id: 'git-commit', + label: 'Commit Changes', + group: 'Git', + icon: , + keywords: ['commit', 'git', 'save'], + action: openCommitDialog, + }, + { + id: 'git-push', + label: 'Push', + group: 'Git', + icon: , + keywords: ['push', 'git', 'upload'], + action: () => runGitWithFeedback(() => git.push(), 'Push'), + }, + { + id: 'git-pull', + label: 'Pull', + group: 'Git', + icon: , + keywords: ['pull', 'git', 'download', 'fetch'], + action: () => runGitWithFeedback(() => git.pull(), 'Pull'), + }, + { + id: 'git-create-pr', + label: 'Create PR', + group: 'Git', + icon: , + keywords: ['pull request', 'pr', 'git'], + action: openPRDialog, + }, + { + id: 'git-rebase', + label: 'Rebase', + group: 'Git', + icon: , + keywords: ['rebase', 'git', 'branch'], + action: () => { + const target = baseBranch?.replace(/^origin\//, '') || 'main'; + return runGitWithFeedback(() => git.rebase(target), 'Rebase'); + }, + }, + { + id: 'git-merge', + label: 'Merge', + group: 'Git', + icon: , + keywords: ['merge', 'git', 'branch'], + action: () => { + const target = baseBranch?.replace(/^origin\//, '') || 'main'; + return runGitWithFeedback(() => git.merge(target), 'Merge'); + }, + }, + ); + } + + // Panels — always available on session page + items.push( + { + id: 'panel-browser', + label: 'Add Browser Panel', + group: 'Panels', + icon: , + keywords: ['browser', 'preview', 'web'], + action: () => panels.addBrowser(), + }, + { + id: 'panel-terminal', + label: 'Add Terminal Panel', + group: 'Panels', + icon: , + keywords: ['terminal', 'shell', 'console'], + action: () => panels.addTerminal(), + }, + { + id: 'panel-plan', + label: 'Add Plan Panel', + group: 'Panels', + icon: , + keywords: ['plan', 'document'], + action: () => panels.addPlan(), + }, + { + id: 'panel-changes', + label: 'Add Changes Panel', + group: 'Panels', + icon: , + keywords: ['changes', 'diff', 'git'], + action: () => panels.addChanges(), + }, + ); + + return items.map((cmd) => ({ ...cmd, priority: PAGE_PRIORITY })); + }, [sessionId, git, panels, cancelTurn, baseBranch, isAgentRunning, hasWorktree, openCommitDialog, openPRDialog, runGitWithFeedback]); + + useRegisterCommands(commands); + + return null; +} diff --git a/apps/web/components/task/chat/tiptap-input.tsx b/apps/web/components/task/chat/tiptap-input.tsx index 1fb5db62e3..ae54387170 100644 --- a/apps/web/components/task/chat/tiptap-input.tsx +++ b/apps/web/components/task/chat/tiptap-input.tsx @@ -393,6 +393,7 @@ export const TipTapInput = forwardRef( // ── Editor ──────────────────────────────────────────────────── const editor = useEditor({ + immediatelyRender: false, extensions: [ Document, Paragraph, @@ -501,7 +502,6 @@ export const TipTapInput = forwardRef( if (sid) setChatDraftContent(sid, e.getJSON()); }, editable: !disabled, - immediatelyRender: false, }); // Guard to prevent circular updates when syncing external value → editor diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 26e3bc3436..49c699ffb1 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -60,7 +60,6 @@ import { CommitDetailPanel } from './commit-detail-panel'; import { PreviewController } from './preview/preview-controller'; import { ReviewDialog } from '@/components/review/review-dialog'; import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff'; -import { useGitOperations } from '@/hooks/use-git-operations'; import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useToast } from '@/components/toast-provider'; @@ -552,10 +551,6 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ const [reviewDialogOpen, setReviewDialogOpen] = useState(false); const { toast } = useToast(); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); - const taskTitle = useAppStore((state) => { - if (!activeTaskId) return undefined; - return state.kanban.tasks.find((t: { id: string }) => t.id === activeTaskId)?.title; - }); const baseBranch = useAppStore((state) => { if (!effectiveSessionId) return undefined; return state.taskSessions.items[effectiveSessionId]?.base_branch; @@ -787,7 +782,6 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ onOpenChange={setReviewDialogOpen} sessionId={effectiveSessionId} baseBranch={baseBranch} - taskTitle={taskTitle} onSendComments={handleReviewSendComments} onOpenFile={reviewOpenFile} gitStatusFiles={reviewGitStatus?.files ?? null} diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index 05b0a6c7cd..6bf87ad33f 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -63,14 +63,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ const autoMarkOnScroll = useAppStore((s) => s.userSettings.reviewAutoMarkOnScroll); const setUserSettings = useAppStore((state) => state.setUserSettings); const userSettings = useAppStore((state) => state.userSettings); - const taskTitle = useAppStore((state) => { - if (!state.tasks.activeTaskId) return undefined; - return state.kanban.tasks.find((t) => t.id === state.tasks.activeTaskId)?.title; - }); - const baseBranch = useAppStore((state) => { - if (!activeSessionId) return undefined; - return state.taskSessions.items[activeSessionId]?.base_branch; - }); const gitStatus = useSessionGitStatus(activeSessionId); const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(activeSessionId); diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index e1682886c4..9fef77a711 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -19,6 +19,8 @@ import { useTasks } from '@/hooks/use-tasks'; import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint'; import type { Layout } from 'react-resizable-panels'; import { TaskArchivedProvider } from './task-archived-context'; +import { SessionCommands } from '@/components/session-commands'; +import { VcsDialogsProvider } from '@/components/vcs/vcs-dialogs'; type TaskPageContentProps = { task: Task | null; @@ -233,7 +235,19 @@ export function TaskPageContent({ return ( +
+ {DEBUG_UI && showDebugOverlay && (
+
); } diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index b04b2fdbc7..b888894d97 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -277,8 +277,6 @@ const TaskTopBar = memo(function TaskTopBar({ )} diff --git a/apps/web/components/toast-provider.tsx b/apps/web/components/toast-provider.tsx index f3a8609a7c..644017eda7 100644 --- a/apps/web/components/toast-provider.tsx +++ b/apps/web/components/toast-provider.tsx @@ -1,10 +1,10 @@ 'use client'; -import { createContext, useCallback, useContext, useMemo, useState } from 'react'; -import { IconCheck, IconX, IconInfoCircle } from '@tabler/icons-react'; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { IconCheck, IconX, IconInfoCircle, IconLoader2 } from '@tabler/icons-react'; import { cn, generateUUID } from '@/lib/utils'; -type ToastVariant = 'default' | 'success' | 'error'; +type ToastVariant = 'default' | 'success' | 'error' | 'loading'; type Toast = { id: string; @@ -16,17 +16,25 @@ type Toast = { type ToastInput = Omit & { duration?: number }; type ToastContextValue = { - toast: (input: ToastInput) => void; + toast: (input: ToastInput) => string; + updateToast: (id: string, input: Partial) => void; + dismissToast: (id: string) => void; }; const ToastContext = createContext(null); -const variantStyles: Record = { +const variantStyles: Record = { default: { container: 'border-border/60 bg-background', icon: 'text-muted-foreground', IconComponent: IconInfoCircle, }, + loading: { + container: 'border-border/60 bg-background', + icon: 'text-muted-foreground', + IconComponent: IconLoader2, + spin: true, + }, success: { container: 'border-green-500/30 bg-green-500/10 dark:bg-green-500/5', icon: 'text-green-600 dark:text-green-400', @@ -41,13 +49,22 @@ const variantStyles: Record([]); + const timersRef = useRef(new Map>()); const removeToast = useCallback((id: string) => { setToasts((prev) => prev.filter((toast) => toast.id !== id)); + timersRef.current.delete(id); }, []); + const scheduleRemoval = useCallback((id: string, duration: number) => { + const existing = timersRef.current.get(id); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => removeToast(id), duration); + timersRef.current.set(id, timer); + }, [removeToast]); + const toast = useCallback( - (input: ToastInput) => { + (input: ToastInput): string => { const id = generateUUID(); const nextToast: Toast = { id, @@ -56,13 +73,45 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { variant: input.variant ?? 'default', }; setToasts((prev) => [...prev, nextToast]); - const duration = input.duration ?? 4000; - window.setTimeout(() => removeToast(id), duration); + // Loading toasts don't auto-dismiss + if (input.variant !== 'loading') { + scheduleRemoval(id, input.duration ?? 4000); + } + return id; + }, + [scheduleRemoval] + ); + + const updateToast = useCallback( + (id: string, input: Partial) => { + setToasts((prev) => + prev.map((t) => + t.id === id + ? { + ...t, + ...(input.title !== undefined && { title: input.title }), + ...(input.description !== undefined && { description: input.description }), + ...(input.variant !== undefined && { variant: input.variant }), + } + : t + ) + ); + // When transitioning away from loading, schedule auto-dismiss + if (input.variant && input.variant !== 'loading') { + scheduleRemoval(id, input.duration ?? 4000); + } + }, + [scheduleRemoval] + ); + + const dismissToast = useCallback( + (id: string) => { + removeToast(id); }, [removeToast] ); - const value = useMemo(() => ({ toast }), [toast]); + const value = useMemo(() => ({ toast, updateToast, dismissToast }), [toast, updateToast, dismissToast]); return ( @@ -82,7 +131,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { )} >
- +
{t.title && ( diff --git a/apps/web/components/vcs-split-button.tsx b/apps/web/components/vcs-split-button.tsx index 0f0ae1e891..5319bb4cc7 100644 --- a/apps/web/components/vcs-split-button.tsx +++ b/apps/web/components/vcs-split-button.tsx @@ -1,27 +1,18 @@ 'use client'; -import { memo, useState, useCallback } from 'react'; +import { memo, useCallback } from 'react'; import { IconGitCommit, IconGitPullRequest, IconGitMerge, IconGitCherryPick, IconLoader2, - IconCheck, IconChevronDown, IconCloudDownload, IconCloudUpload, IconAlertTriangle, } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogClose, -} from '@kandev/ui/dialog'; import { DropdownMenu, DropdownMenuContent, @@ -33,40 +24,25 @@ import { DropdownMenuSubContent, } from '@kandev/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; -import { Checkbox } from '@kandev/ui/checkbox'; -import { Label } from '@kandev/ui/label'; -import { Textarea } from '@kandev/ui/textarea'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useGitOperations } from '@/hooks/use-git-operations'; -import { useToast } from '@/components/toast-provider'; -import type { FileInfo } from '@/lib/state/slices'; +import { useGitWithFeedback } from '@/hooks/use-git-with-feedback'; +import { useVcsDialogs } from '@/components/vcs/vcs-dialogs'; type VcsSplitButtonProps = { sessionId: string | null; baseBranch?: string; - taskTitle?: string; - /** Branch name shown in the PR dialog (e.g. "feature/foo → main") */ - displayBranch?: string | null; }; const VcsSplitButton = memo(function VcsSplitButton({ sessionId, baseBranch, - taskTitle, - displayBranch, }: VcsSplitButtonProps) { - const [commitDialogOpen, setCommitDialogOpen] = useState(false); - const [commitMessage, setCommitMessage] = useState(''); - const [stageAll, setStageAll] = useState(true); - const [prDialogOpen, setPrDialogOpen] = useState(false); - const [prTitle, setPrTitle] = useState(''); - const [prBody, setPrBody] = useState(''); - const [prDraft, setPrDraft] = useState(true); - - const { toast } = useToast(); + const gitWithFeedback = useGitWithFeedback(); const gitStatus = useSessionGitStatus(sessionId); - const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } = + const { pull, push, rebase, merge, isLoading: isGitLoading } = useGitOperations(sessionId); + const { openCommitDialog, openPRDialog } = useVcsDialogs(); // Check if remote branch tracks current branch const currentBranch = gitStatus?.branch; @@ -79,138 +55,34 @@ const VcsSplitButton = memo(function VcsSplitButton({ ? Object.keys(gitStatus.files).length : 0; - let uncommittedAdditions = 0; - let uncommittedDeletions = 0; - if (gitStatus?.files && uncommittedFileCount > 0) { - for (const file of Object.values(gitStatus.files) as FileInfo[]) { - uncommittedAdditions += file.additions || 0; - uncommittedDeletions += file.deletions || 0; - } - } - const aheadCount = gitStatus?.ahead ?? 0; const behindCount = gitStatus?.behind ?? 0; // --- handlers --- - const handleGitOperation = useCallback( - async ( - operation: () => Promise<{ - success: boolean; - output: string; - error?: string; - }>, - operationName: string - ) => { - try { - const result = await operation(); - if (result.success) { - toast({ - title: `${operationName} successful`, - description: - result.output.slice(0, 200) || `${operationName} completed`, - variant: 'success', - }); - } else { - toast({ - title: `${operationName} failed`, - description: result.error || 'An error occurred', - variant: 'error', - }); - } - } catch (e) { - toast({ - title: `${operationName} failed`, - description: - e instanceof Error ? e.message : 'An unexpected error occurred', - variant: 'error', - }); - } - }, - [toast] - ); - const handlePull = useCallback(() => { - handleGitOperation(() => pull(), 'Pull'); - }, [handleGitOperation, pull]); + gitWithFeedback(() => pull(), 'Pull'); + }, [gitWithFeedback, pull]); const handlePush = useCallback( (force = false) => { - handleGitOperation( + gitWithFeedback( () => push({ force }), force ? 'Force Push' : 'Push' ); }, - [handleGitOperation, push] + [gitWithFeedback, push] ); const handleRebase = useCallback(() => { const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main'; - handleGitOperation(() => rebase(targetBranch), 'Rebase'); - }, [handleGitOperation, rebase, baseBranch]); + gitWithFeedback(() => rebase(targetBranch), 'Rebase'); + }, [gitWithFeedback, rebase, baseBranch]); const handleMerge = useCallback(() => { const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main'; - handleGitOperation(() => merge(targetBranch), 'Merge'); - }, [handleGitOperation, merge, baseBranch]); - - const handleOpenCommitDialog = useCallback(() => { - setCommitMessage(''); - setStageAll(true); - setCommitDialogOpen(true); - }, []); - - const handleCommit = useCallback(async () => { - if (!commitMessage.trim()) return; - setCommitDialogOpen(false); - await handleGitOperation( - () => commit(commitMessage.trim(), stageAll), - 'Commit' - ); - setCommitMessage(''); - }, [commitMessage, stageAll, handleGitOperation, commit]); - - const handleOpenPRDialog = useCallback(() => { - setPrTitle(taskTitle || ''); - setPrBody(''); - setPrDialogOpen(true); - }, [taskTitle]); - - const handleCreatePR = useCallback(async () => { - if (!prTitle.trim()) return; - setPrDialogOpen(false); - try { - const result = await createPR( - prTitle.trim(), - prBody.trim(), - baseBranch, - prDraft - ); - if (result.success) { - toast({ - title: prDraft ? 'Draft PR created' : 'PR created', - description: result.pr_url || 'PR created successfully', - variant: 'success', - }); - if (result.pr_url) window.open(result.pr_url, '_blank'); - } else { - toast({ - title: 'Create PR failed', - description: result.error || 'An error occurred', - variant: 'error', - }); - } - } catch (e) { - toast({ - title: 'Create PR failed', - description: - e instanceof Error ? e.message : 'An error occurred', - variant: 'error', - }); - } - setPrTitle(''); - setPrBody(''); - }, [prTitle, prBody, baseBranch, prDraft, createPR, toast]); + gitWithFeedback(() => merge(targetBranch), 'Merge'); + }, [gitWithFeedback, merge, baseBranch]); // --- dynamic primary action --- @@ -232,14 +104,14 @@ const VcsSplitButton = memo(function VcsSplitButton({ uncommittedFileCount > 0 ? `Commit ${uncommittedFileCount} changed file${uncommittedFileCount !== 1 ? 's' : ''}` : 'No changes to commit', - onClick: handleOpenCommitDialog, + onClick: openCommitDialog, }, pr: { icon: , label: 'Create PR', badge: aheadCount > 0 ? aheadCount : null, tooltip: `Create PR (${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead)`, - onClick: handleOpenPRDialog, + onClick: openPRDialog, }, rebase: { icon: , @@ -251,309 +123,123 @@ const VcsSplitButton = memo(function VcsSplitButton({ }[primaryAction]; return ( - <> - {/* Split button */} -
- - - - - {primaryButtonConfig.tooltip} - - - - - - - + + + + + {primaryButtonConfig.tooltip} + + + + + + + + + Create PR + + + + + Pull + {hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && ( + + ↓{gitStatus?.behind} + + )} + + + - - Create PR - - - - - Pull - {hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && ( + + Push + {hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && ( - ↓{gitStatus?.behind} + ↑{gitStatus?.ahead} )} - - - + + handlePush(false)} disabled={isGitLoading || !sessionId} > - Push - {hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && ( - - ↑{gitStatus?.ahead} - - )} - - - handlePush(false)} - disabled={isGitLoading || !sessionId} - > - - Push - - handlePush(true)} - disabled={isGitLoading || !sessionId} - > - - Force Push - - - - - - - Rebase - - onto {baseBranch || 'origin/main'} - - - - - Merge - - from {baseBranch || 'origin/main'} - - - - -
- - {/* Commit Dialog */} - - - - - - Commit Changes - - -
-
- {uncommittedFileCount > 0 ? ( - - - {uncommittedFileCount} - {' '} - file{uncommittedFileCount !== 1 ? 's' : ''} changed - {(uncommittedAdditions > 0 || uncommittedDeletions > 0) && ( - - ( - - +{uncommittedAdditions} - - {' / '} - - -{uncommittedDeletions} - - ) - - )} - - ) : ( - No changes to commit - )} -
-