Files
kandev/apps/web/components/task/task-top-bar.tsx
T
2026-01-17 19:02:22 +00:00

494 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { memo, useMemo, useState } from 'react';
import Link from 'next/link';
import {
IconArrowLeft,
IconArrowUp,
IconBrandVscode,
IconChevronDown,
IconCopy,
IconDots,
IconFolderOpen,
IconGitBranch,
IconGitMerge,
IconGitPullRequest,
IconLoader2,
IconPlayerPlay,
IconPlayerStop,
IconCheck,
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kandev/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover';
import { SessionsDropdown } from './sessions-dropdown';
import { CommitStatBadge, LineStat } from '@/components/diff-stat';
import { useAppStore } from '@/components/state-provider';
import { useSessionGitStatus } from '@/hooks/use-session-git-status';
import { formatUserHomePath } from '@/lib/utils';
type TaskTopBarProps = {
taskId?: string | null;
activeSessionId?: string | null;
taskTitle?: string;
taskDescription?: string;
baseBranch?: string;
onStartAgent?: (agentProfileId: string) => void;
onStopAgent?: () => void;
isAgentRunning?: boolean;
isAgentLoading?: boolean;
worktreePath?: string | null;
worktreeBranch?: string | null;
repositoryPath?: string | null;
repositoryName?: string | null;
};
const TaskTopBar = memo(function TaskTopBar({
taskId,
activeSessionId,
taskTitle,
taskDescription,
baseBranch,
onStartAgent,
onStopAgent,
isAgentRunning = false,
isAgentLoading = false,
worktreePath,
worktreeBranch,
repositoryPath,
repositoryName,
}: TaskTopBarProps) {
const [copiedBranch, setCopiedBranch] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const [copiedRepo, setCopiedRepo] = useState(false);
const [copiedWorktree, setCopiedWorktree] = useState(false);
const [startDialogOpen, setStartDialogOpen] = useState(false);
const [selectedAgentProfileId, setSelectedAgentProfileId] = useState<string | null>(null);
const gitStatus = useSessionGitStatus(activeSessionId ?? null);
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const resolvedAgentProfileId = useMemo(() => {
if (selectedAgentProfileId && agentProfiles.some((profile) => profile.id === selectedAgentProfileId)) {
return selectedAgentProfileId;
}
return agentProfiles[0]?.id ?? '';
}, [agentProfiles, selectedAgentProfileId]);
// Use worktree branch if available, otherwise fall back to base branch
const displayBranch = worktreeBranch || baseBranch;
// Calculate total additions and deletions from all files.
let totalAdditions = 0;
let totalDeletions = 0;
if (gitStatus?.files && Object.keys(gitStatus.files).length > 0) {
for (const file of Object.values(gitStatus.files)) {
totalAdditions += file.additions || 0;
totalDeletions += file.deletions || 0;
}
}
const handleBranchClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (displayBranch) {
navigator.clipboard?.writeText(displayBranch);
setCopiedBranch(true);
setTimeout(() => setCopiedBranch(false), 500);
}
};
const handleCopyRepo = (e: React.MouseEvent) => {
e.stopPropagation();
if (repositoryPath) {
navigator.clipboard?.writeText(formatUserHomePath(repositoryPath));
setCopiedRepo(true);
setTimeout(() => setCopiedRepo(false), 500);
}
};
const handleCopyWorktree = (e: React.MouseEvent) => {
e.stopPropagation();
if (worktreePath) {
navigator.clipboard?.writeText(formatUserHomePath(worktreePath));
setCopiedWorktree(true);
setTimeout(() => setCopiedWorktree(false), 500);
}
};
return (
<header className="flex items-center justify-between p-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link href="/">
<IconArrowLeft className="h-4 w-4" />
Back
</Link>
</Button>
{repositoryName && (
<>
<span className="text-sm text-muted-foreground">{repositoryName}</span>
<span className="text-sm text-muted-foreground">›</span>
</>
)}
<span className="text-sm font-medium">{taskTitle ?? 'Task details'}</span>
<SessionsDropdown
taskId={taskId ?? null}
activeSessionId={activeSessionId ?? null}
taskTitle={taskTitle}
taskDescription={taskDescription}
/>
{displayBranch && (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<div className="group flex items-center gap-1.5 rounded-md px-2 h-7 bg-muted/40 hover:bg-muted/60 cursor-pointer transition-colors">
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{displayBranch}</span>
<button
type="button"
onClick={handleBranchClick}
className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ml-0.5"
>
{copiedBranch ? (
<IconCheck className="h-3 w-3 text-green-500" />
) : (
<IconCopy className="h-3 w-3 text-muted-foreground hover:text-foreground" />
)}
</button>
</div>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Current branch</TooltipContent>
</Tooltip>
<PopoverContent
side="bottom"
sideOffset={5}
className="p-0 w-auto max-w-[600px] gap-1"
>
<div className="px-2 pt-1 pb-2 space-y-1.5">
{repositoryPath && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Repository</div>
<div className="relative group/repo overflow-hidden">
<div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 pr-9 rounded-sm select-text cursor-text whitespace-nowrap">
{formatUserHomePath(repositoryPath)}
</div>
<button
type="button"
onClick={handleCopyRepo}
className="absolute right-1 top-1 p-1 rounded bg-card/80 backdrop-blur-sm hover:bg-card transition-all shadow-sm"
>
{copiedRepo ? (
<IconCheck className="h-3 w-3 text-green-500" />
) : (
<IconCopy className="h-3 w-3 text-muted-foreground" />
)}
</button>
</div>
</div>
)}
{worktreePath && (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">Worktree</div>
<div className="relative group/worktree overflow-hidden">
<div className="text-xs bg-muted/50 px-2 py-1.5 pr-9 rounded-sm select-text cursor-text whitespace-nowrap text-muted-foreground">
{formatUserHomePath(worktreePath)}
</div>
<button
type="button"
onClick={handleCopyWorktree}
className="absolute right-1 top-1 p-1 rounded bg-card/80 backdrop-blur-sm hover:bg-card transition-all shadow-sm"
>
{copiedWorktree ? (
<IconCheck className="h-3 w-3 text-green-500" />
) : (
<IconCopy className="h-3 w-3 text-muted-foreground" />
)}
</button>
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
{/* Git Status: Ahead/Behind */}
{((gitStatus?.ahead ?? 0) > 0 || (gitStatus?.behind ?? 0) > 0) && (
<div className="flex items-center gap-1">
{(gitStatus?.ahead ?? 0) > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">
<CommitStatBadge label={`${gitStatus?.ahead ?? 0} ahead`} tone="ahead" />
</span>
</TooltipTrigger>
<TooltipContent>
{gitStatus?.ahead ?? 0} commit{(gitStatus?.ahead ?? 0) !== 1 ? 's' : ''} ahead of {gitStatus?.remote_branch || 'remote'}
</TooltipContent>
</Tooltip>
)}
{(gitStatus?.behind ?? 0) > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">
<CommitStatBadge label={`${gitStatus?.behind ?? 0} behind`} tone="behind" />
</span>
</TooltipTrigger>
<TooltipContent>
{gitStatus?.behind ?? 0} commit{(gitStatus?.behind ?? 0) !== 1 ? 's' : ''} behind {gitStatus?.remote_branch || 'remote'}
</TooltipContent>
</Tooltip>
)}
</div>
)}
{/* Git Status: Lines Changed */}
{(totalAdditions > 0 || totalDeletions > 0) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">
<LineStat added={totalAdditions} removed={totalDeletions} />
</span>
</TooltipTrigger>
<TooltipContent>Lines changed</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2">
{/* Start/Stop Agent Button */}
{isAgentRunning ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="cursor-pointer"
onClick={onStopAgent}
disabled={isAgentLoading}
>
{isAgentLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconPlayerStop className="h-4 w-4" />
)}
Stop Agent
</Button>
</TooltipTrigger>
<TooltipContent>Stop the agent</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="default"
className="cursor-pointer"
onClick={() => setStartDialogOpen(true)}
disabled={isAgentLoading || agentProfiles.length === 0}
>
{isAgentLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconPlayerPlay className="h-4 w-4" />
)}
Start Agent
</Button>
</TooltipTrigger>
<TooltipContent>
{agentProfiles.length === 0 ? 'Add an agent profile to start.' : 'Start the agent on this task'}
</TooltipContent>
</Tooltip>
)}
<Dialog open={startDialogOpen} onOpenChange={setStartDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Start Agent</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
Select an agent profile to start a new session.
</div>
<Select
value={resolvedAgentProfileId}
onValueChange={(value) => setSelectedAgentProfileId(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select agent profile" />
</SelectTrigger>
<SelectContent>
{agentProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setStartDialogOpen(false)}>
Cancel
</Button>
<Button
type="button"
onClick={() => {
if (!resolvedAgentProfileId) return;
onStartAgent?.(resolvedAgentProfileId);
setStartDialogOpen(false);
}}
disabled={!resolvedAgentProfileId || isAgentLoading}
>
Start Agent
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Editor Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="cursor-pointer"
onClick={() => {
// TODO: Implement open in editor
console.log('Open in editor');
}}
>
<IconBrandVscode className="h-4 w-4" />
Editor
</Button>
</TooltipTrigger>
<TooltipContent>Open in editor</TooltipContent>
</Tooltip>
{/* Create PR 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={() => {
// TODO: Implement create PR
console.log('Create PR');
}}
>
<IconGitPullRequest className="h-4 w-4" />
Create PR
</Button>
</TooltipTrigger>
<TooltipContent>Create pull request</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="rounded-none border-0 border-l px-2 cursor-pointer">
<IconChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
// TODO: Implement pull
console.log('Pull from remote');
}}
>
<IconArrowUp className="h-4 w-4 rotate-180" />
Pull
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
// TODO: Implement rebase
console.log('Rebase');
}}
>
<IconGitBranch className="h-4 w-4" />
Rebase
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
// TODO: Implement push
console.log('Push to remote');
}}
>
<IconArrowUp className="h-4 w-4" />
Push
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
// TODO: Implement merge
console.log('Merge');
}}
>
<IconGitMerge className="h-4 w-4" />
Merge
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* More Options (3-dot) Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer px-2">
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[220px]">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
if (worktreePath) {
navigator.clipboard?.writeText(worktreePath);
}
}}
disabled={!worktreePath}
>
<IconCopy className="h-4 w-4" />
Copy workspace path
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
// TODO: Implement open workspace folder
console.log('Open workspace folder:', worktreePath);
}}
disabled={!worktreePath}
>
<IconFolderOpen className="h-4 w-4" />
Open workspace folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
});
export { TaskTopBar };