feat: improved chat ui topbar

This commit is contained in:
Carlos Florêncio
2026-01-15 02:55:50 +00:00
parent 004fb20618
commit 44abff6493
14 changed files with 1241 additions and 578 deletions
+16 -6
View File
@@ -138,6 +138,15 @@
.kanban-task-pulse {
animation: kanban-task-pulse 5s ease-in-out infinite;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
@keyframes kanban-task-pulse {
@@ -256,21 +265,22 @@
}
.task-chat-editor .cm-editor {
background: transparent;
box-shadow: none;
outline: none;
background: transparent !important;
box-shadow: none !important;
outline: none !important;
}
.task-chat-editor .cm-editor.cm-focused {
outline: none;
outline: none !important;
}
.task-chat-editor .cm-scroller,
.task-chat-editor .cm-content,
.task-chat-editor .cm-line,
.task-chat-editor .cm-line span {
background: transparent;
color: var(--foreground);
background: transparent !important;
background-color: transparent !important;
color: var(--foreground) !important;
}
.task-chat-editor .cm-cursor {
+29 -332
View File
@@ -2,154 +2,36 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import '@git-diff-view/react/styles/diff-view.css';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui/resizable';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
import { TooltipProvider } from '@kandev/ui/tooltip';
import { Textarea } from '@kandev/ui/textarea';
import { IconX } from '@tabler/icons-react';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { TaskChatPanel } from '@/components/task/task-chat-panel';
import { TaskTopBar } from '@/components/task/task-top-bar';
import { TaskLayout } from '@/components/task/task-layout';
import type { Task } from '@/lib/types/http';
import type { OpenFileTab } from '@/lib/types/backend';
import { FILE_EXTENSION_COLORS } from '@/lib/types/backend';
import { TaskFilesPanel } from '@/components/task/task-files-panel';
import { TaskChangesPanel } from '@/components/task/task-changes-panel';
import { TaskRightPanel } from '@/components/task/task-right-panel';
import { FileViewerContent } from '@/components/task/file-viewer-content';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useRepositories } from '@/hooks/use-repositories';
import { useRepositoryBranches } from '@/hooks/use-repository-branches';
import { listTaskComments } from '@/lib/http';
const AGENTS = [
{ id: 'codex', label: 'Codex' },
{ id: 'claude', label: 'Claude Code' },
];
import { useTaskAgent } from '@/hooks/use-task-agent';
import { useTaskComments } from '@/hooks/use-task-comments';
type TaskPageClientProps = {
task: Task | null;
};
const DEFAULT_HORIZONTAL_LAYOUT: [number, number] = [75, 25];
export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
const store = useAppStoreApi();
const commentsState = useAppStore((state) => state.comments);
const [isMounted, setIsMounted] = useState(false);
const [horizontalLayout, setHorizontalLayout] = useState<[number, number]>(
getLocalStorage('task-layout-horizontal', DEFAULT_HORIZONTAL_LAYOUT)
);
const [leftTab, setLeftTab] = useState('chat');
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
const [notes, setNotes] = useState('');
const [isLoadingComments, setIsLoadingComments] = useState(false);
const [isAgentRunning, setIsAgentRunning] = useState(false);
const [isAgentLoading, setIsAgentLoading] = useState(false);
// File viewer tabs
const [openFileTabs, setOpenFileTabs] = useState<OpenFileTab[]>([]);
// Track worktree info separately since it's populated after agent starts
const [worktreePath, setWorktreePath] = useState<string | null>(initialTask?.worktree_path ?? null);
const [worktreeBranch, setWorktreeBranch] = useState<string | null>(initialTask?.worktree_branch ?? null);
// Use task from props but allow updates
const task = initialTask;
// Track task state from store (kanban.tasks) to determine if agent is running
useAppStore((state) => state.kanban.tasks.find((t) => t.id === task?.id));
// Custom hooks for state management
const { isAgentRunning, isAgentLoading, worktreePath, worktreeBranch, handleStartAgent, handleStopAgent } = useTaskAgent(task);
const { isLoading: isLoadingComments } = useTaskComments(task?.id ?? null);
useEffect(() => {
setIsMounted(true);
queueMicrotask(() => setIsMounted(true));
}, []);
// Fetch task execution status from orchestrator on mount
useEffect(() => {
if (!task?.id) return;
const checkExecution = async () => {
const client = getWebSocketClient();
if (!client) return;
try {
const response = await client.request<{
has_execution: boolean;
task_id: string;
status?: string;
}>('task.execution', { task_id: task.id });
console.log('[TaskPage] Task execution check:', response);
if (response.has_execution) {
setIsAgentRunning(true);
}
} catch (err) {
console.error('[TaskPage] Failed to check task execution:', err);
}
};
checkExecution();
}, [task?.id]);
const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id));
const repository = useMemo(
() => repositories.find((item) => item.local_path === task?.repository_url) ?? null,
[repositories, task?.repository_url]
);
const { branches, isLoading: branchesLoading } = useRepositoryBranches(
repository?.id ?? null,
Boolean(repository?.id)
);
// Fetch comments on mount and when task changes
useEffect(() => {
if (!task?.id) return;
// Set taskId immediately so that incoming WebSocket notifications are processed
// before the API call completes (fixes race condition on first agent start)
store.getState().setCommentsTaskId(task.id);
// Clear git status when switching tasks to avoid showing stale data
store.getState().clearGitStatus();
if (commentsState.taskId === task.id) {
return;
}
const fetchComments = async () => {
setIsLoadingComments(true);
store.getState().setCommentsLoading(true);
try {
const response = await listTaskComments(task.id, { limit: 10, sort: 'desc' });
const orderedComments = [...(response.comments ?? [])].reverse();
store.getState().setComments(task.id, orderedComments, {
hasMore: response.has_more,
oldestCursor: response.cursor || (orderedComments[0]?.id ?? null),
});
} catch (error) {
console.error('Failed to fetch comments:', error);
store.getState().setComments(task.id, [], { hasMore: false, oldestCursor: null });
} finally {
setIsLoadingComments(false);
}
};
fetchComments();
}, [commentsState.taskId, task?.id, store]);
// Subscribe to task for real-time updates
useEffect(() => {
if (!task?.id) return;
const client = getWebSocketClient();
if (!client) return;
// Subscribe to task updates
client.subscribe(task.id);
return () => {
// Unsubscribe when leaving
client.unsubscribe(task.id);
};
}, [task?.id]);
const handleSendMessage = useCallback(async (content: string) => {
if (!task?.id) return;
@@ -162,93 +44,7 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
} catch (error) {
console.error('Failed to send comment:', error);
}
}, [task?.id]);
const handleSelectDiffPath = useCallback((path: string) => {
setSelectedDiffPath(path);
setLeftTab('changes');
}, []);
const handleOpenFile = useCallback((file: OpenFileTab) => {
setOpenFileTabs((prev) => {
// If file is already open, just switch to it
if (prev.some((t) => t.path === file.path)) {
return prev;
}
// Add new tab (LRU eviction at max 4)
const maxTabs = 4;
const newTabs = prev.length >= maxTabs ? [...prev.slice(1), file] : [...prev, file];
return newTabs;
});
setLeftTab(`file:${file.path}`);
}, []);
const handleCloseFileTab = useCallback((path: string) => {
setOpenFileTabs((prev) => prev.filter((t) => t.path !== path));
// If closing the active tab, switch to chat
if (leftTab === `file:${path}`) {
setLeftTab('chat');
}
}, [leftTab]);
const handleStartAgent = useCallback(async () => {
if (!task?.id) return;
const client = getWebSocketClient();
if (!client) return;
// Require agent_profile_id to be set on the task
if (!task.agent_profile_id) {
console.error('No agent profile configured for this task. Please edit the task to select an agent profile.');
return;
}
setIsAgentLoading(true);
try {
interface StartResponse {
success: boolean;
task_id: string;
agent_instance_id: string;
status: string;
worktree_path?: string;
worktree_branch?: string;
}
const response = await client.request<StartResponse>('orchestrator.start', {
task_id: task.id,
agent_profile_id: task.agent_profile_id,
}, 15000);
setIsAgentRunning(true);
// Update worktree info from response
if (response?.worktree_path) {
setWorktreePath(response.worktree_path);
setWorktreeBranch(response.worktree_branch ?? null);
}
} catch (error) {
console.error('Failed to start agent:', error);
} finally {
setIsAgentLoading(false);
}
}, [task?.id, task?.agent_profile_id]);
const handleStopAgent = useCallback(async () => {
if (!task?.id) return;
const client = getWebSocketClient();
if (!client) return;
setIsAgentLoading(true);
try {
await client.request('orchestrator.stop', { task_id: task.id }, 15000);
setIsAgentRunning(false);
} catch (error) {
console.error('Failed to stop agent:', error);
} finally {
setIsAgentLoading(false);
}
}, [task?.id]);
const topFilesPanel = <TaskFilesPanel taskId={task?.id ?? null} onSelectDiffPath={handleSelectDiffPath} onOpenFile={handleOpenFile} />;
}, [task]);
if (!isMounted) {
return <div className="h-screen w-full bg-background" />;
@@ -257,126 +53,27 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
return (
<TooltipProvider>
<div className="h-screen w-full flex flex-col bg-background">
<TaskTopBar
taskTitle={task?.title}
baseBranch={task?.branch ?? undefined}
branches={task?.repository_url ? branches : []}
branchesLoading={branchesLoading}
onStartAgent={handleStartAgent}
onStopAgent={handleStopAgent}
isAgentRunning={isAgentRunning}
isAgentLoading={isAgentLoading}
worktreePath={worktreePath}
worktreeBranch={worktreeBranch}
/>
<TaskTopBar
taskTitle={task?.title}
taskDescription={task?.description}
baseBranch={task?.branch ?? undefined}
onStartAgent={handleStartAgent}
onStopAgent={handleStopAgent}
isAgentRunning={isAgentRunning}
isAgentLoading={isAgentLoading}
worktreePath={worktreePath}
worktreeBranch={worktreeBranch}
repositoryPath={task?.repository_url ?? null}
repositoryName={repository?.name ?? null}
/>
<div className="flex-1 min-h-0 px-4 pb-4">
<ResizablePanelGroup
direction="horizontal"
className="h-full"
onLayout={(sizes) => {
setHorizontalLayout(sizes as [number, number]);
setLocalStorage('task-layout-horizontal', sizes);
}}
>
<ResizablePanel defaultSize={horizontalLayout[0]} 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 mr-[5px]">
<Tabs
value={leftTab}
onValueChange={(value) => setLeftTab(value)}
className="flex-1 min-h-0 flex flex-col"
>
<div className="flex items-center gap-1">
<TabsList>
<TabsTrigger value="notes" className="cursor-pointer">
Notes
</TabsTrigger>
<TabsTrigger value="changes" className="cursor-pointer">
All changes
</TabsTrigger>
<TabsTrigger value="chat" className="cursor-pointer">
Chat
</TabsTrigger>
</TabsList>
{openFileTabs.length > 0 && (
<>
<div className="h-4 w-px bg-border mx-1" />
<TabsList className="bg-transparent">
{openFileTabs.map((tab) => {
const ext = tab.name.split('.').pop()?.toLowerCase() || '';
const dotColor = FILE_EXTENSION_COLORS[ext] || 'bg-muted-foreground';
return (
<TabsTrigger
key={tab.path}
value={`file:${tab.path}`}
className="cursor-pointer relative group gap-1.5 data-[state=active]:bg-muted"
>
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
<span className="truncate max-w-[100px]">{tab.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCloseFileTab(tab.path);
}}
className="ml-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground"
>
<IconX className="h-3 w-3" />
</button>
</TabsTrigger>
);
})}
</TabsList>
</>
)}
</div>
<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">
<TaskChangesPanel
selectedDiffPath={selectedDiffPath}
onClearSelected={() => setSelectedDiffPath(null)}
/>
</TabsContent>
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
{task?.id ? (
<TaskChatPanel
agents={AGENTS}
onSend={handleSendMessage}
isLoading={isLoadingComments}
isAgentWorking={isAgentRunning}
taskDescription={task.description}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No task selected
</div>
)}
</TabsContent>
{openFileTabs.map((tab) => (
<TabsContent key={tab.path} value={`file:${tab.path}`} className="mt-3 flex-1 min-h-0">
<FileViewerContent path={tab.path} content={tab.content} />
</TabsContent>
))}
</Tabs>
</div>
</ResizablePanel>
<ResizableHandle className="w-px" />
<ResizablePanel defaultSize={horizontalLayout[1]} minSize={20}>
<TaskRightPanel topPanel={topFilesPanel} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
<TaskLayout
taskId={task?.id ?? null}
taskDescription={task?.description}
isLoadingComments={isLoadingComments}
isAgentWorking={isAgentRunning}
onSendMessage={handleSendMessage}
/>
</div>
</TooltipProvider>
);
+1 -1
View File
@@ -29,7 +29,7 @@ export function CommitStatBadge({ label, tone, className }: CommitStatBadgeProps
return (
<Badge
variant="secondary"
className={cn('border-transparent px-1 text-xs font-semibold', commitTone[tone], className)}
className={cn('border-transparent px-2 text-xs font-semibold rounded-lg', commitTone[tone], className)}
>
{label}
</Badge>
@@ -0,0 +1,19 @@
import Link from 'next/link';
interface EmptyStateInlineProps {
message: string;
linkText: string;
linkHref: string;
}
export function EmptyStateInline({ message, linkText, linkHref }: EmptyStateInlineProps) {
return (
<div className="flex h-7 items-center justify-center gap-2 rounded-sm border border-input px-3 text-xs text-muted-foreground">
<span>{message}</span>
<Link href={linkHref} className="text-primary hover:underline">
{linkText}
</Link>
</div>
);
}
+200 -55
View File
@@ -47,12 +47,19 @@ import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'
interface TaskCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode?: 'task' | 'session';
workspaceId: string | null;
boardId: string | null;
defaultColumnId: string | null;
columns: Array<{ id: string; title: string }>;
editingTask?: { id: string; title: string; description?: string; columnId: string; state?: Task['state'] } | null;
onSuccess?: (task: Task, mode: 'create' | 'edit') => void;
onCreateSession?: (data: {
prompt: string;
agentProfileId: string;
executorId: string;
environmentId: string;
}) => void;
initialValues?: {
title: string;
description?: string;
@@ -66,15 +73,18 @@ interface TaskCreateDialogProps {
export function TaskCreateDialog({
open,
onOpenChange,
mode = 'task',
workspaceId,
boardId,
defaultColumnId,
columns,
editingTask,
onSuccess,
onCreateSession,
initialValues,
submitLabel = 'Create',
}: TaskCreateDialogProps) {
const isSessionMode = mode === 'session';
const isEditMode = submitLabel !== 'Create';
const [title, setTitle] = useState(initialValues?.title ?? '');
const [description, setDescription] = useState(initialValues?.description ?? '');
@@ -194,6 +204,30 @@ export function TaskCreateDialog({
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// Session mode - create a new session
if (isSessionMode) {
const trimmedDescription = description.trim();
if (!trimmedDescription || !agentProfileId) return;
onCreateSession?.({
prompt: trimmedDescription,
agentProfileId,
executorId,
environmentId,
});
// Reset form
setDescription('');
setAgentProfileId('');
setExecutorId('');
setEnvironmentId('');
setShowAdvancedSettings(false);
onOpenChange(false);
return;
}
// Task mode - create or edit task
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
if (isEditMode && editingTask) {
@@ -272,32 +306,53 @@ export function TaskCreateDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-none w-[900px] sm:!max-w-none max-h-[85vh] flex flex-col bg-card">
<DialogHeader>
<DialogTitle>{submitLabel === 'Create' ? 'Create Task' : 'Edit Task'}</DialogTitle>
<DialogTitle>
{isSessionMode ? 'Create New Session' : submitLabel === 'Create' ? 'Create Task' : 'Edit Task'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 overflow-hidden">
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
{!isSessionMode && (
<div>
<Input
autoFocus
required
placeholder="Enter task title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSessionMode}
/>
</div>
)}
{isSessionMode && (
<div>
<Label htmlFor="task-title">Task</Label>
<Input
id="task-title"
value={title}
disabled
placeholder="Task"
className="bg-muted cursor-not-allowed mt-1.5"
/>
</div>
)}
<div>
<Input
autoFocus
required
placeholder="Enter task title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div>
{isSessionMode && <Label htmlFor="prompt">Prompt</Label>}
<Textarea
id={isSessionMode ? 'prompt' : undefined}
ref={descriptionRef}
placeholder="Write a prompt for the agent..."
placeholder={isSessionMode ? 'Describe what you want the agent to do...' : 'Write a prompt for the agent...'}
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
rows={2}
className="min-h-[96px] max-h-[240px] resize-y overflow-auto"
className={isSessionMode ? 'min-h-[120px] resize-none mt-1.5' : 'min-h-[96px] max-h-[240px] resize-y overflow-auto'}
required={isSessionMode}
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div>
{!isSessionMode && (
<div className="grid gap-4 md:grid-cols-3">
<div>
{repositories.length === 0 && !repositoriesLoading ? (
<div className="flex items-center justify-center h-10 px-3 py-2 text-sm border border-input rounded-md bg-background">
<span className="text-muted-foreground mr-2">No repositories found.</span>
@@ -407,19 +462,100 @@ export function TaskCreateDialog({
)}
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
className="text-muted-foreground cursor-pointer"
>
<IconSettings className="h-4 w-4 mr-1" />
{showAdvancedSettings ? 'Hide' : 'More Options'}
</Button>
</div>
{showAdvancedSettings && (
)}
{/* Agent Profile - shown in session mode */}
{isSessionMode && (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Label htmlFor="agent-profile" className="text-sm whitespace-nowrap">
Agent Profile
</Label>
<Select value={agentProfileId} onValueChange={setAgentProfileId} required>
<SelectTrigger id="agent-profile" className="w-[280px]">
<SelectValue placeholder={agentProfilesLoading ? 'Loading agent profiles...' : 'Select agent profile'} />
</SelectTrigger>
<SelectContent>
{agentProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* More Options Toggle */}
<button
type="button"
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer whitespace-nowrap"
>
<IconSettings className="h-4 w-4" />
More options
</button>
</div>
)}
{!isSessionMode && (
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
className="text-muted-foreground cursor-pointer"
>
<IconSettings className="h-4 w-4 mr-1" />
{showAdvancedSettings ? 'Hide' : 'More Options'}
</Button>
</div>
)}
{showAdvancedSettings && isSessionMode && (
<div className="pt-2 border-t">
{/* Executor and Environment in same row */}
<div className="flex items-center gap-6">
{/* Executor */}
<div className="flex items-center gap-3">
<Label htmlFor="executor" className="text-sm whitespace-nowrap">
Executor
</Label>
<Select value={executorId} onValueChange={setExecutorId}>
<SelectTrigger id="executor" className="w-[200px]">
<SelectValue placeholder={executorsLoading ? 'Loading executors...' : 'Select executor'} />
</SelectTrigger>
<SelectContent>
{executors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Environment */}
<div className="flex items-center gap-3">
<Label htmlFor="environment" className="text-sm whitespace-nowrap">
Environment
</Label>
<Select value={environmentId} onValueChange={setEnvironmentId}>
<SelectTrigger id="environment" className="w-[200px]">
<SelectValue placeholder={environmentsLoading ? 'Loading environments...' : 'Select environment'} />
</SelectTrigger>
<SelectContent>
{environments.map((env) => (
<SelectItem key={env.id} value={env.id}>
{env.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{showAdvancedSettings && !isSessionMode && (
<div className="grid gap-4 md:grid-cols-2">
<div>
<Select value={environmentId} onValueChange={setEnvironmentId} disabled={isEditMode}>
@@ -475,41 +611,50 @@ export function TaskCreateDialog({
)}
</div>
<DialogFooter className="border-t border-border pt-3">
<div className="flex flex-1 items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<input
id="start-agent"
type="checkbox"
checked={startAgent}
onChange={(e) => setStartAgent(e.target.checked)}
disabled={isEditMode}
className="h-4 w-4 rounded border border-input text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Label htmlFor="start-agent" className="cursor-pointer">
Start Agent
</Label>
</TooltipTrigger>
<TooltipContent>Start the agent on task creation.</TooltipContent>
</Tooltip>
</TooltipProvider>
{!isSessionMode && (
<div className="flex flex-1 items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<input
id="start-agent"
type="checkbox"
checked={startAgent}
onChange={(e) => setStartAgent(e.target.checked)}
disabled={isEditMode}
className="h-4 w-4 rounded border border-input text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Label htmlFor="start-agent" className="cursor-pointer">
Start Agent
</Label>
</TooltipTrigger>
<TooltipContent>Start the agent on task creation.</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{startAgent && (
<span className="text-xs text-muted-foreground">
A git worktree will be created from the above branch.
</span>
)}
</div>
{startAgent && (
<span className="text-xs text-muted-foreground">
A git worktree will be created from the above branch.
</span>
)}
</div>
)}
<DialogClose asChild>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
</DialogClose>
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT}>
<Button type="submit" disabled={!title.trim() || (!isEditMode && !repositoryId)}>
{submitLabel}
<Button
type="submit"
disabled={
isSessionMode
? !description.trim() || !agentProfileId
: !title.trim() || (!isEditMode && !repositoryId)
}
>
{isSessionMode ? 'Create Session' : submitLabel}
</Button>
</KeyboardShortcutTooltip>
</DialogFooter>
+3 -4
View File
@@ -146,12 +146,12 @@ export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) {
)}
{node.is_dir ? (
isExpanded ? (
<IconFolderOpen className="h-4 w-4 flex-shrink-0 text-blue-500" />
<IconFolderOpen className="h-3 w-3 flex-shrink-0 text-blue-500" />
) : (
<IconFolder className="h-4 w-4 flex-shrink-0 text-blue-500" />
<IconFolder className="h-3 w-3 flex-shrink-0 text-blue-500" />
)
) : (
<IconFile className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<IconFile className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
)}
<span className="truncate">{node.name}</span>
{!node.is_dir && node.size && (
@@ -178,7 +178,6 @@ export function FileBrowser({ taskId, onOpenFile }: FileBrowserProps) {
return (
<div className="flex flex-col h-full">
<div className="text-sm font-medium mb-2 px-2">Files</div>
<ScrollArea className="flex-1">
{isLoadingTree ? (
<div className="p-4 text-sm text-muted-foreground">Loading files...</div>
@@ -0,0 +1,248 @@
'use client';
import { useState, useEffect } from 'react';
import {
IconStack2,
IconLoader2,
IconCheck,
IconAlertCircle,
IconX,
IconPlus,
IconPlayerStopFilled,
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { Badge } from '@kandev/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { TaskCreateDialog } from '../task-create-dialog';
type SessionStatus = 'running' | 'waiting_input' | 'complete' | 'failed';
type Session = {
id: string;
number: number;
agentProfile: string;
startedAt: Date;
status: SessionStatus;
};
// Dummy data - will be replaced with real data later
const DUMMY_SESSIONS: Session[] = [
{ id: '1', number: 1, agentProfile: 'Gemini Flash 2.0', startedAt: new Date(Date.now() - 114000), status: 'running' },
{ id: '2', number: 2, agentProfile: 'Auggie Sonnet 4.5', startedAt: new Date(Date.now() - 192000), status: 'complete' },
{ id: '3', number: 3, agentProfile: 'Gemini Flash 2.0', startedAt: new Date(Date.now() - 45000), status: 'waiting_input' },
];
// Format duration from start time
function formatDuration(startedAt: Date, isRunning: boolean): string {
const now = isRunning ? Date.now() : startedAt.getTime();
const diff = Math.floor((now - startedAt.getTime()) / 1000);
const hours = Math.floor(diff / 3600);
const minutes = Math.floor((diff % 3600) / 60);
const seconds = diff % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
}
function getStatusIcon(status: SessionStatus) {
switch (status) {
case 'running':
return <IconLoader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
case 'complete':
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
case 'waiting_input':
return <IconAlertCircle className="h-3.5 w-3.5 text-yellow-500" />;
case 'failed':
return <IconX className="h-3.5 w-3.5 text-red-500" />;
}
}
function getStatusLabel(status: SessionStatus) {
switch (status) {
case 'running':
return 'Running';
case 'complete':
return 'Complete';
case 'waiting_input':
return 'Waiting for input';
case 'failed':
return 'Failed';
}
}
type SessionsDropdownProps = {
taskTitle?: string;
taskDescription?: string;
};
export function SessionsDropdown({ taskTitle = '', taskDescription = '' }: SessionsDropdownProps) {
const [sessions] = useState<Session[]>(DUMMY_SESSIONS);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState(() => Date.now());
const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
// Update timer every second for running sessions
useEffect(() => {
const hasRunningSessions = sessions.some(s => s.status === 'running');
if (!hasRunningSessions) return;
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => clearInterval(interval);
}, [sessions]);
const handleCancelSession = (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
// TODO: Implement session cancellation
console.log('Cancel session:', sessionId);
};
const handleSelectSession = (sessionId: string) => {
setSelectedSessionId(sessionId);
// TODO: Implement session switching
console.log('Select session:', sessionId);
};
const handleCreateSession = (data: {
prompt: string;
agentProfileId: string;
executorId: string;
environmentId: string;
}) => {
// TODO: Implement session creation
console.log('Create new session:', data);
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 border border-border"
>
<IconStack2 className="h-4 w-4 text-muted-foreground" />
<Badge variant="secondary" className="h-5 px-1.5 text-xs font-normal">
{sessions.length}
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[480px]">
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Agent Sessions</span>
<button
type="button"
onClick={() => setShowNewSessionDialog(true)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<IconPlus className="h-3.5 w-3.5" />
New session
</button>
</div>
<DropdownMenuSeparator />
<div className="max-h-[300px] overflow-y-auto">
{sessions.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
No sessions yet
</div>
) : (
<div className="space-y-0.5">
{sessions.map((session) => {
const duration = formatDuration(session.startedAt, session.status === 'running');
// Use currentTime to force re-render when timer updates
void currentTime;
return (
<div
key={session.id}
onClick={() => handleSelectSession(session.id)}
className={`w-full flex items-center gap-3 px-2 py-1.5 hover:bg-muted/50 rounded-sm cursor-pointer transition-colors ${
selectedSessionId === session.id ? 'bg-muted/50' : ''
}`}
>
{/* Session Number - Fixed width */}
<span className="text-xs font-medium text-muted-foreground w-8 shrink-0">
#{session.number}
</span>
{/* Agent Profile - Flexible width */}
<span className="text-xs text-foreground flex-1 text-left truncate">
{session.agentProfile}
</span>
{/* Duration - Fixed width */}
<span className="text-xs text-muted-foreground w-16 text-right shrink-0">
{duration}
</span>
{/* Status Icon - Fixed width with tooltip */}
<div className="w-5 shrink-0 flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<div>{getStatusIcon(session.status)}</div>
</TooltipTrigger>
<TooltipContent side="left">
{getStatusLabel(session.status)}
</TooltipContent>
</Tooltip>
</div>
{/* Cancel Button - Only for running sessions */}
<div className="w-7 shrink-0 flex items-center justify-center">
{session.status === 'running' && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => handleCancelSession(e, session.id)}
className="h-5 w-5 flex items-center justify-center rounded-md border border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive transition-colors cursor-pointer"
>
<IconPlayerStopFilled className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel session</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<TaskCreateDialog
open={showNewSessionDialog}
onOpenChange={setShowNewSessionDialog}
mode="session"
workspaceId={null}
boardId={null}
defaultColumnId={null}
columns={[]}
onCreateSession={handleCreateSession}
initialValues={{
title: taskTitle,
description: taskDescription,
}}
/>
</>
);
}
+2 -2
View File
@@ -158,9 +158,9 @@ export function TaskChatInput({
return (
<div
className={cn(
'task-chat-editor rounded-md border border-input bg-input/20 dark:bg-input/30 focus-within:border-ring focus-within:ring-[2px] focus-within:ring-ring/30 overflow-hidden',
'task-chat-editor rounded-md border border-input bg-muted/40 focus-within:border-ring focus-within:ring-[2px] focus-within:ring-ring/30 overflow-hidden',
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)]',
'border-dashed border-primary/60 !bg-primary/10 shadow-[inset_0_0_0_1px_rgba(59,130,246,0.35)]',
className
)}
>
+38 -19
View File
@@ -4,12 +4,14 @@ import { memo, useMemo, useState } from 'react';
import {
IconArrowBackUp,
IconExternalLink,
IconPlus,
IconCircleFilled,
IconMinus,
} from '@tabler/icons-react';
import { Badge } from '@kandev/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { LineStat } from '@/components/diff-stat';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/components/state-provider';
import type { FileInfo } from '@/lib/state/store';
import { FileBrowser } from '@/components/task/file-browser';
@@ -26,24 +28,41 @@ type TaskFilesPanelProps = {
onOpenFile: (file: OpenFileTab) => void;
};
const badgeClass = (status: string) =>
cn(
'text-[10px] font-semibold',
(status === 'M' || status === 'modified') && 'bg-yellow-500/15 text-yellow-700',
(status === 'A' || status === 'added') && 'bg-emerald-500/15 text-emerald-700',
(status === 'D' || status === 'deleted') && 'bg-rose-500/15 text-rose-700',
status === 'untracked' && 'bg-blue-500/15 text-blue-700',
status === 'renamed' && 'bg-purple-500/15 text-purple-700'
);
const statusLabel = (status: FileInfo['status']): string => {
const StatusIcon = ({ status }: { status: FileInfo['status'] }) => {
switch (status) {
case 'modified': return 'M';
case 'added': return 'A';
case 'deleted': return 'D';
case 'untracked': return 'U';
case 'renamed': return 'R';
default: return '?';
case 'added':
case 'untracked':
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-emerald-600">
<IconPlus className="h-2 w-2 text-emerald-600" />
</div>
);
case 'modified':
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-yellow-600">
<IconCircleFilled className="h-1 w-1 text-yellow-600" />
</div>
);
case 'deleted':
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-rose-600">
<IconMinus className="h-2 w-2 text-rose-600" />
</div>
);
case 'renamed':
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-purple-600">
<IconCircleFilled className="h-1 w-1 text-purple-600" />
</div>
);
default:
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-muted-foreground">
<IconCircleFilled className="h-1 w-1 text-muted-foreground" />
</div>
);
}
};
@@ -144,7 +163,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ taskId, onSelectDiffPath,
</Tooltip>
</div>
<LineStat added={file.plus} removed={file.minus} />
<Badge className={badgeClass(file.status)}>{statusLabel(file.status)}</Badge>
<StatusIcon status={file.status} />
</div>
</li>
);
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { memo, useState } from 'react';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui/resizable';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { TaskLeftPanel } from './task-left-panel';
import { TaskRightPanel } from './task-right-panel';
import { TaskFilesPanel } from './task-files-panel';
import type { OpenFileTab } from '@/lib/types/backend';
const DEFAULT_HORIZONTAL_LAYOUT: [number, number] = [75, 25];
type TaskLayoutProps = {
taskId: string | null;
taskDescription?: string;
isLoadingComments: boolean;
isAgentWorking: boolean;
onSendMessage: (content: string) => Promise<void>;
};
export const TaskLayout = memo(function TaskLayout({
taskId,
taskDescription,
isLoadingComments,
isAgentWorking,
onSendMessage,
}: TaskLayoutProps) {
const [horizontalLayout, setHorizontalLayout] = useState<[number, number]>(
getLocalStorage('task-layout-horizontal', DEFAULT_HORIZONTAL_LAYOUT)
);
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
const [openFileRequest, setOpenFileRequest] = useState<OpenFileTab | null>(null);
const handleSelectDiffPath = (path: string) => {
setSelectedDiffPath(path);
};
const handleOpenFile = (file: OpenFileTab) => {
setOpenFileRequest(file);
};
const topFilesPanel = (
<TaskFilesPanel
taskId={taskId}
onSelectDiffPath={handleSelectDiffPath}
onOpenFile={handleOpenFile}
/>
);
return (
<div className="flex-1 min-h-0 px-4 pb-4">
<ResizablePanelGroup
direction="horizontal"
className="h-full"
onLayout={(sizes) => {
setHorizontalLayout(sizes as [number, number]);
setLocalStorage('task-layout-horizontal', sizes);
}}
>
<ResizablePanel defaultSize={horizontalLayout[0]} minSize={55}>
<TaskLeftPanel
taskId={taskId}
taskDescription={taskDescription}
isLoadingComments={isLoadingComments}
isAgentWorking={isAgentWorking}
onSendMessage={onSendMessage}
selectedDiffPath={selectedDiffPath}
openFileRequest={openFileRequest}
onDiffPathHandled={() => setSelectedDiffPath(null)}
onFileOpenHandled={() => setOpenFileRequest(null)}
/>
</ResizablePanel>
<ResizableHandle className="w-px" />
<ResizablePanel defaultSize={horizontalLayout[1]} minSize={20}>
<TaskRightPanel topPanel={topFilesPanel} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
});
@@ -0,0 +1,186 @@
'use client';
import { memo, useCallback, useState, useEffect } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
import { Textarea } from '@kandev/ui/textarea';
import { IconX } from '@tabler/icons-react';
import { TaskChatPanel } from './task-chat-panel';
import { TaskChangesPanel } from './task-changes-panel';
import { FileViewerContent } from './file-viewer-content';
import type { OpenFileTab } from '@/lib/types/backend';
import { FILE_EXTENSION_COLORS } from '@/lib/types/backend';
const AGENTS = [
{ id: 'codex', label: 'Codex' },
{ id: 'claude', label: 'Claude Code' },
];
type TaskLeftPanelProps = {
taskId: string | null;
taskDescription?: string;
isLoadingComments: boolean;
isAgentWorking: boolean;
onSendMessage: (content: string) => Promise<void>;
selectedDiffPath: string | null;
openFileRequest: OpenFileTab | null;
onDiffPathHandled: () => void;
onFileOpenHandled: () => void;
};
export const TaskLeftPanel = memo(function TaskLeftPanel({
taskId,
taskDescription,
isLoadingComments,
isAgentWorking,
onSendMessage,
selectedDiffPath: externalSelectedDiffPath,
openFileRequest,
onDiffPathHandled,
onFileOpenHandled,
}: TaskLeftPanelProps) {
const [leftTab, setLeftTab] = useState('chat');
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
const [notes, setNotes] = useState('');
const [openFileTabs, setOpenFileTabs] = useState<OpenFileTab[]>([]);
// Handle external diff path selection
useEffect(() => {
if (externalSelectedDiffPath) {
queueMicrotask(() => {
setSelectedDiffPath(externalSelectedDiffPath);
setLeftTab('changes');
onDiffPathHandled();
});
}
}, [externalSelectedDiffPath, onDiffPathHandled]);
// Handle external file open request
useEffect(() => {
if (openFileRequest) {
queueMicrotask(() => {
setOpenFileTabs((prev) => {
// If file is already open, just switch to it
if (prev.some((t) => t.path === openFileRequest.path)) {
return prev;
}
// Add new tab (LRU eviction at max 4)
const maxTabs = 4;
const newTabs = prev.length >= maxTabs ? [...prev.slice(1), openFileRequest] : [...prev, openFileRequest];
return newTabs;
});
setLeftTab(`file:${openFileRequest.path}`);
onFileOpenHandled();
});
}
}, [openFileRequest, onFileOpenHandled]);
const handleCloseFileTab = useCallback((path: string) => {
setOpenFileTabs((prev) => prev.filter((t) => t.path !== path));
// If closing the active tab, switch to chat
if (leftTab === `file:${path}`) {
setLeftTab('chat');
}
}, [leftTab]);
return (
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-r-0 mr-[5px]">
<Tabs
value={leftTab}
onValueChange={(value) => setLeftTab(value)}
className="flex-1 min-h-0 flex flex-col"
>
<div className="flex items-center gap-1">
<TabsList>
<TabsTrigger value="notes" className="cursor-pointer">
Notes
</TabsTrigger>
<TabsTrigger value="changes" className="cursor-pointer">
All changes
</TabsTrigger>
<TabsTrigger value="chat" className="cursor-pointer">
Chat
</TabsTrigger>
</TabsList>
{openFileTabs.length > 0 && (
<>
<div className="h-4 w-px bg-border mx-1" />
<TabsList className="bg-transparent">
{openFileTabs.map((tab) => {
const ext = tab.name.split('.').pop()?.toLowerCase() || '';
const dotColor = FILE_EXTENSION_COLORS[ext] || 'bg-muted-foreground';
return (
<TabsTrigger
key={tab.path}
value={`file:${tab.path}`}
className="cursor-pointer relative group gap-1.5 data-[state=active]:bg-muted"
>
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
<span className="truncate max-w-[100px]">{tab.name}</span>
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleCloseFileTab(tab.path);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleCloseFileTab(tab.path);
}
}}
className="ml-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground !cursor-pointer"
>
<IconX className="h-3 w-3" />
</span>
</TabsTrigger>
);
})}
</TabsList>
</>
)}
</div>
<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">
<TaskChangesPanel
selectedDiffPath={selectedDiffPath}
onClearSelected={() => setSelectedDiffPath(null)}
/>
</TabsContent>
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
{taskId ? (
<TaskChatPanel
agents={AGENTS}
onSend={onSendMessage}
isLoading={isLoadingComments}
isAgentWorking={isAgentWorking}
taskDescription={taskDescription}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No task selected
</div>
)}
</TabsContent>
{openFileTabs.map((tab) => (
<TabsContent key={tab.path} value={`file:${tab.path}`} className="mt-3 flex-1 min-h-0">
<FileViewerContent path={tab.path} content={tab.content} />
</TabsContent>
))}
</Tabs>
</div>
);
});
+233 -159
View File
@@ -3,63 +3,75 @@
import { memo, useMemo, useState } from 'react';
import Link from 'next/link';
import {
IconArrowDown,
IconArrowLeft,
IconArrowUp,
IconBrandVscode,
IconChevronDown,
IconChevronRight,
IconCopy,
IconEye,
IconFolder,
IconDots,
IconFolderOpen,
IconGitBranch,
IconGitFork,
IconGitMerge,
IconGitPullRequest,
IconLoader2,
IconPencil,
IconPlayerPlay,
IconPlayerStop,
IconCheck,
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { CommitStatBadge, LineStat } from '@/components/diff-stat';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@kandev/ui/dropdown-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
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 type { Branch } from '@/lib/types/http';
import { formatUserHomePath } from '@/lib/utils';
type TaskTopBarProps = {
taskTitle?: string;
taskDescription?: string;
baseBranch?: string;
branches?: Branch[];
branchesLoading?: boolean;
onStartAgent?: () => void;
onStopAgent?: () => void;
isAgentRunning?: boolean;
isAgentLoading?: boolean;
worktreePath?: string | null;
worktreeBranch?: string | null;
repositoryPath?: string | null;
repositoryName?: string | null;
};
const TaskTopBar = memo(function TaskTopBar({
taskTitle,
taskDescription,
baseBranch,
branches = [],
branchesLoading = false,
onStartAgent,
onStopAgent,
isAgentRunning = false,
isAgentLoading = false,
worktreePath,
worktreeBranch,
repositoryPath,
repositoryName,
}: TaskTopBarProps) {
const [branchName, setBranchName] = useState('feature/agent-ui');
const [isEditingBranch, setIsEditingBranch] = useState(false);
const [selectedBaseBranch, setSelectedBaseBranch] = useState(baseBranch ?? 'origin/main');
const [copiedBranch, setCopiedBranch] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const [copiedRepo, setCopiedRepo] = useState(false);
const [copiedWorktree, setCopiedWorktree] = useState(false);
// Get git status from store
const gitStatus = useAppStore((state) => state.gitStatus);
// Use worktree branch if available, otherwise fall back to base branch
const displayBranch = worktreeBranch || baseBranch;
// Calculate total additions and deletions from all files
const { totalAdditions, totalDeletions } = useMemo(() => {
if (!gitStatus.files || Object.keys(gitStatus.files).length === 0) {
@@ -75,6 +87,33 @@ const TaskTopBar = memo(function TaskTopBar({
);
}, [gitStatus.files]);
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">
@@ -84,86 +123,91 @@ const TaskTopBar = memo(function TaskTopBar({
Back
</Link>
</Button>
<span className="text-xs text-muted-foreground">{taskTitle ?? '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
value={selectedBaseBranch}
onValueChange={setSelectedBaseBranch}
disabled={branches.length === 0 && !branchesLoading}
>
{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 taskTitle={taskTitle} taskDescription={taskDescription} />
{displayBranch && (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<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={branchesLoading ? 'Loading branches...' : 'Base branch'}
/>
</SelectTrigger>
<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>Change base branch</TooltipContent>
<TooltipContent side="right">Current branch</TooltipContent>
</Tooltip>
<SelectContent>
{branches.length === 0 ? (
<SelectItem value={selectedBaseBranch} disabled>
{selectedBaseBranch}
</SelectItem>
) : (
branches.map((branch) => (
<SelectItem key={`${branch.type}-${branch.remote || ''}-${branch.name}`} value={branch.name}>
{branch.type === 'remote' && branch.remote ? `${branch.remote}/${branch.name}` : branch.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<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 || gitStatus.behind > 0) && (
<div className="flex items-center gap-1">
@@ -193,6 +237,7 @@ const TaskTopBar = memo(function TaskTopBar({
)}
</div>
)}
{/* Git Status: Lines Changed */}
{(totalAdditions > 0 || totalDeletions > 0) && (
<Tooltip>
@@ -204,42 +249,7 @@ const TaskTopBar = memo(function TaskTopBar({
<TooltipContent>Lines changed</TooltipContent>
</Tooltip>
)}
{/* Worktree Info */}
{worktreePath && (
<div className="flex items-center gap-2 ml-2 pl-2 border-l border-border/50">
<Tooltip>
<TooltipTrigger 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">
<IconFolder className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground max-w-[200px] truncate">
{worktreePath}
</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard?.writeText(worktreePath);
}}
>
<IconCopy className="h-3 w-3" />
</button>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[400px]">
<div className="space-y-1">
<div className="font-medium">Worktree Path</div>
<div className="text-xs text-muted-foreground break-all">{worktreePath}</div>
{worktreeBranch && (
<div className="text-xs">
Branch: <span className="font-mono">{worktreeBranch}</span>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Start/Stop Agent Button */}
@@ -284,65 +294,129 @@ const TaskTopBar = memo(function TaskTopBar({
<TooltipContent>Start the agent on this task</TooltipContent>
</Tooltip>
)}
{/* Editor Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer">
<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>
<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>
{/* 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">
<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 PR</TooltipContent>
<TooltipContent>Create pull request</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="rounded-none border-0 px-2 cursor-pointer">
<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>
<IconPencil className="h-4 w-4" />
Create PR manually
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
// TODO: Implement pull
console.log('Pull from remote');
}}
>
<IconArrowUp className="h-4 w-4 rotate-180" />
Pull
</DropdownMenuItem>
<DropdownMenuItem>
<IconGitMerge className="h-4 w-4" />
Merge
</DropdownMenuItem>
<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>
);
+113
View File
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useState } from 'react';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Task } from '@/lib/types/http';
interface UseTaskAgentReturn {
isAgentRunning: boolean;
isAgentLoading: boolean;
worktreePath: string | null;
worktreeBranch: string | null;
handleStartAgent: () => Promise<void>;
handleStopAgent: () => Promise<void>;
}
export function useTaskAgent(task: Task | null): UseTaskAgentReturn {
const [isAgentRunning, setIsAgentRunning] = useState(false);
const [isAgentLoading, setIsAgentLoading] = useState(false);
const [worktreePath, setWorktreePath] = useState<string | null>(task?.worktree_path ?? null);
const [worktreeBranch, setWorktreeBranch] = useState<string | null>(task?.worktree_branch ?? null);
// Fetch task execution status from orchestrator on mount
useEffect(() => {
if (!task?.id) return;
const checkExecution = async () => {
const client = getWebSocketClient();
if (!client) return;
try {
const response = await client.request<{
has_execution: boolean;
task_id: string;
status?: string;
}>('task.execution', { task_id: task.id });
console.log('[useTaskAgent] Task execution check:', response);
if (response.has_execution) {
setIsAgentRunning(true);
}
} catch (err) {
console.error('[useTaskAgent] Failed to check task execution:', err);
}
};
checkExecution();
}, [task?.id]);
const handleStartAgent = useCallback(async () => {
if (!task?.id) return;
const client = getWebSocketClient();
if (!client) return;
// Require agent_profile_id to be set on the task
if (!task.agent_profile_id) {
console.error('No agent profile configured for this task. Please edit the task to select an agent profile.');
return;
}
setIsAgentLoading(true);
try {
interface StartResponse {
success: boolean;
task_id: string;
agent_instance_id: string;
status: string;
worktree_path?: string;
worktree_branch?: string;
}
const response = await client.request<StartResponse>('orchestrator.start', {
task_id: task.id,
agent_profile_id: task.agent_profile_id,
}, 15000);
setIsAgentRunning(true);
// Update worktree info from response
if (response?.worktree_path) {
setWorktreePath(response.worktree_path);
setWorktreeBranch(response.worktree_branch ?? null);
}
} catch (error) {
console.error('Failed to start agent:', error);
} finally {
setIsAgentLoading(false);
}
}, [task?.id, task?.agent_profile_id]);
const handleStopAgent = useCallback(async () => {
if (!task?.id) return;
const client = getWebSocketClient();
if (!client) return;
setIsAgentLoading(true);
try {
await client.request('orchestrator.stop', { task_id: task.id }, 15000);
setIsAgentRunning(false);
} catch (error) {
console.error('Failed to stop agent:', error);
} finally {
setIsAgentLoading(false);
}
}, [task?.id]);
return {
isAgentRunning,
isAgentLoading,
worktreePath,
worktreeBranch,
handleStartAgent,
handleStopAgent,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import type { Comment } from '@/lib/types/http';
interface UseTaskCommentsReturn {
isLoading: boolean;
}
export function useTaskComments(taskId: string | null): UseTaskCommentsReturn {
const store = useAppStoreApi();
const commentsState = useAppStore((state) => state.comments);
const [isLoading, setIsLoading] = useState(false);
// Fetch comments on mount and when task changes
useEffect(() => {
if (!taskId) return;
// Set taskId immediately so that incoming WebSocket notifications are processed
// before the API call completes (fixes race condition on first agent start)
store.getState().setCommentsTaskId(taskId);
// Clear git status when switching tasks to avoid showing stale data
store.getState().clearGitStatus();
if (commentsState.taskId === taskId) {
return;
}
const fetchComments = async () => {
const client = getWebSocketClient();
if (!client) return;
setIsLoading(true);
store.getState().setCommentsLoading(true);
try {
const response = await client.request<{ comments: Comment[] }>('comment.list', { task_id: taskId }, 10000);
console.log('[useTaskComments] comment.list response:', JSON.stringify(response, null, 2));
store.getState().setComments(taskId, response.comments ?? []);
} catch (error) {
console.error('Failed to fetch comments:', error);
store.getState().setComments(taskId, []);
} finally {
setIsLoading(false);
}
};
fetchComments();
}, [commentsState.taskId, taskId, store]);
// Subscribe to task for real-time updates
useEffect(() => {
if (!taskId) return;
const client = getWebSocketClient();
if (!client) return;
// Subscribe to task updates
client.subscribe(taskId);
return () => {
// Unsubscribe when leaving
client.unsubscribe(taskId);
};
}, [taskId]);
return {
isLoading,
};
}