Merge feat/v1.1-agent-routing-16-17-18: agent routing, chat interface (#16, #17, #18)

This commit is contained in:
Brad Groux 2026-01-31 07:08:06 -06:00
commit a81b4ea1f7
41 changed files with 3895 additions and 464 deletions

View file

@ -0,0 +1,322 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AgentRoutingService } from '../services/agent-routing-service';
import type { AgentRoutingConfig, AppConfig, Task } from '@veritas-kanban/shared';
// Mock ConfigService
const mockGetConfig = vi.fn();
const mockSaveConfig = vi.fn();
vi.mock('../services/config-service.js', () => {
return {
ConfigService: class MockConfigService {
getConfig = mockGetConfig;
saveConfig = mockSaveConfig;
},
};
});
const BASE_CONFIG: AppConfig = {
repos: [],
agents: [
{ type: 'claude-code', name: 'Claude Code', command: 'claude', args: [], enabled: true },
{ type: 'amp', name: 'Amp', command: 'amp', args: [], enabled: true },
{ type: 'copilot', name: 'GitHub Copilot', command: 'copilot', args: [], enabled: true },
{ type: 'gemini', name: 'Gemini CLI', command: 'gemini', args: [], enabled: false },
],
defaultAgent: 'claude-code',
agentRouting: {
enabled: true,
rules: [
{
id: 'code-high',
name: 'High-priority code',
match: { type: 'code', priority: 'high' },
agent: 'claude-code',
model: 'opus',
fallback: 'amp',
enabled: true,
},
{
id: 'code-default',
name: 'Code tasks',
match: { type: 'code' },
agent: 'claude-code',
model: 'sonnet',
fallback: 'copilot',
enabled: true,
},
{
id: 'docs',
name: 'Documentation',
match: { type: 'docs' },
agent: 'claude-code',
model: 'haiku',
enabled: true,
},
{
id: 'disabled-rule',
name: 'Disabled rule',
match: { type: 'feature' },
agent: 'amp',
enabled: false,
},
],
defaultAgent: 'claude-code',
defaultModel: 'sonnet',
fallbackOnFailure: true,
maxRetries: 1,
},
};
describe('AgentRoutingService', () => {
let service: AgentRoutingService;
beforeEach(() => {
mockGetConfig.mockResolvedValue(structuredClone(BASE_CONFIG));
mockSaveConfig.mockResolvedValue(undefined);
service = new AgentRoutingService();
});
describe('resolveAgent', () => {
it('matches high-priority code task to first rule', async () => {
const result = await service.resolveAgent({
type: 'code',
priority: 'high',
});
expect(result.agent).toBe('claude-code');
expect(result.model).toBe('opus');
expect(result.fallback).toBe('amp');
expect(result.rule).toBe('code-high');
expect(result.reason).toContain('High-priority code');
});
it('matches medium-priority code task to second rule', async () => {
const result = await service.resolveAgent({
type: 'code',
priority: 'medium',
});
expect(result.agent).toBe('claude-code');
expect(result.model).toBe('sonnet');
expect(result.fallback).toBe('copilot');
expect(result.rule).toBe('code-default');
});
it('matches docs to docs rule', async () => {
const result = await service.resolveAgent({
type: 'docs',
priority: 'low',
});
expect(result.agent).toBe('claude-code');
expect(result.model).toBe('haiku');
expect(result.rule).toBe('docs');
});
it('skips disabled rules', async () => {
const result = await service.resolveAgent({
type: 'feature',
priority: 'medium',
});
// disabled-rule matches feature but is disabled, so falls through to default
expect(result.rule).toBeUndefined();
expect(result.agent).toBe('claude-code');
expect(result.model).toBe('sonnet');
expect(result.reason).toContain('No routing rules matched');
});
it('falls back to default when no rules match', async () => {
const result = await service.resolveAgent({
type: 'design',
priority: 'low',
});
expect(result.agent).toBe('claude-code');
expect(result.model).toBe('sonnet');
expect(result.rule).toBeUndefined();
expect(result.reason).toContain('No routing rules matched');
});
it('returns default agent when routing is disabled', async () => {
const config = structuredClone(BASE_CONFIG);
config.agentRouting!.enabled = false;
mockGetConfig.mockResolvedValue(config);
const result = await service.resolveAgent({
type: 'code',
priority: 'high',
});
expect(result.agent).toBe('claude-code');
expect(result.reason).toContain('Routing disabled');
});
it('skips rules where agent is disabled', async () => {
const config = structuredClone(BASE_CONFIG);
// Disable claude-code so the first two rules are skipped
config.agents[0].enabled = false;
mockGetConfig.mockResolvedValue(config);
const result = await service.resolveAgent({
type: 'code',
priority: 'high',
});
// Both code rules point to claude-code which is disabled — falls to default
// But default is also claude-code (disabled), so it returns the config default anyway
expect(result.reason).toContain('No routing rules matched');
});
it('matches array criteria', async () => {
const config = structuredClone(BASE_CONFIG);
config.agentRouting!.rules = [
{
id: 'multi-type',
name: 'Multiple types',
match: { type: ['bug', 'hotfix'], priority: ['high', 'medium'] },
agent: 'amp',
enabled: true,
},
];
mockGetConfig.mockResolvedValue(config);
const result = await service.resolveAgent({
type: 'bug',
priority: 'medium',
});
expect(result.agent).toBe('amp');
expect(result.rule).toBe('multi-type');
});
it('matches minSubtasks criteria', async () => {
const config = structuredClone(BASE_CONFIG);
config.agentRouting!.rules = [
{
id: 'complex',
name: 'Complex tasks',
match: { minSubtasks: 5 },
agent: 'amp',
enabled: true,
},
];
mockGetConfig.mockResolvedValue(config);
const result = await service.resolveAgent({
type: 'feature',
priority: 'medium',
subtasks: Array.from({ length: 6 }, (_, i) => ({
id: `s${i}`,
title: `Sub ${i}`,
completed: false,
created: new Date().toISOString(),
})),
});
expect(result.agent).toBe('amp');
expect(result.rule).toBe('complex');
});
it('does NOT match when subtasks below threshold', async () => {
const config = structuredClone(BASE_CONFIG);
config.agentRouting!.rules = [
{
id: 'complex',
name: 'Complex tasks',
match: { minSubtasks: 5 },
agent: 'amp',
enabled: true,
},
];
mockGetConfig.mockResolvedValue(config);
const result = await service.resolveAgent({
type: 'feature',
priority: 'medium',
subtasks: [
{ id: 's1', title: 'Sub 1', completed: false, created: new Date().toISOString() },
],
});
expect(result.rule).toBeUndefined(); // No match
});
});
describe('getFallback', () => {
it('returns fallback agent from matched rule', async () => {
const result = await service.getFallback({ type: 'code', priority: 'high' }, 'claude-code');
expect(result).not.toBeNull();
expect(result!.agent).toBe('amp');
expect(result!.reason).toContain('Fallback');
});
it('returns null when fallback is disabled', async () => {
const config = structuredClone(BASE_CONFIG);
config.agentRouting!.fallbackOnFailure = false;
mockGetConfig.mockResolvedValue(config);
const result = await service.getFallback({ type: 'code', priority: 'high' }, 'claude-code');
expect(result).toBeNull();
});
it('returns default agent as fallback when no specific fallback', async () => {
const result = await service.getFallback(
{ type: 'docs', priority: 'low' },
'claude-code' // docs rule has no fallback, and default is claude-code (same)
);
// claude-code === failedAgent, so no fallback
expect(result).toBeNull();
});
it('returns default agent when it differs from failed', async () => {
const result = await service.getFallback(
{ type: 'docs', priority: 'low' },
'amp' // Failed agent is amp, default is claude-code → valid fallback
);
expect(result).not.toBeNull();
expect(result!.agent).toBe('claude-code');
});
});
describe('updateRoutingConfig', () => {
it('saves valid config', async () => {
const newConfig: AgentRoutingConfig = {
enabled: true,
rules: [],
defaultAgent: 'amp',
fallbackOnFailure: false,
maxRetries: 0,
};
await service.updateRoutingConfig(newConfig);
expect(mockSaveConfig).toHaveBeenCalled();
});
it('rejects duplicate rule IDs', async () => {
const newConfig: AgentRoutingConfig = {
enabled: true,
rules: [
{ id: 'dup', name: 'A', match: {}, agent: 'amp', enabled: true },
{ id: 'dup', name: 'B', match: {}, agent: 'amp', enabled: true },
],
defaultAgent: 'amp',
fallbackOnFailure: false,
maxRetries: 0,
};
await expect(service.updateRoutingConfig(newConfig)).rejects.toThrow('unique');
});
it('rejects maxRetries > 3', async () => {
const newConfig: AgentRoutingConfig = {
enabled: true,
rules: [],
defaultAgent: 'amp',
fallbackOnFailure: false,
maxRetries: 5,
};
await expect(service.updateRoutingConfig(newConfig)).rejects.toThrow('maxRetries');
});
});
});

View file

@ -526,6 +526,8 @@ setHealthWss(wss);
// Track subscriptions: taskId -> Set of WebSocket clients
const agentSubscriptions = new Map<string, Set<WebSocket>>();
// Track chat subscriptions: sessionId -> Set of WebSocket clients
const chatSubscriptions = new Map<string, Set<WebSocket>>();
// ---- Heartbeat: server pings every WS_HEARTBEAT_INTERVAL_MS ----
const heartbeatInterval = setInterval(() => {
@ -598,11 +600,47 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
);
let subscribedTaskId: string | null = null;
let subscribedChatSession: string | null = null;
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
// Handle subscription to chat session
if (message.type === 'chat:subscribe' && message.sessionId) {
// Unsubscribe from previous chat session
if (subscribedChatSession) {
const subs = chatSubscriptions.get(subscribedChatSession);
if (subs) {
subs.delete(ws);
if (subs.size === 0) {
chatSubscriptions.delete(subscribedChatSession);
}
}
}
// Subscribe to new chat session
const sessionId: string = message.sessionId;
subscribedChatSession = sessionId;
if (!chatSubscriptions.has(sessionId)) {
chatSubscriptions.set(sessionId, new Set());
}
chatSubscriptions.get(sessionId)!.add(ws);
// Send confirmation
ws.send(
JSON.stringify({
type: 'chat:subscribed',
sessionId,
})
);
log.debug(
{ sessionId, clients: chatSubscriptions.get(sessionId)!.size },
'Chat subscription added'
);
}
// Handle subscription to agent output
if (message.type === 'subscribe' && message.taskId) {
// Unsubscribe from previous task
@ -706,7 +744,7 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
ws.heartbeatTimer = undefined;
}
// Clean up subscriptions
// Clean up agent subscriptions
if (subscribedTaskId) {
const subs = agentSubscriptions.get(subscribedTaskId);
if (subs) {
@ -716,11 +754,22 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
}
}
}
// Clean up chat subscriptions
if (subscribedChatSession) {
const subs = chatSubscriptions.get(subscribedChatSession);
if (subs) {
subs.delete(ws);
if (subs.size === 0) {
chatSubscriptions.delete(subscribedChatSession);
}
}
}
});
});
// Export for use in other modules
export { wss };
export { wss, chatSubscriptions };
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {

View file

@ -0,0 +1,147 @@
/**
* Agent Routing API Routes
*
* POST /api/agents/route — Resolve the best agent for a task
* GET /api/agents/routing — Get current routing configuration
* PUT /api/agents/routing — Update routing configuration
*/
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { getAgentRoutingService } from '../services/agent-routing-service.js';
import { getTaskService } from '../services/task-service.js';
import { asyncHandler } from '../middleware/async-handler.js';
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
const router: RouterType = Router();
// ─── Validation Schemas ──────────────────────────────────────────
const routeByTaskIdSchema = z.object({
taskId: z.string().min(1),
});
const routeByMetadataSchema = z.object({
type: z.string().optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
subtaskCount: z.number().int().nonnegative().optional(),
});
const routingMatchSchema = z.object({
type: z.union([z.string(), z.array(z.string())]).optional(),
priority: z
.union([z.enum(['low', 'medium', 'high']), z.array(z.enum(['low', 'medium', 'high']))])
.optional(),
project: z.union([z.string(), z.array(z.string())]).optional(),
minSubtasks: z.number().int().nonnegative().optional(),
});
const routingRuleSchema = z.object({
id: z.string().min(1).max(50),
name: z.string().min(1).max(200),
match: routingMatchSchema,
agent: z.string().min(1).max(50),
model: z.string().max(50).optional(),
fallback: z.string().max(50).optional(),
enabled: z.boolean(),
});
const routingConfigSchema = z.object({
enabled: z.boolean(),
rules: z.array(routingRuleSchema),
defaultAgent: z.string().min(1).max(50),
defaultModel: z.string().max(50).optional(),
fallbackOnFailure: z.boolean(),
maxRetries: z.number().int().min(0).max(3),
});
// ─── Routes ──────────────────────────────────────────────────────
/**
* POST /api/agents/route
*
* Resolve the best agent for a task. Accepts either:
* - { taskId: "..." } to look up an existing task
* - { type, priority, project, subtaskCount } for ad-hoc routing
*/
router.post(
'/route',
asyncHandler(async (req, res) => {
const routing = getAgentRoutingService();
// Try taskId first
const taskIdParse = routeByTaskIdSchema.safeParse(req.body);
if (taskIdParse.success) {
const taskService = getTaskService();
const task = await taskService.getTask(taskIdParse.data.taskId);
if (!task) {
throw new NotFoundError('Task not found');
}
const result = await routing.resolveAgent(task);
return res.json(result);
}
// Fall back to metadata
const metaParse = routeByMetadataSchema.safeParse(req.body);
if (metaParse.success) {
const { type, priority, project, subtaskCount } = metaParse.data;
const result = await routing.resolveAgent({
type: type || 'feature',
priority: priority || 'medium',
project,
subtasks: subtaskCount
? Array.from({ length: subtaskCount }, (_, i) => ({
id: `stub_${i}`,
title: '',
completed: false,
created: new Date().toISOString(),
}))
: undefined,
});
return res.json(result);
}
throw new ValidationError('Provide either { taskId } or { type, priority, ... }');
})
);
/**
* GET /api/agents/routing
*
* Get the current routing configuration.
*/
router.get(
'/routing',
asyncHandler(async (_req, res) => {
const routing = getAgentRoutingService();
const config = await routing.getRoutingConfig();
res.json(config);
})
);
/**
* PUT /api/agents/routing
*
* Replace the entire routing configuration.
*/
router.put(
'/routing',
asyncHandler(async (req, res) => {
let parsed;
try {
parsed = routingConfigSchema.parse(req.body);
} catch (error) {
if (error instanceof z.ZodError) {
throw new ValidationError('Invalid routing config', error.errors);
}
throw error;
}
const routing = getAgentRoutingService();
const updated = await routing.updateRoutingConfig(parsed);
res.json(updated);
})
);
export { router as agentRoutingRoutes };

228
server/src/routes/chat.ts Normal file
View file

@ -0,0 +1,228 @@
/**
* Chat Routes
*
* Built-in chat interface for conversing with agents.
*/
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';
import { createLogger } from '../lib/logger.js';
const log = createLogger('chat');
// Load gateway token on startup
loadGatewayToken().catch(() => {});
const router: RouterType = Router();
const chatService = getChatService();
// Validation schemas
const chatSendSchema = z.object({
sessionId: z.string().optional(),
taskId: z.string().optional(),
message: z.string().min(1, 'Message cannot be empty'),
agent: z.string().optional(),
model: z.string().optional(),
mode: z.enum(['ask', 'build']).optional(),
});
/**
* POST /api/chat/send
* Send a message to the chat interface
*
* Returns the user message echo immediately.
* Agent response will stream via WebSocket (chat:message events).
*/
router.post(
'/send',
asyncHandler(async (req, res) => {
// Validate input
const validatedInput = chatSendSchema.parse(req.body);
const input = validatedInput as ChatSendInput;
let session;
let sessionId: string;
// Get or create session
if (input.sessionId) {
session = await chatService.getSession(input.sessionId);
if (!session && input.taskId) {
// Session was deleted — recreate for task-scoped chats
log.info(
{ sessionId: input.sessionId, taskId: input.taskId },
'Recreating deleted task chat session'
);
session = await chatService.createSession({
taskId: input.taskId,
agent: input.agent || 'veritas',
mode: input.mode || 'ask',
});
} else if (!session) {
throw new NotFoundError(`Session ${input.sessionId} not found`);
}
sessionId = input.sessionId;
} else if (input.taskId) {
// Task-scoped session
session = await chatService.getSessionForTask(input.taskId);
if (!session) {
// Create new task-scoped session
session = await chatService.createSession({
taskId: input.taskId,
agent: input.agent || 'veritas',
mode: input.mode || 'ask',
});
}
sessionId = session.id;
} else {
// Create new board-level session
session = await chatService.createSession({
agent: input.agent || 'veritas',
mode: input.mode || 'ask',
});
sessionId = session.id;
}
// Add user message
const userMessage = await chatService.addMessage(sessionId, {
role: 'user',
content: input.message,
});
log.info({ sessionId, messageId: userMessage.id, taskId: session.taskId }, 'Chat message sent');
// Return immediately - agent response will arrive async
res.status(200).json({
sessionId,
messageId: userMessage.id,
message: 'Message sent — agent response incoming',
});
// 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');
});
})
);
/**
* GET /api/chat/sessions
* List all board-level chat sessions
*/
router.get(
'/sessions',
asyncHandler(async (_req, res) => {
const sessions = await chatService.listSessions();
res.json(sessions);
})
);
/**
* GET /api/chat/sessions/:id
* Get a specific chat session with messages
*/
router.get(
'/sessions/:id',
asyncHandler(async (req, res) => {
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const session = await chatService.getSession(sessionId);
if (!session) {
throw new NotFoundError(`Session ${sessionId} not found`);
}
res.json(session);
})
);
/**
* GET /api/chat/sessions/:id/history
* Get messages for a session (messages only, no metadata)
*/
router.get(
'/sessions/:id/history',
asyncHandler(async (req, res) => {
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const session = await chatService.getSession(sessionId);
if (!session) {
throw new NotFoundError(`Session ${sessionId} not found`);
}
res.json(session.messages);
})
);
/**
* DELETE /api/chat/sessions/:id
* Delete a chat session
*/
router.delete(
'/sessions/:id',
asyncHandler(async (req, res) => {
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
await chatService.deleteSession(sessionId);
log.info({ sessionId }, 'Chat session deleted');
res.status(204).send();
})
);
export { router as chatRoutes };

View file

@ -26,6 +26,7 @@ const createTaskSchema = z.object({
priority: z.enum(['low', 'medium', 'high']).optional().default('medium'),
project: z.string().optional(),
sprint: z.string().optional(),
agent: z.string().max(50).optional(), // "auto" | agent type slug
});
const gitSchema = z
@ -103,6 +104,7 @@ const updateTaskSchema = z.object({
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
sprint: z.string().optional(),
agent: z.string().max(50).optional(),
git: gitSchema,
attempt: attemptSchema,
reviewComments: z.array(reviewCommentSchema).optional(),

View file

@ -27,7 +27,9 @@ import attachmentRoutes from '../attachments.js';
// Feature routes
import { configRoutes } from '../config.js';
import { chatRoutes } from '../chat.js';
import { agentRoutes } from '../agents.js';
import { agentRoutingRoutes } from '../agent-routing.js';
import { diffRoutes } from '../diff.js';
import { automationRoutes } from '../automation.js';
import { summaryRoutes } from '../summary.js';
@ -85,6 +87,8 @@ v1Router.use(
// ── Feature routes ───────────────────────────────────────────
v1Router.use('/config', configRoutes);
v1Router.use('/chat', chatRoutes); // Chat interface - must be before agent routes
v1Router.use('/agents', agentRoutingRoutes); // Must be before agentRoutes (/:taskId would match "route"/"routing")
v1Router.use('/agents', agentRoutes);
v1Router.use('/diff', diffRoutes);
v1Router.use('/automation', automationRoutes);

View file

@ -0,0 +1,224 @@
/**
* AgentRoutingService - Task-aware agent/model routing
*
* Matches task metadata (type, priority, project, complexity) against
* user-configured routing rules to select the optimal agent and model.
*
* Rules are evaluated in order — first match wins.
* Falls back to the configured default when no rules match.
*/
import { ConfigService } from './config-service.js';
import {
DEFAULT_ROUTING_CONFIG,
type AgentRoutingConfig,
type RoutingRule,
type RoutingResult,
type RoutingMatchCriteria,
} from '@veritas-kanban/shared';
import type { Task, AgentType, TaskPriority } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('agent-routing');
export class AgentRoutingService {
private configService: ConfigService;
constructor(configService?: ConfigService) {
this.configService = configService || new ConfigService();
}
/**
* Resolve the best agent for a given task.
*
* @param task - Full task object (or partial with type/priority/project/subtasks)
* @returns RoutingResult with the selected agent, optional model, fallback, and reasoning
*/
async resolveAgent(
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>
): Promise<RoutingResult> {
const config = await this.configService.getConfig();
const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG;
// If routing is disabled, return the global default
if (!routing.enabled) {
return {
agent: routing.defaultAgent || config.defaultAgent,
model: routing.defaultModel,
reason: 'Routing disabled — using default agent',
};
}
// Evaluate rules in order (first match wins)
for (const rule of routing.rules) {
if (!rule.enabled) continue;
if (this.matchesRule(task, rule.match)) {
// Verify the agent is actually configured and enabled
const agentConfig = config.agents.find((a) => a.type === rule.agent);
if (!agentConfig?.enabled) {
log.warn(`Rule "${rule.name}" matched but agent "${rule.agent}" is disabled — skipping`);
continue;
}
log.info(
`Task [type=${task.type}, priority=${task.priority}] matched rule "${rule.name}" → ${rule.agent}${rule.model ? ` (${rule.model})` : ''}`
);
return {
agent: rule.agent,
model: rule.model,
fallback: rule.fallback,
rule: rule.id,
reason: `Matched rule: ${rule.name}`,
};
}
}
// No rule matched — use defaults
log.info(
`Task [type=${task.type}, priority=${task.priority}] — no rules matched, using default: ${routing.defaultAgent}`
);
return {
agent: routing.defaultAgent || config.defaultAgent,
model: routing.defaultModel,
reason: 'No routing rules matched — using default agent',
};
}
/**
* Get the fallback agent for a given primary agent.
* Used when an agent fails and `fallbackOnFailure` is enabled.
*/
async getFallback(
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
failedAgent: AgentType
): Promise<RoutingResult | null> {
const config = await this.configService.getConfig();
const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG;
if (!routing.fallbackOnFailure) {
return null;
}
// Find the rule that originally matched (to get its fallback)
for (const rule of routing.rules) {
if (!rule.enabled) continue;
if (rule.agent !== failedAgent) continue;
if (!rule.fallback) continue;
if (!this.matchesRule(task, rule.match)) continue;
const fallbackConfig = config.agents.find((a) => a.type === rule.fallback);
if (!fallbackConfig?.enabled) {
log.warn(`Fallback agent "${rule.fallback}" for rule "${rule.name}" is disabled`);
continue;
}
log.info(`Falling back from ${failedAgent} → ${rule.fallback} (rule: ${rule.name})`);
return {
agent: rule.fallback,
rule: rule.id,
reason: `Fallback: ${failedAgent} failed → ${rule.fallback} (rule: ${rule.name})`,
};
}
// No specific fallback found — try default if it's different from failed
const defaultAgent = routing.defaultAgent || config.defaultAgent;
if (defaultAgent !== failedAgent) {
const defaultConfig = config.agents.find((a) => a.type === defaultAgent);
if (defaultConfig?.enabled) {
return {
agent: defaultAgent,
model: routing.defaultModel,
reason: `Fallback: ${failedAgent} failed → default agent (${defaultAgent})`,
};
}
}
return null;
}
/**
* Get the current routing config (for UI display).
*/
async getRoutingConfig(): Promise<AgentRoutingConfig> {
const config = await this.configService.getConfig();
return config.agentRouting || DEFAULT_ROUTING_CONFIG;
}
/**
* Update routing config.
*/
async updateRoutingConfig(routing: AgentRoutingConfig): Promise<AgentRoutingConfig> {
// Validate rule IDs are unique
const ids = routing.rules.map((r) => r.id);
const uniqueIds = new Set(ids);
if (ids.length !== uniqueIds.size) {
throw new Error('Routing rule IDs must be unique');
}
// Validate maxRetries range
if (routing.maxRetries < 0 || routing.maxRetries > 3) {
throw new Error('maxRetries must be between 0 and 3');
}
const config = await this.configService.getConfig();
await this.configService.saveConfig({ ...config, agentRouting: routing });
return routing;
}
// ─── Private helpers ───────────────────────────────────────────
/**
* Check if a task matches a rule's criteria.
* All specified criteria must match (AND logic).
* Unspecified criteria are ignored (wildcard).
*/
private matchesRule(
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
match: RoutingMatchCriteria
): boolean {
// Type check
if (match.type !== undefined) {
if (!this.matchesValue(task.type, match.type)) return false;
}
// Priority check
if (match.priority !== undefined) {
if (!this.matchesValue(task.priority, match.priority)) return false;
}
// Project check
if (match.project !== undefined) {
if (!task.project) return false;
if (!this.matchesValue(task.project, match.project)) return false;
}
// Complexity (subtask count)
if (match.minSubtasks !== undefined) {
const subtaskCount = task.subtasks?.length ?? 0;
if (subtaskCount < match.minSubtasks) return false;
}
return true;
}
/**
* Check if a value matches a single value or array of acceptable values.
*/
private matchesValue<T>(actual: T, expected: T | T[]): boolean {
if (Array.isArray(expected)) {
return expected.includes(actual);
}
return actual === expected;
}
}
// Singleton
let _instance: AgentRoutingService | null = null;
export function getAgentRoutingService(): AgentRoutingService {
if (!_instance) {
_instance = new AgentRoutingService();
}
return _instance;
}

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,326 @@
/**
* Chat Service
*
* Manages chat sessions stored as markdown files with YAML frontmatter.
* - Task-scoped sessions: .veritas-kanban/chats/task_{taskId}.md
* - Board-level sessions: .veritas-kanban/chats/sessions/{sessionId}.md
*/
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { nanoid } from 'nanoid';
import type { ChatSession, ChatMessage } from '@veritas-kanban/shared';
import { withFileLock } from './file-lock.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('chat-service');
// Default paths - resolve to .veritas-kanban/chats/
const DEFAULT_PROJECT_ROOT = path.resolve(process.cwd(), '..');
const DEFAULT_CHATS_DIR = path.join(DEFAULT_PROJECT_ROOT, '.veritas-kanban', 'chats');
const DEFAULT_SESSIONS_DIR = path.join(DEFAULT_CHATS_DIR, 'sessions');
export interface ChatServiceOptions {
chatsDir?: string;
}
export class ChatService {
private chatsDir: string;
private sessionsDir: string;
constructor(options: ChatServiceOptions = {}) {
this.chatsDir = options.chatsDir || DEFAULT_CHATS_DIR;
this.sessionsDir = path.join(this.chatsDir, 'sessions');
this.ensureDirectories();
}
private async ensureDirectories(): Promise<void> {
await fs.mkdir(this.chatsDir, { recursive: true });
await fs.mkdir(this.sessionsDir, { recursive: true });
}
/**
* Generate a new session ID
*/
private generateSessionId(): string {
return `chat_${nanoid(12)}`;
}
/**
* Generate a new message ID
*/
private generateMessageId(): string {
return `msg_${nanoid(10)}`;
}
/**
* Get file path for a session
*/
private getSessionPath(sessionId: string, taskId?: string): string {
if (taskId) {
return path.join(this.chatsDir, `task_${taskId}.md`);
}
return path.join(this.sessionsDir, `${sessionId}.md`);
}
/**
* Parse a session from markdown file
*/
private parseSession(filePath: string, content: string): ChatSession {
const { data, content: markdown } = matter(content);
// Parse messages from markdown (simple format: role + content blocks)
const messages: ChatMessage[] = [];
const messageBlocks = markdown.split(/\n---\n/);
for (const block of messageBlocks) {
if (!block.trim()) continue;
const lines = block.trim().split('\n');
const metaLine = lines[0];
const messageContent = lines.slice(1).join('\n').trim();
// Parse meta line: **id** | role | timestamp | [agent] | [model]
const match = metaLine.match(
/^\*\*(.+?)\*\*\s*\|\s*(\w+)\s*\|\s*(.+?)(?:\s*\|\s*(.+?))?(?:\s*\|\s*(.+?))?$/
);
if (match) {
const [, id, role, timestamp, agent, model] = match;
messages.push({
id,
role: role as 'user' | 'assistant' | 'system',
content: messageContent,
timestamp,
agent: agent || undefined,
model: model || undefined,
});
}
}
return {
id: data.id,
taskId: data.taskId,
title: data.title,
messages,
agent: data.agent,
model: data.model,
mode: data.mode || 'ask',
created: data.created,
updated: data.updated,
};
}
/**
* Serialize a session to markdown with YAML frontmatter
*/
private serializeSession(session: ChatSession): string {
const frontmatter = {
id: session.id,
taskId: session.taskId,
title: session.title,
agent: session.agent,
model: session.model,
mode: session.mode,
created: session.created,
updated: session.updated,
};
// Remove undefined values
Object.keys(frontmatter).forEach((key) => {
if (frontmatter[key as keyof typeof frontmatter] === undefined) {
delete frontmatter[key as keyof typeof frontmatter];
}
});
// Serialize messages as markdown blocks
const messageBlocks = session.messages.map((msg) => {
const meta = [`**${msg.id}**`, msg.role, msg.timestamp, msg.agent || '', msg.model || '']
.filter(Boolean)
.join(' | ');
return `${meta}\n\n${msg.content}`;
});
const markdown = messageBlocks.join('\n\n---\n\n');
return matter.stringify(markdown, frontmatter);
}
/**
* Get a session by ID
*/
async getSession(sessionId: string): Promise<ChatSession | null> {
// Try to find the session file (could be task-scoped or board-level)
// First check if it's a task-scoped session
const taskMatch = sessionId.match(/^task_(.+)$/);
if (taskMatch) {
const taskId = taskMatch[1];
const filePath = this.getSessionPath(sessionId, taskId);
try {
const content = await fs.readFile(filePath, 'utf-8');
return this.parseSession(filePath, content);
} catch (err: any) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
// Board-level session
const filePath = this.getSessionPath(sessionId);
try {
const content = await fs.readFile(filePath, 'utf-8');
return this.parseSession(filePath, content);
} catch (err: any) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
/**
* Get the session for a specific task
*/
async getSessionForTask(taskId: string): Promise<ChatSession | null> {
const filePath = this.getSessionPath(`task_${taskId}`, taskId);
try {
const content = await fs.readFile(filePath, 'utf-8');
return this.parseSession(filePath, content);
} catch (err: any) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
/**
* List all sessions (board-level only)
*/
async listSessions(): Promise<ChatSession[]> {
try {
const files = await fs.readdir(this.sessionsDir);
const sessions: ChatSession[] = [];
for (const file of files) {
if (!file.endsWith('.md')) continue;
const filePath = path.join(this.sessionsDir, file);
const content = await fs.readFile(filePath, 'utf-8');
sessions.push(this.parseSession(filePath, content));
}
// Sort by updated time (newest first)
sessions.sort((a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime());
return sessions;
} catch (err: any) {
if (err.code === 'ENOENT') return [];
throw err;
}
}
/**
* Create a new session
*/
async createSession(input: {
taskId?: string;
agent: string;
mode?: 'ask' | 'build';
}): Promise<ChatSession> {
const sessionId = input.taskId ? `task_${input.taskId}` : this.generateSessionId();
const now = new Date().toISOString();
const session: ChatSession = {
id: sessionId,
taskId: input.taskId,
title: input.taskId ? `Task ${input.taskId}` : 'New Conversation',
messages: [],
agent: input.agent,
mode: input.mode || 'ask',
created: now,
updated: now,
};
const filePath = this.getSessionPath(sessionId, input.taskId);
const content = this.serializeSession(session);
await withFileLock(filePath, async () => {
await fs.writeFile(filePath, content, 'utf-8');
});
log.info({ sessionId, taskId: input.taskId }, 'Created chat session');
return session;
}
/**
* Add a message to a session
*/
async addMessage(
sessionId: string,
message: Omit<ChatMessage, 'id' | 'timestamp'>
): Promise<ChatMessage> {
const session = await this.getSession(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
const newMessage: ChatMessage = {
id: this.generateMessageId(),
timestamp: new Date().toISOString(),
...message,
};
session.messages.push(newMessage);
session.updated = newMessage.timestamp;
const filePath = this.getSessionPath(sessionId, session.taskId);
const content = this.serializeSession(session);
await withFileLock(filePath, async () => {
await fs.writeFile(filePath, content, 'utf-8');
});
log.debug({ sessionId, messageId: newMessage.id, role: newMessage.role }, 'Added message');
return newMessage;
}
/**
* Delete a session
*/
async deleteSession(sessionId: string): Promise<void> {
const session = await this.getSession(sessionId);
if (!session) {
// Already gone — treat as success
log.info({ sessionId }, 'Chat session already deleted or never existed');
return;
}
const filePath = this.getSessionPath(sessionId, session.taskId);
await withFileLock(filePath, async () => {
try {
await fs.unlink(filePath);
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
}
});
log.info({ sessionId }, 'Deleted chat session');
}
}
// Singleton instance
let chatService: ChatService | null = null;
export function getChatService(): ChatService {
if (!chatService) {
chatService = new ChatService();
}
return chatService;
}

View file

@ -16,6 +16,7 @@ import fs from 'fs/promises';
import path from 'path';
import { ConfigService } from './config-service.js';
import { TaskService } from './task-service.js';
import { getAgentRoutingService } from './agent-routing-service.js';
import { getBreaker } from './circuit-registry.js';
import type { Task, AgentType, TaskAttempt, AttemptStatus } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
@ -95,9 +96,22 @@ export class ClawdbotAgentService {
throw new Error('An agent is already running for this task');
}
// Get agent config
// Get agent config — use routing engine when agent is "auto" or not specified
const config = await this.configService.getConfig();
const agent = agentType || config.defaultAgent;
let agent: AgentType;
let routingReason: string | undefined;
if (!agentType || agentType === 'auto') {
const routing = getAgentRoutingService();
const result = await routing.resolveAgent(task);
agent = result.agent;
routingReason = result.reason;
log.info(
`[ClawdbotAgent] Routing resolved agent for task ${taskId}: ${agent} (${routingReason})`
);
} else {
agent = agentType;
}
// Create attempt
const attemptId = `attempt_${nanoid(8)}`;

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

@ -391,6 +391,7 @@ export class TaskService {
priority: input.priority || 'medium',
project: input.project,
sprint: input.sprint,
agent: input.agent, // Pre-assigned agent (or "auto" for routing)
subtasks: input.subtasks, // Include subtasks from template
blockedBy: input.blockedBy, // Include dependencies from blueprint
created: now,

View file

@ -0,0 +1,41 @@
/**
* Chat Interface Types
*
* Built-in chat interface for conversing with agents about tasks or the board.
*/
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
agent?: string; // Which agent responded
model?: string; // Which model was used
toolCalls?: Array<{
// Collapsible tool-use blocks
name: string;
input: string;
output?: string;
}>;
}
export interface ChatSession {
id: string;
taskId?: string; // Task-scoped (undefined = board-level)
title: string;
messages: ChatMessage[];
agent: string; // Current agent for this session
model?: string;
mode: 'ask' | 'build'; // Ask = read-only, Build = can mutate
created: string;
updated: string;
}
export interface ChatSendInput {
sessionId?: string; // Existing session (omit for new)
taskId?: string; // Task context
message: string;
agent?: string; // Override agent
model?: string; // Override model
mode?: 'ask' | 'build';
}

View file

@ -4,8 +4,8 @@ import type { AgentType, TaskPriority } from './task.types.js';
import type { TelemetryConfig } from './telemetry.types.js';
export interface DevServerConfig {
command: string; // e.g., "pnpm dev" or "npm run dev"
port?: number; // Expected port (auto-detected if not specified)
command: string; // e.g., "pnpm dev" or "npm run dev"
port?: number; // Expected port (auto-detected if not specified)
readyPattern?: string; // Regex pattern to detect when server is ready
}
@ -24,10 +24,106 @@ export interface AgentConfig {
enabled: boolean;
}
// ============ Agent Routing Types ============
/** Criteria for matching a task to a routing rule */
export interface RoutingMatchCriteria {
type?: string | string[]; // Task type(s) — e.g. "code", "bug", "docs"
priority?: TaskPriority | TaskPriority[]; // Task priority(ies)
project?: string | string[]; // Project slug(s)
/** Min subtask count to consider "complex" */
minSubtasks?: number;
}
/** A single routing rule: match criteria → agent selection */
export interface RoutingRule {
id: string; // Unique rule ID for CRUD
name: string; // Human-readable name
match: RoutingMatchCriteria; // Conditions to match
agent: AgentType; // Primary agent to use
model?: string; // Optional model override (e.g. "opus", "sonnet")
fallback?: AgentType; // Fallback agent if primary fails
enabled: boolean; // Can disable without deleting
}
/** Top-level routing configuration */
export interface AgentRoutingConfig {
enabled: boolean; // Master toggle for routing engine
rules: RoutingRule[]; // Ordered list — first match wins
defaultAgent: AgentType; // Fallback when no rules match
defaultModel?: string; // Default model for the default agent
fallbackOnFailure: boolean; // Auto-retry with fallback on failure
maxRetries: number; // Max retries before giving up (0-3)
}
/** Result from the routing engine */
export interface RoutingResult {
agent: AgentType;
model?: string;
fallback?: AgentType;
rule?: string; // ID of matched rule (undefined = default)
reason: string; // Human-readable explanation
}
/** Default routing config */
export const DEFAULT_ROUTING_CONFIG: AgentRoutingConfig = {
enabled: true,
rules: [
{
id: 'code-high',
name: 'High-priority code → Claude Code (Opus)',
match: { type: 'code', priority: 'high' },
agent: 'claude-code',
model: 'opus',
fallback: 'amp',
enabled: true,
},
{
id: 'code-default',
name: 'Code tasks → Claude Code (Sonnet)',
match: { type: 'code' },
agent: 'claude-code',
model: 'sonnet',
fallback: 'copilot',
enabled: true,
},
{
id: 'bug-high',
name: 'High-priority bugs → Claude Code (Opus)',
match: { type: 'bug', priority: 'high' },
agent: 'claude-code',
model: 'opus',
fallback: 'amp',
enabled: true,
},
{
id: 'docs',
name: 'Documentation → Claude Code (Haiku)',
match: { type: 'docs' },
agent: 'claude-code',
model: 'haiku',
enabled: true,
},
{
id: 'review',
name: 'Code review → Claude Code (Opus)',
match: { type: 'review' },
agent: 'claude-code',
model: 'opus',
enabled: true,
},
],
defaultAgent: 'claude-code',
defaultModel: 'sonnet',
fallbackOnFailure: true,
maxRetries: 1,
};
export interface AppConfig {
repos: RepoConfig[];
agents: AgentConfig[];
defaultAgent: AgentType;
agentRouting?: AgentRoutingConfig;
telemetry?: TelemetryConfig;
features?: FeatureSettings;
}
@ -52,16 +148,16 @@ export interface TaskBehaviorSettings {
enableSubtaskAutoComplete: boolean;
enableDependencies: boolean;
enableAttachments: boolean;
attachmentMaxFileSize: number; // bytes
attachmentMaxFileSize: number; // bytes
attachmentMaxPerTask: number;
attachmentMaxTotalSize: number; // bytes
attachmentMaxTotalSize: number; // bytes
enableComments: boolean;
defaultPriority: TaskPriority;
}
/** Agent & git settings */
export interface AgentBehaviorSettings {
timeoutMinutes: number; // 5-480
timeoutMinutes: number; // 5-480
autoCommitOnComplete: boolean;
autoCleanupWorktrees: boolean;
enablePreview: boolean;
@ -70,7 +166,7 @@ export interface AgentBehaviorSettings {
/** Telemetry & activity settings */
export interface TelemetryFeatureSettings {
enabled: boolean;
retentionDays: number; // 7-365
retentionDays: number; // 7-365
enableTraces: boolean;
enableActivityTracking: boolean;
}
@ -81,8 +177,8 @@ export interface NotificationSettings {
onTaskComplete: boolean;
onAgentFailure: boolean;
onReviewNeeded: boolean;
channel: string; // Teams channel ID
webhookUrl?: string; // Optional: Teams webhook URL for immediate delivery
channel: string; // Teams channel ID
webhookUrl?: string; // Optional: Teams webhook URL for immediate delivery
}
/** Archive settings */
@ -94,9 +190,9 @@ export interface ArchiveSettings {
/** Budget tracking settings */
export interface BudgetSettings {
enabled: boolean;
monthlyTokenLimit: number; // Monthly token budget (0 = no limit)
monthlyCostLimit: number; // Monthly cost budget in dollars (0 = no limit)
warningThreshold: number; // Percentage threshold for warning (0-100, default 80)
monthlyTokenLimit: number; // Monthly token budget (0 = no limit)
monthlyCostLimit: number; // Monthly cost budget in dollars (0 = no limit)
warningThreshold: number; // Percentage threshold for warning (0-100, default 80)
}
/** All feature settings combined */
@ -127,9 +223,9 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = {
enableSubtaskAutoComplete: true,
enableDependencies: true,
enableAttachments: true,
attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB
attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB
attachmentMaxPerTask: 20,
attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB
attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB
enableComments: true,
defaultPriority: 'medium',
},
@ -158,8 +254,8 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = {
},
budget: {
enabled: true,
monthlyTokenLimit: 0, // 0 = no limit
monthlyCostLimit: 0, // 0 = no limit (dollars)
warningThreshold: 80, // Warn at 80% of budget
monthlyTokenLimit: 0, // 0 = no limit
monthlyCostLimit: 0, // 0 = no limit (dollars)
warningThreshold: 80, // Warn at 80% of budget
},
};

View file

@ -6,3 +6,4 @@ export * from './telemetry.types.js';
export * from './template.types.js';
export * from './websocket.types.js';
export * from './managed-list.types.js';
export * from './chat.types.js';

View file

@ -125,6 +125,9 @@ export interface Task {
created: string;
updated: string;
// Agent assignment — "auto" uses routing engine, or a specific agent slug
agent?: AgentType | 'auto';
// Code task specific
git?: TaskGit;
@ -196,6 +199,7 @@ export interface CreateTaskInput {
priority?: TaskPriority;
project?: string;
sprint?: string;
agent?: AgentType | 'auto'; // Pre-assign an agent (or "auto" for routing engine)
subtasks?: Subtask[]; // Can be provided when creating from a template
blockedBy?: string[]; // Can be provided when creating from a blueprint
}
@ -208,6 +212,7 @@ export interface UpdateTaskInput {
priority?: TaskPriority;
project?: string;
sprint?: string;
agent?: AgentType | 'auto';
git?: Partial<TaskGit>;
attempt?: TaskAttempt;
reviewComments?: ReviewComment[];
@ -247,6 +252,7 @@ export interface TaskSummary {
type: TaskType;
project?: string;
sprint?: string;
agent?: AgentType | 'auto';
created: string;
updated: string;
subtasks?: Subtask[];

View file

@ -1,12 +1,15 @@
// WebSocket Message Types
import type { AttemptStatus } from './task.types.js';
import type { ChatMessage } from './chat.types.js';
export type WSMessageType =
export type WSMessageType =
| 'agent:output'
| 'agent:status'
| 'agent:complete'
| 'task:updated'
| 'chat:message'
| 'chat:subscribed'
| 'error';
export interface WSMessage {
@ -32,3 +35,11 @@ export interface AgentStatusMessage extends WSMessage {
exitCode?: number;
};
}
export interface ChatMessageEvent extends WSMessage {
type: 'chat:message';
data: {
sessionId: string;
message: ChatMessage;
};
}

View file

@ -12,6 +12,7 @@ import { AuthGuard } from './components/auth';
import { ErrorBoundary } from './components/shared/ErrorBoundary';
import { SkipToContent } from './components/shared/SkipToContent';
import { LiveAnnouncerProvider } from './components/shared/LiveAnnouncer';
import { FloatingChat } from './components/chat/FloatingChat';
// Main app content (only rendered when authenticated)
function AppContent() {
@ -38,6 +39,7 @@ function AppContent() {
</main>
<Toaster />
<KeyboardShortcutsDialog />
<FloatingChat />
</div>
</TaskConfigProvider>
</BulkActionsProvider>

View file

@ -116,7 +116,7 @@ vi.mock('@/components/board/FilterBar', () => ({
FilterBar: () => null,
filterTasks: (tasks: Task[]) => tasks,
filtersToSearchParams: () => new URLSearchParams(),
searchParamsToFilters: () => ({ search: '', project: null, type: null }),
searchParamsToFilters: () => ({ search: '', project: null, type: null, agent: null }),
}));
vi.mock('@/components/board/BulkActionsBar', () => ({

View file

@ -12,11 +12,13 @@ import { Badge } from '@/components/ui/badge';
import type { Task, TaskType } from '@veritas-kanban/shared';
import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes';
import { useProjects } from '@/hooks/useProjects';
import { useConfig } from '@/hooks/useConfig';
export interface FilterState {
search: string;
project: string | null;
type: TaskType | null;
agent: string | null;
}
interface FilterBarProps {
@ -28,12 +30,16 @@ interface FilterBarProps {
export function FilterBar({ filters, onFiltersChange }: FilterBarProps) {
const { data: taskTypes = [], isLoading: typesLoading } = useTaskTypes();
const { data: projects = [], isLoading: projectsLoading } = useProjects();
const { data: config } = useConfig();
const agents = config?.agents || [];
// Count active filters
const activeFilterCount = [filters.search, filters.project, filters.type].filter(Boolean).length;
const activeFilterCount = [filters.search, filters.project, filters.type, filters.agent].filter(
Boolean
).length;
const clearAllFilters = () => {
onFiltersChange({ search: '', project: null, type: null });
onFiltersChange({ search: '', project: null, type: null, agent: null });
};
const updateSearch = (value: string) => {
@ -118,6 +124,28 @@ export function FilterBar({ filters, onFiltersChange }: FilterBarProps) {
</SelectContent>
</Select>
{/* Agent filter */}
<Select
value={filters.agent || 'all'}
onValueChange={(value) =>
onFiltersChange({ ...filters, agent: value === 'all' ? null : value })
}
>
<SelectTrigger className="w-[160px]" aria-label="Filter by agent">
<SelectValue placeholder="All Agents" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Agents</SelectItem>
<SelectItem value="auto">Auto (routing)</SelectItem>
<SelectItem value="unassigned">Unassigned</SelectItem>
{agents.map((a) => (
<SelectItem key={a.type} value={a.type}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Active filter indicator & clear */}
{activeFilterCount > 0 && (
<div className="flex items-center gap-2">
@ -146,6 +174,7 @@ export function filtersToSearchParams(filters: FilterState): URLSearchParams {
if (filters.search) params.set('q', filters.search);
if (filters.project) params.set('project', filters.project);
if (filters.type) params.set('type', filters.type);
if (filters.agent) params.set('agent', filters.agent);
return params;
}
@ -154,6 +183,7 @@ export function searchParamsToFilters(params: URLSearchParams): FilterState {
search: params.get('q') || '',
project: params.get('project') || null,
type: (params.get('type') as TaskType) || null,
agent: params.get('agent') || null,
};
}
@ -179,6 +209,17 @@ export function filterTasks(tasks: Task[], filters: FilterState): Task[] {
return false;
}
// Agent filter
if (filters.agent) {
if (filters.agent === 'unassigned') {
if (task.agent) return false;
} else if (filters.agent === 'auto') {
if (task.agent !== 'auto') return false;
} else {
if (task.agent !== filters.agent) return false;
}
}
return true;
});
}

View file

@ -47,7 +47,7 @@ export function KanbanBoard() {
if (typeof window !== 'undefined') {
return searchParamsToFilters(new URLSearchParams(window.location.search));
}
return { search: '', project: null, type: null };
return { search: '', project: null, type: null, agent: null };
});
const { selectedTaskId, setTasks, setOnOpenTask, setOnMoveTask } = useKeyboard();

View file

@ -0,0 +1,433 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
MessageSquare,
Send,
ChevronDown,
ChevronRight,
Loader2,
Bot,
User,
Trash2,
Download,
} from 'lucide-react';
import {
useChatSession,
useSendChatMessage,
useDeleteChatSession,
useChatStream,
useChatSessions,
} from '@/hooks/useChat';
import { useTask } from '@/hooks/useTasks';
import type { ChatMessage } from '@veritas-kanban/shared';
interface ChatPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
taskId?: string;
}
export function ChatPanel({ open, onOpenChange, taskId }: ChatPanelProps) {
const [message, setMessage] = useState('');
const [mode, setMode] = useState<'ask' | 'build'>('ask');
const [currentSessionId, setCurrentSessionId] = useState<string | undefined>();
const { data: task } = useTask(taskId || '');
const { data: sessions = [] } = useChatSessions();
const { data: session } = useChatSession(currentSessionId);
const { mutate: sendChatMessage, isPending } = useSendChatMessage();
const { mutate: deleteChatSession } = useDeleteChatSession();
const { streamingMessage } = useChatStream(currentSessionId);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (shouldAutoScroll && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [session?.messages, streamingMessage, shouldAutoScroll]);
// Detect manual scroll-up to pause auto-scroll
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
const isAtBottom = Math.abs(target.scrollHeight - target.scrollTop - target.clientHeight) < 50;
setShouldAutoScroll(isAtBottom);
};
// Filter sessions by taskId if scoped
const filteredSessions = useMemo(() => {
if (!taskId) {
return sessions.filter((s) => !s.taskId);
}
return sessions.filter((s) => s.taskId === taskId);
}, [sessions, taskId]);
// Handle sending a message
const handleSend = () => {
if (!message.trim() || isPending) return;
sendChatMessage(
{
sessionId: currentSessionId,
taskId,
message: message.trim(),
mode,
},
{
onSuccess: (response) => {
setCurrentSessionId(response.sessionId);
setMessage('');
setShouldAutoScroll(true);
// Re-focus the input so user can keep typing
requestAnimationFrame(() => inputRef.current?.focus());
},
}
);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Load session on mount — task-scoped sessions use a deterministic ID
useEffect(() => {
if (taskId && !currentSessionId) {
setCurrentSessionId(`task_${taskId}`);
} else if (!taskId && !currentSessionId && filteredSessions.length > 0) {
setCurrentSessionId(filteredSessions[0].id);
}
}, [filteredSessions, currentSessionId, taskId]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="w-[500px] sm:max-w-[500px] overflow-hidden flex flex-col p-0"
side="right"
>
{/* Header action buttons — positioned next to Sheet's built-in X */}
{currentSessionId && session?.messages && session.messages.length > 0 && (
<button
onClick={() => {
if (!session?.messages?.length) return;
const title = taskId && task ? task.title : 'Board Chat';
const date = new Date().toLocaleString();
const lines = [`# Chat Export — ${title}`, `*Exported: ${date}*`, ''];
for (const msg of session.messages) {
const role =
msg.role === 'user'
? '👤 User'
: msg.role === 'assistant'
? '🤖 Assistant'
: '⚙️ System';
const time = new Date(msg.timestamp).toLocaleString();
lines.push('---', '', `### ${role}`, `*${time}*`, '', msg.content, '');
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-${taskId || 'board'}-${new Date().toISOString().slice(0, 10)}.md`;
a.click();
URL.revokeObjectURL(url);
}}
className="absolute right-20 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 hover:text-primary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Download className="h-4 w-4" />
<span className="sr-only">Export chat</span>
</button>
)}
{currentSessionId && session?.messages && session.messages.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="absolute right-12 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 hover:text-destructive focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<Trash2 className="h-4 w-4" />
<span className="sr-only">Clear chat</span>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear chat history?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete all messages in this chat. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (currentSessionId) {
deleteChatSession(currentSessionId, {
onSuccess: () => {
setCurrentSessionId(undefined);
if (taskId) {
setTimeout(() => setCurrentSessionId(`task_${taskId}`), 100);
}
},
});
}
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Clear History
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<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" />
{taskId ? 'Task Chat' : 'Board 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" />
Task: {task.title}
</div>
)}
</SheetHeader>
{/* Messages */}
<ScrollArea className="flex-1 px-4" onScrollCapture={handleScroll} ref={scrollAreaRef}>
<div className="py-4 space-y-4">
{session?.messages.map((msg) => (
<ChatMessageBubble key={msg.id} message={msg} />
))}
{streamingMessage && (
<ChatMessageBubble
message={{
id: 'streaming',
role: 'assistant',
content: streamingMessage.content || '',
timestamp: new Date().toISOString(),
}}
isStreaming
/>
)}
{(!session || session.messages.length === 0) && !streamingMessage && (
<div className="text-center text-muted-foreground py-8">
<Bot className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{taskId ? 'Start a conversation about this task' : 'Start a new chat session'}
</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* Input Area */}
<div className="border-t border-border p-4 flex-shrink-0 space-y-3">
<div className="flex items-center gap-2">
<Input
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Type a message..."
disabled={isPending}
className="flex-1"
autoFocus
/>
<Button onClick={handleSend} disabled={!message.trim() || isPending} size="icon">
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
{/* Mode Toggle */}
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Mode:</span>
<Button
variant={mode === 'ask' ? 'default' : 'outline'}
size="sm"
onClick={() => setMode('ask')}
className="h-7 text-xs"
>
Ask
</Button>
<Button
variant={mode === 'build' ? 'default' : 'outline'}
size="sm"
onClick={() => setMode('build')}
className="h-7 text-xs"
>
Build
</Button>
<span className="text-muted-foreground ml-1">
{mode === 'ask' ? '· Read-only queries' : '· Changes, files, commands'}
</span>
</div>
</div>
</SheetContent>
</Sheet>
);
}
interface ChatMessageBubbleProps {
message: ChatMessage | { id: string; role: string; content: string; timestamp: string };
isStreaming?: boolean;
}
function ChatMessageBubble({ message, isStreaming }: ChatMessageBubbleProps) {
const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const toggleTool = (index: number) => {
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
if (isSystem) {
return (
<div className="text-center text-sm text-muted-foreground italic py-2">{message.content}</div>
);
}
return (
<div className={`flex gap-2 ${isUser ? 'justify-end' : 'justify-start'}`}>
{!isUser && (
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="h-4 w-4 text-primary" />
</div>
)}
<div className={`max-w-[80%] space-y-2`}>
<div
className={`rounded-lg px-3 py-2 text-sm ${
isUser ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground'
}`}
>
<MarkdownContent content={message.content} />
{isStreaming && <span className="inline-block w-1 h-4 bg-current animate-pulse ml-1" />}
</div>
{/* Tool calls */}
{'toolCalls' in message && message.toolCalls && message.toolCalls.length > 0 && (
<div className="space-y-1">
{message.toolCalls.map((tool, idx) => (
<div key={idx} className="border border-border rounded bg-zinc-950 overflow-hidden">
<button
onClick={() => toggleTool(idx)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-zinc-900 transition-colors"
>
{expandedTools.has(idx) ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<code className="text-emerald-400">{tool.name}</code>
</button>
{expandedTools.has(idx) && (
<div className="px-3 pb-2 space-y-2 text-xs font-mono">
<div>
<div className="text-muted-foreground mb-1">Input:</div>
<pre className="text-zinc-300 whitespace-pre-wrap break-all">
{tool.input}
</pre>
</div>
{tool.output && (
<div>
<div className="text-muted-foreground mb-1">Output:</div>
<pre className="text-zinc-300 whitespace-pre-wrap break-all">
{tool.output}
</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Timestamp */}
<div className="text-xs text-muted-foreground px-1">
{new Date(message.timestamp).toLocaleTimeString()}
</div>
</div>
{isUser && (
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-primary flex items-center justify-center">
<User className="h-4 w-4 text-primary-foreground" />
</div>
)}
</div>
);
}
/**
* Simple markdown renderer
* Handles code blocks and basic formatting
*/
function MarkdownContent({ content }: { content: string }) {
// Split content by code blocks
const parts = content.split(/(```[\s\S]*?```|`[^`]+`)/g);
return (
<div className="space-y-2">
{parts.map((part, idx) => {
// Multi-line code block
if (part.startsWith('```')) {
const lines = part.split('\n');
const language = lines[0].replace('```', '').trim();
const code = lines.slice(1, -1).join('\n');
return (
<pre key={idx} className="bg-zinc-950 rounded p-2 overflow-x-auto text-xs">
{language && <div className="text-muted-foreground mb-1">{language}</div>}
<code className="text-zinc-300">{code}</code>
</pre>
);
}
// Inline code
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code key={idx} className="bg-zinc-800 px-1 py-0.5 rounded text-xs">
{part.slice(1, -1)}
</code>
);
}
// Regular text
return (
<span key={idx} className="whitespace-pre-wrap">
{part}
</span>
);
})}
</div>
);
}

View file

@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { MessageSquare } from 'lucide-react';
import { ChatPanel } from './ChatPanel';
import { chatEventTarget } from '@/hooks/useTaskSync';
import { cn } from '@/lib/utils';
/**
* Floating chat bubble — bottom-right corner.
* Opens a board-level ChatPanel (no taskId).
* Pulses when a new response arrives while closed.
*/
export function FloatingChat() {
const [open, setOpen] = useState(false);
const [hasUnread, setHasUnread] = useState(false);
// Listen for incoming chat messages when panel is closed
useEffect(() => {
const handler = (e: Event) => {
const msg = (e as CustomEvent).detail;
// Only pulse for board-level chat messages (no taskId in sessionId)
if (
!open &&
(msg.type === 'chat:message' || msg.type === 'chat:delta') &&
msg.sessionId &&
!msg.sessionId.includes('task_')
) {
setHasUnread(true);
}
};
chatEventTarget.addEventListener('chat', handler);
return () => chatEventTarget.removeEventListener('chat', handler);
}, [open]);
// Clear unread when opening
const handleOpen = () => {
setOpen(true);
setHasUnread(false);
};
return (
<>
{/* Floating button */}
<Button
onClick={handleOpen}
size="icon"
className={cn(
'fixed bottom-6 right-6 z-40 h-14 w-14 rounded-full shadow-lg',
'bg-primary hover:bg-primary/90 text-primary-foreground',
'transition-all duration-200 hover:scale-105',
open && 'hidden'
)}
aria-label="Open chat"
>
<MessageSquare className="h-6 w-6" />
{hasUnread && (
<span className="absolute -top-1 -right-1 flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-4 w-4 bg-emerald-500" />
</span>
)}
</Button>
{/* Chat panel — board-level (no taskId) */}
<ChatPanel open={open} onOpenChange={setOpen} />
</>
);
}

View file

@ -1,9 +1,10 @@
import { Plus, Settings, Keyboard, Activity, Archive } from 'lucide-react';
import { Plus, Settings, Keyboard, Activity, Archive, MessageSquare } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CreateTaskDialog } from '@/components/task/CreateTaskDialog';
import { SettingsDialog } from '@/components/settings/SettingsDialog';
import { ActivitySidebar } from './ActivitySidebar';
import { ArchiveSidebar } from './ArchiveSidebar';
import { ChatPanel } from '@/components/chat/ChatPanel';
import { UserMenu } from './UserMenu';
import { AgentStatusIndicator } from '@/components/shared/AgentStatusIndicator';
import { WebSocketIndicator } from '@/components/shared/WebSocketIndicator';
@ -16,18 +17,20 @@ export function Header() {
const [settingsTab, setSettingsTab] = useState<string | undefined>();
const [activityOpen, setActivityOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const { setOpenCreateDialog, openHelpDialog } = useKeyboard();
const [chatOpen, setChatOpen] = useState(false);
const { setOpenCreateDialog, setOpenChatPanel, openHelpDialog } = useKeyboard();
const openSecuritySettings = useCallback(() => {
setSettingsTab('security');
setSettingsOpen(true);
}, []);
// Register the create dialog opener with keyboard context (ref, no useEffect needed)
// Register the create dialog and chat panel openers with keyboard context (refs, no useEffect needed)
setOpenCreateDialog(() => setCreateOpen(true));
setOpenChatPanel(() => setChatOpen(true));
return (
<header className="border-b border-border bg-card" role="banner">
<header className="sticky top-0 z-50 border-b border-border bg-card" role="banner">
<nav aria-label="Main navigation" className="container mx-auto px-4">
<div className="flex h-14 items-center justify-between">
<div className="flex items-center gap-4">
@ -75,6 +78,15 @@ export function Header() {
>
<Archive className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setChatOpen(true)}
aria-label="Agent Chat"
title="Agent Chat (⌘⇧C)"
>
<MessageSquare className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
@ -100,6 +112,7 @@ export function Header() {
/>
<ActivitySidebar open={activityOpen} onOpenChange={setActivityOpen} />
<ArchiveSidebar open={archiveOpen} onOpenChange={setArchiveOpen} />
<ChatPanel open={chatOpen} onOpenChange={setChatOpen} />
</header>
);
}

View file

@ -20,6 +20,7 @@ const shortcuts: { category: string; items: Shortcut[] }[] = [
category: 'Actions',
items: [
{ keys: ['c'], description: 'Create new task' },
{ keys: ['⌘⇧C'], description: 'Open agent chat' },
{ keys: ['1'], description: 'Move to To Do' },
{ keys: ['2'], description: 'Move to In Progress' },
{ keys: ['3'], description: 'Move to Review' },

View file

@ -1,8 +1,16 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
@ -16,9 +24,26 @@ import {
} from '@/components/ui/alert-dialog';
import { useConfig, useUpdateAgents } from '@/hooks/useConfig';
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
import { Bot, Plus, Pencil, Trash2, Check, X } from 'lucide-react';
import type { AgentConfig, AgentType } from '@veritas-kanban/shared';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { useRoutingConfig, useUpdateRoutingConfig } from '@/hooks/useRouting';
import {
Bot,
Plus,
Pencil,
Trash2,
Check,
X,
Route,
ChevronDown,
ChevronUp,
Loader2,
} from 'lucide-react';
import type {
AgentConfig,
AgentType,
RoutingRule,
AgentRoutingConfig,
} from '@veritas-kanban/shared';
import { DEFAULT_FEATURE_SETTINGS, DEFAULT_ROUTING_CONFIG } from '@veritas-kanban/shared';
import { cn } from '@/lib/utils';
import { ToggleRow, NumberRow, SectionHeader, SaveIndicator } from '../shared';
@ -160,6 +185,9 @@ export function AgentsTab() {
/>
</div>
</div>
{/* Agent Routing Rules */}
<RoutingRulesSection agents={config?.agents || []} />
</div>
);
}
@ -268,6 +296,599 @@ interface AgentFormProps {
onCancel: () => void;
}
// ============ Routing Rules Section ============
interface RoutingRulesSectionProps {
agents: AgentConfig[];
}
function RoutingRulesSection({ agents }: RoutingRulesSectionProps) {
const { data: routingConfig, isLoading } = useRoutingConfig();
const updateRouting = useUpdateRoutingConfig();
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
const [showAddRule, setShowAddRule] = useState(false);
const [expanded, setExpanded] = useState(true);
const config = routingConfig || DEFAULT_ROUTING_CONFIG;
const enabledAgents = agents.filter((a) => a.enabled);
const saveConfig = useCallback(
(updated: AgentRoutingConfig) => {
updateRouting.mutate(updated);
},
[updateRouting]
);
const handleToggleEnabled = () => {
saveConfig({ ...config, enabled: !config.enabled });
};
const handleToggleRule = (ruleId: string) => {
const updated = {
...config,
rules: config.rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)),
};
saveConfig(updated);
};
const handleAddRule = (rule: RoutingRule) => {
saveConfig({ ...config, rules: [...config.rules, rule] });
setShowAddRule(false);
};
const handleEditRule = (originalId: string, updated: RoutingRule) => {
saveConfig({
...config,
rules: config.rules.map((r) => (r.id === originalId ? updated : r)),
});
setEditingRuleId(null);
};
const handleRemoveRule = (ruleId: string) => {
saveConfig({
...config,
rules: config.rules.filter((r) => r.id !== ruleId),
});
};
const handleMoveRule = (ruleId: string, direction: 'up' | 'down') => {
const idx = config.rules.findIndex((r) => r.id === ruleId);
if (idx < 0) return;
const newIdx = direction === 'up' ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= config.rules.length) return;
const newRules = [...config.rules];
[newRules[idx], newRules[newIdx]] = [newRules[newIdx], newRules[idx]];
saveConfig({ ...config, rules: newRules });
};
const handleDefaultAgentChange = (agent: string) => {
saveConfig({ ...config, defaultAgent: agent as AgentType });
};
const handleDefaultModelChange = (model: string) => {
saveConfig({ ...config, defaultModel: model || undefined });
};
const handleFallbackToggle = () => {
saveConfig({ ...config, fallbackOnFailure: !config.fallbackOnFailure });
};
const handleMaxRetriesChange = (value: number) => {
saveConfig({ ...config, maxRetries: Math.min(3, Math.max(0, value)) });
};
const resetRouting = () => {
saveConfig(DEFAULT_ROUTING_CONFIG);
};
if (isLoading) {
return (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading routing config...
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<button
type="button"
className="flex items-center gap-2 text-sm font-medium hover:text-foreground transition-colors"
onClick={() => setExpanded(!expanded)}
>
<Route className="h-4 w-4" />
Agent Routing
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
<div className="flex items-center gap-2">
{updateRouting.isPending && <SaveIndicator isPending />}
<Switch
checked={config.enabled}
onCheckedChange={handleToggleEnabled}
aria-label="Enable agent routing"
/>
</div>
</div>
{expanded && (
<div className={cn('space-y-4', !config.enabled && 'opacity-50 pointer-events-none')}>
{/* Rules list */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Rules (first match wins)
</h4>
{!showAddRule && (
<Button variant="outline" size="sm" onClick={() => setShowAddRule(true)}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Rule
</Button>
)}
</div>
{showAddRule && (
<RoutingRuleForm
agents={enabledAgents}
existingIds={config.rules.map((r) => r.id)}
onSubmit={handleAddRule}
onCancel={() => setShowAddRule(false)}
/>
)}
{config.rules.length === 0 ? (
<div className="text-sm text-muted-foreground py-3 text-center border rounded-md border-dashed">
No routing rules — all tasks use the default agent.
</div>
) : (
<div className="space-y-1">
{config.rules.map((rule, idx) =>
editingRuleId === rule.id ? (
<RoutingRuleForm
key={rule.id}
rule={rule}
agents={enabledAgents}
existingIds={config.rules.filter((r) => r.id !== rule.id).map((r) => r.id)}
onSubmit={(updated) => handleEditRule(rule.id, updated)}
onCancel={() => setEditingRuleId(null)}
/>
) : (
<RoutingRuleItem
key={rule.id}
rule={rule}
agents={agents}
isFirst={idx === 0}
isLast={idx === config.rules.length - 1}
onToggle={() => handleToggleRule(rule.id)}
onEdit={() => setEditingRuleId(rule.id)}
onRemove={() => handleRemoveRule(rule.id)}
onMoveUp={() => handleMoveRule(rule.id, 'up')}
onMoveDown={() => handleMoveRule(rule.id, 'down')}
/>
)
)}
</div>
)}
</div>
{/* Default & Fallback settings */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Defaults
</h4>
<Button variant="ghost" size="sm" className="text-xs h-6" onClick={resetRouting}>
Reset to defaults
</Button>
</div>
<div className="divide-y">
<div className="flex items-center justify-between py-2">
<div>
<Label className="text-sm">Default Agent</Label>
<p className="text-xs text-muted-foreground">Used when no rules match</p>
</div>
<Select value={config.defaultAgent} onValueChange={handleDefaultAgentChange}>
<SelectTrigger className="w-[180px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledAgents.map((a) => (
<SelectItem key={a.type} value={a.type}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-2">
<div>
<Label className="text-sm">Default Model</Label>
<p className="text-xs text-muted-foreground">
Model override for the default agent
</p>
</div>
<Input
value={config.defaultModel || ''}
onChange={(e) => handleDefaultModelChange(e.target.value)}
placeholder="e.g., sonnet"
className="w-[180px] h-8 text-sm"
/>
</div>
<ToggleRow
label="Fallback on Failure"
description="Auto-retry with fallback agent when primary fails"
checked={config.fallbackOnFailure}
onCheckedChange={handleFallbackToggle}
/>
<NumberRow
label="Max Retries"
description="Maximum retry attempts before giving up (0-3)"
value={config.maxRetries}
onChange={handleMaxRetriesChange}
min={0}
max={3}
hideSpinners
maxLength={1}
/>
</div>
</div>
</div>
)}
</div>
);
}
// ============ Routing Rule Item (display mode) ============
interface RoutingRuleItemProps {
rule: RoutingRule;
agents: AgentConfig[];
isFirst: boolean;
isLast: boolean;
onToggle: () => void;
onEdit: () => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
function RoutingRuleItem({
rule,
agents,
isFirst,
isLast,
onToggle,
onEdit,
onRemove,
onMoveUp,
onMoveDown,
}: RoutingRuleItemProps) {
const agentName = agents.find((a) => a.type === rule.agent)?.name || rule.agent;
const fallbackName = rule.fallback
? agents.find((a) => a.type === rule.fallback)?.name || rule.fallback
: null;
const matchLabels: string[] = [];
if (rule.match.type) {
const types = Array.isArray(rule.match.type) ? rule.match.type : [rule.match.type];
matchLabels.push(`type: ${types.join(', ')}`);
}
if (rule.match.priority) {
const priorities = Array.isArray(rule.match.priority)
? rule.match.priority
: [rule.match.priority];
matchLabels.push(`priority: ${priorities.join(', ')}`);
}
if (rule.match.project) {
const projects = Array.isArray(rule.match.project) ? rule.match.project : [rule.match.project];
matchLabels.push(`project: ${projects.join(', ')}`);
}
if (rule.match.minSubtasks) {
matchLabels.push(`≥${rule.match.minSubtasks} subtasks`);
}
return (
<div
className={cn(
'flex items-center gap-2 py-2 px-3 rounded-md border text-sm',
rule.enabled ? 'bg-card' : 'bg-muted/30 opacity-60'
)}
>
{/* Reorder buttons */}
<div className="flex flex-col gap-0.5">
<button
type="button"
disabled={isFirst}
onClick={onMoveUp}
className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="Move up"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
disabled={isLast}
onClick={onMoveDown}
className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="Move down"
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
{/* Rule info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{rule.name}</span>
</div>
<div className="flex flex-wrap gap-1 mt-1">
{matchLabels.map((label, i) => (
<Badge key={i} variant="secondary" className="text-xs font-mono">
{label}
</Badge>
))}
<span className="text-xs text-muted-foreground">→</span>
<Badge variant="outline" className="text-xs">
{agentName}
{rule.model ? ` (${rule.model})` : ''}
</Badge>
{fallbackName && (
<>
<span className="text-xs text-muted-foreground">fallback:</span>
<Badge variant="outline" className="text-xs">
{fallbackName}
</Badge>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRemove}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</Button>
<Switch checked={rule.enabled} onCheckedChange={onToggle} />
</div>
</div>
);
}
// ============ Routing Rule Form (add/edit mode) ============
interface RoutingRuleFormProps {
rule?: RoutingRule;
agents: AgentConfig[];
existingIds: string[];
onSubmit: (rule: RoutingRule) => void;
onCancel: () => void;
}
function RoutingRuleForm({ rule, agents, existingIds, onSubmit, onCancel }: RoutingRuleFormProps) {
const isEditing = !!rule;
const [name, setName] = useState(rule?.name || '');
const [id, setId] = useState(rule?.id || '');
const [matchType, setMatchType] = useState(
rule?.match.type
? Array.isArray(rule.match.type)
? rule.match.type.join(', ')
: rule.match.type
: ''
);
const [matchPriority, setMatchPriority] = useState(
rule?.match.priority
? Array.isArray(rule.match.priority)
? rule.match.priority.join(', ')
: rule.match.priority
: ''
);
const [matchProject, setMatchProject] = useState(
rule?.match.project
? Array.isArray(rule.match.project)
? rule.match.project.join(', ')
: rule.match.project
: ''
);
const [minSubtasks, setMinSubtasks] = useState(rule?.match.minSubtasks?.toString() || '');
const [agent, setAgent] = useState(rule?.agent || agents[0]?.type || '');
const [model, setModel] = useState(rule?.model || '');
const [fallback, setFallback] = useState(rule?.fallback || '');
const autoId = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const effectiveId = id || autoId;
const isDuplicate = !isEditing && existingIds.includes(effectiveId);
const isValid = name.trim() && effectiveId && agent && !isDuplicate;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!isValid) return;
const parseList = (val: string): string | string[] | undefined => {
if (!val.trim()) return undefined;
const items = val
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return items.length === 1 ? items[0] : items.length > 0 ? items : undefined;
};
onSubmit({
id: isEditing ? rule.id : effectiveId,
name: name.trim(),
match: {
type: parseList(matchType),
priority: parseList(matchPriority) as any,
project: parseList(matchProject),
minSubtasks: minSubtasks ? parseInt(minSubtasks, 10) : undefined,
},
agent: agent as AgentType,
model: model.trim() || undefined,
fallback: (fallback.trim() || undefined) as AgentType | undefined,
enabled: rule?.enabled ?? true,
});
};
return (
<form onSubmit={handleSubmit} className="space-y-3 border rounded-lg p-4 bg-muted/30">
<div className="flex items-center gap-2 text-sm font-medium">
<Route className="h-4 w-4" />
{isEditing ? `Edit Rule: ${rule.name}` : 'Add Routing Rule'}
</div>
<div className="grid gap-3">
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Rule Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., High-priority bugs"
/>
</div>
<div className="grid gap-1.5">
<Label>
ID{' '}
{!isEditing && effectiveId && (
<span className="text-xs text-muted-foreground ml-1">({effectiveId})</span>
)}
</Label>
<Input
value={isEditing ? rule.id : id}
onChange={(e) => setId(e.target.value)}
placeholder="auto from name"
disabled={isEditing}
className={cn(isDuplicate && 'border-red-500')}
/>
</div>
</div>
{/* Match criteria */}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>
Match Type(s) <span className="text-xs text-muted-foreground">(comma-separated)</span>
</Label>
<Input
value={matchType}
onChange={(e) => setMatchType(e.target.value)}
placeholder="e.g., code, bug"
className="font-mono text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label>
Match Priority{' '}
<span className="text-xs text-muted-foreground">(low, medium, high)</span>
</Label>
<Input
value={matchPriority}
onChange={(e) => setMatchPriority(e.target.value)}
placeholder="e.g., high"
className="font-mono text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>
Match Project <span className="text-xs text-muted-foreground">(optional)</span>
</Label>
<Input
value={matchProject}
onChange={(e) => setMatchProject(e.target.value)}
placeholder="e.g., rubicon"
className="font-mono text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label>
Min Subtasks <span className="text-xs text-muted-foreground">(complexity)</span>
</Label>
<Input
type="number"
value={minSubtasks}
onChange={(e) => setMinSubtasks(e.target.value)}
placeholder="e.g., 5"
className="font-mono text-sm"
min="0"
/>
</div>
</div>
{/* Agent selection */}
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-1.5">
<Label>Primary Agent</Label>
<Select value={agent} onValueChange={setAgent}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.type} value={a.type}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>
Model <span className="text-xs text-muted-foreground">(optional)</span>
</Label>
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="e.g., opus"
className="font-mono text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label>
Fallback Agent <span className="text-xs text-muted-foreground">(optional)</span>
</Label>
<Select
value={fallback || '__none__'}
onValueChange={(v) => setFallback(v === '__none__' ? '' : v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{agents
.filter((a) => a.type !== agent)
.map((a) => (
<SelectItem key={a.type} value={a.type}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
<X className="h-3.5 w-3.5 mr-1" /> Cancel
</Button>
<Button type="submit" size="sm" disabled={!isValid}>
<Check className="h-3.5 w-3.5 mr-1" /> {isEditing ? 'Save Rule' : 'Add Rule'}
</Button>
</div>
</form>
);
}
// ============ Agent Form (add/edit mode) ============
function AgentForm({ agent, existingTypes, onSubmit, onCancel }: AgentFormProps) {
const isEditing = !!agent;
const [name, setName] = useState(agent?.name || '');

View file

@ -309,10 +309,7 @@ export function AgentStatusIndicator({
if (isLoading && !data) {
return (
<div className={`flex items-center gap-2 min-w-[140px] md:min-w-[180px] ${className}`}>
<div
className="w-2.5 h-2.5 rounded-full bg-gray-400 opacity-50 shrink-0"
aria-hidden="true"
/>
<div className="w-2 h-2 rounded-full bg-gray-400 opacity-50 shrink-0" aria-hidden="true" />
<span className="text-sm text-muted-foreground hidden sm:inline">Loading...</span>
</div>
);
@ -331,7 +328,7 @@ export function AgentStatusIndicator({
>
{/* The pulsing dot */}
<div
className={`agent-status-dot w-2.5 h-2.5 rounded-full shrink-0 ${animationClass}`}
className={`agent-status-dot w-2 h-2 rounded-full shrink-0 ${animationClass}`}
style={{
backgroundColor: config.color,
color: config.color,

View file

@ -31,6 +31,7 @@ import {
useAgentAttempts,
useAgentLog,
} from '@/hooks/useAgent';
import { useResolveAgent } from '@/hooks/useRouting';
import {
Play,
Square,
@ -68,26 +69,30 @@ export function AgentPanel({ task }: AgentPanelProps) {
const { data: agentStatus } = useAgentStatus(task.id);
const { outputs, isConnected, isRunning, clearOutputs } = useAgentStream(task.id);
const { data: attempts, refetch: refetchAttempts } = useAgentAttempts(task.id);
const { data: routingResult } = useResolveAgent(task.id);
const startAgent = useStartAgent();
const stopAgent = useStopAgent();
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
const { data: attemptLog, isLoading: isLoadingLog } = useAgentLog(
viewingAttemptId ? task.id : undefined,
viewingAttemptId ? task.id : undefined,
viewingAttemptId || undefined
);
// Get enabled agents
const enabledAgents = config?.agents.filter(a => a.enabled) || [];
const enabledAgents = config?.agents.filter((a) => a.enabled) || [];
const defaultAgent = config?.defaultAgent;
// Auto-scroll to bottom
@ -108,14 +113,21 @@ export function AgentPanel({ task }: AgentPanelProps) {
const handleStart = () => {
clearOutputs();
setViewingAttemptId(null); // Switch back to live view
startAgent.mutate({
taskId: task.id,
agent: selectedAgent || defaultAgent,
}, {
onSuccess: () => {
refetchAttempts();
startAgent.mutate(
{
taskId: task.id,
agent:
selectedAgent ||
(task.agent && task.agent !== 'auto' ? task.agent : undefined) ||
routingResult?.agent ||
defaultAgent,
},
{
onSuccess: () => {
refetchAttempts();
},
}
});
);
};
const handleStop = () => {
@ -125,7 +137,7 @@ export function AgentPanel({ task }: AgentPanelProps) {
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
sendMessage.mutate({
taskId: task.id,
message: message.trim(),
@ -160,240 +172,266 @@ export function AgentPanel({ task }: AgentPanelProps) {
<Bot className="h-4 w-4" />
AI Agent
</Label>
<div className="flex items-center gap-2">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-500" />
) : (
<WifiOff className="h-3 w-3 text-muted-foreground" />
)}
{isAgentRunning && (
<span className="flex items-center gap-1 text-xs text-green-500">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
Running
</span>
)}
</div>
</div>
<div className="rounded-md border bg-muted/30 overflow-hidden">
{/* Controls */}
<div className="flex items-center gap-2 p-2 border-b bg-card">
{!isAgentRunning ? (
<>
<Select
value={selectedAgent || defaultAgent}
onValueChange={(v) => setSelectedAgent(v as AgentType)}
>
<SelectTrigger className="w-[180px] h-8">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
{enabledAgents.map((agent) => (
<SelectItem key={agent.type} value={agent.type}>
<div className="flex items-center gap-2">
<Bot className="h-3 w-3" />
{agent.name}
{agent.type === defaultAgent && (
<span className="text-xs text-muted-foreground">(default)</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={handleStart}
disabled={!canStart || startAgent.isPending}
>
{startAgent.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Play className="h-4 w-4 mr-1" />
)}
Start
</Button>
</>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
<Square className="h-4 w-4 mr-1" />
Stop
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Stop the agent?</AlertDialogTitle>
<AlertDialogDescription>
This will terminate the running agent. The attempt will be marked as failed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
className="bg-destructive text-destructive-foreground"
>
Stop Agent
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Output */}
<div
ref={outputRef}
onScroll={handleScroll}
className="h-[300px] overflow-y-auto p-3 font-mono text-xs bg-zinc-950 text-zinc-200"
>
{outputs.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Terminal className="h-6 w-6 mr-2 opacity-50" />
{isAgentRunning ? 'Waiting for output...' : 'Agent output will appear here'}
</div>
) : (
outputs.map((output, i) => (
<div
key={i}
className={cn(
'whitespace-pre-wrap break-all',
output.type === 'stderr' && 'text-red-400',
output.type === 'stdin' && 'text-blue-400 bg-blue-500/10 px-2 py-1 rounded my-1',
output.type === 'system' && 'text-yellow-400 italic'
)}
>
{output.type === 'stdin' && <span className="font-bold">You: </span>}
{sanitizeText(output.content)}
</div>
))
)}
</div>
{/* Input */}
{isAgentRunning && (
<form onSubmit={handleSendMessage} className="flex gap-2 p-2 border-t bg-card">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Send a message to the agent..."
className="flex-1 h-8 text-sm"
/>
<Button
type="submit"
size="sm"
disabled={!message.trim() || sendMessage.isPending}
>
<Send className="h-4 w-4" />
</Button>
</form>
)}
</div>
{/* New Attempt button for completed/failed tasks */}
{task.attempt && ['complete', 'failed'].includes(task.attempt.status) && !isAgentRunning && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleStart}
>
<RotateCcw className="h-4 w-4 mr-2" />
New Attempt
</Button>
)}
{/* Attempt History */}
{attempts && attempts.length > 0 && (
<div className="space-y-2">
<Label className="text-muted-foreground flex items-center gap-2">
<History className="h-4 w-4" />
Attempt History ({attempts.length})
</Label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{attempts.map((attemptId) => {
const isCurrentAttempt = task.attempt?.id === attemptId;
const attemptStatus = isCurrentAttempt ? task.attempt?.status : 'complete';
const isViewing = viewingAttemptId === attemptId;
return (
<div
key={attemptId}
className={cn(
"flex items-center gap-2 p-2 rounded-md text-xs cursor-pointer transition-colors",
isViewing ? "bg-primary/10 border border-primary/30" : "bg-muted/50 hover:bg-muted"
)}
onClick={() => {
if (isCurrentAttempt && isAgentRunning) {
setViewingAttemptId(null); // Show live output
} else {
setViewingAttemptId(isViewing ? null : attemptId);
}
}}
>
{attemptStatusIcons[attemptStatus as AttemptStatus] || attemptStatusIcons.complete}
<span className="font-mono flex-1 truncate">{attemptId}</span>
{isCurrentAttempt && (
<Badge variant="secondary" className="text-xs">Current</Badge>
)}
{isViewing && !isCurrentAttempt && (
<Badge variant="outline" className="text-xs">Viewing</Badge>
)}
</div>
);
})}
</div>
</div>
)}
{/* Historical attempt log viewer */}
{viewingAttemptId && viewingAttemptId !== task.attempt?.id && (
<div className="rounded-md border bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between p-2 border-b bg-card">
<span className="text-xs text-muted-foreground">
Viewing: {viewingAttemptId}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setViewingAttemptId(null)}
>
Close
</Button>
</div>
<div className="h-[200px] overflow-y-auto p-3 font-mono text-xs bg-zinc-950 text-zinc-200">
{isLoadingLog ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading log...
</div>
) : attemptLog ? (
<pre className="whitespace-pre-wrap">{attemptLog}</pre>
<div className="flex items-center gap-2">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-500" />
) : (
<div className="text-muted-foreground">No log available</div>
<WifiOff className="h-3 w-3 text-muted-foreground" />
)}
{isAgentRunning && (
<span className="flex items-center gap-1 text-xs text-green-500">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
Running
</span>
)}
</div>
</div>
)}
{/* Current attempt info */}
{task.attempt && !viewingAttemptId && (
<div className="text-xs text-muted-foreground space-y-1 p-2 rounded-md bg-muted/30">
<div className="flex items-center gap-2">
{attemptStatusIcons[task.attempt.status]}
<span className="font-medium">Current: {task.attempt.id}</span>
<div className="rounded-md border bg-muted/30 overflow-hidden">
{/* Controls */}
<div className="flex items-center gap-2 p-2 border-b bg-card">
{!isAgentRunning ? (
<>
{routingResult && !selectedAgent && (
<span
className="text-xs text-muted-foreground truncate max-w-[200px]"
title={routingResult.reason}
>
Rec:{' '}
{enabledAgents.find((a) => a.type === routingResult.agent)?.name ||
routingResult.agent}
{routingResult.model ? ` (${routingResult.model})` : ''}
</span>
)}
<Select
value={
selectedAgent ||
(task.agent && task.agent !== 'auto' ? task.agent : undefined) ||
routingResult?.agent ||
defaultAgent
}
onValueChange={(v) => setSelectedAgent(v as AgentType)}
>
<SelectTrigger className="w-[180px] h-8">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
{enabledAgents.map((agent) => (
<SelectItem key={agent.type} value={agent.type}>
<div className="flex items-center gap-2">
<Bot className="h-3 w-3" />
{agent.name}
{agent.type === defaultAgent && (
<span className="text-xs text-muted-foreground">(default)</span>
)}
</div>
</SelectItem>
))}
</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}
disabled={!canStart || startAgent.isPending}
>
{startAgent.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Play className="h-4 w-4 mr-1" />
)}
Start
</Button>
</>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
<Square className="h-4 w-4 mr-1" />
Stop
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Stop the agent?</AlertDialogTitle>
<AlertDialogDescription>
This will terminate the running agent. The attempt will be marked as failed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
className="bg-destructive text-destructive-foreground"
>
Stop Agent
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<div>Agent: {task.attempt.agent}</div>
{task.attempt.started && (
<div>Started: {new Date(task.attempt.started).toLocaleString()}</div>
)}
{task.attempt.ended && (
<div>Ended: {new Date(task.attempt.ended).toLocaleString()}</div>
{/* Output */}
<div
ref={outputRef}
onScroll={handleScroll}
className="h-[300px] overflow-y-auto p-3 font-mono text-xs bg-zinc-950 text-zinc-200"
>
{outputs.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Terminal className="h-6 w-6 mr-2 opacity-50" />
{isAgentRunning ? 'Waiting for output...' : 'Agent output will appear here'}
</div>
) : (
outputs.map((output, i) => (
<div
key={i}
className={cn(
'whitespace-pre-wrap break-all',
output.type === 'stderr' && 'text-red-400',
output.type === 'stdin' &&
'text-blue-400 bg-blue-500/10 px-2 py-1 rounded my-1',
output.type === 'system' && 'text-yellow-400 italic'
)}
>
{output.type === 'stdin' && <span className="font-bold">You: </span>}
{sanitizeText(output.content)}
</div>
))
)}
</div>
{/* Input */}
{isAgentRunning && (
<form onSubmit={handleSendMessage} className="flex gap-2 p-2 border-t bg-card">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Send a message to the agent..."
className="flex-1 h-8 text-sm"
/>
<Button type="submit" size="sm" disabled={!message.trim() || sendMessage.isPending}>
<Send className="h-4 w-4" />
</Button>
</form>
)}
</div>
)}
{/* New Attempt button for completed/failed tasks */}
{task.attempt &&
['complete', 'failed'].includes(task.attempt.status) &&
!isAgentRunning && (
<Button variant="outline" size="sm" className="w-full" onClick={handleStart}>
<RotateCcw className="h-4 w-4 mr-2" />
New Attempt
</Button>
)}
{/* Attempt History */}
{attempts && attempts.length > 0 && (
<div className="space-y-2">
<Label className="text-muted-foreground flex items-center gap-2">
<History className="h-4 w-4" />
Attempt History ({attempts.length})
</Label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{attempts.map((attemptId) => {
const isCurrentAttempt = task.attempt?.id === attemptId;
const attemptStatus = isCurrentAttempt ? task.attempt?.status : 'complete';
const isViewing = viewingAttemptId === attemptId;
return (
<div
key={attemptId}
className={cn(
'flex items-center gap-2 p-2 rounded-md text-xs cursor-pointer transition-colors',
isViewing
? 'bg-primary/10 border border-primary/30'
: 'bg-muted/50 hover:bg-muted'
)}
onClick={() => {
if (isCurrentAttempt && isAgentRunning) {
setViewingAttemptId(null); // Show live output
} else {
setViewingAttemptId(isViewing ? null : attemptId);
}
}}
>
{attemptStatusIcons[attemptStatus as AttemptStatus] ||
attemptStatusIcons.complete}
<span className="font-mono flex-1 truncate">{attemptId}</span>
{isCurrentAttempt && (
<Badge variant="secondary" className="text-xs">
Current
</Badge>
)}
{isViewing && !isCurrentAttempt && (
<Badge variant="outline" className="text-xs">
Viewing
</Badge>
)}
</div>
);
})}
</div>
</div>
)}
{/* Historical attempt log viewer */}
{viewingAttemptId && viewingAttemptId !== task.attempt?.id && (
<div className="rounded-md border bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between p-2 border-b bg-card">
<span className="text-xs text-muted-foreground">Viewing: {viewingAttemptId}</span>
<Button variant="ghost" size="sm" onClick={() => setViewingAttemptId(null)}>
Close
</Button>
</div>
<div className="h-[200px] overflow-y-auto p-3 font-mono text-xs bg-zinc-950 text-zinc-200">
{isLoadingLog ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading log...
</div>
) : attemptLog ? (
<pre className="whitespace-pre-wrap">{attemptLog}</pre>
) : (
<div className="text-muted-foreground">No log available</div>
)}
</div>
</div>
)}
{/* Current attempt info */}
{task.attempt && !viewingAttemptId && (
<div className="text-xs text-muted-foreground space-y-1 p-2 rounded-md bg-muted/30">
<div className="flex items-center gap-2">
{attemptStatusIcons[task.attempt.status]}
<span className="font-medium">Current: {task.attempt.id}</span>
</div>
<div>Agent: {task.attempt.agent}</div>
{task.attempt.started && (
<div>Started: {new Date(task.attempt.started).toLocaleString()}</div>
)}
{task.attempt.ended && (
<div>Ended: {new Date(task.attempt.ended).toLocaleString()}</div>
)}
</div>
)}
</div>
</FeatureErrorBoundary>
);

View file

@ -21,6 +21,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes';
import { useProjects } from '@/hooks/useProjects';
import { useSprints } from '@/hooks/useSprints';
import { useConfig } from '@/hooks/useConfig';
import { useTemplateForm } from '@/hooks/useTemplateForm';
import { useCreateTaskForm } from '@/hooks/useCreateTaskForm';
import { BlueprintPreview } from './create/BlueprintPreview';
@ -44,6 +45,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
setPriority,
setProject,
setSprint,
setAgent,
setCategoryFilter,
setNewProjectName,
toggleHelp,
@ -61,6 +63,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
priority,
project,
sprint,
agent,
categoryFilter,
showHelp,
showNewProject,
@ -70,7 +73,9 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
const { data: taskTypes = [] } = useTaskTypes();
const { data: projects = [] } = useProjects();
const { data: sprints = [] } = useSprints();
const { data: config } = useConfig();
const enabledAgents = config?.agents.filter((a) => a.enabled) || [];
const {
selectedTemplate,
templates,
@ -89,7 +94,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
const filteredTemplates = useMemo(() => {
if (!templates) return [];
if (categoryFilter === 'all') return templates;
return templates.filter(t => (t.category || 'custom') === categoryFilter);
return templates.filter((t) => (t.category || 'custom') === categoryFilter);
}, [templates, categoryFilter]);
const handleTemplateSelect = (templateId: string) => {
@ -97,10 +102,10 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
clearTemplate();
return;
}
const template = templates?.find(t => t.id === templateId);
const template = templates?.find((t) => t.id === templateId);
if (!template) return;
const defaults = applyTemplate(template);
// Apply template defaults to form state atomically
applyFormDefaults({
@ -111,18 +116,20 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
});
};
const currentTemplate = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null;
const currentTemplate = selectedTemplate
? templates?.find((t) => t.id === selectedTemplate)
: null;
const isBlueprint = Boolean(currentTemplate?.blueprint && currentTemplate.blueprint.length > 0);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Use computed canSubmit instead of inline check
if (!canSubmit(isBlueprint)) {
return;
}
await createTasks(title, description, project, sprint, type, priority);
await createTasks(title, description, project, sprint, type, priority, agent);
// Reset form state atomically
resetForm();
@ -137,7 +144,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
{/* Template selector */}
{templates && templates.length > 0 && (
<div className="border-b pb-2">
@ -157,7 +164,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
{/* Help Section */}
{showHelp && (
<div className="mb-3 p-3 rounded-md bg-muted/50 border border-muted-foreground/20 text-sm space-y-2">
@ -166,29 +173,49 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
<div className="space-y-1.5">
<p className="font-medium text-sm">Using Templates</p>
<ul className="space-y-1 text-xs text-muted-foreground">
<li>• <strong>Simple templates</strong> pre-fill task fields and can include subtasks</li>
<li>• <strong>Variables</strong> like <code className="px-1 py-0.5 rounded bg-muted">{'{{date}}'}</code> or <code className="px-1 py-0.5 rounded bg-muted">{'{{author}}'}</code> are replaced when creating the task</li>
<li>• <strong>Custom variables</strong> (e.g., <code className="px-1 py-0.5 rounded bg-muted">{'{{bugId}}'}</code>) prompt you for values</li>
<li>• <strong>Blueprint templates</strong> create multiple linked tasks with dependencies</li>
<li>
• <strong>Simple templates</strong> pre-fill task fields and can include
subtasks
</li>
<li>
• <strong>Variables</strong> like{' '}
<code className="px-1 py-0.5 rounded bg-muted">{'{{date}}'}</code> or{' '}
<code className="px-1 py-0.5 rounded bg-muted">{'{{author}}'}</code> are
replaced when creating the task
</li>
<li>
• <strong>Custom variables</strong> (e.g.,{' '}
<code className="px-1 py-0.5 rounded bg-muted">{'{{bugId}}'}</code>)
prompt you for values
</li>
<li>
• <strong>Blueprint templates</strong> create multiple linked tasks with
dependencies
</li>
</ul>
</div>
</div>
</div>
)}
<Tabs value={categoryFilter} onValueChange={setCategoryFilter}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all" className="text-xs">All</TabsTrigger>
<TabsTrigger value="bug" className="text-xs">🐛</TabsTrigger>
<TabsTrigger value="feature" className="text-xs">✨</TabsTrigger>
<TabsTrigger value="sprint" className="text-xs">🔄</TabsTrigger>
<TabsTrigger value="all" className="text-xs">
All
</TabsTrigger>
<TabsTrigger value="bug" className="text-xs">
🐛
</TabsTrigger>
<TabsTrigger value="feature" className="text-xs">
✨
</TabsTrigger>
<TabsTrigger value="sprint" className="text-xs">
🔄
</TabsTrigger>
</TabsList>
</Tabs>
<Select
value={selectedTemplate || 'none'}
onValueChange={handleTemplateSelect}
>
<Select value={selectedTemplate || 'none'} onValueChange={handleTemplateSelect}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="Select template..." />
</SelectTrigger>
@ -199,9 +226,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
{template.category && `${getCategoryIcon(template.category)} `}
{template.name}
{template.description && (
<span className="text-muted-foreground ml-2">
— {template.description}
</span>
<span className="text-muted-foreground ml-2">— {template.description}</span>
)}
</SelectItem>
))}
@ -209,7 +234,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
</Select>
</div>
)}
{/* Blueprint preview or regular form */}
<div className="grid gap-4 py-4">
{isBlueprint ? (
@ -218,7 +243,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
<TemplateVariableInputs
variables={requiredCustomVars}
values={customVars}
onChange={(name, value) => setCustomVars(prev => ({ ...prev, [name]: value }))}
onChange={(name, value) => setCustomVars((prev) => ({ ...prev, [name]: value }))}
/>
</>
) : (
@ -233,7 +258,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
autoFocus
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
@ -244,7 +269,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="type">Type</Label>
@ -267,7 +292,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="priority">Priority</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
@ -282,12 +307,12 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="project">Project (optional)</Label>
{!showNewProject ? (
<Select
value={project}
<Select
value={project}
onValueChange={(value) => {
if (value === '__new__') {
onShowNewProject();
@ -339,37 +364,59 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
>
Add
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={hideNewProject}
>
<Button type="button" size="sm" variant="outline" onClick={hideNewProject}>
Cancel
</Button>
</div>
)}
</div>
<div className="grid gap-2">
<Label>Sprint (optional)</Label>
<Select value={sprint || '__none__'} onValueChange={(v) => setSprint(v === '__none__' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="No sprint" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No Sprint</SelectItem>
{sprints.map(s => (
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Sprint (optional)</Label>
<Select
value={sprint || '__none__'}
onValueChange={(v) => setSprint(v === '__none__' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="No sprint" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No Sprint</SelectItem>
{sprints.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Agent</Label>
<Select value={agent || 'auto'} onValueChange={setAgent}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
<span className="text-muted-foreground">Auto</span>
<span className="text-xs text-muted-foreground ml-1">(routing)</span>
</SelectItem>
{enabledAgents.map((a) => (
<SelectItem key={a.type} value={a.type}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<TemplateVariableInputs
variables={requiredCustomVars}
values={customVars}
onChange={(name, value) => setCustomVars(prev => ({ ...prev, [name]: value }))}
onChange={(name, value) => setCustomVars((prev) => ({ ...prev, [name]: value }))}
/>
{subtasks.length > 0 && (
@ -403,15 +450,12 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
</>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={!canSubmit(isBlueprint) || isCreating}
>
<Button type="submit" disabled={!canSubmit(isBlueprint) || isCreating}>
{isCreating ? 'Creating...' : isBlueprint ? 'Create Tasks' : 'Create Task'}
</Button>
</DialogFooter>

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

@ -14,6 +14,7 @@ import { DiffViewer } from './DiffViewer';
import { ReviewPanel } from './ReviewPanel';
import { PreviewPanel } from './PreviewPanel';
import { AttachmentsSection } from './AttachmentsSection';
import { ChatPanel } from '@/components/chat/ChatPanel';
import { ApplyTemplateDialog } from './ApplyTemplateDialog';
import { TaskMetricsPanel } from './TaskMetricsPanel';
import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary';
@ -27,6 +28,7 @@ import {
Paperclip,
Archive,
BarChart3,
MessageSquare,
} from 'lucide-react';
import type { Task, ReviewComment, ReviewState } from '@veritas-kanban/shared';
@ -53,6 +55,7 @@ export function TaskDetailPanel({
const [activeTab, setActiveTab] = useState('details');
const [previewOpen, setPreviewOpen] = useState(false);
const [applyTemplateOpen, setApplyTemplateOpen] = useState(false);
const [taskChatOpen, setTaskChatOpen] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -109,74 +112,87 @@ export function TaskDetailPanel({
</SheetTitle>
</SheetHeader>
{/* 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={() => setApplyTemplateOpen(true)}
className="flex items-center justify-center gap-1 w-full"
>
<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-4"
className="flex-1 flex flex-col overflow-hidden mt-3"
>
<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
<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>
</TabsList>
{!readOnly && (
<Button
variant="outline"
size="sm"
onClick={() => setApplyTemplateOpen(true)}
className="flex items-center gap-1"
>
<FileCode className="h-3 w-3" />
Template
</Button>
)}
{!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 */}
@ -282,6 +298,11 @@ export function TaskDetailPanel({
onOpenChange={setApplyTemplateOpen}
/>
)}
{/* Task-Scoped Chat Panel */}
{localTask && (
<ChatPanel open={taskChatOpen} onOpenChange={setTaskChatOpen} taskId={localTask.id} />
)}
</Sheet>
);
}

View file

@ -12,7 +12,8 @@ import {
import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes';
import { useProjects } from '@/hooks/useProjects';
import { useSprints } from '@/hooks/useSprints';
import type { Task, TaskStatus, TaskPriority } from '@veritas-kanban/shared';
import { useConfig } from '@/hooks/useConfig';
import type { Task, TaskStatus, TaskPriority, AgentType } from '@veritas-kanban/shared';
interface TaskMetadataSectionProps {
task: Task;
@ -21,10 +22,10 @@ interface TaskMetadataSectionProps {
}
const statusLabels: Record<TaskStatus, string> = {
'todo': 'To Do',
todo: 'To Do',
'in-progress': 'In Progress',
'blocked': 'Blocked',
'done': 'Done',
blocked: 'Blocked',
done: 'Done',
};
const priorityLabels: Record<TaskPriority, string> = {
@ -33,15 +34,21 @@ const priorityLabels: Record<TaskPriority, string> = {
high: 'High',
};
export function TaskMetadataSection({ task, onUpdate, readOnly = false }: TaskMetadataSectionProps) {
export function TaskMetadataSection({
task,
onUpdate,
readOnly = false,
}: TaskMetadataSectionProps) {
const { data: taskTypes = [] } = useTaskTypes();
const { data: projects = [] } = useProjects();
const { data: sprints = [] } = useSprints();
const { data: config } = useConfig();
const enabledAgents = config?.agents.filter((a) => a.enabled) || [];
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
// Get current type info
const currentType = taskTypes.find(t => t.id === task.type);
const currentType = taskTypes.find((t) => t.id === task.type);
const typeLabel = currentType ? currentType.label : task.type;
return (
@ -55,16 +62,15 @@ export function TaskMetadataSection({ task, onUpdate, readOnly = false }: TaskMe
{statusLabels[task.status]}
</div>
) : (
<Select
value={task.status}
onValueChange={(v) => onUpdate('status', v as TaskStatus)}
>
<Select value={task.status} onValueChange={(v) => onUpdate('status', v as TaskStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(statusLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
@ -74,14 +80,9 @@ export function TaskMetadataSection({ task, onUpdate, readOnly = false }: TaskMe
<div className="space-y-2">
<Label className="text-muted-foreground">Type</Label>
{readOnly ? (
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
{typeLabel}
</div>
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">{typeLabel}</div>
) : (
<Select
value={task.type}
onValueChange={(v) => onUpdate('type', v)}
>
<Select value={task.type} onValueChange={(v) => onUpdate('type', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@ -118,7 +119,9 @@ export function TaskMetadataSection({ task, onUpdate, readOnly = false }: TaskMe
</SelectTrigger>
<SelectContent>
{Object.entries(priorityLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
@ -131,11 +134,11 @@ export function TaskMetadataSection({ task, onUpdate, readOnly = false }: TaskMe
<Label className="text-muted-foreground">Project</Label>
{readOnly ? (
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
{(projects.find(p => p.id === task.project)?.label) || task.project || 'No project'}
{projects.find((p) => p.id === task.project)?.label || task.project || 'No project'}
</div>
) : !showNewProject ? (
<Select
value={task.project || '__none__'}
<Select
value={task.project || '__none__'}
onValueChange={(value) => {
if (value === '__new__') {
setShowNewProject(true);
@ -208,29 +211,65 @@ export function TaskMetadataSection({ task, onUpdate, readOnly = false }: TaskMe
)}
</div>
{/* Sprint */}
<div className="space-y-2">
<Label className="text-muted-foreground">Sprint</Label>
{readOnly ? (
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
{(sprints.find(s => s.id === task.sprint)?.label) || task.sprint || 'No sprint'}
</div>
) : (
<Select
value={task.sprint || '__none__'}
onValueChange={(value) => onUpdate('sprint', value === '__none__' ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="No sprint" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No Sprint</SelectItem>
{sprints.map(s => (
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Sprint & Agent */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-muted-foreground">Sprint</Label>
{readOnly ? (
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
{sprints.find((s) => s.id === task.sprint)?.label || task.sprint || 'No sprint'}
</div>
) : (
<Select
value={task.sprint || '__none__'}
onValueChange={(value) =>
onUpdate('sprint', value === '__none__' ? undefined : value)
}
>
<SelectTrigger>
<SelectValue placeholder="No sprint" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No Sprint</SelectItem>
{sprints.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Agent</Label>
{readOnly ? (
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
{task.agent === 'auto' || !task.agent
? 'Auto (routing)'
: enabledAgents.find((a) => a.type === task.agent)?.name || task.agent}
</div>
) : (
<Select
value={task.agent || 'auto'}
onValueChange={(value) =>
onUpdate('agent', value === 'auto' ? undefined : (value as AgentType))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto (routing)</SelectItem>
{enabledAgents.map((a) => (
<SelectItem key={a.type} value={a.type}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
);

118
web/src/hooks/useChat.ts Normal file
View file

@ -0,0 +1,118 @@
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';
/**
* List all chat sessions
*/
export function useChatSessions() {
return useQuery({
queryKey: ['chat', 'sessions'],
queryFn: chatApi.listSessions,
staleTime: 30_000,
});
}
/**
* Get a single chat session with messages
*/
export function useChatSession(sessionId: string | undefined) {
return useQuery({
queryKey: ['chat', 'sessions', sessionId],
queryFn: () => chatApi.getSession(sessionId!),
enabled: !!sessionId,
staleTime: 10_000,
});
}
/**
* Send a chat message
*/
export function useSendChatMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ChatSendInput) => chatApi.sendMessage(input),
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] });
}
},
});
}
/**
* Delete a chat session
*/
export function useDeleteChatSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) => chatApi.deleteSession(sessionId),
onSuccess: (_data, sessionId) => {
// Remove the specific session from cache entirely — invalidate alone
// keeps stale data when the refetch 404s (file deleted)
queryClient.removeQueries({ queryKey: ['chat', 'sessions', sessionId] });
queryClient.invalidateQueries({ queryKey: ['chat', 'sessions'] });
},
});
}
/**
* Listen for streaming chat messages via WebSocket
*/
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;
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]);
return { streamingMessage };
}

View file

@ -10,6 +10,7 @@ export interface CreateTaskFormState {
priority: TaskPriority;
project: string;
sprint: string;
agent: string; // "auto" | agent type slug | "" (empty = auto)
categoryFilter: string;
showHelp: boolean;
showNewProject: boolean;
@ -25,6 +26,7 @@ type CreateTaskFormAction =
| { type: 'SET_PRIORITY'; payload: TaskPriority }
| { type: 'SET_PROJECT'; payload: string }
| { type: 'SET_SPRINT'; payload: string }
| { type: 'SET_AGENT'; payload: string }
| { type: 'SET_CATEGORY_FILTER'; payload: string }
| { type: 'TOGGLE_HELP' }
| { type: 'SHOW_NEW_PROJECT' }
@ -42,6 +44,7 @@ const initialState: CreateTaskFormState = {
priority: 'medium',
project: '',
sprint: '',
agent: 'auto',
categoryFilter: 'all',
showHelp: false,
showNewProject: false,
@ -67,6 +70,8 @@ function createTaskFormReducer(
return { ...state, project: action.payload, showNewProject: false };
case 'SET_SPRINT':
return { ...state, sprint: action.payload };
case 'SET_AGENT':
return { ...state, agent: action.payload };
case 'SET_CATEGORY_FILTER':
return { ...state, categoryFilter: action.payload };
case 'TOGGLE_HELP':
@ -97,6 +102,7 @@ export interface UseCreateTaskFormReturn {
setPriority: (priority: TaskPriority) => void;
setProject: (project: string) => void;
setSprint: (sprint: string) => void;
setAgent: (agent: string) => void;
setCategoryFilter: (category: string) => void;
setNewProjectName: (name: string) => void;
// Actions
@ -111,21 +117,21 @@ export interface UseCreateTaskFormReturn {
/**
* Hook for managing CreateTaskDialog form state with useReducer.
*
*
* Replaces 11 separate useState calls with a single, cohesive state machine.
* Benefits:
* - Single source of truth for form state
* - Predictable state transitions
* - Easier to test and debug
* - Related state updates happen atomically
*
*
* @example
* ```tsx
* const { state, setTitle, setPriority, applyTemplate, reset } = useCreateTaskForm();
*
*
* // Apply template defaults
* applyTemplate({ type: 'bug', priority: 'high' });
*
*
* // Check if form can submit
* if (canSubmit(isBlueprint)) handleSubmit();
* ```
@ -158,6 +164,10 @@ export function useCreateTaskForm(): UseCreateTaskFormReturn {
dispatch({ type: 'SET_SPRINT', payload: sprint });
}, []);
const setAgent = useCallback((agent: string) => {
dispatch({ type: 'SET_AGENT', payload: agent });
}, []);
const setCategoryFilter = useCallback((category: string) => {
dispatch({ type: 'SET_CATEGORY_FILTER', payload: category });
}, []);
@ -201,6 +211,7 @@ export function useCreateTaskForm(): UseCreateTaskFormReturn {
setPriority,
setProject,
setSprint,
setAgent,
setCategoryFilter,
setNewProjectName,
toggleHelp,
@ -218,6 +229,7 @@ export function useCreateTaskForm(): UseCreateTaskFormReturn {
setPriority,
setProject,
setSprint,
setAgent,
setCategoryFilter,
setNewProjectName,
toggleHelp,

View file

@ -19,6 +19,10 @@ interface KeyboardContextValue {
closeHelpDialog: () => void;
isHelpOpen: boolean;
// Chat panel
openChatPanel: () => void;
setOpenChatPanel: (fn: () => void) => void;
// Task selection
selectedTaskId: string | null;
setSelectedTaskId: (id: string | null) => void;
@ -48,6 +52,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
// Use refs for callbacks to avoid re-render loops
const openCreateDialogRef = useRef<(() => void) | null>(null);
const openChatPanelRef = useRef<(() => void) | null>(null);
const onOpenTaskRef = useRef<((task: Task) => void) | null>(null);
const onMoveTaskRef = useRef<((taskId: string, status: TaskStatus) => void) | null>(null);
@ -59,6 +64,14 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
openCreateDialogRef.current = fn;
}, []);
const openChatPanel = useCallback(() => {
openChatPanelRef.current?.();
}, []);
const setOpenChatPanel = useCallback((fn: () => void) => {
openChatPanelRef.current = fn;
}, []);
const openHelpDialog = useCallback(() => {
setIsHelpOpen(true);
}, []);
@ -105,6 +118,13 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
const taskList = getTaskList();
const currentIndex = selectedTaskId ? taskList.findIndex((t) => t.id === selectedTaskId) : -1;
// Cmd+Shift+C (or Ctrl+Shift+C on Windows/Linux) - Toggle chat panel
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'C') {
e.preventDefault();
openChatPanel();
return;
}
switch (e.key) {
case 'c':
e.preventDefault();
@ -174,12 +194,14 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [getTaskList, selectedTaskId, isHelpOpen, openCreateDialog]);
}, [getTaskList, selectedTaskId, isHelpOpen, openCreateDialog, openChatPanel]);
const value = useMemo<KeyboardContextValue>(
() => ({
openCreateDialog,
setOpenCreateDialog,
openChatPanel,
setOpenChatPanel,
openHelpDialog,
closeHelpDialog,
isHelpOpen,
@ -193,6 +215,8 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
[
openCreateDialog,
setOpenCreateDialog,
openChatPanel,
setOpenChatPanel,
openHelpDialog,
closeHelpDialog,
isHelpOpen,

View file

@ -0,0 +1,53 @@
/**
* React Query hooks for agent routing configuration.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { routingApi } from '@/lib/api/agent';
import type { AgentRoutingConfig, RoutingResult } from '@veritas-kanban/shared';
/** Fetch the current routing config */
export function useRoutingConfig() {
return useQuery<AgentRoutingConfig>({
queryKey: ['routing-config'],
queryFn: routingApi.getConfig,
staleTime: 60_000,
});
}
/** Update routing config */
export function useUpdateRoutingConfig() {
const queryClient = useQueryClient();
return useMutation<AgentRoutingConfig, Error, AgentRoutingConfig>({
mutationFn: routingApi.updateConfig,
onSuccess: (data) => {
queryClient.setQueryData(['routing-config'], data);
},
});
}
/** Resolve agent for an existing task */
export function useResolveAgent(taskId: string | undefined) {
return useQuery<RoutingResult>({
queryKey: ['routing-resolve', taskId],
queryFn: () => routingApi.resolveForTask(taskId!),
enabled: !!taskId,
staleTime: 30_000,
});
}
/** Resolve agent for ad-hoc metadata (e.g. from create dialog) */
export function useResolveAgentForMetadata(
metadata: {
type?: string;
priority?: string;
project?: string;
subtaskCount?: number;
} | null
) {
return useQuery<RoutingResult>({
queryKey: ['routing-resolve-meta', metadata],
queryFn: () => routingApi.resolveForMetadata(metadata!),
enabled: !!metadata,
staleTime: 15_000,
});
}

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

@ -106,7 +106,8 @@ export function useTemplateForm() {
project: string,
sprint: string,
type: string,
priority: string
priority: string,
agent?: string
) => {
// Build variable context
const context: VariableContext = {
@ -139,6 +140,7 @@ export function useTemplateForm() {
priority: priority as TaskPriority,
project: project.trim() || undefined,
sprint: sprint.trim() || undefined,
agent: agent && agent !== 'auto' ? agent : undefined,
subtasks: interpolatedSubtasks.length > 0 ? interpolatedSubtasks : undefined,
});
}

View file

@ -1,7 +1,7 @@
/**
* Agent, worktree, and preview API endpoints.
*/
import type { AgentType } from '@veritas-kanban/shared';
import type { AgentType, AgentRoutingConfig, RoutingResult } from '@veritas-kanban/shared';
import { API_BASE, handleResponse } from './helpers';
export const worktreeApi = {
@ -102,6 +102,52 @@ export const agentApi = {
},
};
export const routingApi = {
/** Get current routing configuration */
getConfig: async (): Promise<AgentRoutingConfig> => {
const response = await fetch(`${API_BASE}/agents/routing`);
return handleResponse<AgentRoutingConfig>(response);
},
/** Update routing configuration */
updateConfig: async (config: AgentRoutingConfig): Promise<AgentRoutingConfig> => {
const response = await fetch(`${API_BASE}/agents/routing`, {
credentials: 'include',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
return handleResponse<AgentRoutingConfig>(response);
},
/** Resolve the best agent for a task */
resolveForTask: async (taskId: string): Promise<RoutingResult> => {
const response = await fetch(`${API_BASE}/agents/route`, {
credentials: 'include',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId }),
});
return handleResponse<RoutingResult>(response);
},
/** Resolve the best agent for metadata (ad-hoc, e.g. from create dialog) */
resolveForMetadata: async (metadata: {
type?: string;
priority?: string;
project?: string;
subtaskCount?: number;
}): Promise<RoutingResult> => {
const response = await fetch(`${API_BASE}/agents/route`, {
credentials: 'include',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
});
return handleResponse<RoutingResult>(response);
},
};
export const previewApi = {
getStatus: async (taskId: string): Promise<PreviewServer | { status: 'stopped' }> => {
const response = await fetch(`${API_BASE}/preview/${taskId}`);

66
web/src/lib/api/chat.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* Chat API functions
*/
import { API_BASE, handleResponse } from './helpers';
import type { ChatSession, ChatSendInput } from '@veritas-kanban/shared';
/**
* List all chat sessions
*/
export async function listSessions(): Promise<ChatSession[]> {
const response = await fetch(`${API_BASE}/chat/sessions`, {
credentials: 'include',
});
return handleResponse<ChatSession[]>(response);
}
/**
* Get a single chat session with messages
*/
export async function getSession(sessionId: string): Promise<ChatSession> {
const response = await fetch(`${API_BASE}/chat/sessions/${sessionId}`, {
credentials: 'include',
});
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<ChatSendResponse> {
const response = await fetch(`${API_BASE}/chat/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(input),
});
return handleResponse<ChatSendResponse>(response);
}
/**
* Delete a chat session
*/
export async function deleteSession(sessionId: string): Promise<void> {
const response = await fetch(`${API_BASE}/chat/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
});
return handleResponse<void>(response);
}
export const chatApi = {
listSessions,
getSession,
sendMessage,
deleteSession,
};

View file

@ -10,6 +10,7 @@ import { agentApi, worktreeApi, previewApi } from './agent';
import { diffApi, conflictsApi, githubApi } from './diff';
import { templatesApi, taskTypesApi, sprintsApi, activityApi, attachmentsApi } from './entities';
import { timeApi, statusHistoryApi } from './time';
import { chatApi } from './chat';
// Assemble the full API object (matches original structure exactly)
export const api = {
@ -29,6 +30,7 @@ export const api = {
preview: previewApi,
time: timeApi,
statusHistory: statusHistoryApi,
chat: chatApi,
};
// Re-export managed list helper