feat: improved chat ui topbar
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user