Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion dashboard/src/v2/components/chat/InvocationMessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,12 @@ export const InvocationMessageBubble: FunctionComponent<InvocationMessageBubbleP
{/* Widget Slot */}
{widgetData.type === "planning" && (
<div className="mt-4 border-t border-white/5 pt-4">
<PlanningRequestWidget status={widgetData.status} planName={widgetData.planName} liveStatus={widgetData.liveStatus} />
<PlanningRequestWidget
status={widgetData.status}
planName={widgetData.planName}
liveStatus={widgetData.liveStatus}
executionPlan={widgetData.executionPlan}
/>
</div>
)}
{widgetData.type === "external_reference" && widgetData.externalReference && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LivePlanningTaskState["statusKind"], string> = {
Expand Down Expand Up @@ -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 (
<div className="min-w-0 space-y-3">
<span className="sr-only">{executionPlan.ariaLabel}</span>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
{executionPlan.sprintKey ? (
<div className="font-mono text-[11px] font-bold uppercase tracking-[0.14em] text-signal-600 dark:text-signal-400">
{executionPlan.sprintKey}
</div>
) : null}
<div className="mt-0.5 truncate text-[14px] font-semibold text-slate-900 dark:text-white">
{executionPlan.sprintName}
</div>
</div>
<div className="shrink-0 rounded-lg border border-black/[0.06] bg-white/70 px-2.5 py-1 text-right font-mono text-[12px] font-bold tabular-nums text-slate-800 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-slate-100">
{executionPlan.taskSummaryLabel}
</div>
</div>

{executionPlan.goal ? (
<p className="line-clamp-2 text-[12px] leading-5 text-slate-600 dark:text-slate-300">
{executionPlan.goal}
</p>
) : null}

{visibleTasks.length > 0 ? (
<ul className="space-y-1.5" aria-label="Planned task summaries">
{visibleTasks.map((task) => (
<li
key={task.id}
className="flex min-w-0 flex-col gap-1 rounded-lg border border-black/[0.04] bg-white/60 px-2.5 py-2 dark:border-white/[0.06] dark:bg-white/[0.03]"
>
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 font-mono text-[10px] font-semibold text-slate-400">{task.id}</span>
<span className="truncate text-[12px] font-medium text-slate-800 dark:text-slate-200">{task.title}</span>
</div>
{task.summary ? (
<div className="line-clamp-2 text-[11px] leading-4 text-slate-500 dark:text-slate-400">{task.summary}</div>
) : null}
</li>
))}
{hiddenTaskCount > 0 ? (
<li className="rounded-lg border border-dashed border-black/[0.08] px-2.5 py-2 text-[12px] font-medium text-slate-500 dark:border-white/[0.08] dark:text-slate-400">
{hiddenTaskCount} more task{hiddenTaskCount === 1 ? "" : "s"} in this execution plan
</li>
) : null}
</ul>
) : visibleCreatedTaskIds.length > 0 ? (
<div className="flex flex-wrap gap-1.5" aria-label="Created task ids">
{visibleCreatedTaskIds.map((taskId) => (
<span
key={taskId}
className="rounded-md border border-black/[0.06] bg-white/60 px-2 py-1 font-mono text-[11px] font-semibold text-slate-600 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-slate-300"
>
{taskId}
</span>
))}
{hiddenCreatedTaskCount > 0 ? (
<span className="rounded-md border border-dashed border-black/[0.08] px-2 py-1 text-[11px] font-medium text-slate-500 dark:border-white/[0.08] dark:text-slate-400">
+{hiddenCreatedTaskCount} more
</span>
) : null}
</div>
) : null}
</div>
);
};

export const PlanningRequestWidget: FunctionComponent<PlanningRequestWidgetProps> = ({
status,
planName,
isDark = true,
liveStatus,
executionPlan,
}) => {
return (
<ChatWidgetFrame
Expand All @@ -145,6 +227,8 @@ export const PlanningRequestWidget: FunctionComponent<PlanningRequestWidgetProps
>
{liveStatus ? (
<LivePlanningStatusCard liveStatus={liveStatus} />
) : executionPlan ? (
<PersistedExecutionPlanCard executionPlan={executionPlan} />
) : (
<div class="flex flex-col items-center justify-center p-4 min-h-[120px]">
{status === 'running' || status === 'queued' ? (
Expand Down
196 changes: 192 additions & 4 deletions dashboard/src/v2/lib/chat-widget-view-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface ChatWidgetState {
planName: string;
targetWorker?: string;
liveStatus?: LivePlanningWidgetState;
executionPlan?: PlanningExecutionPlanWidgetState;
externalReference?: ExternalReferenceWidgetState;
suppressBodyMarkdown?: boolean;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, unknown>,
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<string, unknown> | null | undefined,
widgetMetadata: Record<string, unknown> | 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")) {
Expand Down Expand Up @@ -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 } : {}),
};
}
Expand All @@ -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 } : {}),
};
}
Expand Down
1 change: 1 addition & 0 deletions docs/dashboard/design-system-live-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading