feat: command panel (#235)

This commit is contained in:
Carlos Florêncio
2026-02-16 22:54:28 +00:00
committed by GitHub
parent 633c4e0e55
commit bfdd48b7ad
24 changed files with 1503 additions and 473 deletions
+4 -1
View File
@@ -39,12 +39,14 @@ function CommandDialog({
children,
className,
showCloseButton = false,
overlayClassName,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
overlayClassName?: string
}) {
return (
<Dialog {...props}>
@@ -53,8 +55,9 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("rounded-xl! p-0 overflow-hidden p-0", className)}
className={cn("rounded-xl! p-0 overflow-hidden", className)}
showCloseButton={showCloseButton}
overlayClassName={overlayClassName}
>
{children}
</DialogContent>
+9 -2
View File
@@ -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({
<ThemeProvider>
<TooltipProvider>
<ToastProvider>
<WebSocketConnector />
{children}
<CommandRegistryProvider>
<WebSocketConnector />
<GlobalCommands />
<CommandPanel />
{children}
</CommandRegistryProvider>
</ToastProvider>
</TooltipProvider>
</ThemeProvider>
+327
View File
@@ -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 (
<CommandItem
key={cmd.id}
value={cmd.id + ' ' + cmd.label + ' ' + (cmd.keywords?.join(' ') ?? '')}
onSelect={() => onSelect(cmd)}
>
{cmd.icon && <span className="text-muted-foreground">{cmd.icon}</span>}
<span>{cmd.label}</span>
{cmd.shortcut && <CommandShortcut>{formatShortcut(cmd.shortcut)}</CommandShortcut>}
{cmd.enterMode && (
<span className="ml-auto text-muted-foreground">
<IconArrowRight className="size-3" />
</span>
)}
</CommandItem>
);
}
export function CommandPanel() {
const { open, setOpen } = useCommandPanelOpen();
const commands = useCommands();
const router = useRouter();
const { toast } = useToast();
const [mode, setMode] = useState<CommandPanelMode>('commands');
const [search, setSearch] = useState('');
const [taskResults, setTaskResults] = useState<Task[]>([]);
const [isSearching, setIsSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<string, { name: string; color: string }>();
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<AbortController | null>(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<string, CommandItemType[]>();
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 (
<CommandDialog open={open} onOpenChange={setOpen} overlayClassName="supports-backdrop-filter:backdrop-blur-none!">
<Command shouldFilter={mode === 'commands'} loop>
<div className="flex items-center border-b border-border [&>[data-slot=command-input-wrapper]]:flex-1">
{mode === 'search-tasks' && (
<button
onClick={() => { setMode('commands'); setSearch(''); }}
className="shrink-0 pl-2 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<span>←</span>
<span>Tasks</span>
<span className="text-muted-foreground/50">›</span>
</button>
)}
<CommandInput
placeholder={mode === 'commands' ? 'Type a command...' : 'Search for tasks...'}
value={search}
onValueChange={setSearch}
onKeyDown={handleKeyDown}
/>
</div>
<CommandList>
{mode === 'commands' && (
<>
<CommandEmpty>No commands found.</CommandEmpty>
{search.trim() ? (
// Flat list when searching — lets cmdk rank by relevance globally
<CommandGroup>
{commands.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={handleSelect} />
))}
</CommandGroup>
) : (
// Grouped by priority when browsing
grouped.map(([group, items]) => (
<CommandGroup key={group} heading={group}>
{items.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={handleSelect} />
))}
</CommandGroup>
))
)}
</>
)}
{mode === 'search-tasks' && (
<>
{isSearching && (
<div className="flex items-center justify-center py-6">
<IconLoader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isSearching && search.trim() && taskResults.length === 0 && (
<CommandEmpty>No tasks found.</CommandEmpty>
)}
{!isSearching && !search.trim() && (
<CommandEmpty>Type to search tasks...</CommandEmpty>
)}
{!isSearching && taskResults.length > 0 && (
<CommandGroup heading="Results">
{taskResults.map((task) => {
const isArchived = ARCHIVED_STATES.has(task.state);
const step = stepMap.get(task.workflow_step_id);
return (
<CommandItem
key={task.id}
value={task.id}
onSelect={() => handleTaskSelect(task)}
className={isArchived ? 'opacity-60' : ''}
>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<IconSearch className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{task.title}</span>
{step && (
<Badge variant="secondary" className="text-[0.6rem] shrink-0">
{step.name}
</Badge>
)}
{isArchived && (
<Badge variant="outline" className="text-[0.6rem] shrink-0 opacity-70">
{task.state}
</Badge>
)}
</div>
{task.updated_at && (
<div className="flex items-center gap-1.5 text-[0.6rem] text-muted-foreground pl-5">
<span>{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}</span>
</div>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
</>
)}
</CommandList>
{/* Footer bar */}
<div className="border-t border-border px-3 py-1.5 flex items-center gap-3 text-[0.6rem] text-muted-foreground">
<KbdGroup>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span>Navigate</span>
</KbdGroup>
<KbdGroup>
<Kbd>↵</Kbd>
<span>{mode === 'search-tasks' ? 'Open' : 'Select'}</span>
</KbdGroup>
{mode === 'search-tasks' && (
<KbdGroup>
<Kbd>⌫</Kbd>
<span>Back</span>
</KbdGroup>
)}
<KbdGroup>
<Kbd>esc</Kbd>
<span>Close</span>
</KbdGroup>
</div>
</Command>
</CommandDialog>
);
}
@@ -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
+134
View File
@@ -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<CommandItem[]>(
() => [
// Search
{
id: 'search-tasks',
label: 'Search Tasks',
group: 'Search',
icon: <IconSearch className="size-3.5" />,
keywords: ['find', 'search', 'task'],
enterMode: 'search-tasks',
priority: 50,
},
// Navigation
{
id: 'nav-home',
label: 'Go to Home',
group: 'Navigation',
icon: <IconHome className="size-3.5" />,
keywords: ['home', 'kanban', 'board'],
action: () => router.push('/'),
},
{
id: 'nav-tasks',
label: 'Go to All Tasks',
group: 'Navigation',
icon: <IconList className="size-3.5" />,
keywords: ['tasks', 'list', 'all'],
action: () => router.push('/tasks'),
},
{
id: 'nav-settings',
label: 'Go to Settings',
group: 'Navigation',
icon: <IconSettings className="size-3.5" />,
keywords: ['settings', 'preferences', 'config'],
action: () => router.push('/settings/general'),
},
{
id: 'nav-stats',
label: 'Go to Stats',
group: 'Navigation',
icon: <IconChartBar className="size-3.5" />,
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'
? <IconSun className="size-3.5" />
: <IconMoon className="size-3.5" />,
keywords: ['theme', 'dark', 'light', 'mode'],
action: () => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'),
},
// Settings sub-pages
{
id: 'settings-agents',
label: 'Agents Settings',
group: 'Settings',
icon: <IconRobot className="size-3.5" />,
keywords: ['agents', 'ai', 'claude'],
action: () => router.push('/settings/agents'),
},
{
id: 'settings-executors',
label: 'Executors Settings',
group: 'Settings',
icon: <IconCpu className="size-3.5" />,
keywords: ['executors', 'compute', 'run'],
action: () => router.push('/settings/executors'),
},
{
id: 'settings-environments',
label: 'Environments Settings',
group: 'Settings',
icon: <IconServer className="size-3.5" />,
keywords: ['environments', 'env', 'variables'],
action: () => router.push('/settings/environments'),
},
{
id: 'settings-workspace',
label: 'Workspace Settings',
group: 'Settings',
icon: <IconFolder className="size-3.5" />,
keywords: ['workspace', 'project'],
action: () => router.push('/settings/workspace'),
},
{
id: 'settings-prompts',
label: 'Prompts Settings',
group: 'Settings',
icon: <IconMessageCircle className="size-3.5" />,
keywords: ['prompts', 'templates', 'message'],
action: () => router.push('/settings/prompts'),
},
],
[router, resolvedTheme, setTheme]
);
useRegisterCommands(commands);
return null;
}
+77
View File
@@ -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<CommandItem[]>(
() => [
{
id: 'task-create',
label: 'Create New Task',
group: 'Tasks',
icon: <IconPlus className="size-3.5" />,
shortcut: SHORTCUTS.NEW_TASK,
keywords: ['new', 'create', 'task', 'add'],
action: onCreateTask,
priority: 0,
},
{
id: 'view-kanban',
label: 'Switch to Kanban View',
group: 'View',
icon: <IconLayoutKanban className="size-3.5" />,
keywords: ['kanban', 'board', 'view'],
priority: 0,
action: () => {
router.push('/');
onViewModeChange('');
},
},
{
id: 'view-pipeline',
label: 'Switch to Pipeline View',
group: 'View',
icon: <IconGitBranch className="size-3.5" />,
keywords: ['pipeline', 'graph', 'view'],
priority: 0,
action: () => {
router.push('/');
onViewModeChange('graph2');
},
},
{
id: 'view-list',
label: 'Switch to List View',
group: 'View',
icon: <IconList className="size-3.5" />,
keywords: ['list', 'table', 'view'],
priority: 0,
action: () => router.push(linkToTasks()),
},
],
[onCreateTask, router, onViewModeChange]
);
useRegisterCommands(commands);
return null;
}
+2
View File
@@ -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 (
<div className="h-dvh w-full flex flex-col">
<HomepageCommands onCreateTask={handleCreate} />
<KanbanHeader
onCreateTask={handleCreate}
workspaceId={workspaceState.activeId ?? undefined}
+83 -1
View File
@@ -20,8 +20,15 @@ import {
IconX,
IconLoader2,
IconChevronDown,
IconCommand,
IconSearch,
IconHome,
IconGitCommit,
IconTerminal2,
IconArrowDown,
} from '@tabler/icons-react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@kandev/ui/collapsible';
import { Kbd } from '@kandev/ui/kbd';
import { AgentLogo } from '@/components/agent-logo';
import { ProfileFormFields, type ProfileFormData } from '@/components/settings/profile-form-fields';
import { profileToPermissionsMap, permissionsToProfilePatch } from '@/lib/agent-permissions';
@@ -40,7 +47,7 @@ type AgentSetting = {
dirty: boolean;
};
const TOTAL_STEPS = 3;
const TOTAL_STEPS = 4;
const RUNTIMES = [
{
@@ -178,11 +185,13 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
{step === 0 && 'AI Agents'}
{step === 1 && 'Environments'}
{step === 2 && 'Agentic Workflows'}
{step === 3 && 'Command Panel'}
</DialogTitle>
<DialogDescription className="text-center">
{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.'}
</DialogDescription>
</DialogHeader>
@@ -197,6 +206,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
)}
{step === 1 && <StepEnvironments />}
{step === 2 && <StepWorkflows templates={templates} loading={loadingTemplates} />}
{step === 3 && <StepCommandPanel />}
</div>
{/* 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 (
<div className="space-y-4">
{/* Mock command panel preview */}
<div className="rounded-lg border bg-card overflow-hidden">
{/* Search input */}
<div className="flex items-center gap-2 px-3 py-2 border-b">
<IconSearch className="h-3.5 w-3.5 text-muted-foreground/50" />
<span className="text-xs text-muted-foreground/50">Type a command...</span>
</div>
{/* Sample commands */}
<div className="py-1">
{COMMAND_PANEL_PREVIEW_ITEMS.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="flex items-center gap-3 px-3 py-1.5 text-sm first:bg-muted/50"
>
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 text-xs">{item.label}</span>
{item.trailing && (
<span className="text-xs text-muted-foreground">{item.trailing}</span>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="flex items-center gap-3 px-3 py-1.5 border-t text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span className="text-[0.6rem]">Navigate</span>
</span>
<span className="inline-flex items-center gap-1">
<Kbd>↵</Kbd>
<span className="text-[0.6rem]">Select</span>
</span>
<span className="inline-flex items-center gap-1">
<Kbd>esc</Kbd>
<span className="text-[0.6rem]">Close</span>
</span>
</div>
</div>
{/* Shortcut hint */}
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Press</span>
<span className="inline-flex items-center gap-0.5">
<Kbd><IconCommand className="size-3" /></Kbd>
<Kbd>K</Kbd>
</span>
<span>to open it anytime</span>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
);
}
function TemplateCard({ template, isDefault }: { template: WorkflowTemplate; isDefault?: boolean }) {
const steps = (template.default_steps ?? [])
.slice()
@@ -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<string, FileInfo> | 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}
@@ -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({
<VcsSplitButton
sessionId={sessionId}
baseBranch={baseBranch}
taskTitle={taskTitle}
/>
{/* Close button */}
+191
View File
@@ -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<CommandItem[]>(() => {
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: <IconPlayerStop className="size-3.5" />,
keywords: ['cancel', 'stop', 'turn'],
action: cancelTurn,
});
}
items.push({
id: 'session-plan-mode',
label: 'Toggle Plan Mode',
group: 'Session',
icon: <IconFileTextSpark className="size-3.5" />,
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: <IconGitCommit className="size-3.5" />,
keywords: ['commit', 'git', 'save'],
action: openCommitDialog,
},
{
id: 'git-push',
label: 'Push',
group: 'Git',
icon: <IconArrowUp className="size-3.5" />,
keywords: ['push', 'git', 'upload'],
action: () => runGitWithFeedback(() => git.push(), 'Push'),
},
{
id: 'git-pull',
label: 'Pull',
group: 'Git',
icon: <IconArrowDown className="size-3.5" />,
keywords: ['pull', 'git', 'download', 'fetch'],
action: () => runGitWithFeedback(() => git.pull(), 'Pull'),
},
{
id: 'git-create-pr',
label: 'Create PR',
group: 'Git',
icon: <IconGitPullRequest className="size-3.5" />,
keywords: ['pull request', 'pr', 'git'],
action: openPRDialog,
},
{
id: 'git-rebase',
label: 'Rebase',
group: 'Git',
icon: <IconGitBranch className="size-3.5" />,
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: <IconGitMerge className="size-3.5" />,
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: <IconBrowser className="size-3.5" />,
keywords: ['browser', 'preview', 'web'],
action: () => panels.addBrowser(),
},
{
id: 'panel-terminal',
label: 'Add Terminal Panel',
group: 'Panels',
icon: <IconTerminal2 className="size-3.5" />,
keywords: ['terminal', 'shell', 'console'],
action: () => panels.addTerminal(),
},
{
id: 'panel-plan',
label: 'Add Plan Panel',
group: 'Panels',
icon: <IconFileText className="size-3.5" />,
keywords: ['plan', 'document'],
action: () => panels.addPlan(),
},
{
id: 'panel-changes',
label: 'Add Changes Panel',
group: 'Panels',
icon: <IconFileDiff className="size-3.5" />,
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;
}
@@ -393,6 +393,7 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(
// ── Editor ────────────────────────────────────────────────────
const editor = useEditor({
immediatelyRender: false,
extensions: [
Document,
Paragraph,
@@ -501,7 +502,6 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(
if (sid) setChatDraftContent(sid, e.getJSON());
},
editable: !disabled,
immediatelyRender: false,
});
// Guard to prevent circular updates when syncing external value → editor
@@ -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}
@@ -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);
@@ -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 (
<TooltipProvider>
<VcsDialogsProvider
sessionId={effectiveSessionId ?? null}
baseBranch={task?.repositories?.[0]?.base_branch}
taskTitle={task?.title}
displayBranch={worktreeBranch}
>
<div className="h-screen w-full flex flex-col bg-background">
<SessionCommands
sessionId={effectiveSessionId ?? null}
baseBranch={task?.repositories?.[0]?.base_branch}
isAgentRunning={isAgentWorking}
hasWorktree={Boolean(worktreeBranch)}
/>
{DEBUG_UI && showDebugOverlay && (
<DebugOverlay
title="Task Debug"
@@ -298,6 +312,7 @@ export function TaskPageContent({
/>
</TaskArchivedProvider>
</div>
</VcsDialogsProvider>
</TooltipProvider>
);
}
@@ -277,8 +277,6 @@ const TaskTopBar = memo(function TaskTopBar({
<VcsSplitButton
sessionId={activeSessionId ?? null}
baseBranch={baseBranch}
taskTitle={taskTitle}
displayBranch={displayBranch}
/>
</>
)}
+59 -10
View File
@@ -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<Toast, 'id'> & { duration?: number };
type ToastContextValue = {
toast: (input: ToastInput) => void;
toast: (input: ToastInput) => string;
updateToast: (id: string, input: Partial<ToastInput>) => void;
dismissToast: (id: string) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
const variantStyles: Record<ToastVariant, { container: string; icon: string; IconComponent: typeof IconCheck }> = {
const variantStyles: Record<ToastVariant, { container: string; icon: string; IconComponent: typeof IconCheck; spin?: boolean }> = {
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<ToastVariant, { container: string; icon: string; Ico
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
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<ToastInput>) => {
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 (
<ToastContext.Provider value={value}>
@@ -82,7 +131,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
)}
>
<div className={cn('mt-0.5 flex-shrink-0', styles.icon)}>
<Icon className="h-5 w-5" />
<Icon className={cn('h-5 w-5', styles.spin && 'animate-spin')} />
</div>
<div className="flex-1 space-y-1">
{t.title && (
+122 -436
View File
@@ -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: <IconGitPullRequest className="h-4 w-4" />,
label: 'Create PR',
badge: aheadCount > 0 ? aheadCount : null,
tooltip: `Create PR (${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead)`,
onClick: handleOpenPRDialog,
onClick: openPRDialog,
},
rebase: {
icon: <IconGitCherryPick className="h-4 w-4" />,
@@ -251,309 +123,123 @@ const VcsSplitButton = memo(function VcsSplitButton({
}[primaryAction];
return (
<>
{/* Split button */}
<div className="inline-flex rounded-md border border-border overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 cursor-pointer"
onClick={primaryButtonConfig.onClick}
disabled={isGitLoading || !sessionId}
>
{primaryButtonConfig.icon}
{primaryButtonConfig.label}
{primaryButtonConfig.badge != null && (
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{primaryButtonConfig.badge}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>{primaryButtonConfig.tooltip}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 border-l px-2 cursor-pointer"
disabled={isGitLoading || !sessionId}
>
{isGitLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconChevronDown className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
<div className="inline-flex rounded-md border border-border overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 cursor-pointer"
onClick={primaryButtonConfig.onClick}
disabled={isGitLoading || !sessionId}
>
{primaryButtonConfig.icon}
{primaryButtonConfig.label}
{primaryButtonConfig.badge != null && (
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{primaryButtonConfig.badge}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>{primaryButtonConfig.tooltip}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 border-l px-2 cursor-pointer"
disabled={isGitLoading || !sessionId}
>
{isGitLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconChevronDown className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={openPRDialog}
disabled={isGitLoading || !sessionId}
>
<IconGitPullRequest className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Create PR</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handlePull}
disabled={isGitLoading || !sessionId}
>
<IconCloudDownload className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Pull</span>
{hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
↓{gitStatus?.behind}
</span>
)}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="cursor-pointer gap-3"
onClick={handleOpenPRDialog}
disabled={isGitLoading || !sessionId}
>
<IconGitPullRequest className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Create PR</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handlePull}
disabled={isGitLoading || !sessionId}
>
<IconCloudDownload className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Pull</span>
{hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && (
<IconCloudUpload className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Push</span>
{hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
↓{gitStatus?.behind}
↑{gitStatus?.ahead}
</span>
)}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={() => handlePush(false)}
disabled={isGitLoading || !sessionId}
>
<IconCloudUpload className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Push</span>
{hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
↑{gitStatus?.ahead}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={() => handlePush(false)}
disabled={isGitLoading || !sessionId}
>
<IconCloudUpload className="h-4 w-4 text-muted-foreground" />
<span>Push</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={() => handlePush(true)}
disabled={isGitLoading || !sessionId}
>
<IconAlertTriangle className="h-4 w-4 text-muted-foreground" />
<span>Force Push</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleRebase}
disabled={isGitLoading || !sessionId}
>
<IconGitCherryPick className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Rebase</span>
<span className="text-xs text-muted-foreground">
onto {baseBranch || 'origin/main'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleMerge}
disabled={isGitLoading || !sessionId}
>
<IconGitMerge className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Merge</span>
<span className="text-xs text-muted-foreground">
from {baseBranch || 'origin/main'}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Commit Dialog */}
<Dialog open={commitDialogOpen} onOpenChange={setCommitDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitCommit className="h-5 w-5" />
Commit Changes
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="text-sm text-muted-foreground">
{uncommittedFileCount > 0 ? (
<span>
<span className="font-medium text-foreground">
{uncommittedFileCount}
</span>{' '}
file{uncommittedFileCount !== 1 ? 's' : ''} changed
{(uncommittedAdditions > 0 || uncommittedDeletions > 0) && (
<span className="ml-2">
(
<span className="text-green-600">
+{uncommittedAdditions}
</span>
{' / '}
<span className="text-red-600">
-{uncommittedDeletions}
</span>
)
</span>
)}
</span>
) : (
<span>No changes to commit</span>
)}
</div>
<Textarea
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={4}
className="resize-none"
autoFocus
/>
<div className="flex items-center gap-2">
<Checkbox
id="vcs-stage-all"
checked={stageAll}
onCheckedChange={(checked) => setStageAll(checked === true)}
/>
<Label
htmlFor="vcs-stage-all"
className="text-sm text-muted-foreground cursor-pointer"
<span>Push</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={() => handlePush(true)}
disabled={isGitLoading || !sessionId}
>
Stage all changes before committing
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
onClick={handleCommit}
disabled={!commitMessage.trim() || isGitLoading}
>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Committing...
</>
) : (
<>
<IconCheck className="h-4 w-4 mr-2" />
Commit
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create PR Dialog */}
<Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitPullRequest className="h-5 w-5" />
Create Pull Request
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{displayBranch && (
<div className="text-sm text-muted-foreground">
{baseBranch ? (
<span>
Creating PR from{' '}
<span className="font-medium text-foreground">
{displayBranch}
</span>{' '}
→{' '}
<span className="font-medium text-foreground">
{baseBranch}
</span>
</span>
) : (
<span>
Creating PR from{' '}
<span className="font-medium text-foreground">
{displayBranch}
</span>
</span>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="vcs-pr-title" className="text-sm">
Title
</Label>
<input
id="vcs-pr-title"
type="text"
placeholder="Pull request title..."
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="vcs-pr-body" className="text-sm">
Description
</Label>
<Textarea
id="vcs-pr-body"
placeholder="Describe your changes..."
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
rows={6}
className="resize-none"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="vcs-pr-draft"
checked={prDraft}
onCheckedChange={(checked) => setPrDraft(checked === true)}
/>
<Label
htmlFor="vcs-pr-draft"
className="text-sm cursor-pointer"
>
Create as draft
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
onClick={handleCreatePR}
disabled={!prTitle.trim() || isGitLoading}
>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
<>
<IconGitPullRequest className="h-4 w-4 mr-2" />
Create PR
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
<IconAlertTriangle className="h-4 w-4 text-muted-foreground" />
<span>Force Push</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleRebase}
disabled={isGitLoading || !sessionId}
>
<IconGitCherryPick className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Rebase</span>
<span className="text-xs text-muted-foreground">
onto {baseBranch || 'origin/main'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleMerge}
disabled={isGitLoading || !sessionId}
>
<IconGitMerge className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Merge</span>
<span className="text-xs text-muted-foreground">
from {baseBranch || 'origin/main'}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
});
+293
View File
@@ -0,0 +1,293 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import {
IconGitCommit,
IconGitPullRequest,
IconLoader2,
IconCheck,
} from '@tabler/icons-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@kandev/ui/dialog';
import { Button } from '@kandev/ui/button';
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 { useGitWithFeedback } from '@/hooks/use-git-with-feedback';
import { useToast } from '@/components/toast-provider';
import type { FileInfo } from '@/lib/state/slices';
type VcsDialogsContextValue = {
openCommitDialog: () => void;
openPRDialog: () => void;
};
const VcsDialogsContext = createContext<VcsDialogsContextValue | null>(null);
export function useVcsDialogs() {
const ctx = useContext(VcsDialogsContext);
if (!ctx) throw new Error('useVcsDialogs must be used within VcsDialogsProvider');
return ctx;
}
type VcsDialogsProviderProps = {
sessionId: string | null;
baseBranch?: string;
taskTitle?: string;
displayBranch?: string | null;
children: ReactNode;
};
export function VcsDialogsProvider({
sessionId,
baseBranch,
taskTitle,
displayBranch,
children,
}: VcsDialogsProviderProps) {
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 { commit, createPR, isLoading: isGitLoading } = useGitOperations(sessionId);
// Uncommitted file stats
const uncommittedFileCount = gitStatus?.files
? 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 openCommitDialog = useCallback(() => {
setCommitMessage('');
setStageAll(true);
setCommitDialogOpen(true);
}, []);
const handleCommit = useCallback(async () => {
if (!commitMessage.trim()) return;
setCommitDialogOpen(false);
await gitWithFeedback(
() => commit(commitMessage.trim(), stageAll),
'Commit'
);
setCommitMessage('');
}, [commitMessage, stageAll, gitWithFeedback, commit]);
const openPRDialog = 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]);
return (
<VcsDialogsContext.Provider value={{ openCommitDialog, openPRDialog }}>
{children}
{/* Commit Dialog */}
<Dialog open={commitDialogOpen} onOpenChange={setCommitDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitCommit className="h-5 w-5" />
Commit Changes
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="text-sm text-muted-foreground">
{uncommittedFileCount > 0 ? (
<span>
<span className="font-medium text-foreground">
{uncommittedFileCount}
</span>{' '}
file{uncommittedFileCount !== 1 ? 's' : ''} changed
{(uncommittedAdditions > 0 || uncommittedDeletions > 0) && (
<span className="ml-2">
(
<span className="text-green-600">+{uncommittedAdditions}</span>
{' / '}
<span className="text-red-600">-{uncommittedDeletions}</span>
)
</span>
)}
</span>
) : (
<span>No changes to commit</span>
)}
</div>
<Textarea
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={4}
className="resize-none"
autoFocus
/>
<div className="flex items-center gap-2">
<Checkbox
id="vcs-stage-all"
checked={stageAll}
onCheckedChange={(checked) => setStageAll(checked === true)}
/>
<Label htmlFor="vcs-stage-all" className="text-sm text-muted-foreground cursor-pointer">
Stage all changes before committing
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleCommit} disabled={!commitMessage.trim() || isGitLoading}>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Committing...
</>
) : (
<>
<IconCheck className="h-4 w-4 mr-2" />
Commit
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create PR Dialog */}
<Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitPullRequest className="h-5 w-5" />
Create Pull Request
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{displayBranch && (
<div className="text-sm text-muted-foreground">
{baseBranch ? (
<span>
Creating PR from{' '}
<span className="font-medium text-foreground">{displayBranch}</span>
{' → '}
<span className="font-medium text-foreground">{baseBranch}</span>
</span>
) : (
<span>
Creating PR from{' '}
<span className="font-medium text-foreground">{displayBranch}</span>
</span>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="vcs-pr-title" className="text-sm">Title</Label>
<input
id="vcs-pr-title"
type="text"
placeholder="Pull request title..."
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="vcs-pr-body" className="text-sm">Description</Label>
<Textarea
id="vcs-pr-body"
placeholder="Describe your changes..."
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
rows={6}
className="resize-none"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="vcs-pr-draft"
checked={prDraft}
onCheckedChange={(checked) => setPrDraft(checked === true)}
/>
<Label htmlFor="vcs-pr-draft" className="text-sm cursor-pointer">
Create as draft
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleCreatePR} disabled={!prTitle.trim() || isGitLoading}>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
<>
<IconGitPullRequest className="h-4 w-4 mr-2" />
Create PR
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</VcsDialogsContext.Provider>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useCallback } from 'react';
import { useToast } from '@/components/toast-provider';
type GitOperationResult = { success: boolean; output: string; error?: string };
/**
* Wraps a git operation with toast feedback (loading → success/error).
*/
export function useGitWithFeedback() {
const { toast, updateToast } = useToast();
const run = useCallback(
async (
operation: () => Promise<GitOperationResult>,
operationName: string
) => {
const toastId = toast({
title: `${operationName}...`,
variant: 'loading',
});
try {
const result = await operation();
if (result.success) {
updateToast(toastId, {
title: `${operationName} successful`,
description: result.output.slice(0, 200) || `${operationName} completed`,
variant: 'success',
});
} else {
updateToast(toastId, {
title: `${operationName} failed`,
description: result.error || 'An error occurred',
variant: 'error',
});
}
} catch (e) {
updateToast(toastId, {
title: `${operationName} failed`,
description: e instanceof Error ? e.message : 'An unexpected error occurred',
variant: 'error',
});
}
},
[toast, updateToast]
);
return run;
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { useEffect, useId } from 'react';
import { useCommandRegistry } from '@/lib/commands/command-registry';
import type { CommandItem } from '@/lib/commands/types';
/**
* Register commands that appear in the command panel.
* Commands are automatically unregistered when the component unmounts.
*/
export function useRegisterCommands(commands: CommandItem[]) {
const { register, unregister } = useCommandRegistry();
const sourceId = useId();
useEffect(() => {
register(sourceId, commands);
return () => unregister(sourceId);
}, [sourceId, commands, register, unregister]);
}
@@ -0,0 +1,91 @@
'use client';
import {
createContext,
useContext,
useCallback,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from 'react';
import type { CommandItem } from './types';
type CommandRegistryContextValue = {
register: (sourceId: string, commands: CommandItem[]) => void;
unregister: (sourceId: string) => void;
subscribe: (callback: () => void) => () => void;
getSnapshot: () => CommandItem[];
open: boolean;
setOpen: (open: boolean) => void;
};
const CommandRegistryContext = createContext<CommandRegistryContextValue | null>(null);
export function CommandRegistryProvider({ children }: { children: ReactNode }) {
const commandsRef = useRef(new Map<string, CommandItem[]>());
const listenersRef = useRef(new Set<() => void>());
const snapshotRef = useRef<CommandItem[]>([]);
const [open, setOpen] = useState(false);
const notify = useCallback(() => {
// Rebuild snapshot
const all: CommandItem[] = [];
for (const items of commandsRef.current.values()) {
all.push(...items);
}
snapshotRef.current = all;
for (const listener of listenersRef.current) {
listener();
}
}, []);
const register = useCallback(
(sourceId: string, commands: CommandItem[]) => {
commandsRef.current.set(sourceId, commands);
notify();
},
[notify]
);
const unregister = useCallback(
(sourceId: string) => {
commandsRef.current.delete(sourceId);
notify();
},
[notify]
);
const subscribe = useCallback((callback: () => void) => {
listenersRef.current.add(callback);
return () => {
listenersRef.current.delete(callback);
};
}, []);
const getSnapshot = useCallback(() => snapshotRef.current, []);
return (
<CommandRegistryContext.Provider
value={{ register, unregister, subscribe, getSnapshot, open, setOpen }}
>
{children}
</CommandRegistryContext.Provider>
);
}
export function useCommandRegistry() {
const ctx = useContext(CommandRegistryContext);
if (!ctx) throw new Error('useCommandRegistry must be used within CommandRegistryProvider');
return ctx;
}
export function useCommands(): CommandItem[] {
const { subscribe, getSnapshot } = useCommandRegistry();
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function useCommandPanelOpen() {
const { open, setOpen } = useCommandRegistry();
return { open, setOpen };
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import type { KeyboardShortcut } from '@/lib/keyboard/constants';
export type CommandPanelMode = 'commands' | 'search-tasks';
export type CommandItem = {
id: string;
label: string;
group: string;
icon?: ReactNode;
shortcut?: KeyboardShortcut;
keywords?: string[];
/** For level-2 transitions: set the mode instead of running an action */
enterMode?: CommandPanelMode;
/** Standard action — close panel and execute */
action?: () => void;
/** Lower values appear first. Page-specific = 0, global = 100. Default: 100 */
priority?: number;
};
+8
View File
@@ -121,5 +121,13 @@ export const SHORTCUTS = {
FOCUS_INPUT: {
key: KEYS.SLASH,
},
COMMAND_PANEL: {
key: KEYS.P,
modifiers: { ctrlOrCmd: true },
},
COMMAND_PANEL_SHIFT: {
key: KEYS.P,
modifiers: { ctrlOrCmd: true, shift: true },
},
} as const;