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
5 changes: 1 addition & 4 deletions docs-web/content/docs/developer-http-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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/`. |

---

Expand Down
Loading