diff --git a/dashboard/src/lib/components/views/ChatView.svelte b/dashboard/src/lib/components/views/ChatView.svelte index 1f6bf9e..f9e9745 100644 --- a/dashboard/src/lib/components/views/ChatView.svelte +++ b/dashboard/src/lib/components/views/ChatView.svelte @@ -1,36 +1,30 @@ @@ -114,54 +192,217 @@ - + + {#if phaseLabel} +
+ {#if plannerStore.phase === 'sending' || plannerStore.phase === 'starting' || plannerStore.phase === 'submitting'} + + {:else} + + {/if} + {phaseLabel} + + {#if plannerStore.phase === 'submitted'} + + {/if} +
+ {/if} + +
- {#each messages as msg (msg)} + + {#if plannerStore.messages.length === 0} +
+
+ ◈ +
+
+ Plan a task for the agent team +
+
+ Describe what you want built. The Planner will validate your request and ensure the + task spec is complete before routing to the Architect. +
+
+ {/if} + + + {#each plannerStore.messages as msg, i (i)}
{#if msg.role !== 'event'}
- {msg.role === 'user' ? 'You' : 'Orchestrator'} | {msg.time} + {msgLabel(msg)} | {msg.time}
{/if} - {msg.text} + + {#if msg.role === 'planner'} + {#each msg.text.split('\n') as line, li (li)} + {#if line.trim() === ''} +
+ {:else} +

{line}

+ {/if} + {/each} + {:else} + {msg.text} + {/if}
{/each} + + + {#if showChecklist && plannerStore.checklist} + {@const cl = plannerStore.checklist} +
+
+ + Task Readiness + + + {cl.required_satisfied ? 'READY' : 'INCOMPLETE'} + +
+
+ {#each cl.items as item (item.field)} + {@const isReq = item.priority === 'required'} + {@const isRec = item.priority === 'recommended'} +
+ + + {item.satisfied ? '\u2713' : isReq ? '!' : '\u00b7'} + + + + + {item.field} + + + + + {item.priority} + + + + {#if item.satisfied && item.value} + + {#if Array.isArray(item.value)} + {item.value.join(', ')} + {:else} + {typeof item.value === 'string' && item.value.length > 60 + ? item.value.slice(0, 60) + '...' + : item.value} + {/if} + + {/if} + + + {#if item.auto_inferred} + + auto + + {/if} +
+ {/each} +
+
+ {/if}
- + + {#if submitEnabled} +
+ + Task spec complete — ready to submit to Architect + + +
+ {/if} + +
diff --git a/dashboard/src/lib/sse.ts b/dashboard/src/lib/sse.ts index 07dc8e3..f1ff42d 100644 --- a/dashboard/src/lib/sse.ts +++ b/dashboard/src/lib/sse.ts @@ -16,13 +16,15 @@ * * Issue #37 * Issue #85: Added tool_call event handling + * Issue #106 Phase B: Added planner_message event handling */ import { connection } from '$lib/stores/connection.svelte.js'; import { agentsStore } from '$lib/stores/agents.svelte.js'; import { tasksStore } from '$lib/stores/tasks.svelte.js'; import { memoryStore } from '$lib/stores/memory.svelte.js'; -import type { SSEEventType, ToolCallEvent } from '$lib/types/api.js'; +import { plannerStore } from '$lib/stores/planner.svelte.js'; +import type { SSEEventType, ToolCallEvent, PlannerMessageEvent } from '$lib/types/api.js'; /** Backoff config */ const INITIAL_DELAY_MS = 1000; @@ -54,6 +56,21 @@ function isToolCallEvent(payload: Record): payload is ToolCallE ); } +/** + * Type guard for PlannerMessageEvent payloads. + * Issue #106 Phase B. Validates all required fields including + * the warnings array (CodeRabbit fix #2). + */ +function isPlannerMessageEvent(payload: Record): payload is PlannerMessageEvent { + return ( + typeof payload.session_id === 'string' && + typeof payload.message === 'string' && + typeof payload.ready === 'boolean' && + Array.isArray(payload.warnings) && + (payload.warnings as unknown[]).every((w) => typeof w === 'string') + ); +} + /** * Route an SSE event to the appropriate store handler. */ @@ -89,6 +106,12 @@ function dispatch(eventType: SSEEventType, payload: Record) { } break; + case 'planner_message': + if (isPlannerMessageEvent(payload)) { + plannerStore.handleSSE(payload); + } + break; + case 'log_line': // Log lines are consumed by the BottomPanel directly. // For now, dispatch a custom DOM event that components can listen to. @@ -163,7 +186,8 @@ function connect() { 'task_complete', 'memory_added', 'log_line', - 'tool_call' + 'tool_call', + 'planner_message' ]; for (const type of eventTypes) { diff --git a/dashboard/src/lib/stores/index.ts b/dashboard/src/lib/stores/index.ts index 03202b9..113f706 100644 --- a/dashboard/src/lib/stores/index.ts +++ b/dashboard/src/lib/stores/index.ts @@ -5,6 +5,7 @@ * Issue #38: initAllStores() + destroyAllStores() * Issue #51: Removed mock mode — always live * Issue #106: Added workspacesStore + * Issue #106 Phase B: Added plannerStore */ export { agentsStore } from './agents.svelte.js'; @@ -13,12 +14,14 @@ export { memoryStore } from './memory.svelte.js'; export { prsStore } from './prs.svelte.js'; export { connection } from './connection.svelte.js'; export { workspacesStore } from './workspaces.svelte.js'; +export { plannerStore } from './planner.svelte.js'; import { agentsStore } from './agents.svelte.js'; import { tasksStore } from './tasks.svelte.js'; import { memoryStore } from './memory.svelte.js'; import { prsStore } from './prs.svelte.js'; import { workspacesStore } from './workspaces.svelte.js'; +import { plannerStore } from './planner.svelte.js'; /** * Kick off initial data fetch for all stores. @@ -48,4 +51,5 @@ export function destroyAllStores(): void { memoryStore.reset(); prsStore.reset(); workspacesStore.reset(); + plannerStore.reset(); } diff --git a/dashboard/src/lib/stores/planner.svelte.ts b/dashboard/src/lib/stores/planner.svelte.ts new file mode 100644 index 0000000..19d365d --- /dev/null +++ b/dashboard/src/lib/stores/planner.svelte.ts @@ -0,0 +1,344 @@ +/** + * Planner store — reactive Planner session state. + * + * Manages the conversational planner flow: + * 1. Start session (POST /api/planner) with workspace + * 2. Exchange messages (POST /api/planner/{id}/message) + * 3. Submit to Architect (POST /api/planner/{id}/submit) + * + * Updated in real-time from SSE `planner_message` events. + * + * Issue #106 Phase B: ChatView planner UI + */ + +import type { + PlannerSessionResponse, + PlannerSubmitResponse, + PlannerChecklist, + PlannerTaskSpec, + PlannerMessageEvent +} from '$lib/types/api.js'; + +// -- Chat message type for display -- + +export interface PlannerChatMessage { + role: 'user' | 'planner' | 'system' | 'event'; + text: string; + time: string; +} + +// -- Planner session phases -- + +export type PlannerPhase = + | 'idle' // No active session + | 'starting' // POST /api/planner in flight + | 'chatting' // Active session, exchanging messages + | 'sending' // POST /api/planner/{id}/message in flight + | 'submitting' // POST /api/planner/{id}/submit in flight + | 'submitted'; // Session submitted, task created + +// -- State -- + +let sessionId = $state(null); +let phase = $state('idle'); +let messages = $state([]); +let taskSpec = $state(null); +let checklist = $state(null); +let ready = $state(false); +let warnings = $state([]); +let error = $state(null); +let submittedTaskId = $state(null); + +/** + * Generation counter for stale-response detection. + * Incremented on reset() and startSession(). Any async method + * captures the current value before awaiting and bails if it + * changed by the time the response arrives. + * (CodeRabbit fix #3) + */ +let requestGeneration = $state(0); + +/** Format current time as HH:MM. */ +function nowTime(): string { + return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +/** Apply a PlannerSessionResponse to local state. */ +function applySession(resp: PlannerSessionResponse) { + sessionId = resp.session_id; + taskSpec = resp.task_spec; + checklist = resp.checklist; + ready = resp.ready; + warnings = resp.warnings ?? []; +} + +export const plannerStore = { + // -- Getters -- + + get sessionId() { return sessionId; }, + get phase() { return phase; }, + get messages() { return messages; }, + get taskSpec() { return taskSpec; }, + get checklist() { return checklist; }, + get ready() { return ready; }, + get warnings() { return warnings; }, + get error() { return error; }, + get submittedTaskId() { return submittedTaskId; }, + + /** Whether we have an active (non-submitted) session. */ + get hasActiveSession(): boolean { + return sessionId !== null && phase !== 'idle' && phase !== 'submitted'; + }, + + /** Whether the user can type a message. */ + get canSend(): boolean { + return phase === 'chatting'; + }, + + /** Whether the submit button should be available. */ + get canSubmit(): boolean { + return phase === 'chatting' && ready; + }, + + // -- Actions -- + + /** + * Start a new Planner session for a workspace. + * + * Calls POST /api/planner with the workspace path. + * On success, transitions to 'chatting' phase with the + * initial system message from the Planner. + */ + async startSession( + workspace: string, + options?: { pin?: string } + ): Promise { + const generation = ++requestGeneration; + error = null; + phase = 'starting'; + + // Reset previous session + messages = []; + taskSpec = null; + checklist = null; + ready = false; + warnings = []; + submittedTaskId = null; + + try { + const payload: Record = { workspace }; + if (options?.pin) payload.pin = options.pin; + + const res = await fetch('/api/planner', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const body = await res.json(); + + // Stale-response guard (CodeRabbit fix #3) + if (generation !== requestGeneration) return false; + + if (res.ok && body.data) { + const resp = body.data as PlannerSessionResponse; + applySession(resp); + phase = 'chatting'; + + // Add the initial system message + if (resp.message) { + messages = [{ + role: 'system', + text: resp.message, + time: nowTime() + }]; + } + + return true; + } + + error = body.errors?.[0] ?? 'Failed to start planner session'; + phase = 'idle'; + return false; + } catch (err) { + if (generation !== requestGeneration) return false; + error = err instanceof Error ? err.message : 'Network error'; + phase = 'idle'; + return false; + } + }, + + /** + * Send a user message to the Planner. + * + * Adds the user message to chat immediately, then calls + * POST /api/planner/{id}/message. On success, adds the + * Planner's response and updates checklist state. + */ + async sendMessage(text: string): Promise { + if (!sessionId || phase !== 'chatting') return false; + + const generation = requestGeneration; + const currentSessionId = sessionId; + error = null; + + // Add user message immediately + messages = [...messages, { + role: 'user', + text, + time: nowTime() + }]; + + phase = 'sending'; + + try { + const res = await fetch(`/api/planner/${currentSessionId}/message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text }) + }); + const body = await res.json(); + + // Stale-response guard (CodeRabbit fix #3) + if (generation !== requestGeneration || currentSessionId !== sessionId) return false; + + if (res.ok && body.data) { + const resp = body.data as PlannerSessionResponse; + applySession(resp); + phase = 'chatting'; + + // Add planner response + if (resp.message) { + messages = [...messages, { + role: 'planner', + text: resp.message, + time: nowTime() + }]; + } + + // Add warnings as event messages + if (resp.warnings?.length) { + for (const w of resp.warnings) { + messages = [...messages, { + role: 'event', + text: w, + time: nowTime() + }]; + } + } + + return true; + } + + error = body.errors?.[0] ?? 'Failed to send message'; + phase = 'chatting'; // Allow retry + messages = [...messages, { + role: 'event', + text: error ?? 'Message failed', + time: nowTime() + }]; + return false; + } catch (err) { + if (generation !== requestGeneration || currentSessionId !== sessionId) return false; + error = err instanceof Error ? err.message : 'Network error'; + phase = 'chatting'; // Allow retry + messages = [...messages, { + role: 'event', + text: error ?? 'Network error', + time: nowTime() + }]; + return false; + } + }, + + /** + * Submit the current session to the Architect. + * + * Calls POST /api/planner/{id}/submit. On success, creates + * the task and transitions to 'submitted' phase. + */ + async submit(): Promise { + if (!sessionId || !ready) return null; + + const generation = requestGeneration; + const currentSessionId = sessionId; + error = null; + phase = 'submitting'; + + try { + const res = await fetch(`/api/planner/${currentSessionId}/submit`, { + method: 'POST' + }); + const body = await res.json(); + + // Stale-response guard (CodeRabbit fix #3) + if (generation !== requestGeneration || currentSessionId !== sessionId) return null; + + if (res.ok && body.data) { + const resp = body.data as PlannerSubmitResponse; + submittedTaskId = resp.task_id; + phase = 'submitted'; + + messages = [...messages, { + role: 'system', + text: `Task submitted (${resp.task_id}). Routing to Architect for blueprint generation...`, + time: nowTime() + }]; + + return resp.task_id; + } + + error = body.errors?.[0] ?? 'Failed to submit task'; + phase = 'chatting'; // Allow retry + messages = [...messages, { + role: 'event', + text: error ?? 'Submit failed', + time: nowTime() + }]; + return null; + } catch (err) { + if (generation !== requestGeneration || currentSessionId !== sessionId) return null; + error = err instanceof Error ? err.message : 'Network error'; + phase = 'chatting'; // Allow retry + messages = [...messages, { + role: 'event', + text: error ?? 'Network error', + time: nowTime() + }]; + return null; + } + }, + + /** + * Handle an SSE planner_message event. + * + * This fires when the Planner responds via SSE (in addition to + * the HTTP response). Useful for updating state if another tab + * is interacting with the same session. + */ + handleSSE(data: PlannerMessageEvent) { + if (data.session_id !== sessionId) return; + + ready = data.ready; + warnings = data.warnings ?? []; + + // Don't add a duplicate message — the HTTP response already + // added it. SSE is for cross-tab sync only. + }, + + /** + * Reset to idle state. Discards the current session. + * Increments requestGeneration to invalidate any in-flight responses. + */ + reset() { + requestGeneration++; + sessionId = null; + phase = 'idle'; + messages = []; + taskSpec = null; + checklist = null; + ready = false; + warnings = []; + error = null; + submittedTaskId = null; + } +}; diff --git a/dashboard/src/lib/types/api.ts b/dashboard/src/lib/types/api.ts index 24e799e..37904b8 100644 --- a/dashboard/src/lib/types/api.ts +++ b/dashboard/src/lib/types/api.ts @@ -9,6 +9,7 @@ * Issue #85: Added tool_call SSE event type and ToolCallEvent interface * Issue #106: Added workspace types, updated CreateTaskRequest * Issue #106 Phase A: Added BrowseDirectoryEntry, BrowseDirectoryResponse + * Issue #106 Phase B: Added Planner types, planner_message SSE event */ // -- Envelope -- @@ -208,7 +209,8 @@ export type SSEEventType = | 'task_complete' | 'memory_added' | 'log_line' - | 'tool_call'; + | 'tool_call' + | 'planner_message'; export interface ToolCallEvent { task_id: string; @@ -218,6 +220,13 @@ export interface ToolCallEvent { result_preview: string; } +export interface PlannerMessageEvent { + session_id: string; + message: string; + ready: boolean; + warnings: string[]; +} + export interface SSEEventData { type: SSEEventType; timestamp: string; @@ -253,3 +262,48 @@ export interface BrowseDirectoryResponse { parent_path: string | null; entries: BrowseDirectoryEntry[]; } + +// -- Planner (Issue #106 Phase B) -- + +export interface PlannerChecklistItem { + field: string; + priority: 'required' | 'recommended' | 'optional'; + satisfied: boolean; + auto_inferred: boolean; + value: string | string[] | null; +} + +export interface PlannerChecklist { + items: PlannerChecklistItem[]; + required_satisfied: boolean; + has_warnings: boolean; + missing_required: string[]; + missing_recommended: string[]; +} + +export interface PlannerTaskSpec { + workspace: string; + objective: string; + languages: string[]; + frameworks: string[]; + output_type: string | null; + acceptance_criteria: string[]; + constraints: string[]; + related_files: string[]; +} + +export interface PlannerSessionResponse { + session_id: string; + message: string; + task_spec: PlannerTaskSpec; + checklist: PlannerChecklist; + ready: boolean; + warnings: string[]; +} + +export interface PlannerSubmitResponse { + session_id: string; + task_id: string; + status: TaskStatus; + description: string; +} diff --git a/dashboard/src/routes/api/planner/+server.ts b/dashboard/src/routes/api/planner/+server.ts new file mode 100644 index 0000000..e53a9a6 --- /dev/null +++ b/dashboard/src/routes/api/planner/+server.ts @@ -0,0 +1,28 @@ +/** + * POST /api/planner — proxy to FastAPI POST /tasks/plan + * + * Starts a new Planner session with workspace context. + * + * Issue #106 Phase B + */ +import { json } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import type { PlannerSessionResponse } from '$lib/types/api.js'; +import type { RequestHandler } from './$types.js'; + +export const POST: RequestHandler = async ({ request }) => { + let body: unknown; + try { + body = await request.json(); + } catch { + return json({ data: null, errors: ['Invalid JSON body'] }, { status: 400 }); + } + const result = await apiFetch('/tasks/plan', { + method: 'POST', + body: JSON.stringify(body) + }); + if (!result.ok) { + return json({ data: null, errors: result.errors }, { status: result.status }); + } + return json({ data: result.data, errors: [] }, { status: 201 }); +}; diff --git a/dashboard/src/routes/api/planner/[id]/message/+server.ts b/dashboard/src/routes/api/planner/[id]/message/+server.ts new file mode 100644 index 0000000..6b26e1f --- /dev/null +++ b/dashboard/src/routes/api/planner/[id]/message/+server.ts @@ -0,0 +1,31 @@ +/** + * POST /api/planner/[id]/message — proxy to FastAPI POST /tasks/plan/{id}/message + * + * Sends a user message to an existing Planner session. + * + * Issue #106 Phase B + */ +import { json } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import type { PlannerSessionResponse } from '$lib/types/api.js'; +import type { RequestHandler } from './$types.js'; + +export const POST: RequestHandler = async ({ request, params }) => { + let body: unknown; + try { + body = await request.json(); + } catch { + return json({ data: null, errors: ['Invalid JSON body'] }, { status: 400 }); + } + const result = await apiFetch( + `/tasks/plan/${params.id}/message`, + { + method: 'POST', + body: JSON.stringify(body) + } + ); + if (!result.ok) { + return json({ data: null, errors: result.errors }, { status: result.status }); + } + return json({ data: result.data, errors: [] }); +}; diff --git a/dashboard/src/routes/api/planner/[id]/submit/+server.ts b/dashboard/src/routes/api/planner/[id]/submit/+server.ts new file mode 100644 index 0000000..b58f933 --- /dev/null +++ b/dashboard/src/routes/api/planner/[id]/submit/+server.ts @@ -0,0 +1,22 @@ +/** + * POST /api/planner/[id]/submit — proxy to FastAPI POST /tasks/plan/{id}/submit + * + * Submits a Planner session to the Architect, creating a task. + * + * Issue #106 Phase B + */ +import { json } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import type { PlannerSubmitResponse } from '$lib/types/api.js'; +import type { RequestHandler } from './$types.js'; + +export const POST: RequestHandler = async ({ params }) => { + const result = await apiFetch( + `/tasks/plan/${params.id}/submit`, + { method: 'POST' } + ); + if (!result.ok) { + return json({ data: null, errors: result.errors }, { status: result.status }); + } + return json({ data: result.data, errors: [] }); +};