feat: command panel (#235)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user