diff --git a/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx b/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx index 76c1c36629..d469e136ec 100644 --- a/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx +++ b/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx @@ -193,7 +193,12 @@ export const InvocationMessageBubble: FunctionComponent - + )} {widgetData.type === "external_reference" && widgetData.externalReference && ( diff --git a/dashboard/src/v2/components/chat/widgets/PlanningRequestWidget.tsx b/dashboard/src/v2/components/chat/widgets/PlanningRequestWidget.tsx index 64bd7d4b61..9b6b5cce51 100644 --- a/dashboard/src/v2/components/chat/widgets/PlanningRequestWidget.tsx +++ b/dashboard/src/v2/components/chat/widgets/PlanningRequestWidget.tsx @@ -3,13 +3,18 @@ import { AlertTriangle, CheckCircle2, Circle, Clock3, Loader2, PauseCircle, XCir import { ChatWidgetFrame, type ExecutionStatus } from "./ChatWidgetFrame.js"; import { ContainerShip } from "../../ui/PlanningShip.js"; import { ChatRuntimeBadge } from "../ChatRuntimeBadge.js"; -import type { LivePlanningTaskState, LivePlanningWidgetState } from "../../../lib/chat-widget-view-models.js"; +import type { + LivePlanningTaskState, + LivePlanningWidgetState, + PlanningExecutionPlanWidgetState, +} from "../../../lib/chat-widget-view-models.js"; export interface PlanningRequestWidgetProps { status: ExecutionStatus; planName: string; isDark?: boolean; liveStatus?: LivePlanningWidgetState; + executionPlan?: PlanningExecutionPlanWidgetState; } const statusTone: Record = { @@ -127,11 +132,88 @@ const LivePlanningStatusCard: FunctionComponent<{ liveStatus: LivePlanningWidget ); }; +const PersistedExecutionPlanCard: FunctionComponent<{ executionPlan: PlanningExecutionPlanWidgetState }> = ({ executionPlan }) => { + const visibleTasks = executionPlan.tasks.slice(0, 5); + const hiddenTaskCount = Math.max(0, executionPlan.tasks.length - visibleTasks.length); + const visibleCreatedTaskIds = visibleTasks.length === 0 ? executionPlan.createdTaskIds.slice(0, 5) : []; + const hiddenCreatedTaskCount = visibleTasks.length === 0 + ? Math.max(0, executionPlan.createdTaskIds.length - visibleCreatedTaskIds.length) + : 0; + + return ( +
+ {executionPlan.ariaLabel} +
+
+ {executionPlan.sprintKey ? ( +
+ {executionPlan.sprintKey} +
+ ) : null} +
+ {executionPlan.sprintName} +
+
+
+ {executionPlan.taskSummaryLabel} +
+
+ + {executionPlan.goal ? ( +

+ {executionPlan.goal} +

+ ) : null} + + {visibleTasks.length > 0 ? ( +
    + {visibleTasks.map((task) => ( +
  • +
    + {task.id} + {task.title} +
    + {task.summary ? ( +
    {task.summary}
    + ) : null} +
  • + ))} + {hiddenTaskCount > 0 ? ( +
  • + {hiddenTaskCount} more task{hiddenTaskCount === 1 ? "" : "s"} in this execution plan +
  • + ) : null} +
+ ) : visibleCreatedTaskIds.length > 0 ? ( +
+ {visibleCreatedTaskIds.map((taskId) => ( + + {taskId} + + ))} + {hiddenCreatedTaskCount > 0 ? ( + + +{hiddenCreatedTaskCount} more + + ) : null} +
+ ) : null} +
+ ); +}; + export const PlanningRequestWidget: FunctionComponent = ({ status, planName, isDark = true, liveStatus, + executionPlan, }) => { return ( {liveStatus ? ( + ) : executionPlan ? ( + ) : (
{status === 'running' || status === 'queued' ? ( diff --git a/dashboard/src/v2/lib/chat-widget-view-models.ts b/dashboard/src/v2/lib/chat-widget-view-models.ts index f34d8b29e5..82c03ee9e8 100644 --- a/dashboard/src/v2/lib/chat-widget-view-models.ts +++ b/dashboard/src/v2/lib/chat-widget-view-models.ts @@ -25,6 +25,7 @@ export interface ChatWidgetState { planName: string; targetWorker?: string; liveStatus?: LivePlanningWidgetState; + executionPlan?: PlanningExecutionPlanWidgetState; externalReference?: ExternalReferenceWidgetState; suppressBodyMarkdown?: boolean; } @@ -91,6 +92,25 @@ export interface LivePlanningWidgetState { tasks: LivePlanningTaskState[]; } +export interface PlanningExecutionPlanTaskSummaryState { + id: string; + title: string; + summary: string | null; +} + +export interface PlanningExecutionPlanWidgetState { + sprintId: string | null; + sprintNumber: number | null; + sprintKey: string | null; + sprintName: string; + goal: string | null; + taskCount: number; + createdTaskIds: string[]; + tasks: PlanningExecutionPlanTaskSummaryState[]; + taskSummaryLabel: string; + ariaLabel: string; +} + export interface ChatWidgetLiveData { projectId: string | null; projectTasks?: Task[] | null; @@ -187,6 +207,12 @@ const readArray = (value: unknown): unknown[] => ( Array.isArray(value) ? value : [] ); +const readStringArray = (value: unknown): string[] => ( + readArray(value) + .map((entry) => readString(entry)) + .filter((entry): entry is string => Boolean(entry)) +); + const readFirstString = (...values: unknown[]): string | null => { for (const value of values) { const stringValue = readString(value); @@ -361,6 +387,161 @@ const formatStatusLabel = (value: string | null | undefined): string => { .replace(/\b\w/g, (letter) => letter.toUpperCase()); }; +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const formatExecutionPlanName = (executionPlan: PlanningExecutionPlanWidgetState): string => { + if (!executionPlan.sprintKey) { + return executionPlan.sprintName; + } + const prefixPattern = new RegExp(`^${escapeRegExp(executionPlan.sprintKey)}\\s*[:\\-]?\\s*`, "i"); + const normalizedName = executionPlan.sprintName.replace(prefixPattern, "").trim(); + if (!normalizedName || normalizedName === executionPlan.sprintKey) { + return executionPlan.sprintKey; + } + return `${executionPlan.sprintKey}: ${normalizedName}`; +}; + +const readExecutionPlanTaskSummaries = ( + executionPlan: Record, + createdTaskIds: string[], +): PlanningExecutionPlanTaskSummaryState[] => { + const candidates = [ + executionPlan.taskSummaries, + executionPlan.task_summaries, + executionPlan.tasks, + executionPlan.createdTasks, + executionPlan.created_tasks, + ]; + const rawTasks = candidates.find((candidate) => readArray(candidate).length > 0); + return readArray(rawTasks) + .map((entry, index): PlanningExecutionPlanTaskSummaryState | null => { + const record = readRecord(entry); + if (!record) { + const title = readString(entry); + return title ? { id: createdTaskIds[index] ?? `task-${index + 1}`, title, summary: null } : null; + } + + const id = readFirstString( + record.id, + record.taskId, + record.task_id, + record.key, + record.taskKey, + record.task_key, + createdTaskIds[index], + ) ?? `task-${index + 1}`; + const title = readFirstString(record.title, record.name, record.summary, record.description, id); + if (!title) { + return null; + } + + const summary = readFirstString( + record.summary, + record.description, + record.promptSummary, + record.prompt_summary, + ); + return { + id, + title, + summary: summary && summary !== title ? summary : null, + }; + }) + .filter((entry): entry is PlanningExecutionPlanTaskSummaryState => Boolean(entry)); +}; + +const formatExecutionPlanTaskSummaryLabel = ( + taskCount: number, + createdTaskIds: string[], + tasks: PlanningExecutionPlanTaskSummaryState[], +): string => { + const effectiveTaskCount = taskCount || tasks.length || createdTaskIds.length; + const plannedLabel = `${effectiveTaskCount} planned task${effectiveTaskCount === 1 ? "" : "s"}`; + if (createdTaskIds.length > 0 && createdTaskIds.length !== effectiveTaskCount) { + return `${plannedLabel}, ${createdTaskIds.length} created`; + } + return plannedLabel; +}; + +const readExecutionPlanState = ( + metadata: Record | null | undefined, + widgetMetadata: Record | null, +): PlanningExecutionPlanWidgetState | null => { + const executionPlan = readRecord(metadata?.executionPlan) + ?? readRecord(metadata?.execution_plan) + ?? readRecord(widgetMetadata?.executionPlan) + ?? readRecord(widgetMetadata?.execution_plan); + if (!executionPlan) { + return null; + } + + const sprintId = readFirstString(executionPlan.sprintId, executionPlan.sprint_id); + const sprintNumber = readFirstNumber(executionPlan.sprintNumber, executionPlan.sprint_number); + const sprintKey = readFirstString(executionPlan.sprintKey, executionPlan.sprint_key) + ?? (sprintNumber !== null ? `SPR-${sprintNumber}` : sprintId); + const goal = readFirstString(executionPlan.goal); + const createdTaskIds = [ + ...new Set([ + ...readStringArray(executionPlan.createdTaskIds), + ...readStringArray(executionPlan.created_task_ids), + ]), + ]; + const tasks = readExecutionPlanTaskSummaries(executionPlan, createdTaskIds); + const rawTaskCount = readFirstNumber(executionPlan.taskCount, executionPlan.task_count); + const taskCount = rawTaskCount !== null && rawTaskCount >= 0 + ? Math.trunc(rawTaskCount) + : tasks.length || createdTaskIds.length; + const sprintName = readFirstString(executionPlan.sprintName, executionPlan.sprint_name) + ?? sprintKey + ?? "Execution Plan"; + + const hasPlanDetails = Boolean( + sprintId + || sprintNumber !== null + || sprintKey + || goal + || taskCount > 0 + || createdTaskIds.length > 0 + || tasks.length > 0 + || readString(executionPlan.sprintName) + || readString(executionPlan.sprint_name), + ); + if (!hasPlanDetails) { + return null; + } + + const taskSummaryLabel = formatExecutionPlanTaskSummaryLabel(taskCount, createdTaskIds, tasks); + const ariaParts = ["Planning execution plan", formatExecutionPlanName({ + sprintId, + sprintNumber, + sprintKey, + sprintName, + goal, + taskCount, + createdTaskIds, + tasks, + taskSummaryLabel, + ariaLabel: "", + })]; + if (goal) { + ariaParts.push(`Goal ${goal}`); + } + ariaParts.push(taskSummaryLabel); + + return { + sprintId, + sprintNumber, + sprintKey, + sprintName, + goal, + taskCount, + createdTaskIds, + tasks, + taskSummaryLabel, + ariaLabel: ariaParts.join(". "), + }; +}; + const normalizeExternalProviderValue = (value: unknown): ExternalReferenceProvider | null => { const normalized = readString(value)?.toLowerCase().replace(/[\s_-]+/g, "") ?? ""; if (normalized.includes("jira") || normalized.includes("atlassian")) { @@ -952,17 +1133,21 @@ const extractWidgetStateFromMetadata = ( liveData?: ChatWidgetLiveData, ): ChatWidgetState => { const widgetMetadata = getWidgetMetadata(metadata); + const executionPlan = readExecutionPlanState(metadata, widgetMetadata); if (widgetMetadata && widgetMetadata.type === "planning_request") { const status = (widgetMetadata.status as ExecutionStatus) || (metadata?.status as ExecutionStatus) || "completed"; - const planName = (widgetMetadata.route_path as string) || (metadata?.planName as string) || (metadata?.title as string) || "Execution Plan"; + const planName = executionPlan + ? formatExecutionPlanName(executionPlan) + : (widgetMetadata.route_path as string) || (metadata?.planName as string) || (metadata?.title as string) || "Execution Plan"; const targetWorker = widgetMetadata.target_worker as string | undefined; - const liveStatus = buildLivePlanningWidgetState(metadata, status, planName, liveData); + const liveStatus = executionPlan ? null : buildLivePlanningWidgetState(metadata, status, planName, liveData); return { type: "planning", status: liveStatus ? mapSprintRunStatusToExecutionStatus(liveStatus.runStatus, status) : status, planName, targetWorker, + ...(executionPlan ? { executionPlan } : {}), ...(liveStatus ? { liveStatus } : {}), }; } @@ -988,12 +1173,15 @@ const extractWidgetStateFromMetadata = ( if (isPlanning || metadata.routeKind === "virtual" || metadata.routeKind === "worker") { const status = (metadata.status as ExecutionStatus) || "completed"; - const planName = (metadata.planName as string) || (metadata.title as string) || "Execution Plan"; - const liveStatus = buildLivePlanningWidgetState(metadata, status, planName, liveData); + const planName = executionPlan + ? formatExecutionPlanName(executionPlan) + : (metadata.planName as string) || (metadata.title as string) || "Execution Plan"; + const liveStatus = executionPlan ? null : buildLivePlanningWidgetState(metadata, status, planName, liveData); return { type: "planning", status: liveStatus ? mapSprintRunStatusToExecutionStatus(liveStatus.runStatus, status) : status, planName, + ...(executionPlan ? { executionPlan } : {}), ...(liveStatus ? { liveStatus } : {}), }; } diff --git a/docs-web/content/docs/developer-http-api.mdx b/docs-web/content/docs/developer-http-api.mdx index 306c06f501..2372fe011c 100644 --- a/docs-web/content/docs/developer-http-api.mdx +++ b/docs-web/content/docs/developer-http-api.mdx @@ -156,10 +156,7 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n | `PATCH` | `/api/agent-presets/:agentPresetId` | Update. | | `DELETE` | `/api/agent-presets/:agentPresetId` | Delete. | | `POST` | `/api/agent-presets/:agentPresetId/import-markdown` | Import from a single file. | -| `POST` | `/api/agent-presets/:agentPresetId/export-markdown` | Export one sqlite preset to the project `.code-ux/agents/` directory. | -| `POST` | `/api/projects/:projectId/agent-presets/sync-markdown` | Backward-compatible bulk pull from `.code-ux/agents/`. | -| `POST` | `/api/projects/:projectId/agent-presets/pull-markdown` | Explicitly discover/import project markdown into sqlite. | -| `POST` | `/api/projects/:projectId/agent-presets/push-markdown` | Export sqlite presets to project markdown when mirroring is enabled. | +| `POST` | `/api/projects/:projectId/agent-presets/sync-markdown` | Bulk-sync from `.code-ux/agents/`. | --- diff --git a/docs-web/content/docs/registry.ts b/docs-web/content/docs/registry.ts index 8127c785e3..edfd4357c3 100644 --- a/docs-web/content/docs/registry.ts +++ b/docs-web/content/docs/registry.ts @@ -178,7 +178,7 @@ export const docsRegistry: Record = { path: '/docs/user-dashboard-chat', section: 'User Guide', title: "Chat", - description: "The Chat page (/chat) is a conversation surface that lets you talk to agents for project-backed Q&A, inspect MCP tool invocations, and get local onboarding help before any project exists.", + description: "The Chat page (/chat) is a thread-based conversation surface that lets you talk to agents for project-backed Q&A, inspect execution invocation transcripts and MCP tool invocations, and get local onboarding help before...", }, 'user-dashboard-agents': { id: 'user-dashboard-agents', diff --git a/docs-web/content/docs/user-dashboard-agents.mdx b/docs-web/content/docs/user-dashboard-agents.mdx index e5ee6c2901..12fd9587d9 100644 --- a/docs-web/content/docs/user-dashboard-agents.mdx +++ b/docs-web/content/docs/user-dashboard-agents.mdx @@ -8,10 +8,11 @@ An *agent preset* is a reusable persona consisting of: - A markdown **system instruction** that prepends every session this agent runs. - An optional **memory template** — controls how project / sprint memory is injected into prompts. - Optional persistent skill storage attachments, stored as shared project skill storage IDs for future retrieval. -- Optional runtime metadata such as provider/model preferences, a nullable Docker root-mode override for local CLI task runs, and optional MCP access including default-off Code UX built-in tools and custom MCP server links. +- Optional MCP access, including default-off Code UX built-in tools and custom MCP server links. +- Optional runtime metadata such as provider/model preferences and a nullable Docker root-mode override for local CLI task runs. - A set of **labels** for tagging and filtering. -Agent presets show up wherever a chat thread or planning request needs to choose an agent. +Agent presets show up wherever a chat thread or planning request needs to choose an agent. SQLite is the live authority for these presets; markdown files are the project-local import/export copy used for review and sharing. ## Project name privacy @@ -55,11 +56,15 @@ Agent presets can be defined as markdown files inside `/.code-ux/agents/

/api/realtime` for HTTPS deployments). On the server side, `DashboardRealtimeService` in `src/services/dashboard-realtime-service.ts` coordinates events, and the websocket upgrade/transport is handled in `src/server/dashboard-realtime-websocket-server.ts`. The connection: diff --git a/docs-web/content/docs/user-dashboard-settings.mdx b/docs-web/content/docs/user-dashboard-settings.mdx index a4b2275578..62241e85cf 100644 --- a/docs-web/content/docs/user-dashboard-settings.mdx +++ b/docs-web/content/docs/user-dashboard-settings.mdx @@ -269,7 +269,7 @@ Related docs: Customizes the dashboard background image, animation mode, static color, and pattern overlay. -**What it controls:** Image upload, animated/static mode, animation style, color picker, and overlay pattern shape the visual layer behind panels. Onboarding previews Theme, Navigation Mode, Reduced Motion, Background Mode, Static Color, and supported Zoom Level while it is open, while Animation Style, Pattern Overlay, and custom background image remain available here after onboarding. +**What it controls:** Background Image, Background Mode, Animation Style, Static Color, and Pattern Overlay shape the visual layer behind panels. Onboarding previews Theme, Navigation Mode, Reduced Motion, Background Mode, Static Color, and supported Zoom Level while it is open, while Animation Style, Pattern Overlay, and custom background image remain available here after onboarding. **Recommended defaults:** Prefer lightweight images and readable contrast; use static mode if motion is distracting. diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index 73e6dfec8b..12a59199db 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -89,7 +89,7 @@ The **Invocations** tab is a structured log of server-created execution invocati Use this for debugging provider runs and MCP client integrations, for example to inspect agent transcripts or see exactly what arguments your LLM is passing to tools like `manage_memory` or `manage_settings`. -Invocation transcripts use the same live sprint status card as thread messages when planning metadata links them to a sprint. This means a planning invocation and its related chat message should show consistent task progress without a separate refresh control. Parsed provider conversation turns stream into running invocation transcripts for provider-backed planning, QA review, dashboard/chat replies, CI repair, merge-conflict repair, memory remediation, setup, and task coding; text-only provider output is appended when the run completes. +Invocation transcripts use the same live sprint status card as thread messages when planning metadata links them to a sprint. Completed sprint-planning invocations append a final assistant summary with `metadata.widget_metadata.type = "planning_request"`, `status = "completed"`, and `metadata.executionPlan` for that invocation's linked sprint, including the sprint id, created task ids, and planned task titles. The plan shown in the transcript is replayed from persisted invocation message metadata, not from the currently selected sprint or the latest planning run for the project, so historical planning transcripts remain sprint-specific and stable. Parsed provider conversation turns stream into running invocation transcripts for provider-backed planning, QA review, dashboard/chat replies, CI repair, merge-conflict repair, memory remediation, setup, and task coding; text-only provider output is appended when the run completes. Invocation transcripts use the same external-reference cards as thread messages for recognized Jira, GitHub, and GitLab payloads, including JSON payloads that would otherwise appear as raw punctuation-heavy output. This keeps linked work readable while preserving the original backend metadata and message content. diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 5e610b2a07..4f69c3e2cd 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -96,6 +96,7 @@ Project management: - Sends a created sprint to the Planning agent through the configured virtual worker provider, creates subtasks from the reply, and can auto-start the sprint - Auto-start orchestration now prepares the local sprint feature branch automatically and attempts to push it to `origin` when that remote exists - Planning overrides may explicitly target a specific `planningAgentPresetId`, task coding routing mode, manual worker preset, and virtual CLI provider/model for that one request. + - Completed planning invocations persist the generated execution plan on that invocation's transcript message as `metadata.executionPlan`, scoped to the linked sprint for the request. Replaying the invocation transcript reads that persisted message metadata rather than the currently selected sprint or a later planning run. - `GET /api/projects/:projectId/conversations/threads` - Lists project conversation threads - `POST /api/projects/:projectId/conversations/threads` @@ -512,6 +513,7 @@ Legacy runtime: - Chat page logs invocation activity explicitly in the background, providing observable execution artifacts directly in the chat view. - Chat page filters the "Threads" mode to show user-facing conversation threads (`scope === "project"`). - Chat page "Invocations" mode provides a read-only list with metadata for active/completed execution invocations without cluttering the main thread rail. +- Sprint-planning invocation transcripts include the execution plan generated for that invocation's linked sprint. The plan card is replayed from persisted invocation message metadata (`metadata.widget_metadata.type = "planning_request"` plus `metadata.executionPlan`), so historical transcripts do not change when the operator selects another sprint or replans the same project later. - Invocation cards and detail headers now show the resolved provider model when available, so planning runs expose the same model visibility as worker cards. - Invocation cards and the invocation message stream now surface classified provider errors such as `Rate limit` and `Quota reset`, including retry wait information when Code UX is backing off automatically. If Code UX restarts while an invocation is sleeping until a retry time, startup recovery closes the stale running invocation with a recovery message and moves task-backed work back to a retryable state so the recovered sprint loop can start a fresh continuation. - Chat page now receives websocket updates for thread assignment changes and incoming thread messages in the active thread diff --git a/docs/dashboard/design-system-chat.md b/docs/dashboard/design-system-chat.md index 63bed3d3b0..68a3d2ef95 100644 --- a/docs/dashboard/design-system-chat.md +++ b/docs/dashboard/design-system-chat.md @@ -18,7 +18,7 @@ The chat and invocation design system for the Code UX dashboard defines the layo - **Tool Calls / Reasoning**: Presented as full-width, compact cards rather than standard bubbles to clearly differentiate them as structural operations or internal thoughts rather than user-facing dialogue. - **Prompt suggestion tags**: Agent replies can append optional next-step prompt tags below the normal markdown body when message metadata includes `metadata.promptSuggestions`. These tags are adjuncts to the bubble, not replacements for the transcript; the markdown reply remains visible and readable even when suggestions are present. Selecting a tag populates and focuses the composer with that prompt so the user can review or edit it before sending. Tags must never auto-send a prompt from standard thread bubbles, and invocation transcripts remain read-only even if prompt-suggestion metadata is present. - **Widgets**: specialized components (Routing, Planning, Container) embedded within the stream to provide rich status and execution context without cluttering the text transcript. They use a unified visual language (`ChatWidgetFrame`). - - **Planning status**: planning widgets prefer live project state when available. The view-model resolves a sprint from message metadata (`sprintId`, `sprintRunId`, or planning widget metadata) or from the active execution run, then combines project task records with the execution snapshot's dispatches and runtime events. The card shows sprint key/name, request/task/run materialization, a real `progressbar`, queued/completed counts, and per-task status labels. Both project task records and the execution snapshot must report loaded for the active project before the live card is rendered; otherwise the widget falls back to the generic planning card instead of rendering partial or invented progress. + - **Planning status**: planning widgets prefer live project state when available. The view-model resolves a sprint from message metadata (`sprintId`, `sprintRunId`, or planning widget metadata) or from the active execution run, then combines project task records with the execution snapshot's dispatches and runtime events. Completed sprint-planning invocations also append an assistant transcript message with `metadata.widget_metadata.type = "planning_request"`, `metadata.widget_metadata.status = "completed"`, and `metadata.executionPlan` containing the exact invocation's `projectId`, `sprintId`, sprint label, goal, created task IDs, and planned task summaries. The card shows sprint key/name, request/task/run materialization, a real `progressbar`, queued/completed counts, and per-task status labels. Both project task records and the execution snapshot must report loaded for the active project before the live card is rendered; otherwise the widget falls back to the generic planning card instead of rendering partial or invented progress. - **Reasoning turns**: internal thinking output renders as a dedicated `ReasoningWidget`, not as a generic assistant bubble and not as a tool-call widget. It keeps the text plain and whitespace-preserving, adds provider/model/timing/token context in the header, and collapses long content behind an expand/collapse button with `aria-expanded` and `aria-controls`. - **Self-reflection turns**: planning and QA reflection metadata renders as a dedicated `SelfReflectionWidget`, not as the raw system text. The widget summarizes the reflection purpose, attempt, pass/fail/error state, and final decision, then lists each criterion with a 5-star visual rating derived from the 1-10 score, the numeric score, threshold, textual pass/fail state, rationale, and improvement instructions when present. Star ratings expose semantic labels for screen readers, and pass/fail is always shown as text plus icon so it is not color-only. - **External references**: Normal thread bubbles and invocation transcript bubbles render Jira issues, GitHub issues/pull requests, and GitLab issues/merge requests as `ExternalReferenceWidget` cards when the frontend view-model recognizes explicit metadata (`widget_metadata`, `externalReference`, `linkedIssue`, or top-level provider/kind/url fields) or a JSON-looking message body with the same fields. The widget shows provider, issue key or number, title, state/status, safe `http`/`https` outbound link, repository/project path, labels, assignee/author, and a short preview. Malformed JSON or unsupported providers stay on the normal markdown path so chat storage contracts and transcripts are not mutated. diff --git a/docs/dashboard/design-system-live-runtime.md b/docs/dashboard/design-system-live-runtime.md index e1287f5bde..356ada2c76 100644 --- a/docs/dashboard/design-system-live-runtime.md +++ b/docs/dashboard/design-system-live-runtime.md @@ -72,6 +72,7 @@ By adhering to these rules, the Live page remains a focused, professional worksp - Transport recovery is a page-level state. The banner announces disconnected transport and blocking connection errors assertively; reconnecting, refreshing, and stale states are polite and do not interrupt the operator's current task. - Invocation feeds should keep existing rows during refresh, expose a polite feed summary, and use assertive copy only for operator-level blocking failures. Transcript links should include the invocation purpose and a shortened invocation ID so repeated transcript controls are distinguishable. - Invocation transcripts render planning and QA self-reflection messages as structured reflection cards when `metadata.reflection` is present. These cards show the reflection purpose, attempt, final decision, pass/fail/error text, per-criterion star ratings, numeric scores, thresholds, rationales, and improvement instructions without exposing raw provider prompts or credentials. +- Planning invocation transcripts render persisted `metadata.executionPlan` details as an adjunct card beneath the markdown message. Use the selected message metadata for sprint key/name, goal, created task ids, and task summaries so historical planning turns remain distinguishable without fetching current sprint state; legacy virtual-route messages still fall back to the generic `Execution Plan` widget. - Attention queues should keep open, claimed, resolved, and cleared counts visible through refresh. Claim, resolve/release, and dismiss actions stay focus-stable while pending and report outcome or in-progress feedback without causing repeated submissions. - Attention queue rows are shared between the Live sidebar and Overview telemetry. Live remains the interactive owner for claim, resolve, and dismiss actions; Overview uses the same labels, status/severity tones, markdown summary rendering, and list semantics in a read-only selected-sprint telemetry surface. - The Live page passes the persisted top-nav selected sprint into the live snapshot hook. When a selected sprint exists, the attention queue must trust the backend selected-sprint snapshot rather than filtering mixed project-wide queue data in the component. diff --git a/src/services/planning-agent-service.ts b/src/services/planning-agent-service.ts index a6b3f0d870..e4f7a0131b 100644 --- a/src/services/planning-agent-service.ts +++ b/src/services/planning-agent-service.ts @@ -27,6 +27,7 @@ import { parsePlannedSprintReply, PlanningParseError } from "./planning-json-ext import { extractJsonFromText } from "../domain/llm/json-extraction.js"; import type { PlannedSprintPayload, PlannedTaskDraft } from "../contracts/project-management-types.js"; import { persistPlannedTasks } from "./planning-task-persistence.js"; +import { buildPlanningExecutionPlanMessage } from "./planning-execution-plan-message.js"; import { ProviderExecutionService, resolveEffectiveModel } from "./provider-execution-service.js"; import { StructuredAgentRequestService, type StructuredAgentRequestResult } from "./structured-agent-request-service.js"; import { ProviderInvocationCancelledError, StructuredProviderResponseService } from "./structured-provider-response-service.js"; @@ -457,6 +458,8 @@ export class PlanningAgentService { if (Object.keys(sprintUpdate).length > 0) { this.deps.projectManagementRepository.updateSprint(sprint.id, sprintUpdate); } + const finalSprintName = sprintUpdate.name || sprint.name; + const finalSprintGoal = sprintUpdate.goal || sprint.goal; const { createdTaskIds } = persistPlannedTasks( projectId, @@ -466,6 +469,22 @@ export class PlanningAgentService { { defaultAgentPresetId: manualCodingAgent?.id || null }, ); + if (invocation && isExecutionInvocationActiveForFinalize(this.deps.executionRepository, invocation.id)) { + this.deps.executionRepository?.appendExecutionInvocationMessage( + invocation.id, + buildPlanningExecutionPlanMessage({ + invocationId: invocation.id, + projectId, + sprintId, + sprintNumber: sprint.number, + sprintName: finalSprintName, + goal: finalSprintGoal, + tasks: payload.tasks, + createdTaskIds, + }), + ); + } + const titles: string[] = []; for (const t of payload.tasks) { titles.push(t.title); diff --git a/src/services/planning-execution-plan-message.ts b/src/services/planning-execution-plan-message.ts new file mode 100644 index 0000000000..5ad1b459a7 --- /dev/null +++ b/src/services/planning-execution-plan-message.ts @@ -0,0 +1,96 @@ +import type { AppendExecutionInvocationMessageInput } from "../contracts/execution-types.js"; +import type { PlannedTaskDraft, TaskExecutorType, TaskPriority } from "../contracts/project-management-types.js"; + +export interface PlanningExecutionPlanMessageInput { + invocationId: string; + projectId: string; + sprintId: string; + sprintNumber: number | null; + sprintName: string; + goal: string; + tasks: readonly PlannedTaskDraft[]; + createdTaskIds: readonly string[]; +} + +interface PlanningExecutionPlanTaskSummary { + key: string; + title: string; + description: string; + priority: TaskPriority; + executorType: TaskExecutorType; + dependsOn: string[]; +} + +interface PlanningExecutionPlanMetadata { + invocationId: string; + projectId: string; + sprintId: string; + sprintNumber: number | null; + sprintName: string; + goal: string; + taskCount: number; + createdTaskIds: string[]; + tasks: PlanningExecutionPlanTaskSummary[]; +} + +export function buildPlanningExecutionPlanMessage( + input: PlanningExecutionPlanMessageInput, +): AppendExecutionInvocationMessageInput { + const tasks = input.tasks.map((task) => ({ + key: task.key, + title: task.title, + description: task.description, + priority: task.priority || "medium", + executorType: task.executorType || "auto", + dependsOn: [...(task.dependsOn || [])], + })); + const executionPlan: PlanningExecutionPlanMetadata = { + invocationId: input.invocationId, + projectId: input.projectId, + sprintId: input.sprintId, + sprintNumber: input.sprintNumber, + sprintName: input.sprintName, + goal: input.goal, + taskCount: tasks.length, + createdTaskIds: [...input.createdTaskIds], + tasks, + }; + + return { + role: "assistant", + contentMarkdown: buildPlanningExecutionPlanMarkdown(executionPlan), + metadata: { + widget_metadata: { + type: "planning_request", + status: "completed", + projectId: input.projectId, + sprintId: input.sprintId, + sprintNumber: input.sprintNumber, + sprintName: input.sprintName, + }, + executionPlan, + }, + }; +} + +function buildPlanningExecutionPlanMarkdown(plan: PlanningExecutionPlanMetadata): string { + const sprintLabel = plan.sprintNumber === null + ? plan.sprintName + : `Sprint ${plan.sprintNumber} - ${plan.sprintName}`; + const lines = [ + `## Execution Plan: ${sprintLabel}`, + "", + `Goal: ${plan.goal}`, + "", + `Planned ${plan.taskCount} ${plan.taskCount === 1 ? "task" : "tasks"}:`, + ]; + + for (const task of plan.tasks) { + const dependencySummary = task.dependsOn.length > 0 + ? ` (depends on ${task.dependsOn.map((key) => `\`${key}\``).join(", ")})` + : ""; + lines.push(`- \`${task.key}\` - ${task.title}${dependencySummary}`); + } + + return lines.join("\n"); +} diff --git a/tests/backend/services/planning-agent-service.integration.test.ts b/tests/backend/services/planning-agent-service.integration.test.ts index c5cf6f93a5..ce77f9d11f 100644 --- a/tests/backend/services/planning-agent-service.integration.test.ts +++ b/tests/backend/services/planning-agent-service.integration.test.ts @@ -156,6 +156,16 @@ describe("PlanningAgentService Integration", () => { }; } + function findCompletedExecutionPlanMessage( + executionRepository: ExecutionRepository, + invocationId: string, + ) { + return executionRepository.listExecutionInvocationMessages(invocationId).find((message) => { + const widgetMetadata = message.metadata?.widget_metadata as Record | undefined; + return widgetMetadata?.type === "planning_request" && widgetMetadata.status === "completed"; + }); + } + function reflectionResult(score: number): string { return JSON.stringify({ criteria: [ @@ -301,6 +311,90 @@ describe("PlanningAgentService Integration", () => { expect(messages[0].contentMarkdown).toContain("Turn sprint goals into concrete executable tasks."); }); + it("persists sprint-specific execution plan metadata for separate planning invocations", async () => { + const { + projectRepository, + connectionRepository, + executionRepository, + settingsRepository, + syncService, + executionControlService, + project, + sprint: firstSprint, + } = await setupTestHarness({ + name: "First Planning Sprint", + goal: "Plan the first sprint.", + }); + const secondSprint = projectRepository.createSprint(project.id, { + name: "Second Planning Sprint", + goal: "Plan the second sprint.", + }); + + const service = new PlanningAgentService({ + projectManagementRepository: projectRepository, + connectionChatRepository: connectionRepository, + executionRepository, + settingsRepository, + agentPresetSyncService: syncService, + executionControlService: executionControlService as any, + providerRunner: createPlanningTextProviderRunner([ + JSON.stringify(planningProviderPayload("First sprint task")), + JSON.stringify(planningProviderPayload("Second sprint task")), + ]), + }); + + const firstResult = await service.planSprint(project.id, firstSprint.id, {}); + const secondResult = await service.planSprint(project.id, secondSprint.id, {}); + + const firstInvocation = executionRepository + .listExecutionInvocations({ projectId: project.id, sprintId: firstSprint.id }) + .find((record) => record.sprintId === firstSprint.id); + const secondInvocation = executionRepository + .listExecutionInvocations({ projectId: project.id, sprintId: secondSprint.id }) + .find((record) => record.sprintId === secondSprint.id); + expect(firstInvocation).toBeDefined(); + expect(secondInvocation).toBeDefined(); + + const firstMessage = findCompletedExecutionPlanMessage(executionRepository, firstInvocation!.id); + const secondMessage = findCompletedExecutionPlanMessage(executionRepository, secondInvocation!.id); + const firstPlan = firstMessage?.metadata?.executionPlan as { + projectId: string; + sprintId: string; + sprintName: string; + taskCount: number; + createdTaskIds: string[]; + tasks: Array<{ title: string }>; + } | undefined; + const secondPlan = secondMessage?.metadata?.executionPlan as { + projectId: string; + sprintId: string; + sprintName: string; + taskCount: number; + createdTaskIds: string[]; + tasks: Array<{ title: string }>; + } | undefined; + + expect(firstPlan).toMatchObject({ + projectId: project.id, + sprintId: firstSprint.id, + sprintName: "First Planning Sprint", + taskCount: 1, + createdTaskIds: firstResult.createdTaskIds, + tasks: [{ title: "First sprint task" }], + }); + expect(secondPlan).toMatchObject({ + projectId: project.id, + sprintId: secondSprint.id, + sprintName: "Second Planning Sprint", + taskCount: 1, + createdTaskIds: secondResult.createdTaskIds, + tasks: [{ title: "Second sprint task" }], + }); + expect(firstPlan?.sprintId).not.toBe(secondPlan?.sprintId); + expect(firstMessage?.contentMarkdown).toContain("- `T01` - First sprint task"); + expect(secondMessage?.contentMarkdown).toContain("- `T01` - Second sprint task"); + }); + it("auto-starts after planning when self-reflection is disabled", async () => { const { projectRepository, diff --git a/tests/backend/services/planning-agent-service.test.ts b/tests/backend/services/planning-agent-service.test.ts index 37a5d32729..d94de1d772 100644 --- a/tests/backend/services/planning-agent-service.test.ts +++ b/tests/backend/services/planning-agent-service.test.ts @@ -344,6 +344,34 @@ describe("PlanningAgentService", () => { expect(createdTasks).toHaveLength(1); expect(createdTasks[0]?.title).toBe("Plan via virtual worker"); + const planningInvocation = executionRepository + .listExecutionInvocations({ projectId: project.id }) + .find((record) => record.sprintId === sprint.id); + expect(planningInvocation).toBeDefined(); + const messages = executionRepository.listExecutionInvocationMessages(planningInvocation!.id); + const executionPlanMessage = messages.find((message) => { + const widgetMetadata = message.metadata?.widget_metadata as Record | undefined; + return widgetMetadata?.type === "planning_request" && widgetMetadata.status === "completed"; + }); + const executionPlan = executionPlanMessage?.metadata?.executionPlan as { + projectId: string; + sprintId: string; + taskCount: number; + createdTaskIds: string[]; + tasks: Array<{ key: string; title: string }>; + } | undefined; + expect(executionPlanMessage?.role).toBe("assistant"); + expect(executionPlanMessage?.contentMarkdown).toContain("## Execution Plan: Sprint 1 - Virtual Planning Sprint"); + expect(executionPlan).toMatchObject({ + projectId: project.id, + sprintId: sprint.id, + taskCount: 1, + createdTaskIds: planned.createdTaskIds, + tasks: [ + { key: "T01", title: "Plan via virtual worker" }, + ], + }); + const statsSnapshot = executionRepository.getProjectStatsSnapshot(project.id, "24h"); expect(statsSnapshot.usage.totalTokens).toBe(1_030); expect(statsSnapshot.sprints[0]).toMatchObject({ diff --git a/tests/dashboard/lib/chat-widget-view-models.test.ts b/tests/dashboard/lib/chat-widget-view-models.test.ts index c293d8f824..3d55a1aad5 100644 --- a/tests/dashboard/lib/chat-widget-view-models.test.ts +++ b/tests/dashboard/lib/chat-widget-view-models.test.ts @@ -522,6 +522,79 @@ describe("Chat Widget View Models", () => { expect(result).toEqual({ type: "planning", status: "queued", planName: "Execution Plan" }); }); + it("uses execution plan metadata for sprint-specific invocation planning widgets", () => { + const message = { + metadata: { + routeKind: "virtual", + status: "completed", + executionPlan: { + sprintId: "sprint-14", + sprintNumber: 14, + sprintName: "Stabilize chat transcripts", + goal: "Render persisted execution plans in invocation transcripts.", + taskCount: 2, + createdTaskIds: ["task-1", "task-2"], + taskSummaries: [ + { key: "T01", title: "Parse execution plan metadata", summary: "Build a safe view model from the selected message." }, + { key: "T02", title: "Render compact task summaries", summary: "Show enough task context to distinguish sprint plans." }, + ], + }, + }, + } as unknown as ExecutionInvocationMessageRecord; + + const result = getInvocationWidgetData(message, { + projectId: "project-1", + projectTasks: [createTask({ sprintId: "sprint-live", sprint: "Live Sprint", title: "Live task" })], + projectTasksLoading: false, + projectTasksLoaded: true, + execution: createExecution({ + sprintRuns: [{ ...createExecution().sprintRuns[0]!, sprintId: "sprint-live", sprintName: "Live Sprint", sprintNumber: 99 }], + }), + executionLoading: false, + executionLoaded: true, + }); + + expect(result.type).toBe("planning"); + expect(result.status).toBe("completed"); + expect(result.planName).toBe("SPR-14: Stabilize chat transcripts"); + expect(result.executionPlan).toEqual(expect.objectContaining({ + sprintId: "sprint-14", + sprintNumber: 14, + sprintKey: "SPR-14", + sprintName: "Stabilize chat transcripts", + goal: "Render persisted execution plans in invocation transcripts.", + taskCount: 2, + createdTaskIds: ["task-1", "task-2"], + taskSummaryLabel: "2 planned tasks", + })); + expect(result.executionPlan?.tasks).toEqual([ + { + id: "T01", + title: "Parse execution plan metadata", + summary: "Build a safe view model from the selected message.", + }, + { + id: "T02", + title: "Render compact task summaries", + summary: "Show enough task context to distinguish sprint plans.", + }, + ]); + expect(result.liveStatus).toBeUndefined(); + }); + + it("keeps legacy virtual route fallback when execution plan metadata is absent or malformed", () => { + const message = { + metadata: { + routeKind: "virtual", + status: "queued", + executionPlan: "legacy-route-without-plan-details", + }, + } as unknown as ExecutionInvocationMessageRecord; + + const result = getInvocationWidgetData(message); + expect(result).toEqual({ type: "planning", status: "queued", planName: "Execution Plan" }); + }); + it("returns planning if metadata.routeKind is worker", () => { const message = { metadata: { diff --git a/tests/dashboard/v2/chat-message-bubbles.test.tsx b/tests/dashboard/v2/chat-message-bubbles.test.tsx index 501c59b85b..5cd166c331 100644 --- a/tests/dashboard/v2/chat-message-bubbles.test.tsx +++ b/tests/dashboard/v2/chat-message-bubbles.test.tsx @@ -778,6 +778,91 @@ describe("Chat Message Bubbles", () => { expect(container.textContent).toContain("Preparing to plan..."); }); + it("renders distinct persisted execution plans for different invocation planning messages", () => { + const firstMessage = createInvocationMessage({ + id: "msg_plan_alpha", + contentMarkdown: "Planning transcript for alpha sprint", + metadata: { + routeKind: "virtual", + status: "completed", + executionPlan: { + sprintId: "sprint-alpha", + sprintNumber: 31, + sprintName: "Runtime Planning", + goal: "Make invocation planning cards sprint-specific.", + taskCount: 2, + createdTaskIds: ["task-alpha-1", "task-alpha-2"], + taskSummaries: [ + { key: "T01", title: "Parse metadata execution plan", summary: "Use the selected invocation message metadata." }, + { key: "T02", title: "Render alpha task summary", summary: "Expose alpha-specific task context." }, + ], + }, + }, + }); + const secondMessage = createInvocationMessage({ + id: "msg_plan_beta", + contentMarkdown: "Planning transcript for beta sprint", + metadata: { + routeKind: "virtual", + status: "completed", + executionPlan: { + sprintId: "sprint-beta", + sprintNumber: 32, + sprintName: "Provider Recovery", + goal: "Make adjacent planning cards visually distinguishable.", + taskCount: 3, + createdTaskIds: ["task-beta-1", "task-beta-2", "task-beta-3"], + taskSummaries: [ + { key: "T01", title: "Render beta task summary", summary: "Expose beta-specific task context." }, + { key: "T02", title: "Keep markdown visible", summary: "Do not suppress invocation message content." }, + { key: "T03", title: "Preserve fallback behavior", summary: "Legacy virtual routes still render safely." }, + ], + }, + }, + }); + + const { container } = render( +

+ + +
+ ); + const view = within(container); + + expect(view.getByText("SPR-31")).toBeInTheDocument(); + expect(view.getByText("Runtime Planning")).toBeInTheDocument(); + expect(view.getByText("2 planned tasks")).toBeInTheDocument(); + expect(view.getByText("Render alpha task summary")).toBeInTheDocument(); + expect(view.getByText("Expose alpha-specific task context.")).toBeInTheDocument(); + + expect(view.getByText("SPR-32")).toBeInTheDocument(); + expect(view.getByText("Provider Recovery")).toBeInTheDocument(); + expect(view.getByText("3 planned tasks")).toBeInTheDocument(); + expect(view.getByText("Render beta task summary")).toBeInTheDocument(); + expect(view.getByText("Expose beta-specific task context.")).toBeInTheDocument(); + + expect(container.textContent).toContain("Planning transcript for alpha sprint"); + expect(container.textContent).toContain("Planning transcript for beta sprint"); + }); + + it("keeps legacy virtual route invocation planning fallback safe without execution plan metadata", () => { + const message = createInvocationMessage({ + id: "msg_legacy_virtual", + contentMarkdown: "Legacy virtual route transcript", + metadata: { + routeKind: "virtual", + status: "queued", + executionPlan: "not-an-object", + }, + }); + + const { container } = render(); + + expect(container.textContent).toContain("Legacy virtual route transcript"); + expect(container.textContent).toContain("Execution Plan"); + expect(container.textContent).toContain("Preparing to plan..."); + }); + it("renders passing planning self-reflection as a rich widget", () => { const message: ExecutionInvocationMessageRecord = { id: "msg_reflection_pass",