diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 92b34b11a5..551cf2b8c4 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -2,6 +2,7 @@ import { PageClient } from '@/app/page-client'; import { StateHydrator } from '@/components/state-hydrator'; import { fetchBoardSnapshot, fetchBoards, fetchWorkspaces } from '@/lib/ssr/http'; import { snapshotToState } from '@/lib/ssr/mapper'; +import type { AppState } from '@/lib/state/store'; // Server Component: runs on the server for SSR and data hydration. type PageProps = { @@ -21,7 +22,7 @@ export default async function Page({ searchParams }: PageProps) { workspaces.workspaces.find((workspace) => workspace.id === workspaceId)?.id ?? workspaces.workspaces[0]?.id ?? null; - let initialState = { + let initialState: Partial = { workspaces: { items: workspaces.workspaces.map((workspace) => ({ id: workspace.id, diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 28da9345cb..f76e35c177 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -627,7 +627,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa }; const handleDeleteWorkspace = async () => { - if (deleteConfirmText !== 'delete') return; + if (deleteConfirmText !== 'delete' || !currentWorkspace) return; try { await deleteWorkspaceRequest.run(currentWorkspace.id); setWorkspaces(workspaces.filter((workspace) => workspace.id !== currentWorkspace.id)); diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 215b20cb27..61e665a26c 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -9,79 +9,51 @@ import { Textarea } from '@/components/ui/textarea'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { TaskChatPanel } from '@/components/task/task-chat-panel'; import { TaskTopBar } from '@/components/task/task-top-bar'; -import type { Task } from '@/lib/types/http'; +import type { Task, Comment } from '@/lib/types/http'; 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 { getBackendConfig } from '@/lib/config'; import { listRepositories, listRepositoryBranches } from '@/lib/http/client'; import { useRequest } from '@/lib/http/use-request'; - -type ChatMessage = { - id: string; - role: 'user' | 'agent'; - content: string; -}; - -type ChatSession = { - id: string; - title: string; - messages: ChatMessage[]; -}; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { useAppStoreApi } from '@/components/state-provider'; +import { useAppStore } from '@/components/state-provider'; const AGENTS = [ { id: 'codex', label: 'Codex' }, { id: 'claude', label: 'Claude Code' }, ]; -const INITIAL_CHATS: ChatSession[] = [ - { - id: 'chat-1', - title: 'Build overview', - messages: [ - { - id: 'm1', - role: 'agent', - content: 'I can help with this task. What should I tackle first?', - }, - ], - }, -]; - type TaskPageClientProps = { task: Task | null; }; -function buildInitialChats(taskPrompt?: string): ChatSession[] { - if (!taskPrompt) { - return INITIAL_CHATS; - } - return [ - { - ...INITIAL_CHATS[0], - messages: [ - { - id: 'prompt', - role: 'user', - content: taskPrompt, - }, - ...INITIAL_CHATS[0].messages, - ], - }, - ]; -} - export default function TaskPage({ task }: TaskPageClientProps) { + const store = useAppStoreApi(); const defaultHorizontalLayout: [number, number] = [75, 25]; const [horizontalLayout, setHorizontalLayout] = useState<[number, number]>(() => getLocalStorage('task-layout-horizontal', defaultHorizontalLayout) ); - const [chats, setChats] = useState(() => - buildInitialChats(task?.description ?? '') - ); const [leftTab, setLeftTab] = useState('chat'); const [selectedDiffPath, setSelectedDiffPath] = useState(null); const [notes, setNotes] = useState(''); + const [isLoadingComments, setIsLoadingComments] = useState(false); + const [isAgentRunning, setIsAgentRunning] = useState(false); + const [isAgentLoading, setIsAgentLoading] = useState(false); + + // Track task state from store (kanban.tasks) to determine if agent is running + const taskFromStore = useAppStore((state) => + state.kanban.tasks.find((t) => t.id === task?.id) + ); + + // Update isAgentRunning based on task state + useEffect(() => { + // Check if task state indicates agent is working + const taskState = task?.state; + setIsAgentRunning(taskState === 'IN_PROGRESS'); + }, [task?.state]); + const fetchBranches = useCallback(async (workspaceId: string, repoPath: string) => { const response = await listRepositories(getBackendConfig().apiBaseUrl, workspaceId); const repo = response.repositories.find((item) => item.local_path === repoPath); @@ -91,27 +63,105 @@ export default function TaskPage({ task }: TaskPageClientProps) { }, []); const branchesRequest = useRequest(fetchBranches); - const activeChat = chats[0]; + // Fetch comments on mount and when task changes + useEffect(() => { + if (!task?.id) return; - const handleSendMessage = useCallback((content: string) => { - setChats((currentChats) => { - const [first, ...rest] = currentChats; - if (!first) return currentChats; - return [ - { - ...first, - messages: [...first.messages, { id: crypto.randomUUID(), role: 'user', content }], - }, - ...rest, - ]; - }); - }, []); + const fetchComments = async () => { + const client = getWebSocketClient(); + if (!client) return; + + setIsLoadingComments(true); + store.getState().setCommentsLoading(true); + + try { + const response = await client.request('comment.list', { task_id: task.id }, 10000); + console.log('[API] comment.list response:', JSON.stringify(response, null, 2)); + store.getState().setComments(task.id, response); + } catch (error) { + console.error('Failed to fetch comments:', error); + store.getState().setComments(task.id, []); + } finally { + setIsLoadingComments(false); + } + }; + + fetchComments(); + }, [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; + + const client = getWebSocketClient(); + if (!client) return; + + try { + await client.request('comment.add', { task_id: task.id, content }, 10000); + } catch (error) { + console.error('Failed to send comment:', error); + } + }, [task?.id]); const handleSelectDiffPath = useCallback((path: string) => { setSelectedDiffPath(path); setLeftTab('changes'); }, []); + const handleStartAgent = useCallback(async () => { + if (!task?.id) return; + + const client = getWebSocketClient(); + if (!client) return; + + setIsAgentLoading(true); + try { + // Use task's agent_type if set, otherwise default to 'augment-agent' + const agentType = task.agent_type ?? 'augment-agent'; + await client.request('orchestrator.start', { + task_id: task.id, + agent_type: agentType, + }, 15000); + setIsAgentRunning(true); + } catch (error) { + console.error('Failed to start agent:', error); + } finally { + setIsAgentLoading(false); + } + }, [task?.id, task?.agent_type]); + + 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 = ; useEffect(() => { @@ -127,6 +177,10 @@ export default function TaskPage({ task }: TaskPageClientProps) { baseBranch={task?.branch ?? undefined} branches={task?.repository_url ? branchesRequest.data ?? [] : []} branchesLoading={branchesRequest.isLoading} + onStartAgent={handleStartAgent} + onStopAgent={handleStopAgent} + isAgentRunning={isAgentRunning} + isAgentLoading={isAgentLoading} />
@@ -153,7 +207,7 @@ export default function TaskPage({ task }: TaskPageClientProps) { All changes - Chat {activeChat ? `• ${activeChat.title}` : ''} + Chat @@ -174,7 +228,18 @@ export default function TaskPage({ task }: TaskPageClientProps) { - + {task?.id ? ( + + ) : ( +
+ No task selected +
+ )}
diff --git a/apps/web/components/connection-status.tsx b/apps/web/components/connection-status.tsx index 2a01836f41..48f018da19 100644 --- a/apps/web/components/connection-status.tsx +++ b/apps/web/components/connection-status.tsx @@ -14,6 +14,8 @@ export function ConnectionStatus() { return { color: 'text-emerald-500', text: 'Connected', variant: 'default' as const, pulse: false }; case 'connecting': return { color: 'text-amber-500', text: 'Connecting', variant: 'secondary' as const, pulse: true }; + case 'reconnecting': + return { color: 'text-orange-500', text: 'Reconnecting', variant: 'secondary' as const, pulse: true }; case 'error': return { color: 'text-red-500', text: 'Error', variant: 'destructive' as const, pulse: false }; case 'disconnected': diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx index 72feabf7ef..6df1d61566 100644 --- a/apps/web/components/settings/repository-card.tsx +++ b/apps/web/components/settings/repository-card.tsx @@ -48,7 +48,7 @@ export function RepositoryCard({ const { toast } = useToast(); const [deleteOpen, setDeleteOpen] = useState(false); const saveRequest = useRequest(() => onSave(repository.id)); - const deleteRequest = useRequest(() => onDelete(repository.id)); + const deleteRequest = useRequest(async () => { await onDelete(repository.id); }); const handleSave = async () => { try { diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index 2a9967784c..0d595828eb 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -9,30 +9,33 @@ import { } from '@/components/ui/breadcrumb'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar'; +import { TooltipProvider } from '@/components/ui/tooltip'; export function SettingsLayoutClient({ children }: { children: React.ReactNode }) { return ( - - - -
-
- - - - - - Home - - - - + + + + +
+
+ + + + + + Home + + + + +
+
+
+ {children}
-
-
- {children} -
-
-
+ + + ); } diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 69dcfa0933..d02b0a5191 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -1,7 +1,10 @@ 'use client'; -import { FormEvent, useState } from 'react'; -import { IconBrain, IconListCheck, IconPaperclip } from '@tabler/icons-react'; +import { FormEvent, useEffect, useRef, useState } from 'react'; +import { IconBrain, IconListCheck, IconLoader2, IconPaperclip, IconTerminal2 } from '@tabler/icons-react'; +import ReactMarkdown from 'react-markdown'; +import remarkBreaks from 'remark-breaks'; +import remarkGfm from 'remark-gfm'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { @@ -13,18 +16,8 @@ import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; - -type ChatMessage = { - id: string; - role: 'user' | 'agent'; - content: string; -}; - -type ChatSession = { - id: string; - title: string; - messages: ChatMessage[]; -}; +import { useAppStore } from '@/components/state-provider'; +import type { Comment } from '@/lib/types/http'; type AgentOption = { id: string; @@ -32,43 +25,111 @@ type AgentOption = { }; type TaskChatPanelProps = { - activeChat?: ChatSession; + taskId: string; agents: AgentOption[]; onSend: (message: string) => void; + isLoading?: boolean; }; -export function TaskChatPanel({ activeChat, agents, onSend }: TaskChatPanelProps) { +export function TaskChatPanel({ taskId, agents, onSend, isLoading }: TaskChatPanelProps) { const [messageInput, setMessageInput] = useState(''); const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? ''); const [planModeEnabled, setPlanModeEnabled] = useState(false); + const [isSending, setIsSending] = useState(false); + const messagesEndRef = useRef(null); - const handleSubmit = (event: FormEvent) => { + const commentsState = useAppStore((state) => state.comments); + const comments = commentsState?.items ?? []; + const commentsLoading = commentsState?.isLoading ?? false; + + // Filter to only show message, content, and tool_call types (not progress, etc) + const visibleComments = comments.filter( + (c) => c.type === 'message' || c.type === 'content' || c.type === 'tool_call' || !c.type + ); + + // Scroll to bottom when new comments arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [visibleComments.length]); + + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const trimmed = messageInput.trim(); - if (!trimmed) return; - onSend(trimmed); + if (!trimmed || isSending) return; + setIsSending(true); setMessageInput(''); + try { + await onSend(trimmed); + } finally { + setIsSending(false); + } + }; + + const formatContent = (comment: Comment) => { + // For content type comments, the content might be streamed chunks + // For message type, show as-is + return comment.content || '(empty)'; }; return ( <>
- {(activeChat?.messages ?? []).map((message) => ( -
-

- {message.role === 'user' ? 'You' : 'Agent'} -

-

{message.content}

+ {(isLoading || commentsLoading) && ( +
+ + Loading comments...
- ))} + )} + {!isLoading && !commentsLoading && visibleComments.length === 0 && ( +
+ No messages yet. Start the conversation! +
+ )} + {visibleComments.map((comment) => { + // Tool call comments have a special rendering + if (comment.type === 'tool_call') { + const metadata = comment.metadata as { title?: string; status?: string } | undefined; + return ( +
+ + + {metadata?.title || comment.content} + +
+ ); + } + + // Regular message or agent response - render as markdown + const isUser = comment.author_type === 'user'; + return ( +
+

+ {isUser ? 'You' : 'Agent'} +

+ {isUser ? ( +

{formatContent(comment)}

+ ) : ( +
+ + {formatContent(comment)} + +
+ )} +
+ ); + })} +