mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-05 19:27:50 +00:00
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:
parent
643b62d90d
commit
89a5fd8b20
10 changed files with 555 additions and 187 deletions
|
|
@ -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');
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
270
server/src/services/gateway-chat-client.ts
Normal file
270
server/src/services/gateway-chat-client.ts
Normal 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 '';
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue