feat: wire up chat AI responses via Clawdbot gateway, fix UI bugs

- Chat now generates actual AI responses via Clawdbot gateway WebSocket
- Gateway chat client handles auth, streaming deltas, and final response
- Chat responses broadcast to UI via kanban WebSocket (shared connection)
- Fix double X close button on chat panel (SheetContent built-in + manual)
- Move Chat/Template buttons above tab row to reduce cramping
- Chat/Template buttons now 50/50 full width
- Fix subtask add button height mismatch
- Fix chat send crash (API response type mismatch)
- Remove non-functional agent/model dropdowns from chat panel
- Add model picker (sonnet/opus/haiku) to Agent panel in task detail
- Replace tooltip with inline mode hint text in chat
- Load chat history on panel open (deterministic task session ID)
- Chat stream uses shared WebSocket instead of opening duplicate connection
This commit is contained in:
Brad Groux 2026-01-31 06:44:42 -06:00
parent 643b62d90d
commit 89a5fd8b20
10 changed files with 555 additions and 187 deletions

View file

@ -7,6 +7,8 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { getChatService } from '../services/chat-service.js';
import { sendGatewayChat, loadGatewayToken } from '../services/gateway-chat-client.js';
import { broadcastChatMessage } from '../services/broadcast-service.js';
import type { ChatSendInput } from '@veritas-kanban/shared';
import { asyncHandler } from '../middleware/async-handler.js';
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
@ -14,6 +16,9 @@ import { createLogger } from '../lib/logger.js';
const log = createLogger('chat');
// Load gateway token on startup
loadGatewayToken().catch(() => {});
const router: RouterType = Router();
const chatService = getChatService();
@ -80,15 +85,68 @@ router.post(
log.info({ sessionId, messageId: userMessage.id, taskId: session.taskId }, 'Chat message sent');
// Return immediately - agent response will stream via WebSocket
// Return immediately - agent response will arrive async
res.status(200).json({
sessionId,
messageId: userMessage.id,
message: 'Message sent — agent response will stream via WebSocket',
message: 'Message sent — agent response incoming',
});
// TODO: Trigger agent response (will be handled by WebSocket integration)
// The WebSocket server will listen for 'chat:message' events and stream the response
// Trigger async AI response via Clawdbot Gateway
const gatewaySessionKey = `kanban-chat-${sessionId}`;
sendGatewayChat(input.message, gatewaySessionKey, {
onDelta: (text) => {
// Broadcast streaming chunk to kanban WebSocket clients
broadcastChatMessage(sessionId, {
type: 'chat:delta',
sessionId,
text,
});
},
onFinal: async (response) => {
try {
// Save the assistant response to the session
const assistantMessage = await chatService.addMessage(sessionId, {
role: 'assistant',
content: response.text,
agent: session.agent,
});
log.info({ sessionId, messageId: assistantMessage.id }, 'Assistant response saved');
// Broadcast final message to kanban WebSocket clients
broadcastChatMessage(sessionId, {
type: 'chat:message',
sessionId,
message: assistantMessage,
});
} catch (err: any) {
log.error({ err: err.message, sessionId }, 'Failed to save assistant response');
}
},
onError: async (error) => {
log.error({ error, sessionId }, 'Gateway chat error');
// Save error as system message
try {
await chatService.addMessage(sessionId, {
role: 'system',
content: `Error: ${error}`,
});
broadcastChatMessage(sessionId, {
type: 'chat:error',
sessionId,
error,
});
} catch (err: any) {
log.error({ err: err.message }, 'Failed to save error message');
}
},
}).catch((err) => {
log.error({ err: err.message, sessionId }, 'Gateway chat failed');
});
})
);

View file

@ -11,7 +11,13 @@ export function initBroadcast(wss: WebSocketServer): void {
wssRef = wss;
}
export type TaskChangeType = 'created' | 'updated' | 'deleted' | 'archived' | 'restored' | 'reordered';
export type TaskChangeType =
| 'created'
| 'updated'
| 'deleted'
| 'archived'
| 'restored'
| 'reordered';
export interface TaskChangeEvent {
type: 'task:changed';
@ -42,7 +48,31 @@ export function broadcastTaskChange(changeType: TaskChangeType, taskId?: string)
const payload = JSON.stringify(message);
wssRef.clients.forEach((client: WebSocket) => {
if (client.readyState === 1) { // WebSocket.OPEN = 1
if (client.readyState === 1) {
// WebSocket.OPEN = 1
client.send(payload);
}
});
}
export interface ChatBroadcastEvent {
type: 'chat:delta' | 'chat:message' | 'chat:error';
sessionId: string;
text?: string;
message?: unknown;
error?: string;
}
/**
* Broadcast a chat message/event to all connected WebSocket clients.
*/
export function broadcastChatMessage(sessionId: string, event: ChatBroadcastEvent): void {
if (!wssRef) return;
const payload = JSON.stringify(event);
wssRef.clients.forEach((client: WebSocket) => {
if (client.readyState === 1) {
client.send(payload);
}
});
@ -63,7 +93,8 @@ export function broadcastTelemetryEvent(event: AnyTelemetryEvent): void {
const payload = JSON.stringify(message);
wssRef.clients.forEach((client: WebSocket) => {
if (client.readyState === 1) { // WebSocket.OPEN = 1
if (client.readyState === 1) {
// WebSocket.OPEN = 1
client.send(payload);
}
});

View file

@ -0,0 +1,270 @@
/**
* Gateway Chat Client
*
* Connects to the Clawdbot Gateway WebSocket to proxy chat messages.
* Handles authentication, message sending, and response collection.
*/
import WebSocket from 'ws';
import { randomUUID } from 'crypto';
import { createLogger } from '../lib/logger.js';
const log = createLogger('gateway-chat');
const GATEWAY_URL = process.env.CLAWDBOT_GATEWAY || 'http://127.0.0.1:18789';
const PROTOCOL_VERSION = 3;
const CONNECT_TIMEOUT_MS = 10_000;
const RESPONSE_TIMEOUT_MS = 120_000; // 2 minutes for AI response
// Cached token — populated lazily
let cachedToken: string | null = null;
function getToken(): string {
if (cachedToken) return cachedToken;
return process.env.CLAWDBOT_GATEWAY_TOKEN || '';
}
interface ChatResponse {
text: string;
usage?: Record<string, unknown>;
error?: string;
}
interface StreamCallbacks {
onDelta?: (text: string) => void;
onFinal?: (response: ChatResponse) => void;
onError?: (error: string) => void;
}
/**
* Send a message to the Clawdbot Gateway and collect the response.
* Opens a temporary WebSocket connection for each request.
*/
export async function sendGatewayChat(
message: string,
sessionKey: string,
callbacks?: StreamCallbacks
): Promise<ChatResponse> {
const wsUrl = GATEWAY_URL.replace(/^http/, 'ws');
return new Promise((resolve, reject) => {
let connected = false;
let responseText = '';
let responseUsage: Record<string, unknown> | undefined;
let connectTimer: ReturnType<typeof setTimeout>;
let responseTimer: ReturnType<typeof setTimeout>;
const ws = new WebSocket(wsUrl);
const cleanup = () => {
clearTimeout(connectTimer);
clearTimeout(responseTimer);
try {
ws.close();
} catch {
/* ignore */
}
};
connectTimer = setTimeout(() => {
if (!connected) {
cleanup();
const err = 'Gateway connection timeout';
callbacks?.onError?.(err);
reject(new Error(err));
}
}, CONNECT_TIMEOUT_MS);
ws.on('error', (err) => {
log.error({ err: err.message }, 'Gateway WebSocket error');
cleanup();
const errMsg = `Gateway connection failed: ${err.message}`;
callbacks?.onError?.(errMsg);
reject(new Error(errMsg));
});
ws.on('message', (data) => {
let msg: any;
try {
msg = JSON.parse(data.toString());
} catch {
return;
}
// Step 1: Handle challenge → send connect
if (msg.type === 'event' && msg.event === 'connect.challenge') {
ws.send(
JSON.stringify({
type: 'req',
id: randomUUID(),
method: 'connect',
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: 'gateway-client',
version: '1.0.0',
platform: 'node',
mode: 'backend',
},
auth: { token: getToken() },
},
})
);
return;
}
// Step 2: Handle connect response → send chat.send
if (msg.type === 'res' && msg.ok && msg.payload?.type === 'hello-ok') {
connected = true;
clearTimeout(connectTimer);
log.info({ sessionKey }, 'Connected to gateway, sending chat message');
// Start response timeout
responseTimer = setTimeout(() => {
cleanup();
const err = 'Gateway response timeout';
callbacks?.onError?.(err);
reject(new Error(err));
}, RESPONSE_TIMEOUT_MS);
ws.send(
JSON.stringify({
type: 'req',
id: randomUUID(),
method: 'chat.send',
params: {
sessionKey,
message,
idempotencyKey: randomUUID(),
},
})
);
return;
}
// Handle chat.send ack
if (msg.type === 'res' && msg.ok && msg.payload?.runId) {
log.debug({ runId: msg.payload.runId }, 'Chat run started');
return;
}
// Handle errors
if (msg.type === 'res' && !msg.ok) {
cleanup();
const errMsg = msg.error?.message || 'Unknown gateway error';
log.error({ error: msg.error }, 'Gateway error');
callbacks?.onError?.(errMsg);
reject(new Error(errMsg));
return;
}
// Step 3: Handle streaming chat events
if (msg.type === 'event' && msg.event === 'chat') {
const payload = msg.payload || {};
if (payload.state === 'delta') {
// Gateway sends full accumulated text in each delta, not incremental chunks
const content = payload.message?.content;
if (Array.isArray(content)) {
let fullText = '';
for (const block of content) {
if (block.type === 'text' && block.text) {
fullText += block.text;
}
}
// Calculate the new chunk (what was added since last delta)
const newChunk = fullText.slice(responseText.length);
responseText = fullText;
if (newChunk) {
callbacks?.onDelta?.(newChunk);
}
}
}
if (payload.state === 'final') {
// Extract final text if we didn't get it from deltas
if (!responseText && payload.message?.content) {
const content = payload.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (typeof content === 'string') {
responseText = content;
}
}
responseUsage = payload.usage;
const response: ChatResponse = {
text: responseText,
usage: responseUsage,
};
log.info({ sessionKey, textLength: responseText.length }, 'Chat response complete');
cleanup();
callbacks?.onFinal?.(response);
resolve(response);
return;
}
if (payload.state === 'error') {
cleanup();
const errMsg = payload.errorMessage || 'Chat error';
callbacks?.onError?.(errMsg);
reject(new Error(errMsg));
return;
}
if (payload.state === 'aborted') {
cleanup();
const response: ChatResponse = {
text: responseText || '(response aborted)',
};
callbacks?.onFinal?.(response);
resolve(response);
return;
}
}
});
ws.on('close', () => {
if (!connected) {
reject(new Error('Gateway WebSocket closed before connecting'));
}
});
});
}
/**
* Load the gateway token from config file if not in env
*/
export async function loadGatewayToken(): Promise<string> {
if (cachedToken) return cachedToken;
if (process.env.CLAWDBOT_GATEWAY_TOKEN) {
cachedToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
return cachedToken;
}
try {
const fs = await import('fs/promises');
const path = await import('path');
const configPath = path.join(process.env.HOME || '', '.clawdbot', 'clawdbot.json');
const raw = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(raw);
const token = config?.gateway?.auth?.token;
if (token) {
cachedToken = token;
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
return token;
}
} catch (err: any) {
log.warn({ err: err.message }, 'Failed to load gateway token from config');
}
return '';
}

View file

@ -3,31 +3,13 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
MessageSquare,
Send,
X,
ChevronDown,
ChevronRight,
Loader2,
Bot,
User,
} from 'lucide-react';
import { MessageSquare, Send, ChevronDown, ChevronRight, Loader2, Bot, User } from 'lucide-react';
import {
useChatSession,
useSendChatMessage,
useChatStream,
useChatSessions,
} from '@/hooks/useChat';
import { useConfig } from '@/hooks/useConfig';
import { useTask } from '@/hooks/useTasks';
import type { ChatMessage } from '@veritas-kanban/shared';
@ -40,12 +22,7 @@ interface ChatPanelProps {
export function ChatPanel({ open, onOpenChange, taskId }: ChatPanelProps) {
const [message, setMessage] = useState('');
const [mode, setMode] = useState<'ask' | 'build'>('ask');
const [selectedAgent, setSelectedAgent] = useState<string>('claude-code');
const [selectedModel, setSelectedModel] = useState<string>('sonnet');
const [currentSessionId, setCurrentSessionId] = useState<string | undefined>();
const { data: config } = useConfig();
const enabledAgents = config?.agents?.filter((a: { enabled: boolean }) => a.enabled) || [];
const { data: task } = useTask(taskId || '');
const { data: sessions = [] } = useChatSessions();
const { data: session } = useChatSession(currentSessionId);
@ -87,13 +64,11 @@ export function ChatPanel({ open, onOpenChange, taskId }: ChatPanelProps) {
sessionId: currentSessionId,
taskId,
message: message.trim(),
agent: selectedAgent,
model: selectedModel,
mode,
},
{
onSuccess: (newSession) => {
setCurrentSessionId(newSession.id);
onSuccess: (response) => {
setCurrentSessionId(response.sessionId);
setMessage('');
setShouldAutoScroll(true);
},
@ -108,14 +83,14 @@ export function ChatPanel({ open, onOpenChange, taskId }: ChatPanelProps) {
}
};
// Load most recent session on mount if exists
// Load session on mount — task-scoped sessions use a deterministic ID
useEffect(() => {
if (!currentSessionId && filteredSessions.length > 0) {
if (taskId && !currentSessionId) {
setCurrentSessionId(`task_${taskId}`);
} else if (!taskId && !currentSessionId && filteredSessions.length > 0) {
setCurrentSessionId(filteredSessions[0].id);
}
}, [filteredSessions, currentSessionId]);
const models = ['sonnet', 'opus', 'haiku'];
}, [filteredSessions, currentSessionId, taskId]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@ -124,47 +99,11 @@ export function ChatPanel({ open, onOpenChange, taskId }: ChatPanelProps) {
side="right"
>
{/* Header */}
<SheetHeader className="border-b border-border px-4 py-3 flex-shrink-0">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2">
<Bot className="h-5 w-5" />
Agent Chat
</SheetTitle>
<div className="flex items-center gap-2">
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledAgents.map((a: { type: string; name: string }) => (
<SelectItem key={a.type} value={a.type}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-24 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<SheetHeader className="border-b border-border px-4 py-3 pr-10 flex-shrink-0">
<SheetTitle className="flex items-center gap-2">
<Bot className="h-5 w-5" />
Agent Chat
</SheetTitle>
{taskId && task && (
<div className="text-xs text-muted-foreground flex items-center gap-2 pt-2 border-t border-border/50 mt-2">
<MessageSquare className="h-3 w-3" />
@ -241,20 +180,9 @@ export function ChatPanel({ open, onOpenChange, taskId }: ChatPanelProps) {
>
Build
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-help">ⓘ</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">
<strong>Ask:</strong> Read-only queries and questions
<br />
<strong>Build:</strong> Make changes, create files, execute commands
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="text-muted-foreground ml-1">
{mode === 'ask' ? '· Read-only queries' : '· Changes, files, commands'}
</span>
</div>
</div>
</SheetContent>

View file

@ -76,10 +76,13 @@ export function AgentPanel({ task }: AgentPanelProps) {
const sendMessage = useSendMessage();
const [selectedAgent, setSelectedAgent] = useState<AgentType | undefined>();
const [selectedModel, setSelectedModel] = useState<string | undefined>();
const [message, setMessage] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
const [viewingAttemptId, setViewingAttemptId] = useState<string | null>(null);
const models = ['sonnet', 'opus', 'haiku'];
const outputRef = useRef<HTMLDivElement>(null);
// Fetch log for historical attempt
@ -226,6 +229,21 @@ export function AgentPanel({ task }: AgentPanelProps) {
))}
</SelectContent>
</Select>
<Select
value={selectedModel || routingResult?.model || 'sonnet'}
onValueChange={setSelectedModel}
>
<SelectTrigger className="w-[100px] h-8">
<SelectValue placeholder="Model..." />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={handleStart}

View file

@ -23,13 +23,13 @@ export function SubtasksSection({ task, onAutoCompleteChange }: SubtasksSectionP
const deleteSubtask = useDeleteSubtask();
const subtasks = task.subtasks || [];
const completedCount = subtasks.filter(s => s.completed).length;
const completedCount = subtasks.filter((s) => s.completed).length;
const totalCount = subtasks.length;
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
const handleAddSubtask = async () => {
if (!newSubtaskTitle.trim()) return;
setIsAdding(true);
try {
await addSubtask.mutateAsync({ taskId: task.id, title: newSubtaskTitle.trim() });
@ -72,7 +72,7 @@ export function SubtasksSection({ task, onAutoCompleteChange }: SubtasksSectionP
{/* Progress bar */}
{totalCount > 0 && (
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
@ -85,8 +85,8 @@ export function SubtasksSection({ task, onAutoCompleteChange }: SubtasksSectionP
<div
key={subtask.id}
className={cn(
"flex items-center gap-2 p-2 rounded-md group hover:bg-muted/50 transition-colors",
subtask.completed && "opacity-60"
'flex items-center gap-2 p-2 rounded-md group hover:bg-muted/50 transition-colors',
subtask.completed && 'opacity-60'
)}
>
<Checkbox
@ -94,10 +94,12 @@ export function SubtasksSection({ task, onAutoCompleteChange }: SubtasksSectionP
onCheckedChange={() => handleToggleSubtask(subtask)}
className="flex-shrink-0"
/>
<span className={cn(
"flex-1 text-sm",
subtask.completed && "line-through text-muted-foreground"
)}>
<span
className={cn(
'flex-1 text-sm',
subtask.completed && 'line-through text-muted-foreground'
)}
>
{subtask.title}
</span>
<Button
@ -123,9 +125,10 @@ export function SubtasksSection({ task, onAutoCompleteChange }: SubtasksSectionP
disabled={isAdding}
/>
<Button
size="sm"
size="icon"
onClick={handleAddSubtask}
disabled={!newSubtaskTitle.trim() || isAdding}
className="h-9 w-9 shrink-0"
>
<Plus className="h-4 w-4" />
</Button>

View file

@ -112,83 +112,87 @@ export function TaskDetailPanel({
</SheetTitle>
</SheetHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden mt-4"
>
<div className="flex items-center gap-2">
<TabsList
className={`grid flex-1 ${isCodeTask ? (taskSettings.enableAttachments ? 'grid-cols-7' : 'grid-cols-6') : taskSettings.enableAttachments ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value="details">Details</TabsTrigger>
{taskSettings.enableAttachments && (
<TabsTrigger value="attachments" className="flex items-center gap-1">
<Paperclip className="h-3 w-3" />
Attachments
</TabsTrigger>
)}
{isCodeTask && (
<>
<TabsTrigger value="git" className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
Git
</TabsTrigger>
<TabsTrigger value="agent" className="flex items-center gap-1">
<Bot className="h-3 w-3" />
Agent
</TabsTrigger>
<TabsTrigger
value="changes"
disabled={!hasWorktree}
className="flex items-center gap-1"
>
<FileDiff className="h-3 w-3" />
Changes
</TabsTrigger>
<TabsTrigger value="review" className="flex items-center gap-1">
<ClipboardCheck className="h-3 w-3" />
Review
</TabsTrigger>
</>
)}
<TabsTrigger value="metrics" className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
Metrics
</TabsTrigger>
</TabsList>
{/* Action buttons above tabs */}
<div className="grid grid-cols-2 gap-2 mt-4 flex-shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setTaskChatOpen(true)}
className="flex items-center justify-center gap-1 w-full"
>
<MessageSquare className="h-3 w-3" />
Chat
</Button>
{!readOnly ? (
<Button
variant="outline"
size="sm"
onClick={() => setTaskChatOpen(true)}
className="flex items-center gap-1"
onClick={() => setApplyTemplateOpen(true)}
className="flex items-center justify-center gap-1 w-full"
>
<MessageSquare className="h-3 w-3" />
Chat
<FileCode className="h-3 w-3" />
Template
</Button>
{!readOnly && (
<Button
variant="outline"
size="sm"
onClick={() => setApplyTemplateOpen(true)}
className="flex items-center gap-1"
>
<FileCode className="h-3 w-3" />
Template
</Button>
) : (
<div />
)}
{!readOnly && isCodeTask && localTask.git?.repo && agentSettings.enablePreview && (
<Button
variant="outline"
size="sm"
onClick={() => setPreviewOpen(true)}
className="flex items-center justify-center gap-1 w-full col-span-2"
>
<Monitor className="h-3 w-3" />
Preview
</Button>
)}
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden mt-3"
>
<TabsList
className={`grid w-full flex-shrink-0 ${isCodeTask ? (taskSettings.enableAttachments ? 'grid-cols-7' : 'grid-cols-6') : taskSettings.enableAttachments ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value="details">Details</TabsTrigger>
{taskSettings.enableAttachments && (
<TabsTrigger value="attachments" className="flex items-center gap-1">
<Paperclip className="h-3 w-3" />
Attachments
</TabsTrigger>
)}
{!readOnly && isCodeTask && localTask.git?.repo && agentSettings.enablePreview && (
<Button
variant="outline"
size="sm"
onClick={() => setPreviewOpen(true)}
className="flex items-center gap-1"
>
<Monitor className="h-3 w-3" />
Preview
</Button>
{isCodeTask && (
<>
<TabsTrigger value="git" className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
Git
</TabsTrigger>
<TabsTrigger value="agent" className="flex items-center gap-1">
<Bot className="h-3 w-3" />
Agent
</TabsTrigger>
<TabsTrigger
value="changes"
disabled={!hasWorktree}
className="flex items-center gap-1"
>
<FileDiff className="h-3 w-3" />
Changes
</TabsTrigger>
<TabsTrigger value="review" className="flex items-center gap-1">
<ClipboardCheck className="h-3 w-3" />
Review
</TabsTrigger>
</>
)}
</div>
<TabsTrigger value="metrics" className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
Metrics
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto mt-4">
{/* Details Tab */}

View file

@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { chatApi } from '@/lib/api/chat';
import { chatEventTarget } from '@/hooks/useTaskSync';
import type { ChatMessage, ChatSendInput } from '@veritas-kanban/shared';
/**
@ -34,11 +35,12 @@ export function useSendChatMessage() {
return useMutation({
mutationFn: (input: ChatSendInput) => chatApi.sendMessage(input),
onSuccess: (session) => {
// Update the session cache
queryClient.setQueryData(['chat', 'sessions', session.id], session);
// Invalidate sessions list
onSuccess: (response) => {
// Invalidate sessions list and the specific session to refetch
queryClient.invalidateQueries({ queryKey: ['chat', 'sessions'] });
if (response.sessionId) {
queryClient.invalidateQueries({ queryKey: ['chat', 'sessions', response.sessionId] });
}
},
});
}
@ -59,22 +61,53 @@ export function useDeleteChatSession() {
/**
* Listen for streaming chat messages via WebSocket
* This is a placeholder for when WebSocket streaming is implemented
*/
export function useChatStream(sessionId: string | undefined) {
const [streamingMessage, setStreamingMessage] = useState<Partial<ChatMessage> | null>(null);
const [, setStreamingText] = useState('');
const queryClient = useQueryClient();
// Listen for chat events from the shared WebSocket (via useTaskSync)
useEffect(() => {
if (!sessionId) return;
// TODO: Connect to WebSocket and listen for chat:message:chunk events
// For now, this is a placeholder. When backend WebSocket streaming is ready:
// 1. Subscribe to chat:message:chunk events for this sessionId
// 2. Accumulate chunks in streamingMessage state
// 3. On chat:message:complete, clear streaming + invalidate session query
const handler = (e: Event) => {
const msg = (e as CustomEvent).detail;
const msgSessionId = msg.sessionId as string;
if (msgSessionId !== sessionId) return;
if (msg.type === 'chat:delta') {
const text = msg.text as string;
setStreamingText((prev) => {
const newText = prev + text;
setStreamingMessage({
id: 'streaming',
role: 'assistant',
content: newText,
timestamp: new Date().toISOString(),
});
return newText;
});
}
if (msg.type === 'chat:message') {
setStreamingMessage(null);
setStreamingText('');
queryClient.invalidateQueries({ queryKey: ['chat', 'sessions', sessionId] });
}
if (msg.type === 'chat:error') {
setStreamingMessage(null);
setStreamingText('');
queryClient.invalidateQueries({ queryKey: ['chat', 'sessions', sessionId] });
}
};
chatEventTarget.addEventListener('chat', handler);
return () => {
chatEventTarget.removeEventListener('chat', handler);
setStreamingMessage(null);
setStreamingText('');
};
}, [sessionId, queryClient]);

View file

@ -2,6 +2,10 @@ import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useWebSocket, type WebSocketMessage, type ConnectionState } from './useWebSocket';
// Global event target for chat WebSocket events
// Chat hooks subscribe to this instead of opening their own WebSocket
export const chatEventTarget = new EventTarget();
/**
* Connects to the Veritas Kanban WebSocket server and listens for
* task:changed events. When received, invalidates the React Query
@ -20,6 +24,15 @@ export function useTaskSync(): {
const handleMessage = useCallback(
(message: WebSocketMessage) => {
// Forward chat events to the chat event target
if (
message.type === 'chat:delta' ||
message.type === 'chat:message' ||
message.type === 'chat:error'
) {
chatEventTarget.dispatchEvent(new CustomEvent('chat', { detail: message }));
}
if (message.type === 'task:changed') {
// Invalidate task queries to trigger a refetch
queryClient.invalidateQueries({ queryKey: ['tasks'] });

View file

@ -24,17 +24,27 @@ export async function getSession(sessionId: string): Promise<ChatSession> {
return handleResponse<ChatSession>(response);
}
/**
* Chat send response from the API
* (Not a full ChatSession — agent response streams via WebSocket)
*/
export interface ChatSendResponse {
sessionId: string;
messageId: string;
message: string;
}
/**
* Send a chat message
*/
export async function sendMessage(input: ChatSendInput): Promise<ChatSession> {
export async function sendMessage(input: ChatSendInput): Promise<ChatSendResponse> {
const response = await fetch(`${API_BASE}/chat/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(input),
});
return handleResponse<ChatSession>(response);
return handleResponse<ChatSendResponse>(response);
}
/**