feat: task page
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconBrandVscode,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconChevronUp,
|
||||
IconCopy,
|
||||
IconEye,
|
||||
IconGitBranch,
|
||||
IconGitFork,
|
||||
IconGitMerge,
|
||||
IconGitPullRequest,
|
||||
IconPencil,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CommitStatBadge, LineStat } from '@/components/diff-stat';
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'agent';
|
||||
content: string;
|
||||
};
|
||||
|
||||
type ChatSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const AGENTS = [
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
{ id: 'claude', label: 'Claude Code' },
|
||||
];
|
||||
|
||||
const INITIAL_CHATS: ChatSession[] = [
|
||||
{
|
||||
id: 'chat-1',
|
||||
title: 'Build overview',
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'agent',
|
||||
content: 'I can help with this task. What should I tackle first?',
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
role: 'user',
|
||||
content: 'Please review the changes and summarize the key diffs.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
const ALL_FILES = [
|
||||
'apps/web/app/page.tsx',
|
||||
'apps/web/app/task/[id]/page.tsx',
|
||||
'apps/web/components/kanban-board.tsx',
|
||||
'apps/web/components/kanban-card.tsx',
|
||||
'apps/web/components/kanban-column.tsx',
|
||||
'apps/web/components/task-create-dialog.tsx',
|
||||
];
|
||||
|
||||
const COMMANDS = [
|
||||
{ id: 'dev', label: 'npm run dev' },
|
||||
{ id: 'lint', label: 'npm run lint' },
|
||||
{ id: 'test', label: 'npm run test' },
|
||||
];
|
||||
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
export default function TaskPage() {
|
||||
const [selectedAgent, setSelectedAgent] = useState(AGENTS[0].id);
|
||||
const [chats, setChats] = useState<ChatSession[]>(INITIAL_CHATS);
|
||||
const [leftTab, setLeftTab] = useState<'notes' | 'changes' | 'chat'>('chat');
|
||||
const [messageInput, setMessageInput] = useState('');
|
||||
const [topTab, setTopTab] = useState<'diff' | 'files'>('diff');
|
||||
const [activeTerminalId, setActiveTerminalId] = useState(1);
|
||||
const [terminalIds, setTerminalIds] = useState([1]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isBottomCollapsed, setIsBottomCollapsed] = useState(false);
|
||||
const [branchName, setBranchName] = useState('feature/agent-ui');
|
||||
const [isEditingBranch, setIsEditingBranch] = useState(false);
|
||||
|
||||
const activeChat = chats[0];
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const trimmed = messageInput.trim();
|
||||
if (!trimmed) return;
|
||||
setChats((currentChats) => {
|
||||
const [first, ...rest] = currentChats;
|
||||
if (!first) return currentChats;
|
||||
return [
|
||||
{
|
||||
...first,
|
||||
messages: [
|
||||
...first.messages,
|
||||
{ id: crypto.randomUUID(), role: 'user', content: trimmed },
|
||||
],
|
||||
},
|
||||
...rest,
|
||||
];
|
||||
});
|
||||
setMessageInput('');
|
||||
};
|
||||
|
||||
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}`;
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => setIsEditingBranch(true)}
|
||||
>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<IconChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<Select defaultValue="origin/main">
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconGitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="Base branch" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
<CommitStatBadge label="2 ahead" tone="ahead" />
|
||||
<CommitStatBadge label="4 behind" tone="behind" />
|
||||
<LineStat added={855} removed={8} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="cursor-pointer">
|
||||
<IconBrandVscode className="h-4 w-4" />
|
||||
Editor
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="cursor-pointer">
|
||||
<IconArrowDown className="h-4 w-4" />
|
||||
Pull
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="cursor-pointer">
|
||||
<IconEye className="h-4 w-4" />
|
||||
Review
|
||||
</Button>
|
||||
<Select defaultValue="create-pr">
|
||||
<SelectTrigger className="w-[190px] h-8 cursor-pointer">
|
||||
<SelectValue placeholder="Create PR" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="create-pr">
|
||||
<span className="flex items-center gap-2">
|
||||
<IconGitPullRequest className="h-4 w-4" />
|
||||
Create PR
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="create-pr-manual">
|
||||
<span className="flex items-center gap-2">
|
||||
<IconPencil className="h-4 w-4" />
|
||||
Create PR manually
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="merge">
|
||||
<span className="flex items-center gap-2">
|
||||
<IconGitMerge className="h-4 w-4" />
|
||||
Merge
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="rebase">
|
||||
<span className="flex items-center gap-2">
|
||||
<IconGitBranch className="h-4 w-4" />
|
||||
Rebase
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0 px-4 pb-4">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
<ResizablePanel defaultSize={75} minSize={55}>
|
||||
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-r-0">
|
||||
<Tabs
|
||||
value={leftTab}
|
||||
onValueChange={(value) => setLeftTab(value as typeof leftTab)}
|
||||
className="flex-1 min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
<TabsTrigger value="changes">All changes</TabsTrigger>
|
||||
<TabsTrigger value="chat">Chat {activeChat ? `• ${activeChat.title}` : ''}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="notes" className="mt-3 flex-1 min-h-0">
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
placeholder="Add task notes here..."
|
||||
className="min-h-0 h-full resize-none"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="changes" 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">
|
||||
<pre className="text-xs leading-relaxed whitespace-pre-wrap text-foreground">{`diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx
|
||||
index 1234567..89abcde 100644
|
||||
--- a/apps/web/components/kanban-board.tsx
|
||||
+++ b/apps/web/components/kanban-board.tsx
|
||||
@@ -1,5 +1,5 @@
|
||||
-export function KanbanBoard() {
|
||||
+export function KanbanBoard() {
|
||||
// ...`}</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
|
||||
<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="min-h-[90px] resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Select agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGENTS.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
{agent.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit">Submit</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="w-px" />
|
||||
<ResizablePanel defaultSize={25} minSize={20}>
|
||||
{isBottomCollapsed ? (
|
||||
<div className="h-full min-h-0 flex flex-col gap-1">
|
||||
<div className="flex-1 min-h-0">
|
||||
<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 typeof topTab)}
|
||||
className="flex-1 min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="diff">Diff files</TabsTrigger>
|
||||
<TabsTrigger value="files">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="flex items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-foreground">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 p-3 h-full">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{ALL_FILES.map((file) => {
|
||||
const { folder, file: name } = splitPath(file);
|
||||
return (
|
||||
<li key={file} className="truncate text-foreground">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-12 border border-border/70 rounded-lg bg-card flex items-center justify-between px-3 border-l-0">
|
||||
<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">Commands</TabsTrigger>
|
||||
{terminalIds.map((id) => (
|
||||
<TabsTrigger key={id} value={`terminal-${id}`}>
|
||||
{id === 1 ? 'Terminal' : `Terminal ${id}`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger value="add" onClick={addTerminal}>
|
||||
+
|
||||
</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>
|
||||
) : (
|
||||
<ResizablePanelGroup direction="vertical" className="h-full">
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<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 typeof topTab)}
|
||||
className="flex-1 min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="diff">Diff files</TabsTrigger>
|
||||
<TabsTrigger value="files">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="flex items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-foreground">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 p-3 h-full">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{ALL_FILES.map((file) => {
|
||||
const { folder, file: name } = splitPath(file);
|
||||
return (
|
||||
<li key={file} className="truncate text-foreground">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="h-px" />
|
||||
<ResizablePanel defaultSize={45} 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">
|
||||
<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">Commands</TabsTrigger>
|
||||
{terminalIds.map((id) => (
|
||||
<div key={id} className="group flex items-center gap-1">
|
||||
<TabsTrigger value={`terminal-${id}`}>
|
||||
{id === 1 ? 'Terminal' : `Terminal ${id}`}
|
||||
</TabsTrigger>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
|
||||
onClick={() => removeTerminal(id)}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<TabsTrigger value="add" onClick={addTerminal}>
|
||||
+
|
||||
</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>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
type CommitStatBadgeProps = {
|
||||
label: string;
|
||||
tone: 'ahead' | 'behind';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type LineStatProps = {
|
||||
added?: number;
|
||||
removed?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const commitTone = {
|
||||
ahead: 'text-emerald-600',
|
||||
behind: 'text-yellow-600',
|
||||
};
|
||||
|
||||
const lineTone = {
|
||||
add: 'text-emerald-600',
|
||||
remove: 'text-rose-600',
|
||||
};
|
||||
|
||||
export function CommitStatBadge({ label, tone, className }: CommitStatBadgeProps) {
|
||||
return (
|
||||
<Badge variant="stat" className={cn('text-xs font-semibold', commitTone[tone], className)}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineStat({ added, removed, className }: LineStatProps) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-2 text-xs font-semibold', className)}>
|
||||
{typeof added === 'number' && <span className={lineTone.add}>+{added}</span>}
|
||||
{typeof removed === 'number' && <span className={lineTone.remove}>-{removed}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const VIEWS: Record<
|
||||
string,
|
||||
@@ -74,7 +75,7 @@ export function KanbanBoard() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [activeViewId, setActiveViewId] = useState('team');
|
||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const isMounted = useSyncExternalStore(
|
||||
() => () => {},
|
||||
@@ -94,10 +95,6 @@ export function KanbanBoard() {
|
||||
() => tasks.find((task) => task.id === activeTaskId) ?? null,
|
||||
[tasks, activeTaskId]
|
||||
);
|
||||
const editingTask = useMemo(
|
||||
() => tasks.find((task) => task.id === editingTaskId) ?? null,
|
||||
[tasks, editingTaskId]
|
||||
);
|
||||
const activeView = VIEWS[activeViewId] ?? VIEWS.team;
|
||||
const activeColumns = activeView.columns;
|
||||
|
||||
@@ -126,16 +123,6 @@ export function KanbanBoard() {
|
||||
};
|
||||
|
||||
const handleDialogSubmit = (title: string, description?: string) => {
|
||||
if (editingTaskId) {
|
||||
setTasks((tasks) =>
|
||||
tasks.map((task) =>
|
||||
task.id === editingTaskId ? { ...task, title, description } : task
|
||||
)
|
||||
);
|
||||
setEditingTaskId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const columnId = activeColumns[0]?.id ?? 'todo';
|
||||
const newTask: Task = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -146,9 +133,8 @@ export function KanbanBoard() {
|
||||
setTasks((tasks) => [...tasks, newTask]);
|
||||
};
|
||||
|
||||
const handleEditTask = (task: Task) => {
|
||||
setEditingTaskId(task.id);
|
||||
setIsDialogOpen(true);
|
||||
const handleOpenTask = (task: Task) => {
|
||||
router.push(`/task/${task.id}`);
|
||||
};
|
||||
|
||||
const getTasksForColumn = (columnId: string) => {
|
||||
@@ -161,7 +147,7 @@ export function KanbanBoard() {
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background">
|
||||
<header className="flex items-center justify-between p-6 pb-4">
|
||||
<header className="flex items-center justify-between p-4 pb-3">
|
||||
<h1 className="text-2xl font-bold">KanDev.ai</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={activeViewId} onValueChange={setActiveViewId}>
|
||||
@@ -176,12 +162,7 @@ export function KanbanBoard() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingTaskId(null);
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
<IconPlus className="h-4 w-4" />
|
||||
Add task
|
||||
</Button>
|
||||
@@ -189,21 +170,12 @@ export function KanbanBoard() {
|
||||
</div>
|
||||
</header>
|
||||
<TaskCreateDialog
|
||||
key={`${editingTaskId ?? 'new'}-${isDialogOpen ? 'open' : 'closed'}`}
|
||||
key={isDialogOpen ? 'open' : 'closed'}
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) {
|
||||
setEditingTaskId(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={(title, description) => handleDialogSubmit(title, description)}
|
||||
initialValues={
|
||||
editingTask
|
||||
? { title: editingTask.title, description: editingTask.description }
|
||||
: undefined
|
||||
}
|
||||
submitLabel={editingTask ? 'Save' : 'Create'}
|
||||
/>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -211,7 +183,7 @@ export function KanbanBoard() {
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="flex-1 min-h-0 px-6 pb-6">
|
||||
<div className="flex-1 min-h-0 px-4 pb-4">
|
||||
<div
|
||||
className="grid gap-px bg-border rounded-lg overflow-hidden h-full"
|
||||
style={{ gridTemplateColumns: `repeat(${activeColumns.length}, minmax(0, 1fr))` }}
|
||||
@@ -221,7 +193,7 @@ export function KanbanBoard() {
|
||||
key={column.id}
|
||||
column={column}
|
||||
tasks={getTasksForColumn(column.id)}
|
||||
onEditTask={handleEditTask}
|
||||
onOpenTask={handleOpenTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -46,10 +46,7 @@ export function KanbanCard({ task, onClick }: KanbanCardProps) {
|
||||
size="sm"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'cursor-grab active:cursor-grabbing mb-2 w-full py-0',
|
||||
isDragging && 'opacity-50 z-50'
|
||||
)}
|
||||
className={cn('cursor-pointer mb-2 w-full py-0', isDragging && 'opacity-50 z-50')}
|
||||
onClick={() => onClick?.(task)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
|
||||
@@ -14,10 +14,10 @@ export interface Column {
|
||||
interface KanbanColumnProps {
|
||||
column: Column;
|
||||
tasks: Task[];
|
||||
onEditTask: (task: Task) => void;
|
||||
onOpenTask: (task: Task) => void;
|
||||
}
|
||||
|
||||
export function KanbanColumn({ column, tasks, onEditTask }: KanbanColumnProps) {
|
||||
export function KanbanColumn({ column, tasks, onOpenTask }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: column.id,
|
||||
});
|
||||
@@ -26,12 +26,12 @@ export function KanbanColumn({ column, tasks, onEditTask }: KanbanColumnProps) {
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex flex-col h-full min-w-0 bg-card p-4 min-h-[600px]',
|
||||
'flex flex-col h-full min-w-0 bg-card p-5 min-h-[600px] rounded-lg',
|
||||
isOver && 'ring-2 ring-primary'
|
||||
)}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/70 pb-3 mb-4 px-2">
|
||||
<div className="flex items-center justify-between border-b border-border/70 pb-3 mb-4 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('w-3 h-3 rounded-full', column.color)} />
|
||||
<h2 className="font-semibold text-base">{column.title}</h2>
|
||||
@@ -42,12 +42,12 @@ export function KanbanColumn({ column, tasks, onEditTask }: KanbanColumnProps) {
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden space-y-2 px-2 pt-3">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden space-y-2 px-3 pt-4">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center mt-8">No tasks yet</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<KanbanCard key={task.id} task={task} onClick={onEditTask} />
|
||||
<KanbanCard key={task.id} task={task} onClick={onOpenTask} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Direction = 'horizontal' | 'vertical';
|
||||
|
||||
interface ResizableSplitProps {
|
||||
direction: Direction;
|
||||
initialSizes?: [number, number];
|
||||
minSizes?: [number, number];
|
||||
className?: string;
|
||||
handleClassName?: string;
|
||||
children: [ReactNode, ReactNode];
|
||||
}
|
||||
|
||||
export function ResizableSplit({
|
||||
direction,
|
||||
initialSizes = [50, 50],
|
||||
minSizes = [20, 20],
|
||||
className,
|
||||
handleClassName,
|
||||
children,
|
||||
}: ResizableSplitProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [sizes, setSizes] = useState<[number, number]>(initialSizes);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const clampSizes = useCallback(
|
||||
(nextFirst: number) => {
|
||||
const minFirst = minSizes[0];
|
||||
const minSecond = minSizes[1];
|
||||
const clampedFirst = Math.min(100 - minSecond, Math.max(minFirst, nextFirst));
|
||||
return [clampedFirst, 100 - clampedFirst] as [number, number];
|
||||
},
|
||||
[minSizes]
|
||||
);
|
||||
|
||||
const updateSizes = useCallback(
|
||||
(event: PointerEvent | React.PointerEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const total = direction === 'horizontal' ? rect.width : rect.height;
|
||||
if (!total) return;
|
||||
const offset =
|
||||
direction === 'horizontal' ? event.clientX - rect.left : event.clientY - rect.top;
|
||||
const nextFirst = (offset / total) * 100;
|
||||
setSizes(clampSizes(nextFirst));
|
||||
},
|
||||
[clampSizes, direction]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
updateSizes(event);
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
updateSizes(moveEvent);
|
||||
};
|
||||
const handleUp = (upEvent: PointerEvent) => {
|
||||
updateSizes(upEvent);
|
||||
setIsDragging(false);
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
},
|
||||
[updateSizes]
|
||||
);
|
||||
|
||||
const isHorizontal = direction === 'horizontal';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'flex min-h-0 min-w-0 h-full w-full',
|
||||
isHorizontal ? 'flex-row' : 'flex-col',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="min-h-0 min-w-0"
|
||||
style={{ flex: `0 0 ${sizes[0]}%` }}
|
||||
>
|
||||
{children[0]}
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={direction}
|
||||
onPointerDown={handlePointerDown}
|
||||
className={cn(
|
||||
isHorizontal
|
||||
? 'w-2 cursor-col-resize border-l border-transparent'
|
||||
: 'h-2 cursor-row-resize border-t border-transparent',
|
||||
'bg-transparent hover:border-border/70',
|
||||
isDragging && 'border-border',
|
||||
handleClassName
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className="min-h-0 min-w-0"
|
||||
style={{ flex: `1 1 ${sizes[1]}%` }}
|
||||
>
|
||||
{children[1]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const badgeVariants = cva(
|
||||
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
stat: "bg-transparent border-transparent",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle>) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-2 items-center justify-center bg-transparent outline-none cursor-col-resize data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:h-2 data-[panel-group-direction=vertical]:w-full",
|
||||
"hover:bg-border/60 data-[resize-handle-state=drag]:bg-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn("inline-flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"text-xs font-semibold uppercase tracking-wide px-2 py-1 rounded-md cursor-pointer text-muted-foreground hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("min-h-0 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
Generated
+11
@@ -20,6 +20,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"shadcn": "^3.6.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
@@ -9620,6 +9621,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable-panels": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz",
|
||||
"integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"shadcn": "^3.6.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
|
||||
Reference in New Issue
Block a user