diff --git a/frontend/src/components/EventCard.tsx b/frontend/src/components/EventCard.tsx index 48185fe9..62d84d73 100644 --- a/frontend/src/components/EventCard.tsx +++ b/frontend/src/components/EventCard.tsx @@ -1,5 +1,8 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMitzoStore } from '@mitzo/client/hooks'; import type { CalendarEvent } from '../hooks/useCalendarData'; +import { buildMeetingPrepPrompt, buildMeetingContext } from '../lib/calendar-utils'; function formatTime(isoStr: string): string { if (!isoStr.includes('T')) return ''; @@ -9,6 +12,18 @@ function formatTime(isoStr: string): string { export function EventCard({ event }: { event: CalendarEvent }) { const [expanded, setExpanded] = useState(false); + const navigate = useNavigate(); + const setPendingSession = useMitzoStore((s) => s.setPendingSession); + + function handlePrepClick(e: React.MouseEvent) { + e.stopPropagation(); + setPendingSession({ + prompt: buildMeetingPrepPrompt(event), + context: buildMeetingContext(event), + agentName: 'mitzo-calendar', + }); + navigate('/chat'); + } if (event.type === 'milestone') { return ( @@ -70,17 +85,22 @@ export function EventCard({ event }: { event: CalendarEvent }) { )} )} - {event.hangoutLink && ( - e.stopPropagation()} - > - Join video call - - )} +
+ + {event.hangoutLink && ( + e.stopPropagation()} + > + Join video call + + )} +
)} diff --git a/frontend/src/lib/__tests__/calendar-utils.test.ts b/frontend/src/lib/__tests__/calendar-utils.test.ts new file mode 100644 index 00000000..a3ae46d9 --- /dev/null +++ b/frontend/src/lib/__tests__/calendar-utils.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { buildMeetingPrepPrompt, buildMeetingContext } from '../calendar-utils'; +import type { CalendarEvent } from '../../hooks/useCalendarData'; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'evt-1', + type: 'meeting', + title: 'Standup', + start: '2026-05-22T10:00:00Z', + end: '2026-05-22T10:30:00Z', + ...overrides, + }; +} + +describe('buildMeetingPrepPrompt', () => { + it('includes title and formatted time range', () => { + const result = buildMeetingPrepPrompt(makeEvent()); + expect(result).toContain('Prepare for "Standup"'); + expect(result).toContain(' at '); + }); + + it('omits time for all-day events (no T in start)', () => { + const result = buildMeetingPrepPrompt(makeEvent({ start: '2026-05-22', end: '2026-05-23' })); + expect(result).toBe('Prepare for "Standup"'); + }); + + it('omits time when start is empty', () => { + const result = buildMeetingPrepPrompt(makeEvent({ start: '' })); + expect(result).toBe('Prepare for "Standup"'); + }); +}); + +describe('buildMeetingContext', () => { + it('includes meeting title', () => { + const result = buildMeetingContext(makeEvent()); + expect(result).toContain('**Meeting:** Standup'); + }); + + it('includes location when present', () => { + const result = buildMeetingContext(makeEvent({ location: 'Room 42' })); + expect(result).toContain('**Where:** Room 42'); + }); + + it('omits location when absent', () => { + const result = buildMeetingContext(makeEvent()); + expect(result).not.toContain('**Where:**'); + }); + + it('formats attendees from email addresses', () => { + const result = buildMeetingContext( + makeEvent({ attendees: ['alice@example.com', 'bob@example.com'] }), + ); + expect(result).toContain('**Attendees:** alice, bob'); + }); + + it('truncates attendees beyond 10 with count', () => { + const attendees = Array.from({ length: 12 }, (_, i) => `user${i}@example.com`); + const result = buildMeetingContext(makeEvent({ attendees })); + expect(result).toContain('(+2 more)'); + expect(result).not.toContain('user10'); + }); + + it('includes video link when present', () => { + const result = buildMeetingContext(makeEvent({ hangoutLink: 'https://meet.google.com/abc' })); + expect(result).toContain('**Video:** [Join call](https://meet.google.com/abc)'); + }); + + it('includes context prompts at the end', () => { + const result = buildMeetingContext(makeEvent()); + expect(result).toContain('Review recent Jira activity'); + expect(result).toContain('key topics and decisions'); + }); +}); diff --git a/frontend/src/lib/calendar-utils.ts b/frontend/src/lib/calendar-utils.ts new file mode 100644 index 00000000..1fb05e0f --- /dev/null +++ b/frontend/src/lib/calendar-utils.ts @@ -0,0 +1,68 @@ +import type { CalendarEvent } from '../hooks/useCalendarData'; + +export function buildMeetingPrepPrompt(event: CalendarEvent): string { + const time = formatEventTime(event); + const when = time ? ` at ${time}` : ''; + + return `Prepare for "${event.title}"${when}`; +} + +export function buildMeetingContext(event: CalendarEvent): string { + const lines: string[] = []; + + lines.push(`**Meeting:** ${event.title}`); + + if (event.start) { + const startDate = new Date(event.start); + const dateStr = startDate.toLocaleDateString([], { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + lines.push(`**When:** ${dateStr}`); + } + + if (event.location) { + lines.push(`**Where:** ${event.location}`); + } + + if (event.attendees && event.attendees.length > 0) { + const displayAttendees = event.attendees + .slice(0, 10) + .map((email) => { + const name = email.split('@')[0]; + return name; + }) + .join(', '); + + const suffix = event.attendees.length > 10 ? ` (+${event.attendees.length - 10} more)` : ''; + lines.push(`**Attendees:** ${displayAttendees}${suffix}`); + } + + if (event.hangoutLink) { + lines.push(`**Video:** [Join call](${event.hangoutLink})`); + } + + lines.push(''); + lines.push('**Context for this meeting:**'); + lines.push('- Review recent Jira activity for attendees'); + lines.push('- Check relevant docs and recent conversations'); + lines.push('- Identify key topics and decisions needed'); + + return lines.join('\n'); +} + +function formatEventTime(event: CalendarEvent): string { + if (!event.start || !event.start.includes('T')) return ''; + + const start = new Date(event.start); + const end = event.end ? new Date(event.end) : null; + + const startTime = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + if (!end) return startTime; + + const endTime = end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + return `${startTime}–${endTime}`; +} diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 3a571988..d9008017 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -138,6 +138,7 @@ export function ChatView() { model: modelState, mode, ...(pendingSession.telosTaskId ? { telosTaskId: pendingSession.telosTaskId } : {}), + ...(pendingSession.agentName ? { agentName: pendingSession.agentName } : {}), }); clearPendingSession(); forceScrollToBottom(); diff --git a/frontend/src/pages/TodoView.tsx b/frontend/src/pages/TodoView.tsx index 8f185f30..0ee70878 100644 --- a/frontend/src/pages/TodoView.tsx +++ b/frontend/src/pages/TodoView.tsx @@ -190,6 +190,8 @@ export function TodoView() { setPendingSession({ prompt: buildPrompt(item), context: buildTodoContext(item), + telosTaskId: item.id, + agentName: 'mitzo-telos', }); navigate('/chat'); } diff --git a/frontend/src/styles/calendar.css b/frontend/src/styles/calendar.css index 99d6beb8..cda87bab 100644 --- a/frontend/src/styles/calendar.css +++ b/frontend/src/styles/calendar.css @@ -304,12 +304,39 @@ color: var(--text-dim); } +.cal-event-actions { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-top: var(--space-2); +} + +.cal-action-prep { + background: var(--accent); + color: #fff; + border: none; + padding: var(--space-2) var(--space-3); + border-radius: 6px; + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} + +.cal-action-prep:hover { + opacity: 0.9; +} + +.cal-action-prep:active { + transform: scale(0.98); +} + .cal-detail-link { display: inline-block; - margin-top: var(--space-1h); color: var(--accent); font-size: var(--text-xs); text-decoration: none; + padding: var(--space-1) 0; } .cal-detail-link:hover { diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index 02dc35a2..8e5ee23f 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -56,12 +56,14 @@ export interface SendMessageOptions { extraTools?: string; isolation?: boolean; telosTaskId?: string; + agentName?: string; } export interface PendingSession { prompt: string; context: string; telosTaskId?: string; + agentName?: string; } export interface MitzoStoreState { @@ -317,6 +319,7 @@ export function createMitzoStore(options: MitzoStoreOptions): StoreApi ({ startChat: vi.fn(), @@ -2742,3 +2743,107 @@ describe('dispatchV2Message session_suspend', () => { expect(sessionReg.suspend).toHaveBeenCalledWith('conn-1:sess-1', 5); }); }); +// ─── handleSendV2 agentName parameter ──────────────────────────────────────── + +describe('handleSendV2 agentName', () => { + it('forwards agentName from message to startChat', () => { + (startChat as ReturnType).mockClear(); + const ctx = createContext(); + const transport = mockTransport(); + ctx.connRegistry.register('c1', transport); + + handleSendV2( + 'c1', + transport, + { + type: 'send' as const, + sessionId: null, + prompt: 'test', + clientMsgId: 'msg-1', + agentName: 'mitzo-telos', + }, + ctx, + ); + + const callArgs = (startChat as ReturnType).mock.calls[0]; + const options = callArgs[3]; + expect(options.agentName).toBe('mitzo-telos'); + }); + + it('omits agentName when not provided in message', () => { + (startChat as ReturnType).mockClear(); + const ctx = createContext(); + const transport = mockTransport(); + ctx.connRegistry.register('c1', transport); + + handleSendV2( + 'c1', + transport, + { + type: 'send' as const, + sessionId: null, + prompt: 'test', + clientMsgId: 'msg-1', + }, + ctx, + ); + + const callArgs = (startChat as ReturnType).mock.calls[0]; + const options = callArgs[3]; + expect(options.agentName).toBeUndefined(); + }); + + it('rejects path traversal attempts via schema validation', () => { + const ctx = createContext(); + const transport = mockTransport(); + ctx.connRegistry.register('c1', transport); + + // The Zod schema should reject these before handleSendV2 is called + // This test documents the expected validation behavior + expect(() => + V2SendMessage.parse({ + type: 'send', + sessionId: null, + prompt: 'test', + clientMsgId: 'msg-1', + agentName: '../../etc/passwd', + }), + ).toThrow(); + + expect(() => + V2SendMessage.parse({ + type: 'send', + sessionId: null, + prompt: 'test', + clientMsgId: 'msg-1', + agentName: '../secrets', + }), + ).toThrow(); + + expect(() => + V2SendMessage.parse({ + type: 'send', + sessionId: null, + prompt: 'test', + clientMsgId: 'msg-1', + agentName: 'mitzo/evil', + }), + ).toThrow(); + }); + + it('accepts valid agent names', () => { + // These should all parse successfully + const validNames = ['mitzo-telos', 'mitzo-calendar', 'mitzo_test', 'agent123', 'a-b_c-1']; + + validNames.forEach((name) => { + const result = V2SendMessage.parse({ + type: 'send', + sessionId: null, + prompt: 'test', + clientMsgId: 'msg-1', + agentName: name, + }); + expect(result.agentName).toBe(name); + }); + }); +}); diff --git a/server/chat.ts b/server/chat.ts index 819914f9..49820893 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -36,6 +36,7 @@ import { SESSION_MESSAGES_LIMIT, USER_CLOSEOUT_TIMEOUT_MS, ZERO_TURN_GRACE_MS, + DEFAULT_AGENT_NAME, } from './constants.js'; import { INTERNAL_TOKEN } from './internal-token.js'; import { buildTaskSystemPrompt } from './task-context.js'; @@ -570,6 +571,7 @@ export async function startChat( clientMsgId?: string; onSessionResolved?: (sessionId: string) => void; telosTaskId?: string; + agentName?: string; }, ) { return withSpanAsync( @@ -599,6 +601,7 @@ async function _startChatInner( clientMsgId?: string; onSessionResolved?: (sessionId: string) => void; telosTaskId?: string; + agentName?: string; }, ) { const abortController = new AbortController(); @@ -722,6 +725,8 @@ async function _startChatInner( // Build session env with worktree paths for the agent (all repos including primary) const sessionEnv = sdkEnv(); sessionEnv.MITZO_SESSION_ID = wtId; + const agentName = options.agentName ?? DEFAULT_AGENT_NAME; + sessionEnv.MITZO_AGENT_NAME = agentName; for (const [name, { path }] of repoWorktrees) { sessionEnv[`MITZO_REPO_${name.toUpperCase()}`] = path; } @@ -773,7 +778,7 @@ async function _startChatInner( let tokenBudget = DEFAULT_TOKEN_BUDGET; try { if (compileModule.loadAgentDefinition) { - const recipePath = join(cwd, '.agents', 'mitzo-conversational.yaml'); + const recipePath = join(cwd, '.agents', `${agentName}.yaml`); const def = (await compileModule.loadAgentDefinition(recipePath)) as Record< string, unknown diff --git a/server/constants.ts b/server/constants.ts index 0b0aeb64..fb977b38 100644 --- a/server/constants.ts +++ b/server/constants.ts @@ -42,6 +42,9 @@ export const SHUTDOWN_GRACE_MS = 5_000; export const GUARD_STATS_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes export const WORKTREE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours +// --- Agent Selection --- +export const DEFAULT_AGENT_NAME = 'mitzo-conversational'; + // --- Session List --- /** Grace period for zero-turn inactive sessions — recently created ones stay visible. */ export const ZERO_TURN_GRACE_MS = 60 * 60 * 1000; // 1 hour diff --git a/server/ws-handler-v2.ts b/server/ws-handler-v2.ts index e56969d3..8bc24877 100644 --- a/server/ws-handler-v2.ts +++ b/server/ws-handler-v2.ts @@ -449,6 +449,7 @@ export function handleSendV2( contextBlocks: msg.contextBlocks, clientMsgId: msg.clientMsgId, telosTaskId: msg.telosTaskId, + agentName: msg.agentName, }); applySkillPolicy(sessionClientId); } else { @@ -469,6 +470,7 @@ export function handleSendV2( clientMsgId: msg.clientMsgId, onSessionResolved, telosTaskId: msg.telosTaskId, + agentName: msg.agentName, }); applySkillPolicy(sessionClientId); }