mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 20:57:51 +00:00
This commit is contained in:
commit
a81b4ea1f7
41 changed files with 3895 additions and 464 deletions
322
server/src/__tests__/agent-routing-service.test.ts
Normal file
322
server/src/__tests__/agent-routing-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
147
server/src/routes/agent-routing.ts
Normal file
147
server/src/routes/agent-routing.ts
Normal 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
228
server/src/routes/chat.ts
Normal 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 };
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
224
server/src/services/agent-routing-service.ts
Normal file
224
server/src/services/agent-routing-service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
326
server/src/services/chat-service.ts
Normal file
326
server/src/services/chat-service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
|
|
|
|||
270
server/src/services/gateway-chat-client.ts
Normal file
270
server/src/services/gateway-chat-client.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* Gateway Chat Client
|
||||
*
|
||||
* Connects to the Clawdbot Gateway WebSocket to proxy chat messages.
|
||||
* Handles authentication, message sending, and response collection.
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
|
||||
const log = createLogger('gateway-chat');
|
||||
|
||||
const GATEWAY_URL = process.env.CLAWDBOT_GATEWAY || 'http://127.0.0.1:18789';
|
||||
const PROTOCOL_VERSION = 3;
|
||||
const CONNECT_TIMEOUT_MS = 10_000;
|
||||
const RESPONSE_TIMEOUT_MS = 120_000; // 2 minutes for AI response
|
||||
|
||||
// Cached token — populated lazily
|
||||
let cachedToken: string | null = null;
|
||||
|
||||
function getToken(): string {
|
||||
if (cachedToken) return cachedToken;
|
||||
return process.env.CLAWDBOT_GATEWAY_TOKEN || '';
|
||||
}
|
||||
|
||||
interface ChatResponse {
|
||||
text: string;
|
||||
usage?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StreamCallbacks {
|
||||
onDelta?: (text: string) => void;
|
||||
onFinal?: (response: ChatResponse) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the Clawdbot Gateway and collect the response.
|
||||
* Opens a temporary WebSocket connection for each request.
|
||||
*/
|
||||
export async function sendGatewayChat(
|
||||
message: string,
|
||||
sessionKey: string,
|
||||
callbacks?: StreamCallbacks
|
||||
): Promise<ChatResponse> {
|
||||
const wsUrl = GATEWAY_URL.replace(/^http/, 'ws');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let connected = false;
|
||||
let responseText = '';
|
||||
let responseUsage: Record<string, unknown> | undefined;
|
||||
let connectTimer: ReturnType<typeof setTimeout>;
|
||||
let responseTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(connectTimer);
|
||||
clearTimeout(responseTimer);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
connectTimer = setTimeout(() => {
|
||||
if (!connected) {
|
||||
cleanup();
|
||||
const err = 'Gateway connection timeout';
|
||||
callbacks?.onError?.(err);
|
||||
reject(new Error(err));
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
|
||||
ws.on('error', (err) => {
|
||||
log.error({ err: err.message }, 'Gateway WebSocket error');
|
||||
cleanup();
|
||||
const errMsg = `Gateway connection failed: ${err.message}`;
|
||||
callbacks?.onError?.(errMsg);
|
||||
reject(new Error(errMsg));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Handle challenge → send connect
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: randomUUID(),
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: 'gateway-client',
|
||||
version: '1.0.0',
|
||||
platform: 'node',
|
||||
mode: 'backend',
|
||||
},
|
||||
auth: { token: getToken() },
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Handle connect response → send chat.send
|
||||
if (msg.type === 'res' && msg.ok && msg.payload?.type === 'hello-ok') {
|
||||
connected = true;
|
||||
clearTimeout(connectTimer);
|
||||
|
||||
log.info({ sessionKey }, 'Connected to gateway, sending chat message');
|
||||
|
||||
// Start response timeout
|
||||
responseTimer = setTimeout(() => {
|
||||
cleanup();
|
||||
const err = 'Gateway response timeout';
|
||||
callbacks?.onError?.(err);
|
||||
reject(new Error(err));
|
||||
}, RESPONSE_TIMEOUT_MS);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: randomUUID(),
|
||||
method: 'chat.send',
|
||||
params: {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey: randomUUID(),
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle chat.send ack
|
||||
if (msg.type === 'res' && msg.ok && msg.payload?.runId) {
|
||||
log.debug({ runId: msg.payload.runId }, 'Chat run started');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (msg.type === 'res' && !msg.ok) {
|
||||
cleanup();
|
||||
const errMsg = msg.error?.message || 'Unknown gateway error';
|
||||
log.error({ error: msg.error }, 'Gateway error');
|
||||
callbacks?.onError?.(errMsg);
|
||||
reject(new Error(errMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Handle streaming chat events
|
||||
if (msg.type === 'event' && msg.event === 'chat') {
|
||||
const payload = msg.payload || {};
|
||||
|
||||
if (payload.state === 'delta') {
|
||||
// Gateway sends full accumulated text in each delta, not incremental chunks
|
||||
const content = payload.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
let fullText = '';
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
fullText += block.text;
|
||||
}
|
||||
}
|
||||
// Calculate the new chunk (what was added since last delta)
|
||||
const newChunk = fullText.slice(responseText.length);
|
||||
responseText = fullText;
|
||||
if (newChunk) {
|
||||
callbacks?.onDelta?.(newChunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.state === 'final') {
|
||||
// Extract final text if we didn't get it from deltas
|
||||
if (!responseText && payload.message?.content) {
|
||||
const content = payload.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (typeof content === 'string') {
|
||||
responseText = content;
|
||||
}
|
||||
}
|
||||
|
||||
responseUsage = payload.usage;
|
||||
|
||||
const response: ChatResponse = {
|
||||
text: responseText,
|
||||
usage: responseUsage,
|
||||
};
|
||||
|
||||
log.info({ sessionKey, textLength: responseText.length }, 'Chat response complete');
|
||||
cleanup();
|
||||
callbacks?.onFinal?.(response);
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.state === 'error') {
|
||||
cleanup();
|
||||
const errMsg = payload.errorMessage || 'Chat error';
|
||||
callbacks?.onError?.(errMsg);
|
||||
reject(new Error(errMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.state === 'aborted') {
|
||||
cleanup();
|
||||
const response: ChatResponse = {
|
||||
text: responseText || '(response aborted)',
|
||||
};
|
||||
callbacks?.onFinal?.(response);
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (!connected) {
|
||||
reject(new Error('Gateway WebSocket closed before connecting'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the gateway token from config file if not in env
|
||||
*/
|
||||
export async function loadGatewayToken(): Promise<string> {
|
||||
if (cachedToken) return cachedToken;
|
||||
if (process.env.CLAWDBOT_GATEWAY_TOKEN) {
|
||||
cachedToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
const configPath = path.join(process.env.HOME || '', '.clawdbot', 'clawdbot.json');
|
||||
const raw = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
const token = config?.gateway?.auth?.token;
|
||||
if (token) {
|
||||
cachedToken = token;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
return token;
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.warn({ err: err.message }, 'Failed to load gateway token from config');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
41
shared/src/types/chat.types.ts
Normal file
41
shared/src/types/chat.types.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
433
web/src/components/chat/ChatPanel.tsx
Normal file
433
web/src/components/chat/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
web/src/components/chat/FloatingChat.tsx
Normal file
69
web/src/components/chat/FloatingChat.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 || '');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
118
web/src/hooks/useChat.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
53
web/src/hooks/useRouting.ts
Normal file
53
web/src/hooks/useRouting.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
66
web/src/lib/api/chat.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue