chore: refactored task components

This commit is contained in:
Carlos Florêncio
2026-01-11 11:19:34 +00:00
parent 6a46eb5a54
commit a7f8c767d7
7 changed files with 1097 additions and 995 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
'use client';
import { memo, useEffect, useMemo, useState } from 'react';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import { IconArrowBackUp, IconCopy, IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react';
import { useTheme } from 'next-themes';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { buildDiffData, CHANGED_FILES, DIFF_SAMPLES } from '@/components/task/task-data';
type TaskChangesPanelProps = {
selectedDiffPath: string | null;
onClearSelected: () => void;
};
const TaskChangesPanel = memo(function TaskChangesPanel({
selectedDiffPath,
onClearSelected,
}: TaskChangesPanelProps) {
const defaultDiffMode: 'unified' | 'split' = 'unified';
const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(defaultDiffMode);
const { resolvedTheme } = useTheme();
useEffect(() => {
const storedDiffMode = getLocalStorage('task-diff-view-mode', defaultDiffMode);
if (storedDiffMode !== defaultDiffMode) {
setDiffViewMode(storedDiffMode);
}
}, []);
const diffTargets = useMemo(
() => (selectedDiffPath ? [selectedDiffPath] : CHANGED_FILES.map((file) => file.path)),
[selectedDiffPath]
);
const diffModeEnum = diffViewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified;
const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
const selectedDiffContent = selectedDiffPath
? DIFF_SAMPLES[selectedDiffPath]?.newContent ?? ''
: '';
const isSingleDiffSelected = Boolean(selectedDiffPath && DIFF_SAMPLES[selectedDiffPath]);
return (
<div className="flex flex-col gap-2 h-full">
<div className="flex items-center justify-between gap-3">
<Badge variant="secondary" className="rounded-full text-xs">
{selectedDiffPath ?? 'All files'}
</Badge>
<div className="flex items-center gap-1.5">
{selectedDiffPath && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs cursor-pointer"
onClick={onClearSelected}
>
All changes
</Button>
</TooltipTrigger>
<TooltipContent>Show all changes</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs cursor-pointer"
disabled={!isSingleDiffSelected}
onClick={async () => {
if (!isSingleDiffSelected) return;
await navigator.clipboard.writeText(selectedDiffContent);
}}
>
<IconCopy className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Copy file contents</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs cursor-pointer"
disabled={!isSingleDiffSelected}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Discard changes</TooltipContent>
</Tooltip>
<div className="inline-flex rounded-md border border-border overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 px-2 text-xs rounded-none cursor-pointer',
diffViewMode === 'unified' && 'bg-muted'
)}
onClick={() => {
setDiffViewMode('unified');
setLocalStorage('task-diff-view-mode', 'unified');
}}
>
<IconLayoutRows className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Unified view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 px-2 text-xs rounded-none cursor-pointer',
diffViewMode === 'split' && 'bg-muted'
)}
onClick={() => {
setDiffViewMode('split');
setLocalStorage('task-diff-view-mode', 'split');
}}
>
<IconLayoutColumns className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Split view</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 h-full">
<div className="space-y-4">
{diffTargets.map((path) => (
<div key={path} className="space-y-2">
{!selectedDiffPath && (
<div className="flex items-center justify-between">
<Badge variant="secondary" className="rounded-full text-xs">
{path}
</Badge>
</div>
)}
<DiffView data={buildDiffData(path)} diffViewMode={diffModeEnum} diffViewTheme={diffTheme} />
</div>
))}
</div>
</div>
</div>
);
});
export { TaskChangesPanel };
@@ -0,0 +1,155 @@
'use client';
import { FormEvent, useState } from 'react';
import { IconBrain, IconListCheck, IconPaperclip } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
type ChatMessage = {
id: string;
role: 'user' | 'agent';
content: string;
};
type ChatSession = {
id: string;
title: string;
messages: ChatMessage[];
};
type AgentOption = {
id: string;
label: string;
};
type TaskChatPanelProps = {
activeChat?: ChatSession;
agents: AgentOption[];
onSend: (message: string) => void;
};
export function TaskChatPanel({ activeChat, agents, onSend }: TaskChatPanelProps) {
const [messageInput, setMessageInput] = useState('');
const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? '');
const [planModeEnabled, setPlanModeEnabled] = useState(false);
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
const trimmed = messageInput.trim();
if (!trimmed) return;
onSend(trimmed);
setMessageInput('');
};
return (
<>
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 space-y-3">
{(activeChat?.messages ?? []).map((message) => (
<div
key={message.id}
className={cn(
'max-w-[80%] rounded-lg px-3 py-2 text-sm leading-relaxed',
message.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
)}
>
<p className="text-[11px] uppercase tracking-wide mb-1 opacity-70">
{message.role === 'user' ? 'You' : 'Agent'}
</p>
<p>{message.content}</p>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-2">
<Textarea
value={messageInput}
onChange={(event) => setMessageInput(event.target.value)}
placeholder="Write to submit work to the agent..."
className={cn(
'min-h-[90px] resize-none',
planModeEnabled &&
'border-dashed border-primary/60 !bg-primary/20 dark:!bg-primary/20 shadow-[inset_0_0_0_1px_rgba(59,130,246,0.35)]'
)}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="w-[160px] cursor-pointer">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="icon" className="h-9 w-9 cursor-pointer">
<IconBrain className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Thinking level</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top">
<DropdownMenuItem>High</DropdownMenuItem>
<DropdownMenuItem>Medium</DropdownMenuItem>
<DropdownMenuItem>Low</DropdownMenuItem>
<DropdownMenuItem>Off</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className={cn(
'h-9 w-9 cursor-pointer',
planModeEnabled &&
'bg-primary/15 text-primary border-primary/40 shadow-[0_0_0_1px_rgba(59,130,246,0.35)]'
)}
onClick={() => setPlanModeEnabled((value) => !value)}
>
<IconListCheck className="h-4 w-4" />
</Button>
{planModeEnabled && (
<span className="text-xs font-medium text-primary">Plan mode active</span>
)}
</div>
</TooltipTrigger>
<TooltipContent>Toggle plan mode</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="outline" size="icon" className="h-9 w-9 cursor-pointer">
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add attachments</TooltipContent>
</Tooltip>
<Button type="submit">Submit</Button>
</div>
</div>
</form>
</>
);
}
+155
View File
@@ -0,0 +1,155 @@
export const CHANGED_FILES = [
{ path: 'apps/web/components/kanban-board.tsx', status: 'M', plus: 12, minus: 4 },
{ path: 'apps/web/components/kanban-card.tsx', status: 'M', plus: 3, minus: 2 },
{ path: 'apps/web/components/task-create-dialog.tsx', status: 'A', plus: 55, minus: 0 },
{ path: 'apps/web/components/kanban-column.tsx', status: 'D', plus: 0, minus: 18 },
];
export const DIFF_SAMPLES: Record<string, { diff: string; newContent: string }> = {
'apps/web/components/kanban-board.tsx': {
diff: [
'diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx',
'index 7b45ad2..9c0f2ee 100644',
'--- a/apps/web/components/kanban-board.tsx',
'+++ b/apps/web/components/kanban-board.tsx',
'@@ -14,6 +14,7 @@ export function KanbanBoard() {',
' const columns = useMemo(() => getColumns(view), [view]);',
' const tasks = useMemo(() => getTasks(), []);',
'+ const hasAlerts = tasks.some((task) => task.priority === "high");',
' return (',
' <div className="kanban-board">',
' <BoardHeader />',
].join('\n'),
newContent: [
'export function KanbanBoard() {',
' const columns = useMemo(() => getColumns(view), [view]);',
' const tasks = useMemo(() => getTasks(), []);',
' const hasAlerts = tasks.some((task) => task.priority === "high");',
' return (',
' <div className="kanban-board">',
' <BoardHeader />',
' </div>',
' );',
'}',
].join('\n'),
},
'apps/web/components/kanban-card.tsx': {
diff: [
'diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx',
'index a14d022..a0cc12f 100644',
'--- a/apps/web/components/kanban-card.tsx',
'+++ b/apps/web/components/kanban-card.tsx',
'@@ -8,7 +8,8 @@ export function KanbanCard({ task }: KanbanCardProps) {',
' return (',
' <Card className="kanban-card">',
'- <h4 className="title">{task.title}</h4>',
'+ <h4 className="title">{task.title}</h4>',
'+ <span className="tag">{task.assignee}</span>',
' <p className="summary">{task.summary}</p>',
' </Card>',
' );',
].join('\n'),
newContent: [
'export function KanbanCard({ task }: KanbanCardProps) {',
' return (',
' <Card className="kanban-card">',
' <h4 className="title">{task.title}</h4>',
' <span className="tag">{task.assignee}</span>',
' <p className="summary">{task.summary}</p>',
' </Card>',
' );',
'}',
].join('\n'),
},
'apps/web/components/task-create-dialog.tsx': {
diff: [
'diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx',
'new file mode 100644',
'index 0000000..6c1a1f0',
'--- /dev/null',
'+++ b/apps/web/components/task-create-dialog.tsx',
'@@ -0,0 +1,6 @@',
'+export function TaskCreateDialog() {',
'+ return (',
'+ <Dialog>',
'+ <DialogContent>Create task</DialogContent>',
'+ </Dialog>',
'+ );',
'+}',
].join('\n'),
newContent: [
'export function TaskCreateDialog() {',
' return (',
' <Dialog>',
' <DialogContent>Create task</DialogContent>',
' </Dialog>',
' );',
'}',
].join('\n'),
},
'apps/web/components/kanban-column.tsx': {
diff: [
'diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx',
'deleted file mode 100644',
'index 9a9b7aa..0000000',
'--- a/apps/web/components/kanban-column.tsx',
'+++ /dev/null',
'@@ -1,5 +0,0 @@',
'-export function KanbanColumn() {',
'- return (',
'- <section className="kanban-column">Column</section>',
'- );',
'-}',
].join('\n'),
newContent: '',
},
};
export const COMMANDS = [
{ id: 'dev', label: 'npm run dev' },
{ id: 'lint', label: 'npm run lint' },
{ id: 'test', label: 'npm run test' },
];
export const FILE_TREE = [
[
'app',
['api', ['hello', ['route.ts']], 'page.tsx', 'layout.tsx', ['blog', ['page.tsx']]],
],
['components', ['ui', 'button.tsx', 'card.tsx'], 'header.tsx', 'footer.tsx'],
['lib', ['util.ts']],
['public', 'favicon.ico', 'vercel.svg'],
'.eslintrc.json',
'.gitignore',
'next.config.js',
'tailwind.config.js',
'package.json',
'README.md',
];
export function buildDiffData(filePath: string) {
const sample = DIFF_SAMPLES[filePath];
if (!sample) {
return {
hunks: [
[
`diff --git a/${filePath} b/${filePath}`,
'index 0000000..0000000 100644',
`--- a/${filePath}`,
`+++ b/${filePath}`,
'@@ -1,1 +1,1 @@',
'-',
'+',
].join('\n'),
],
oldFile: { fileName: filePath, fileLang: 'ts' },
newFile: { fileName: filePath, fileLang: 'ts' },
};
}
return {
hunks: [sample.diff],
oldFile: { fileName: filePath, fileLang: 'ts' },
newFile: { fileName: filePath, fileLang: 'ts' },
};
}
@@ -0,0 +1,195 @@
'use client';
import type { CSSProperties } from 'react';
import { memo, useState } from 'react';
import {
IconArrowBackUp,
IconChevronRight,
IconExternalLink,
IconFile,
IconFolder,
} from '@tabler/icons-react';
import { Badge } from '@/components/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarProvider,
SidebarRail,
} from '@/components/ui/sidebar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { LineStat } from '@/components/diff-stat';
import { cn } from '@/lib/utils';
import { CHANGED_FILES, FILE_TREE } from '@/components/task/task-data';
type TaskFilesPanelProps = {
onSelectDiffPath: (path: string) => void;
};
type TreeItem = string | TreeItem[];
const badgeClass = (status: string) =>
cn(
'text-[10px] font-semibold',
status === 'M' && 'bg-yellow-500/15 text-yellow-700',
status === 'A' && 'bg-emerald-500/15 text-emerald-700',
status === 'D' && 'bg-rose-500/15 text-rose-700'
);
const splitPath = (path: string) => {
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) return { folder: '', file: path };
return {
folder: path.slice(0, lastSlash),
file: path.slice(lastSlash + 1),
};
};
function Tree({ item }: { item: TreeItem }) {
const [name, ...items] = Array.isArray(item) ? item : [item];
if (!items.length) {
return (
<SidebarMenuButton className="data-[active=true]:bg-transparent">
<IconFile className="h-4 w-4" />
{name}
</SidebarMenuButton>
);
}
return (
<SidebarMenuItem>
<Collapsible
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
defaultOpen={name === 'components' || name === 'ui'}
>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<IconChevronRight className="h-4 w-4 transition-transform" />
<IconFolder className="h-4 w-4" />
{name}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{items.map((subItem, index) => (
<Tree key={index} item={subItem} />
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
);
}
const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath }: TaskFilesPanelProps) {
const [topTab, setTopTab] = useState<'diff' | 'files'>('diff');
return (
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-l-0">
<Tabs value={topTab} onValueChange={(value) => setTopTab(value as 'diff' | 'files')} className="flex-1 min-h-0">
<TabsList>
<TabsTrigger value="diff" className="cursor-pointer">
Diff files
</TabsTrigger>
<TabsTrigger value="files" className="cursor-pointer">
All files
</TabsTrigger>
</TabsList>
<TabsContent value="diff" className="mt-3 flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 h-full">
<ul className="space-y-2">
{CHANGED_FILES.map((file) => {
const { folder, file: name } = splitPath(file.path);
return (
<li
key={file.path}
className="group flex items-center justify-between gap-3 text-sm rounded-md px-1 py-0.5 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => onSelectDiffPath(file.path)}
>
<button type="button" className="min-w-0 text-left cursor-pointer">
<p className="truncate text-foreground">
<span className="text-foreground/60">{folder}/</span>
<span className="font-medium text-foreground">{name}</span>
</p>
</button>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Discard changes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
}}
>
<IconExternalLink className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Open in editor</TooltipContent>
</Tooltip>
</div>
<LineStat added={file.plus} removed={file.minus} />
<Badge className={badgeClass(file.status)}>{file.status}</Badge>
</div>
</li>
);
})}
</ul>
</div>
</TabsContent>
<TabsContent value="files" className="mt-3 flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background h-full">
<SidebarProvider
className="h-full w-full"
style={{ "--sidebar-width": "100%" } as CSSProperties}
>
<Sidebar collapsible="none" className="h-full w-full">
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{FILE_TREE.map((item, index) => (
<Tree key={index} item={item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</SidebarProvider>
</div>
</TabsContent>
</Tabs>
</div>
);
});
export { TaskFilesPanel };
@@ -0,0 +1,201 @@
'use client';
import type { ReactNode } from 'react';
import { memo, useEffect, useState } from 'react';
import { IconChevronDown, IconChevronUp, IconX } from '@tabler/icons-react';
import { Badge } from '@/components/ui/badge';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { COMMANDS } from '@/components/task/task-data';
type TaskRightPanelProps = {
topPanel: ReactNode;
};
const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanelProps) {
const defaultRightLayout: [number, number] = [55, 45];
const [rightLayout, setRightLayout] = useState(defaultRightLayout);
const [rightSeed, setRightSeed] = useState(0);
const [activeTerminalId, setActiveTerminalId] = useState(1);
const [terminalIds, setTerminalIds] = useState([1]);
const [isBottomCollapsed, setIsBottomCollapsed] = useState(false);
useEffect(() => {
const storedRight = getLocalStorage('task-layout-right', defaultRightLayout);
if (storedRight[0] !== defaultRightLayout[0] || storedRight[1] !== defaultRightLayout[1]) {
setRightLayout(storedRight);
setRightSeed((value) => value + 1);
}
}, []);
const addTerminal = () => {
setTerminalIds((ids) => {
const nextId = Math.max(0, ...ids) + 1;
setActiveTerminalId(nextId);
return [...ids, nextId];
});
};
const removeTerminal = (id: number) => {
setTerminalIds((ids) => {
const nextIds = ids.filter((terminalId) => terminalId !== id);
if (activeTerminalId === id) {
const fallback = nextIds[0] ?? 1;
setActiveTerminalId(fallback);
}
return nextIds.length ? nextIds : [1];
});
};
const terminalTabValue = activeTerminalId === 0 ? 'commands' : `terminal-${activeTerminalId}`;
if (isBottomCollapsed) {
return (
<div className="h-full min-h-0 flex flex-col gap-1">
<div className="flex-1 min-h-0">{topPanel}</div>
<div className="h-12 border border-border/70 rounded-lg bg-card flex items-center justify-between px-3 border-l-0 mt-[2px]">
<Tabs
value={terminalTabValue}
onValueChange={(value) => {
if (value === 'commands') {
setActiveTerminalId(0);
return;
}
const parsed = Number(value.replace('terminal-', ''));
if (!Number.isNaN(parsed)) {
setActiveTerminalId(parsed);
}
}}
className="flex-1 min-h-0"
>
<TabsList>
<TabsTrigger value="commands" className="cursor-pointer">
Commands
</TabsTrigger>
{terminalIds.map((id) => (
<TabsTrigger key={id} value={`terminal-${id}`} className="cursor-pointer">
{id === 1 ? 'Terminal' : `Terminal ${id}`}
</TabsTrigger>
))}
<TabsTrigger value="add" onClick={addTerminal} className="cursor-pointer">
+
</TabsTrigger>
</TabsList>
</Tabs>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setIsBottomCollapsed(false)}
>
<IconChevronUp className="h-4 w-4" />
</button>
</div>
</div>
);
}
return (
<ResizablePanelGroup
key={rightSeed}
direction="vertical"
className="h-full"
onLayout={(sizes) => {
setRightLayout(sizes as [number, number]);
setLocalStorage('task-layout-right', sizes);
}}
>
<ResizablePanel defaultSize={rightLayout[0]} minSize={30}>
{topPanel}
</ResizablePanel>
<ResizableHandle className="h-px" />
<ResizablePanel defaultSize={rightLayout[1]} minSize={20}>
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-l-0 mt-[5px]">
<Tabs
value={terminalTabValue}
onValueChange={(value) => {
if (value === 'commands') {
setActiveTerminalId(0);
return;
}
const parsed = Number(value.replace('terminal-', ''));
if (!Number.isNaN(parsed)) {
setActiveTerminalId(parsed);
}
}}
className="flex-1 min-h-0"
>
<div className="flex items-center justify-between mb-3">
<TabsList>
<TabsTrigger value="commands" className="cursor-pointer">
Commands
</TabsTrigger>
{terminalIds.map((id) => (
<TabsTrigger
key={id}
value={`terminal-${id}`}
className="group flex items-center gap-1 pr-1 cursor-pointer"
>
{id === 1 ? 'Terminal' : `Terminal ${id}`}
{terminalIds.length > 1 && (
<span
role="button"
tabIndex={-1}
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeTerminal(id);
}}
>
<IconX className="h-3.5 w-3.5" />
</span>
)}
</TabsTrigger>
))}
<TabsTrigger value="add" onClick={addTerminal} className="cursor-pointer">
+
</TabsTrigger>
</TabsList>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setIsBottomCollapsed(true)}
>
<IconChevronDown className="h-4 w-4" />
</button>
</div>
<TabsContent value="commands" className="flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 space-y-3 h-full">
<div className="grid gap-2">
{COMMANDS.map((command) => (
<button
key={command.id}
type="button"
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm text-left hover:bg-muted"
>
<span>{command.label}</span>
<Badge variant="secondary">Run</Badge>
</button>
))}
</div>
</div>
</TabsContent>
{terminalIds.map((id) => (
<TabsContent key={id} value={`terminal-${id}`} className="flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 space-y-3 h-full">
<div className="rounded-md border border-border bg-black/90 text-green-200 font-mono text-xs p-3 space-y-2">
<p className="text-green-400">kan-dev@workspace:~$</p>
<p className="text-green-200">_</p>
</div>
</div>
</TabsContent>
))}
</Tabs>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
});
export { TaskRightPanel };
+196
View File
@@ -0,0 +1,196 @@
'use client';
import { memo, useState } from 'react';
import Link from 'next/link';
import {
IconArrowDown,
IconArrowLeft,
IconBrandVscode,
IconChevronDown,
IconChevronRight,
IconCopy,
IconEye,
IconGitBranch,
IconGitFork,
IconGitMerge,
IconGitPullRequest,
IconPencil,
} from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { CommitStatBadge, LineStat } from '@/components/diff-stat';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
const TaskTopBar = memo(function TaskTopBar() {
const [branchName, setBranchName] = useState('feature/agent-ui');
const [isEditingBranch, setIsEditingBranch] = useState(false);
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>
<span className="text-xs text-muted-foreground">Task details</span>
<div className="flex items-center gap-2">
<div className="group flex items-center gap-2 rounded-md px-2 h-8 hover:bg-muted/40 cursor-default">
<IconGitFork className="h-4 w-4 text-muted-foreground" />
{isEditingBranch ? (
<input
className="bg-background text-sm outline-none w-[160px] rounded-md border border-border/70 px-1"
value={branchName}
onChange={(event) => setBranchName(event.target.value)}
onBlur={() => setIsEditingBranch(false)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === 'Escape') {
setIsEditingBranch(false);
}
}}
autoFocus
/>
) : (
<>
<span className="text-sm font-medium">{branchName}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setIsEditingBranch(true)}
>
<IconPencil className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Edit branch name</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => navigator.clipboard?.writeText(branchName)}
>
<IconCopy className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Copy branch name</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>
<IconChevronRight className="h-4 w-4 text-muted-foreground" />
<Select defaultValue="origin/main">
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="w-[190px] h-8 cursor-pointer border border-transparent bg-transparent hover:bg-muted/40 data-[state=open]:bg-background data-[state=open]:border-border/70">
<SelectValue placeholder="Base branch" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>Change base branch</TooltipContent>
</Tooltip>
<SelectContent>
<SelectItem value="origin/main">origin/main</SelectItem>
<SelectItem value="origin/develop">origin/develop</SelectItem>
<SelectItem value="origin/release">origin/release</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">
<CommitStatBadge label="2 ahead" tone="ahead" />
</span>
</TooltipTrigger>
<TooltipContent>Commits ahead of base</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">
<CommitStatBadge label="4 behind" tone="behind" />
</span>
</TooltipTrigger>
<TooltipContent>Commits behind base</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">
<LineStat added={855} removed={8} />
</span>
</TooltipTrigger>
<TooltipContent>Lines changed</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer">
<IconBrandVscode className="h-4 w-4" />
Editor
</Button>
</TooltipTrigger>
<TooltipContent>Open in editor</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer">
<IconArrowDown className="h-4 w-4" />
Pull
</Button>
</TooltipTrigger>
<TooltipContent>Pull from remote</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer">
<IconEye className="h-4 w-4" />
Review
</Button>
</TooltipTrigger>
<TooltipContent>Open review</TooltipContent>
</Tooltip>
<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">
<IconGitPullRequest className="h-4 w-4" />
Create PR
</Button>
</TooltipTrigger>
<TooltipContent>Create PR</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="rounded-none border-0 px-2 cursor-pointer">
<IconChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<IconPencil className="h-4 w-4" />
Create PR manually
</DropdownMenuItem>
<DropdownMenuItem>
<IconGitMerge className="h-4 w-4" />
Merge
</DropdownMenuItem>
<DropdownMenuItem>
<IconGitBranch className="h-4 w-4" />
Rebase
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
});
export { TaskTopBar };