Files
kandev/apps/web/app/task/[id]/page-client.tsx
T
jcfs ad2a0bd9d0 fix(web): fix real-time comments not appearing on first agent start
- Initialize comments.items array in setCommentsTaskId and addComment
- Fixes race condition where WebSocket notifications arrived before API call completed
2026-01-12 02:57:09 +00:00

261 lines
9.3 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import '@git-diff-view/react/styles/diff-view.css';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TooltipProvider } from '@/components/ui/tooltip';
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, 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';
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' },
];
type TaskPageClientProps = {
task: Task | null;
};
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 [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);
// 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);
if (!repo) return [];
const branchResponse = await listRepositoryBranches(getBackendConfig().apiBaseUrl, repo.id);
return branchResponse.branches;
}, []);
const branchesRequest = useRequest(fetchBranches);
// 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);
const fetchComments = async () => {
const client = getWebSocketClient();
if (!client) return;
setIsLoadingComments(true);
store.getState().setCommentsLoading(true);
try {
const response = await client.request<Comment[]>('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 = <TaskFilesPanel onSelectDiffPath={handleSelectDiffPath} />;
useEffect(() => {
if (!task?.workspace_id || !task.repository_url) return;
branchesRequest.run(task.workspace_id, task.repository_url).catch(() => {});
}, [branchesRequest.run, task?.repository_url, task?.workspace_id]);
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 ? branchesRequest.data ?? [] : []}
branchesLoading={branchesRequest.isLoading}
onStartAgent={handleStartAgent}
onStopAgent={handleStopAgent}
isAgentRunning={isAgentRunning}
isAgentLoading={isAgentLoading}
/>
<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"
>
<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>
<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
taskId={task.id}
agents={AGENTS}
onSend={handleSendMessage}
isLoading={isLoadingComments}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No task selected
</div>
)}
</TabsContent>
</Tabs>
</div>
</ResizablePanel>
<ResizableHandle className="w-px" />
<ResizablePanel defaultSize={horizontalLayout[1]} minSize={20}>
<TaskRightPanel topPanel={topFilesPanel} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
);
}