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/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/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",