feat(FN-4076): tighten mobile agents header and popup, add compact planning
Merges FN-4076: tightens the mobile Agents header and aligns the controls popup on smaller screens, while also introducing compact planning breakdown task creation with preserved payload coverage — tests for scoped and empty-generated planning payloads were added alongside fixes to the legacy API an Fusion-Task-Id: FN-4076
This commit is contained in:
@@ -25,6 +25,7 @@ Use the 💡 button to open planning mode:
|
|||||||
- Summary view includes a **Priority** selector (`low`, `normal`, `high`, `urgent`) so single-task creation can set priority before task creation
|
- Summary view includes a **Priority** selector (`low`, `normal`, `high`, `urgent`) so single-task creation can set priority before task creation
|
||||||
- Break-into-tasks mode includes per-subtask **Priority** selectors (`low`, `normal`, `high`, `urgent`) so each generated task can be prioritized before creation
|
- Break-into-tasks mode includes per-subtask **Priority** selectors (`low`, `normal`, `high`, `urgent`) so each generated task can be prioritized before creation
|
||||||
- Break-into-tasks descriptions are structured with subtask-specific guidance first, then a separate larger-plan context section (plus `## Planning Interview Context` when interview history exists)
|
- Break-into-tasks descriptions are structured with subtask-specific guidance first, then a separate larger-plan context section (plus `## Planning Interview Context` when interview history exists)
|
||||||
|
- Final multi-task creation now uses a compact request payload: unchanged generated subtask descriptions stay server-side, while any edits to title, description, size, priority, and dependencies are preserved when tasks are created
|
||||||
- Sessions persist when the modal is closed — resume from the sidebar list at any time; reasoning context is restored automatically
|
- Sessions persist when the modal is closed — resume from the sidebar list at any time; reasoning context is restored automatically
|
||||||
- Back navigation rewinds the server-side planning session to the previous answered question so you can revise earlier answers and continue from the corrected turn
|
- Back navigation rewinds the server-side planning session to the previous answered question so you can revise earlier answers and continue from the corrected turn
|
||||||
- On the summary screen, **Refine Further** continues through the backend planning session (including resumed completed sessions) and waits for a real follow-up question or updated summary; it does not switch to an empty question view
|
- On the summary screen, **Refine Further** continues through the backend planning session (including resumed completed sessions) and waits for a real follow-up question or updated summary; it does not switch to an empty question view
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
|
|||||||
- Suggested dependencies from existing tasks
|
- Suggested dependencies from existing tasks
|
||||||
- Key deliverables checklist
|
- Key deliverables checklist
|
||||||
5. Create the task directly from the summary
|
5. Create the task directly from the summary
|
||||||
6. Or use **Break into Tasks** to generate multiple subtasks where each description starts with subtask-specific implementation guidance, followed by a separate larger-plan context section (including planning interview context when available); each subtask also supports per-subtask priority selection (`low`, `normal`, `high`, `urgent`) before creation
|
6. Or use **Break into Tasks** to generate multiple subtasks where each description starts with subtask-specific implementation guidance, followed by a separate larger-plan context section (including planning interview context when available); each subtask also supports per-subtask priority selection (`low`, `normal`, `high`, `urgent`) before creation, and final task creation submits a compact payload that only includes edited subtask fields while keeping unchanged generated descriptions server-side
|
||||||
|
|
||||||
**Features**:
|
**Features**:
|
||||||
- **Rate Limiting**: Maximum 5 planning sessions per hour per IP
|
- **Rate Limiting**: Maximum 5 planning sessions per hour per IP
|
||||||
|
|||||||
@@ -2712,6 +2712,15 @@ export interface SubtaskItem {
|
|||||||
dependsOn: string[];
|
dependsOn: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlanningSubtaskDraft {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
suggestedSize?: "S" | "M" | "L";
|
||||||
|
priority?: TaskPriority;
|
||||||
|
dependsOn?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** SSE event types for planning session streaming */
|
/** SSE event types for planning session streaming */
|
||||||
export type PlanningStreamEvent =
|
export type PlanningStreamEvent =
|
||||||
| { type: "thinking"; data: string }
|
| { type: "thinking"; data: string }
|
||||||
@@ -2975,14 +2984,7 @@ export function startPlanningBreakdown(
|
|||||||
/** Create multiple tasks from a completed planning session */
|
/** Create multiple tasks from a completed planning session */
|
||||||
export function createTasksFromPlanning(
|
export function createTasksFromPlanning(
|
||||||
planningSessionId: string,
|
planningSessionId: string,
|
||||||
subtasks: Array<{
|
subtasks: PlanningSubtaskDraft[],
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
suggestedSize: "S" | "M" | "L";
|
|
||||||
priority?: TaskPriority;
|
|
||||||
dependsOn: string[];
|
|
||||||
}>,
|
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
): Promise<{ tasks: Task[] }> {
|
): Promise<{ tasks: Task[] }> {
|
||||||
return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), {
|
return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.agents-view-primary-actions {
|
.agents-view-primary-actions {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
@@ -100,9 +101,10 @@
|
|||||||
|
|
||||||
.agent-controls-panel {
|
.agent-controls-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(var(--header-height) + var(--space-sm));
|
top: calc(100% + var(--space-sm));
|
||||||
right: var(--space-lg);
|
right: 0;
|
||||||
width: min(calc(var(--space-2xl) * 16), calc(100% - var(--space-2xl)));
|
width: min(calc(var(--space-2xl) * 16), calc(100vw - var(--space-2xl)));
|
||||||
|
max-width: calc(100vw - var(--space-2xl));
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -133,6 +135,17 @@
|
|||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-controls-mobile-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-controls-mobile-actions .btn {
|
||||||
|
flex: 1 1 calc(var(--space-2xl) * 4);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.agents-org-full-view {
|
.agents-org-full-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1263,9 +1276,9 @@
|
|||||||
|
|
||||||
/* === AgentsView mobile header (cleaner stacked layout) ==================== */
|
/* === AgentsView mobile header (cleaner stacked layout) ==================== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* Single-row header: hide the redundant "Agents" title (the icon + page
|
/* Single-row header: keep a compact textual title visible while lower-
|
||||||
context already make it obvious), and pack view-toggle + Controls + New
|
priority actions move into the Controls popup so narrow phones keep a
|
||||||
Agent + Refresh into one no-wrap row. */
|
stable single-row layout. */
|
||||||
.agents-view-header {
|
.agents-view-header {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1284,11 +1297,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.agents-view-title h2 {
|
.agents-view-title h2 {
|
||||||
font-size: var(--space-lg);
|
display: block;
|
||||||
white-space: nowrap;
|
font-size: var(--space-md);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout: title (Bot) on the left, view-toggle right after it, then the
|
/* Layout: compact title on the left, view-toggle right after it, then the
|
||||||
primary-actions group pinned to the far right of the row. The flex
|
primary-actions group pinned to the far right of the row. The flex
|
||||||
container grows to fill remaining space, and `space-between` separates
|
container grows to fill remaining space, and `space-between` separates
|
||||||
the toggle (left side) from primary-actions (right side). */
|
the toggle (left side) from primary-actions (right side). */
|
||||||
@@ -1302,13 +1316,21 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View toggle (List/Board/Tree/Org) — keep the 32px pill chrome from the
|
/* View toggle (List/Board/Tree/Org) — expand the pill and inner buttons to
|
||||||
base styles. Inner buttons stay at the base 28×28 (centered in the 2px
|
the shared 36px mobile touch-target while preserving the compact grouped
|
||||||
pill padding). All surrounding header icons below are sized to match
|
chrome used elsewhere in the dashboard header. */
|
||||||
that 28×28 inner footprint so every icon lines up with the view-toggle
|
|
||||||
buttons. */
|
|
||||||
.agents-view-controls .view-toggle {
|
.agents-view-controls .view-toggle {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
height: calc(var(--space-lg) * 2 + var(--space-sm));
|
||||||
|
padding: calc(var(--space-xs) / 2);
|
||||||
|
gap: calc(var(--space-xs) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-view-controls .view-toggle .view-toggle-btn {
|
||||||
|
width: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
|
height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
|
min-width: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
|
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
}
|
}
|
||||||
|
|
||||||
.agents-view-primary-actions {
|
.agents-view-primary-actions {
|
||||||
@@ -1332,23 +1354,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Import + New Agent: icon-only on mobile, sized to match view-toggle
|
|
||||||
buttons so the no-wrap action row never overflows (FN-3895 follow-up). */
|
|
||||||
.agents-view-primary-actions .agent-import-trigger,
|
|
||||||
.agents-view-primary-actions .btn-task-create {
|
|
||||||
width: calc(var(--space-lg) * 2 + var(--space-xs));
|
|
||||||
height: calc(var(--space-lg) * 2 + var(--space-xs));
|
|
||||||
min-width: calc(var(--space-lg) * 2 + var(--space-xs));
|
|
||||||
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0;
|
|
||||||
gap: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-view-primary-actions .btn-icon {
|
.agents-view-primary-actions .btn-icon {
|
||||||
width: calc(var(--space-lg) * 2 + var(--space-xs));
|
width: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
height: calc(var(--space-lg) * 2 + var(--space-xs));
|
height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
@@ -1356,13 +1361,16 @@
|
|||||||
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title row: keep Bot icon + "Agents" text on a single line so the
|
/* Title row: keep the Bot icon aligned with the compact control row. */
|
||||||
view-toggle and primary actions get pushed to the right edge. */
|
|
||||||
.agents-view-title {
|
.agents-view-title {
|
||||||
height: calc(var(--space-lg) * 2);
|
height: calc(var(--space-lg) * 2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-controls-mobile-actions .btn {
|
||||||
|
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
|
}
|
||||||
|
|
||||||
.agents-org-full-view {
|
.agents-org-full-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -799,127 +799,158 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
>
|
>
|
||||||
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
|
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!isMobileViewport && (
|
||||||
className="btn btn-sm agent-import-trigger"
|
<>
|
||||||
onClick={() => {
|
<button
|
||||||
setIsImporting(true);
|
className="btn btn-sm agent-import-trigger"
|
||||||
setIsControlsPanelOpen(false);
|
onClick={() => {
|
||||||
}}
|
setIsImporting(true);
|
||||||
aria-label="Import"
|
setIsControlsPanelOpen(false);
|
||||||
title="Import"
|
}}
|
||||||
>
|
aria-label="Import"
|
||||||
<Upload size={16} />
|
title="Import"
|
||||||
Import
|
>
|
||||||
</button>
|
<Upload size={16} />
|
||||||
<button
|
Import
|
||||||
className="btn btn-task-create btn-sm"
|
</button>
|
||||||
onClick={() => {
|
<button
|
||||||
handleOpenNewAgent();
|
className="btn btn-task-create btn-sm"
|
||||||
setIsControlsPanelOpen(false);
|
onClick={() => {
|
||||||
}}
|
handleOpenNewAgent();
|
||||||
aria-label="New Agent"
|
setIsControlsPanelOpen(false);
|
||||||
title="New Agent"
|
}}
|
||||||
>
|
aria-label="New Agent"
|
||||||
<Plus size={16} />
|
title="New Agent"
|
||||||
New Agent
|
>
|
||||||
</button>
|
<Plus size={16} />
|
||||||
|
New Agent
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isControlsPanelOpen && (
|
||||||
|
<div
|
||||||
|
ref={controlsPanelRef}
|
||||||
|
id={controlsPanelId}
|
||||||
|
className="agent-controls-panel agent-controls-panel--scrollable"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Agent controls"
|
||||||
|
aria-modal="false"
|
||||||
|
>
|
||||||
|
<div className="agent-controls">
|
||||||
|
<div className="agent-controls-filters">
|
||||||
|
<div className="agent-state-filter">
|
||||||
|
<Filter size={14} />
|
||||||
|
<select
|
||||||
|
className="agent-state-filter-select"
|
||||||
|
value={filterState}
|
||||||
|
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
||||||
|
aria-label="Filter agents by state"
|
||||||
|
>
|
||||||
|
<option value="all">All States</option>
|
||||||
|
<option value="idle">Idle</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="running">Running</option>
|
||||||
|
<option value="paused">Paused</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-label agent-system-filter">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showSystemAgents}
|
||||||
|
onChange={(e) => setShowSystemAgents(e.target.checked)}
|
||||||
|
aria-label="Show system agents"
|
||||||
|
/>
|
||||||
|
Show system agents
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobileViewport && (
|
||||||
|
<div className="agent-controls-mobile-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm agent-import-trigger"
|
||||||
|
onClick={() => {
|
||||||
|
setIsImporting(true);
|
||||||
|
setIsControlsPanelOpen(false);
|
||||||
|
}}
|
||||||
|
aria-label="Import"
|
||||||
|
title="Import"
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-task-create btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleOpenNewAgent();
|
||||||
|
setIsControlsPanelOpen(false);
|
||||||
|
}}
|
||||||
|
aria-label="New Agent"
|
||||||
|
title="New Agent"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
New Agent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="agent-global-controls agent-controls-actions">
|
||||||
|
<div className="heartbeat-multiplier-group">
|
||||||
|
<div className="heartbeat-multiplier-controls">
|
||||||
|
<label htmlFor="globalHeartbeatMultiplier" className="heartbeat-multiplier-label">
|
||||||
|
Heartbeat Speed
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="globalHeartbeatMultiplier"
|
||||||
|
className="heartbeat-multiplier-slider touch-target"
|
||||||
|
type="range"
|
||||||
|
min={0.1}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
value={heartbeatMultiplier}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = Number(e.target.value);
|
||||||
|
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||||||
|
}}
|
||||||
|
disabled={isSavingMultiplier}
|
||||||
|
/>
|
||||||
|
<span className="heartbeat-multiplier-value">×{heartbeatMultiplier.toFixed(1)}</span>
|
||||||
|
<select
|
||||||
|
className="heartbeat-multiplier-preset"
|
||||||
|
value={String(
|
||||||
|
HEARTBEAT_MULTIPLIER_PRESETS.reduce((closest, candidate) => {
|
||||||
|
return Math.abs(candidate - heartbeatMultiplier) < Math.abs(closest - heartbeatMultiplier) ? candidate : closest;
|
||||||
|
}, HEARTBEAT_MULTIPLIER_PRESETS[0])
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = Number(e.target.value);
|
||||||
|
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||||||
|
}}
|
||||||
|
disabled={isSavingMultiplier}
|
||||||
|
aria-label="Heartbeat speed preset"
|
||||||
|
>
|
||||||
|
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
|
||||||
|
<option key={multiplier} value={String(multiplier)}>
|
||||||
|
×{multiplier}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<small className="text-secondary">
|
||||||
|
Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AgentTokenStatsPanel agents={displayAgents} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isControlsPanelOpen && (
|
|
||||||
<div
|
|
||||||
ref={controlsPanelRef}
|
|
||||||
id={controlsPanelId}
|
|
||||||
className="agent-controls-panel agent-controls-panel--scrollable"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Agent controls"
|
|
||||||
aria-modal="false"
|
|
||||||
>
|
|
||||||
<div className="agent-controls">
|
|
||||||
<div className="agent-controls-filters">
|
|
||||||
<div className="agent-state-filter">
|
|
||||||
<Filter size={14} />
|
|
||||||
<select
|
|
||||||
className="agent-state-filter-select"
|
|
||||||
value={filterState}
|
|
||||||
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
|
||||||
aria-label="Filter agents by state"
|
|
||||||
>
|
|
||||||
<option value="all">All States</option>
|
|
||||||
<option value="idle">Idle</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="running">Running</option>
|
|
||||||
<option value="paused">Paused</option>
|
|
||||||
<option value="error">Error</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="checkbox-label agent-system-filter">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showSystemAgents}
|
|
||||||
onChange={(e) => setShowSystemAgents(e.target.checked)}
|
|
||||||
aria-label="Show system agents"
|
|
||||||
/>
|
|
||||||
Show system agents
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="agent-global-controls agent-controls-actions">
|
|
||||||
<div className="heartbeat-multiplier-group">
|
|
||||||
<div className="heartbeat-multiplier-controls">
|
|
||||||
<label htmlFor="globalHeartbeatMultiplier" className="heartbeat-multiplier-label">
|
|
||||||
Heartbeat Speed
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="globalHeartbeatMultiplier"
|
|
||||||
className="heartbeat-multiplier-slider touch-target"
|
|
||||||
type="range"
|
|
||||||
min={0.1}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
value={heartbeatMultiplier}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value);
|
|
||||||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
|
||||||
}}
|
|
||||||
disabled={isSavingMultiplier}
|
|
||||||
/>
|
|
||||||
<span className="heartbeat-multiplier-value">×{heartbeatMultiplier.toFixed(1)}</span>
|
|
||||||
<select
|
|
||||||
className="heartbeat-multiplier-preset"
|
|
||||||
value={String(
|
|
||||||
HEARTBEAT_MULTIPLIER_PRESETS.reduce((closest, candidate) => {
|
|
||||||
return Math.abs(candidate - heartbeatMultiplier) < Math.abs(closest - heartbeatMultiplier) ? candidate : closest;
|
|
||||||
}, HEARTBEAT_MULTIPLIER_PRESETS[0])
|
|
||||||
)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value);
|
|
||||||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
|
||||||
}}
|
|
||||||
disabled={isSavingMultiplier}
|
|
||||||
aria-label="Heartbeat speed preset"
|
|
||||||
>
|
|
||||||
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
|
|
||||||
<option key={multiplier} value={String(multiplier)}>
|
|
||||||
×{multiplier}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<small className="text-secondary">
|
|
||||||
Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AgentTokenStatsPanel agents={displayAgents} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NewAgentDialog
|
<NewAgentDialog
|
||||||
isOpen={isCreating}
|
isOpen={isCreating}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
updateGlobalSettings,
|
updateGlobalSettings,
|
||||||
type PlanningSession,
|
type PlanningSession,
|
||||||
type SubtaskItem,
|
type SubtaskItem,
|
||||||
|
type PlanningSubtaskDraft,
|
||||||
type ModelInfo,
|
type ModelInfo,
|
||||||
type ConversationHistoryEntry,
|
type ConversationHistoryEntry,
|
||||||
type AiSessionSummary,
|
type AiSessionSummary,
|
||||||
@@ -69,7 +70,7 @@ type ViewState =
|
|||||||
| { type: "question"; session: PlanningSession }
|
| { type: "question"; session: PlanningSession }
|
||||||
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
|
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
|
||||||
| { type: "error"; session: PlanningSession; errorMessage: string }
|
| { type: "error"; session: PlanningSession; errorMessage: string }
|
||||||
| { type: "breakdown"; sessionId: string; subtasks: SubtaskItem[]; dirty: boolean }
|
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
|
||||||
| { type: "loading" }
|
| { type: "loading" }
|
||||||
| { type: "creating" };
|
| { type: "creating" };
|
||||||
|
|
||||||
@@ -94,6 +95,41 @@ function normalizePlanningSummary(summary: PlanningSummary): PlanningSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||||
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompactPlanningSubtaskDrafts(
|
||||||
|
originalSubtasks: SubtaskItem[],
|
||||||
|
editedSubtasks: SubtaskItem[],
|
||||||
|
): PlanningSubtaskDraft[] {
|
||||||
|
const originalById = new Map(originalSubtasks.map((subtask) => [subtask.id, subtask]));
|
||||||
|
|
||||||
|
return editedSubtasks.map((subtask) => {
|
||||||
|
const original = originalById.get(subtask.id);
|
||||||
|
const normalizedPriority = normalizeTaskPriority(subtask.priority);
|
||||||
|
const draft: PlanningSubtaskDraft = { id: subtask.id };
|
||||||
|
|
||||||
|
if (!original || subtask.title !== original.title) {
|
||||||
|
draft.title = subtask.title;
|
||||||
|
}
|
||||||
|
if (!original || subtask.description !== original.description) {
|
||||||
|
draft.description = subtask.description;
|
||||||
|
}
|
||||||
|
if (!original || subtask.suggestedSize !== original.suggestedSize) {
|
||||||
|
draft.suggestedSize = subtask.suggestedSize;
|
||||||
|
}
|
||||||
|
if (!original || normalizedPriority !== normalizeTaskPriority(original.priority)) {
|
||||||
|
draft.priority = normalizedPriority;
|
||||||
|
}
|
||||||
|
if (!original || !areStringArraysEqual(subtask.dependsOn, original.dependsOn)) {
|
||||||
|
draft.dependsOn = subtask.dependsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||||
}
|
}
|
||||||
@@ -1505,14 +1541,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await startPlanningBreakdown(view.session.sessionId, editedSummary ?? undefined, projectId);
|
const result = await startPlanningBreakdown(view.session.sessionId, editedSummary ?? undefined, projectId);
|
||||||
|
const normalizedSubtasks = result.subtasks.map((subtask) => ({
|
||||||
|
...subtask,
|
||||||
|
priority: normalizeTaskPriority(subtask.priority),
|
||||||
|
dependsOn: [...subtask.dependsOn],
|
||||||
|
}));
|
||||||
setLockSessionId(result.sessionId);
|
setLockSessionId(result.sessionId);
|
||||||
setView({
|
setView({
|
||||||
type: "breakdown",
|
type: "breakdown",
|
||||||
sessionId: result.sessionId,
|
sessionId: result.sessionId,
|
||||||
subtasks: result.subtasks.map((subtask) => ({
|
originalSubtasks: normalizedSubtasks.map((subtask) => ({ ...subtask, dependsOn: [...subtask.dependsOn] })),
|
||||||
...subtask,
|
subtasks: normalizedSubtasks.map((subtask) => ({ ...subtask, dependsOn: [...subtask.dependsOn] })),
|
||||||
priority: normalizeTaskPriority(subtask.priority),
|
|
||||||
})),
|
|
||||||
dirty: false,
|
dirty: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1531,10 +1570,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const completedSessionId = view.sessionId;
|
const completedSessionId = view.sessionId;
|
||||||
const result = await createTasksFromPlanning(
|
const result = await createTasksFromPlanning(
|
||||||
completedSessionId,
|
completedSessionId,
|
||||||
view.subtasks.map((subtask) => ({
|
buildCompactPlanningSubtaskDrafts(
|
||||||
...subtask,
|
view.originalSubtasks,
|
||||||
priority: normalizeTaskPriority(subtask.priority),
|
view.subtasks.map((subtask) => ({
|
||||||
})),
|
...subtask,
|
||||||
|
priority: normalizeTaskPriority(subtask.priority),
|
||||||
|
})),
|
||||||
|
),
|
||||||
projectId,
|
projectId,
|
||||||
);
|
);
|
||||||
onTasksCreated(result.tasks);
|
onTasksCreated(result.tasks);
|
||||||
@@ -1564,7 +1606,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
handleClose();
|
handleClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err) || "Failed to create tasks");
|
setError(getErrorMessage(err) || "Failed to create tasks");
|
||||||
setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty });
|
setView({
|
||||||
|
type: "breakdown",
|
||||||
|
sessionId: view.sessionId,
|
||||||
|
originalSubtasks: view.originalSubtasks,
|
||||||
|
subtasks: view.subtasks,
|
||||||
|
dirty: view.dirty,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
|
}, [broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
|
||||||
|
|
||||||
@@ -1995,7 +2043,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
{view.type === "breakdown" && (
|
{view.type === "breakdown" && (
|
||||||
<BreakdownView
|
<BreakdownView
|
||||||
subtasks={view.subtasks}
|
subtasks={view.subtasks}
|
||||||
dirty={view.dirty}
|
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
onUpdateSubtasks={(newSubtasks) =>
|
onUpdateSubtasks={(newSubtasks) =>
|
||||||
setView({ ...view, subtasks: newSubtasks, dirty: true })
|
setView({ ...view, subtasks: newSubtasks, dirty: true })
|
||||||
@@ -2485,7 +2532,6 @@ function createEmptySubtask(index: number): SubtaskItem {
|
|||||||
|
|
||||||
interface BreakdownViewProps {
|
interface BreakdownViewProps {
|
||||||
subtasks: SubtaskItem[];
|
subtasks: SubtaskItem[];
|
||||||
dirty: boolean;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onUpdateSubtasks: (subtasks: SubtaskItem[]) => void;
|
onUpdateSubtasks: (subtasks: SubtaskItem[]) => void;
|
||||||
onCreateTasks: () => void;
|
onCreateTasks: () => void;
|
||||||
@@ -2494,7 +2540,6 @@ interface BreakdownViewProps {
|
|||||||
|
|
||||||
function BreakdownView({
|
function BreakdownView({
|
||||||
subtasks,
|
subtasks,
|
||||||
dirty: _dirty,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
onUpdateSubtasks,
|
onUpdateSubtasks,
|
||||||
onCreateTasks,
|
onCreateTasks,
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ describe("AgentsView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps New Agent directly accessible while controls live in popup", async () => {
|
it("keeps New Agent directly accessible on desktop while controls live in popup", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
|
||||||
@@ -466,11 +466,23 @@ describe("AgentsView", () => {
|
|||||||
await openControlsPanel();
|
await openControlsPanel();
|
||||||
expect(screen.getByLabelText("Filter agents by state")).toBeTruthy();
|
expect(screen.getByLabelText("Filter agents by state")).toBeTruthy();
|
||||||
expect(screen.getByLabelText("Show system agents")).toBeTruthy();
|
expect(screen.getByLabelText("Show system agents")).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "Import" })).toBeTruthy();
|
expect(screen.getAllByRole("button", { name: "Import" }).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
||||||
expect(screen.getByLabelText("Heartbeat speed preset")).toBeTruthy();
|
expect(screen.getByLabelText("Heartbeat speed preset")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves import and new-agent actions into the controls popup on mobile", async () => {
|
||||||
|
mockViewportMode.mockReturnValue("mobile");
|
||||||
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Import" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "New Agent" })).toBeNull();
|
||||||
|
|
||||||
|
await openControlsPanel();
|
||||||
|
expect(screen.getByRole("button", { name: "Import" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("closes controls popup on Escape and outside click", async () => {
|
it("closes controls popup on Escape and outside click", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
const trigger = await openControlsPanel();
|
const trigger = await openControlsPanel();
|
||||||
@@ -1397,6 +1409,18 @@ describe("AgentsView", () => {
|
|||||||
expect(css).toContain(".org-chart-children > .org-chart-node::before");
|
expect(css).toContain(".org-chart-children > .org-chart-node::before");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a compact mobile Agents label visible, anchors the controls popup to the action row, and expands view toggles to 36px touch targets", () => {
|
||||||
|
const css = loadAllAppCss();
|
||||||
|
expect(css).toContain(".agents-view-primary-actions {\n position: relative;");
|
||||||
|
expect(css).toContain(".agent-controls-panel {\n position: absolute;\n top: calc(100% + var(--space-sm));\n right: 0;");
|
||||||
|
expect(css).toContain(".agents-view-title h2 {\n display: block;\n font-size: var(--space-md);");
|
||||||
|
expect(css).toContain(".agent-controls-mobile-actions {");
|
||||||
|
expect(css).toContain(".agent-controls-mobile-actions .btn {");
|
||||||
|
expect(css).toContain(".agents-view-controls .view-toggle .view-toggle-btn {");
|
||||||
|
expect(css).toContain("min-width: calc(var(--space-lg) * 2 + var(--space-xs));");
|
||||||
|
expect(css).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs));");
|
||||||
|
});
|
||||||
|
|
||||||
it("switches org chart to vertical layout mode when estimated width exceeds viewport", async () => {
|
it("switches org chart to vertical layout mode when estimated width exceeds viewport", async () => {
|
||||||
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(320);
|
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(320);
|
||||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||||
|
|||||||
@@ -935,7 +935,262 @@ describe("PlanningModeModal", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||||
"session-breakdown-priority",
|
"session-breakdown-priority",
|
||||||
[expect.objectContaining({ id: "subtask-1", priority: "urgent" })],
|
[{ id: "subtask-1", priority: "urgent" }],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends only edited breakdown fields when creating tasks", async () => {
|
||||||
|
const resumedSummary: PlanningSummary = {
|
||||||
|
title: "Resume-to-breakdown-compact",
|
||||||
|
description: "Recovered summary for compact breakdown",
|
||||||
|
suggestedSize: "M",
|
||||||
|
suggestedDependencies: [],
|
||||||
|
keyDeliverables: ["Implement", "Verify"],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchAiSession.mockResolvedValueOnce({
|
||||||
|
id: "session-breakdown-compact",
|
||||||
|
type: "planning",
|
||||||
|
status: "complete",
|
||||||
|
title: "Resume-to-breakdown-compact",
|
||||||
|
inputPayload: JSON.stringify({ initialPlan: "Recover and break down compactly" }),
|
||||||
|
conversationHistory: "[]",
|
||||||
|
currentQuestion: null,
|
||||||
|
result: JSON.stringify(resumedSummary),
|
||||||
|
thinkingOutput: "",
|
||||||
|
error: null,
|
||||||
|
projectId: null,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockStartPlanningBreakdown.mockResolvedValueOnce({
|
||||||
|
sessionId: "session-breakdown-compact",
|
||||||
|
subtasks: [
|
||||||
|
{
|
||||||
|
id: "subtask-1",
|
||||||
|
title: "First subtask",
|
||||||
|
description: "First description",
|
||||||
|
suggestedSize: "M",
|
||||||
|
dependsOn: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "subtask-2",
|
||||||
|
title: "Second subtask",
|
||||||
|
description: "Second description",
|
||||||
|
suggestedSize: "S",
|
||||||
|
dependsOn: ["subtask-1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlanningModeModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onTaskCreated={mockOnTaskCreated}
|
||||||
|
onTasksCreated={vi.fn()}
|
||||||
|
tasks={mockTasks}
|
||||||
|
resumeSessionId="session-breakdown-compact"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstSubtask = screen.getByTestId("subtask-item-0");
|
||||||
|
fireEvent.change(within(firstSubtask).getAllByRole("textbox")[0]!, {
|
||||||
|
target: { value: "Edited first subtask" },
|
||||||
|
});
|
||||||
|
const secondSubtask = screen.getByTestId("subtask-item-1");
|
||||||
|
fireEvent.change(within(secondSubtask).getAllByRole("textbox")[1]!, {
|
||||||
|
target: { value: "Edited second description" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||||
|
"session-breakdown-compact",
|
||||||
|
[
|
||||||
|
{ id: "subtask-1", title: "Edited first subtask" },
|
||||||
|
{ id: "subtask-2", description: "Edited second description" },
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes client-added subtasks in the compact create-tasks payload", async () => {
|
||||||
|
const resumedSummary: PlanningSummary = {
|
||||||
|
title: "Resume-to-breakdown-add-subtask",
|
||||||
|
description: "Recovered summary for added subtask",
|
||||||
|
suggestedSize: "M",
|
||||||
|
suggestedDependencies: [],
|
||||||
|
keyDeliverables: ["Implement"],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchAiSession.mockResolvedValueOnce({
|
||||||
|
id: "session-breakdown-add-subtask",
|
||||||
|
type: "planning",
|
||||||
|
status: "complete",
|
||||||
|
title: "Resume-to-breakdown-add-subtask",
|
||||||
|
inputPayload: JSON.stringify({ initialPlan: "Recover and add a subtask" }),
|
||||||
|
conversationHistory: "[]",
|
||||||
|
currentQuestion: null,
|
||||||
|
result: JSON.stringify(resumedSummary),
|
||||||
|
thinkingOutput: "",
|
||||||
|
error: null,
|
||||||
|
projectId: null,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockStartPlanningBreakdown.mockResolvedValueOnce({
|
||||||
|
sessionId: "session-breakdown-add-subtask",
|
||||||
|
subtasks: [
|
||||||
|
{
|
||||||
|
id: "subtask-1",
|
||||||
|
title: "Existing subtask",
|
||||||
|
description: "Existing description",
|
||||||
|
suggestedSize: "M",
|
||||||
|
dependsOn: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlanningModeModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onTaskCreated={mockOnTaskCreated}
|
||||||
|
onTasksCreated={vi.fn()}
|
||||||
|
tasks={mockTasks}
|
||||||
|
resumeSessionId="session-breakdown-add-subtask"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Add subtask" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add subtask" }));
|
||||||
|
const addedSubtask = screen.getByTestId("subtask-item-1");
|
||||||
|
const addedTextboxes = within(addedSubtask).getAllByRole("textbox");
|
||||||
|
fireEvent.change(addedTextboxes[0]!, { target: { value: "Rollout follow-up" } });
|
||||||
|
fireEvent.change(addedTextboxes[1]!, { target: { value: "Prepare rollout notes" } });
|
||||||
|
fireEvent.change(within(addedSubtask).getByLabelText("Size"), { target: { value: "S" } });
|
||||||
|
fireEvent.change(within(addedSubtask).getByLabelText("Priority"), { target: { value: "high" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||||
|
"session-breakdown-add-subtask",
|
||||||
|
[
|
||||||
|
{ id: "subtask-1" },
|
||||||
|
{
|
||||||
|
id: "subtask-2",
|
||||||
|
title: "Rollout follow-up",
|
||||||
|
description: "Prepare rollout notes",
|
||||||
|
suggestedSize: "S",
|
||||||
|
priority: "high",
|
||||||
|
dependsOn: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits removed generated subtasks from the compact create-tasks payload", async () => {
|
||||||
|
const resumedSummary: PlanningSummary = {
|
||||||
|
title: "Resume-to-breakdown-remove-subtask",
|
||||||
|
description: "Recovered summary for removed subtask",
|
||||||
|
suggestedSize: "M",
|
||||||
|
suggestedDependencies: [],
|
||||||
|
keyDeliverables: ["Implement", "Verify"],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchAiSession.mockResolvedValueOnce({
|
||||||
|
id: "session-breakdown-remove-subtask",
|
||||||
|
type: "planning",
|
||||||
|
status: "complete",
|
||||||
|
title: "Resume-to-breakdown-remove-subtask",
|
||||||
|
inputPayload: JSON.stringify({ initialPlan: "Recover and remove a subtask" }),
|
||||||
|
conversationHistory: "[]",
|
||||||
|
currentQuestion: null,
|
||||||
|
result: JSON.stringify(resumedSummary),
|
||||||
|
thinkingOutput: "",
|
||||||
|
error: null,
|
||||||
|
projectId: null,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockStartPlanningBreakdown.mockResolvedValueOnce({
|
||||||
|
sessionId: "session-breakdown-remove-subtask",
|
||||||
|
subtasks: [
|
||||||
|
{
|
||||||
|
id: "subtask-1",
|
||||||
|
title: "Existing subtask",
|
||||||
|
description: "Existing description",
|
||||||
|
suggestedSize: "M",
|
||||||
|
dependsOn: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "subtask-2",
|
||||||
|
title: "Generated follow-up",
|
||||||
|
description: "Generated follow-up description",
|
||||||
|
suggestedSize: "S",
|
||||||
|
dependsOn: ["subtask-1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlanningModeModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onTaskCreated={mockOnTaskCreated}
|
||||||
|
onTasksCreated={vi.fn()}
|
||||||
|
tasks={mockTasks}
|
||||||
|
resumeSessionId="session-breakdown-remove-subtask"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondSubtask = screen.getByTestId("subtask-item-1");
|
||||||
|
fireEvent.click(within(secondSubtask).getByRole("button", { name: "Remove" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||||
|
"session-breakdown-remove-subtask",
|
||||||
|
[{ id: "subtask-1" }],
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1141,6 +1141,19 @@ describe("PlanningModeModal", () => {
|
|||||||
|
|
||||||
fireEvent.change(sizeSelect, { target: { value: "L" } });
|
fireEvent.change(sizeSelect, { target: { value: "L" } });
|
||||||
expect(sizeSelect.value).toBe("L");
|
expect(sizeSelect.value).toBe("L");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Create Tasks"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||||
|
"session-123",
|
||||||
|
[
|
||||||
|
{ id: "subtask-1", suggestedSize: "L" },
|
||||||
|
{ id: "subtask-2" },
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
parseAgentResponse,
|
parseAgentResponse,
|
||||||
buildDepthPromptSuffix,
|
buildDepthPromptSuffix,
|
||||||
generateSubtasksFromPlanning,
|
generateSubtasksFromPlanning,
|
||||||
|
mergePlanningSubtaskDrafts,
|
||||||
formatInterviewQA,
|
formatInterviewQA,
|
||||||
SESSION_TTL_MS,
|
SESSION_TTL_MS,
|
||||||
GENERATION_TIMEOUT_MS,
|
GENERATION_TIMEOUT_MS,
|
||||||
@@ -2242,6 +2243,76 @@ describe("planning module", () => {
|
|||||||
expect(result[i]?.dependsOn).toEqual([`subtask-${i}`]);
|
expect(result[i]?.dependsOn).toEqual([`subtask-${i}`]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("merges compact subtask drafts onto generated planning subtasks", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const sessionId = await createCompletedSession(mockIp, "Compact draft merge test");
|
||||||
|
|
||||||
|
const generated = generateSubtasksFromPlanning(sessionId);
|
||||||
|
const merged = mergePlanningSubtaskDrafts(sessionId, [
|
||||||
|
{ id: generated[0]!.id },
|
||||||
|
{
|
||||||
|
id: generated[1]!.id,
|
||||||
|
title: "Edited tests deliverable",
|
||||||
|
description: "Edited description",
|
||||||
|
suggestedSize: "L",
|
||||||
|
priority: "urgent",
|
||||||
|
dependsOn: [generated[0]!.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generated[2]!.id,
|
||||||
|
dependsOn: [generated[0]!.id, generated[1]!.id],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(merged[0]).toEqual(generated[0]);
|
||||||
|
expect(merged[1]).toEqual({
|
||||||
|
...generated[1],
|
||||||
|
title: "Edited tests deliverable",
|
||||||
|
description: "Edited description",
|
||||||
|
suggestedSize: "L",
|
||||||
|
priority: "urgent",
|
||||||
|
});
|
||||||
|
expect(merged[2]).toEqual({
|
||||||
|
...generated[2],
|
||||||
|
dependsOn: [generated[0]!.id, generated[1]!.id],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves client-added subtasks when merging compact drafts", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const sessionId = await createCompletedSession(mockIp, "Client-added compact draft test");
|
||||||
|
|
||||||
|
const merged = mergePlanningSubtaskDrafts(sessionId, [
|
||||||
|
{ id: "subtask-1" },
|
||||||
|
{
|
||||||
|
id: "subtask-99",
|
||||||
|
title: "New client-added subtask",
|
||||||
|
description: "Create docs and rollout notes",
|
||||||
|
suggestedSize: "S",
|
||||||
|
priority: "high",
|
||||||
|
dependsOn: ["subtask-1"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(merged[1]).toEqual({
|
||||||
|
id: "subtask-99",
|
||||||
|
title: "New client-added subtask",
|
||||||
|
description: "Create docs and rollout notes",
|
||||||
|
suggestedSize: "S",
|
||||||
|
priority: "high",
|
||||||
|
dependsOn: ["subtask-1"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a client-added compact subtask draft omits its title", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const sessionId = await createCompletedSession(mockIp, "Unknown compact draft test");
|
||||||
|
|
||||||
|
expect(() => mergePlanningSubtaskDrafts(sessionId, [{ id: "subtask-999" }])).toThrow(
|
||||||
|
"Client-added subtask must have a title: subtask-999",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1255,8 +1255,8 @@ describe("projectId store scoping regressions", () => {
|
|||||||
planningSessionId: "plan-session-2",
|
planningSessionId: "plan-session-2",
|
||||||
projectId,
|
projectId,
|
||||||
subtasks: [
|
subtasks: [
|
||||||
{ id: "sub-1", title: "First scoped task", description: "First", suggestedSize: "S", dependsOn: [] },
|
{ id: "subtask-1", title: "First scoped task", description: "First", suggestedSize: "S", dependsOn: [] },
|
||||||
{ id: "sub-2", title: "Second scoped task", description: "Second", suggestedSize: "M", dependsOn: ["sub-1"] },
|
{ id: "subtask-2", title: "Second scoped task", description: "Second", suggestedSize: "M", dependsOn: ["subtask-1"] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
{ "Content-Type": "application/json" },
|
{ "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -1526,7 +1526,7 @@ describe("Planning Mode Routes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates multiple planning tasks with per-subtask priorities and defaults", async () => {
|
it("creates multiple planning tasks from compact subtask drafts while preserving edited fields", async () => {
|
||||||
(store.createTask as ReturnType<typeof vi.fn>)
|
(store.createTask as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
id: "FN-201",
|
id: "FN-201",
|
||||||
@@ -1543,6 +1543,14 @@ describe("Planning Mode Routes", () => {
|
|||||||
dependencies: [],
|
dependencies: [],
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "FN-203",
|
||||||
|
description: "Third",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
});
|
});
|
||||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
@@ -1560,6 +1568,24 @@ describe("Planning Mode Routes", () => {
|
|||||||
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
|
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
|
||||||
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
|
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
|
||||||
|
|
||||||
|
const breakdownRes = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start-breakdown",
|
||||||
|
JSON.stringify({ sessionId: planningSessionId }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
expect(breakdownRes.status).toBe(200);
|
||||||
|
|
||||||
|
const generatedSubtasks = breakdownRes.body.subtasks as Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
suggestedSize: "S" | "M" | "L";
|
||||||
|
priority?: string;
|
||||||
|
dependsOn: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
buildApp(),
|
buildApp(),
|
||||||
"POST",
|
"POST",
|
||||||
@@ -1568,19 +1594,19 @@ describe("Planning Mode Routes", () => {
|
|||||||
planningSessionId,
|
planningSessionId,
|
||||||
subtasks: [
|
subtasks: [
|
||||||
{
|
{
|
||||||
id: "subtask-1",
|
id: generatedSubtasks[0]!.id,
|
||||||
title: "Auth backend",
|
title: "Auth backend",
|
||||||
description: "Implement backend",
|
description: "Implement backend",
|
||||||
suggestedSize: "M",
|
suggestedSize: "L",
|
||||||
priority: "urgent",
|
priority: "urgent",
|
||||||
dependsOn: [],
|
dependsOn: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "subtask-2",
|
id: generatedSubtasks[1]!.id,
|
||||||
title: "Auth UI",
|
},
|
||||||
description: "Implement UI",
|
{
|
||||||
suggestedSize: "S",
|
id: generatedSubtasks[2]!.id,
|
||||||
dependsOn: ["subtask-1"],
|
dependsOn: [generatedSubtasks[0]!.id, generatedSubtasks[1]!.id],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -1590,12 +1616,211 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(store.createTask).toHaveBeenNthCalledWith(
|
expect(store.createTask).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.objectContaining({ title: "Auth backend", priority: "urgent" }),
|
expect.objectContaining({ title: "Auth backend", description: "Implement backend", priority: "urgent" }),
|
||||||
);
|
);
|
||||||
expect(store.createTask).toHaveBeenNthCalledWith(
|
expect(store.createTask).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
expect.objectContaining({ title: "Auth UI", priority: "normal" }),
|
expect.objectContaining({
|
||||||
|
title: generatedSubtasks[1]!.title,
|
||||||
|
description: generatedSubtasks[1]!.description,
|
||||||
|
priority: "normal",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
expect(store.createTask).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.objectContaining({
|
||||||
|
title: generatedSubtasks[2]!.title,
|
||||||
|
description: generatedSubtasks[2]!.description,
|
||||||
|
priority: "normal",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-201", { size: "L" });
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-203", { dependencies: ["FN-201", "FN-202"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports client-added subtasks and omitted generated subtasks in compact breakdown payloads", async () => {
|
||||||
|
(store.createTask as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "FN-210",
|
||||||
|
description: "Generated task",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "FN-211",
|
||||||
|
description: "Client-added task",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||||
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const startRes = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start",
|
||||||
|
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
const planningSessionId = startRes.body.sessionId;
|
||||||
|
|
||||||
|
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
|
||||||
|
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
|
||||||
|
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
|
||||||
|
|
||||||
|
const breakdownRes = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start-breakdown",
|
||||||
|
JSON.stringify({ sessionId: planningSessionId }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
expect(breakdownRes.status).toBe(200);
|
||||||
|
const generatedSubtasks = breakdownRes.body.subtasks as Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
suggestedSize: "S" | "M" | "L";
|
||||||
|
priority?: string;
|
||||||
|
dependsOn: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/planning/create-tasks",
|
||||||
|
JSON.stringify({
|
||||||
|
planningSessionId,
|
||||||
|
subtasks: [
|
||||||
|
{ id: generatedSubtasks[0]!.id },
|
||||||
|
{
|
||||||
|
id: "subtask-99",
|
||||||
|
title: "Rollout follow-up",
|
||||||
|
description: "Prepare rollout notes",
|
||||||
|
suggestedSize: "S",
|
||||||
|
priority: "high",
|
||||||
|
dependsOn: [generatedSubtasks[0]!.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(store.createTask).toHaveBeenCalledTimes(2);
|
||||||
|
expect(store.createTask).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
title: generatedSubtasks[0]!.title,
|
||||||
|
description: generatedSubtasks[0]!.description,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.createTask).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Rollout follow-up",
|
||||||
|
description: "Prepare rollout notes",
|
||||||
|
priority: "high",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-211", { size: "S" });
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-211", { dependencies: ["FN-210"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts compact breakdown payloads that avoid oversized planning create-tasks requests", async () => {
|
||||||
|
const createdTaskBase = {
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
for (let index = 0; index < 15; index += 1) {
|
||||||
|
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...createdTaskBase,
|
||||||
|
id: `FN-${300 + index}`,
|
||||||
|
description: `Task ${index + 1}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||||
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const startRes = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start",
|
||||||
|
JSON.stringify({ initialPlan: "Break a large platform plan into many tasks" }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
const planningSessionId = startRes.body.sessionId;
|
||||||
|
|
||||||
|
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "large" } }), { "Content-Type": "application/json" });
|
||||||
|
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must support auth, settings, dashboards, workflows, imports, sync, audits, search, mobile, docs, QA, releases, telemetry, reliability, and security." } }), { "Content-Type": "application/json" });
|
||||||
|
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
|
||||||
|
|
||||||
|
const summaryOverride = {
|
||||||
|
title: "Large planning summary",
|
||||||
|
description: `${"Large planning context. ".repeat(400)}${"Detailed implementation note. ".repeat(400)}`,
|
||||||
|
suggestedSize: "L",
|
||||||
|
suggestedDependencies: [],
|
||||||
|
keyDeliverables: Array.from({ length: 15 }, (_, index) => `Deliverable ${index + 1}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
const breakdownRes = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start-breakdown",
|
||||||
|
JSON.stringify({ sessionId: planningSessionId, summary: summaryOverride }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
expect(breakdownRes.status).toBe(200);
|
||||||
|
|
||||||
|
const generatedSubtasks = breakdownRes.body.subtasks as Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
suggestedSize: "S" | "M" | "L";
|
||||||
|
priority?: string;
|
||||||
|
dependsOn: string[];
|
||||||
|
}>;
|
||||||
|
expect(generatedSubtasks).toHaveLength(15);
|
||||||
|
|
||||||
|
const oversizedLegacyPayload = JSON.stringify({ planningSessionId, subtasks: generatedSubtasks });
|
||||||
|
const compactPayload = JSON.stringify({
|
||||||
|
planningSessionId,
|
||||||
|
subtasks: generatedSubtasks.map((subtask) => ({ id: subtask.id })),
|
||||||
|
});
|
||||||
|
expect(Buffer.byteLength(oversizedLegacyPayload)).toBeGreaterThan(100 * 1024);
|
||||||
|
expect(Buffer.byteLength(compactPayload)).toBeLessThan(8 * 1024);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/planning/create-tasks",
|
||||||
|
compactPayload,
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(store.createTask).toHaveBeenCalledTimes(15);
|
||||||
|
expect(store.createTask).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
title: generatedSubtasks[0]!.title,
|
||||||
|
description: generatedSubtasks[0]!.description,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.createTask).toHaveBeenNthCalledWith(
|
||||||
|
15,
|
||||||
|
expect.objectContaining({
|
||||||
|
title: generatedSubtasks[14]!.title,
|
||||||
|
description: generatedSubtasks[14]!.description,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.logEntry).toHaveBeenCalledTimes(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies branchSelection when creating a planning task", async () => {
|
it("applies branchSelection when creating a planning task", async () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
PlanningQuestion,
|
PlanningQuestion,
|
||||||
PlanningSummary,
|
PlanningSummary,
|
||||||
PlanningResponse,
|
PlanningResponse,
|
||||||
|
TaskPriority,
|
||||||
TaskStore,
|
TaskStore,
|
||||||
NtfyNotificationEvent,
|
NtfyNotificationEvent,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
@@ -2234,6 +2235,15 @@ function buildPlanningSubtaskDescription(input: {
|
|||||||
return `${input.taskGuidance}\n\n${contextSections.join("\n\n")}`;
|
return `${input.taskGuidance}\n\n${contextSections.join("\n\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlanningSubtaskDraft {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
suggestedSize?: "S" | "M" | "L";
|
||||||
|
priority?: TaskPriority;
|
||||||
|
dependsOn?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
|
export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return [];
|
if (!session) return [];
|
||||||
@@ -2303,6 +2313,51 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergePlanningSubtaskDrafts(
|
||||||
|
sessionId: string,
|
||||||
|
drafts: PlanningSubtaskDraft[],
|
||||||
|
): SubtaskItem[] {
|
||||||
|
const generatedSubtasks = generateSubtasksFromPlanning(sessionId);
|
||||||
|
const generatedById = new Map(generatedSubtasks.map((subtask) => [subtask.id, subtask]));
|
||||||
|
|
||||||
|
return drafts.map((draft) => {
|
||||||
|
const generated = generatedById.get(draft.id);
|
||||||
|
const normalizedDependsOn = Array.isArray(draft.dependsOn)
|
||||||
|
? draft.dependsOn.filter((dependency): dependency is string => typeof dependency === "string")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!generated) {
|
||||||
|
const title = typeof draft.title === "string" ? draft.title.trim() : "";
|
||||||
|
if (!title) {
|
||||||
|
throw new Error(`Client-added subtask must have a title: ${draft.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = typeof draft.description === "string" ? draft.description : title;
|
||||||
|
return {
|
||||||
|
id: draft.id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
suggestedSize: draft.suggestedSize === "S" || draft.suggestedSize === "M" || draft.suggestedSize === "L"
|
||||||
|
? draft.suggestedSize
|
||||||
|
: "M",
|
||||||
|
priority: draft.priority ?? DEFAULT_TASK_PRIORITY,
|
||||||
|
dependsOn: normalizedDependsOn ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: generated.id,
|
||||||
|
title: typeof draft.title === "string" ? draft.title : generated.title,
|
||||||
|
description: typeof draft.description === "string" ? draft.description : generated.description,
|
||||||
|
suggestedSize: draft.suggestedSize === "S" || draft.suggestedSize === "M" || draft.suggestedSize === "L"
|
||||||
|
? draft.suggestedSize
|
||||||
|
: generated.suggestedSize,
|
||||||
|
priority: draft.priority ?? generated.priority ?? DEFAULT_TASK_PRIORITY,
|
||||||
|
dependsOn: normalizedDependsOn ?? generated.dependsOn,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup a session (used after task creation).
|
* Cleanup a session (used after task creation).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1172,7 +1172,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
/**
|
/**
|
||||||
* POST /api/planning/create-tasks
|
* POST /api/planning/create-tasks
|
||||||
* Create multiple tasks from a completed planning session (after optional editing).
|
* Create multiple tasks from a completed planning session (after optional editing).
|
||||||
* Body: { planningSessionId: string, subtasks: Array<{id, title, description, suggestedSize, dependsOn}> }
|
* Body: { planningSessionId: string, subtasks: Array<{ id, title?, description?, suggestedSize?, priority?, dependsOn? }> }
|
||||||
* Returns: { tasks: Task[] }
|
* Returns: { tasks: Task[] }
|
||||||
*/
|
*/
|
||||||
router.post("/planning/create-tasks", async (req, res) => {
|
router.post("/planning/create-tasks", async (req, res) => {
|
||||||
@@ -1181,11 +1181,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
planningSessionId?: string;
|
planningSessionId?: string;
|
||||||
subtasks?: Array<{
|
subtasks?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title?: string;
|
||||||
description: string;
|
description?: string;
|
||||||
suggestedSize: "S" | "M" | "L";
|
suggestedSize?: "S" | "M" | "L";
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
dependsOn: string[];
|
dependsOn?: string[];
|
||||||
}>;
|
}>;
|
||||||
branch?: unknown;
|
branch?: unknown;
|
||||||
baseBranch?: unknown;
|
baseBranch?: unknown;
|
||||||
@@ -1202,7 +1202,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const { getSession, cleanupSession, formatInterviewQA } = await import("../planning.js");
|
const { getSession, cleanupSession, formatInterviewQA, mergePlanningSubtaskDrafts } = await import("../planning.js");
|
||||||
|
|
||||||
const session = getSession(planningSessionId);
|
const session = getSession(planningSessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -1218,14 +1218,44 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
? `Source: ${session.initialPlan.slice(0, 200)}\n\n${qaSection}`
|
? `Source: ${session.initialPlan.slice(0, 200)}\n\n${qaSection}`
|
||||||
: `Source: ${session.initialPlan.slice(0, 200)}`;
|
: `Source: ${session.initialPlan.slice(0, 200)}`;
|
||||||
|
|
||||||
// Validate each subtask
|
|
||||||
for (const item of subtasks) {
|
for (const item of subtasks) {
|
||||||
if (!item || typeof item.id !== "string" || typeof item.title !== "string" || !item.title.trim()) {
|
if (!item || typeof item.id !== "string" || !item.id.trim()) {
|
||||||
throw badRequest("Each subtask must include id and title");
|
throw badRequest("Each subtask must include id");
|
||||||
|
}
|
||||||
|
if (item.title !== undefined && (typeof item.title !== "string" || !item.title.trim())) {
|
||||||
|
throw badRequest("Each edited subtask title must be a non-empty string");
|
||||||
|
}
|
||||||
|
if (item.description !== undefined && typeof item.description !== "string") {
|
||||||
|
throw badRequest("Each edited subtask description must be a string");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
item.suggestedSize !== undefined
|
||||||
|
&& item.suggestedSize !== "S"
|
||||||
|
&& item.suggestedSize !== "M"
|
||||||
|
&& item.suggestedSize !== "L"
|
||||||
|
) {
|
||||||
|
throw badRequest("Each edited subtask suggestedSize must be S, M, or L");
|
||||||
}
|
}
|
||||||
if (item.priority !== undefined && !isTaskPriority(item.priority)) {
|
if (item.priority !== undefined && !isTaskPriority(item.priority)) {
|
||||||
throw badRequest("Each subtask priority must be one of low, normal, high, urgent");
|
throw badRequest("Each subtask priority must be one of low, normal, high, urgent");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
item.dependsOn !== undefined
|
||||||
|
&& (!Array.isArray(item.dependsOn) || item.dependsOn.some((dependency) => typeof dependency !== "string"))
|
||||||
|
) {
|
||||||
|
throw badRequest("Each edited subtask dependsOn value must be an array of ids");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mergedSubtasks;
|
||||||
|
try {
|
||||||
|
mergedSubtasks = mergePlanningSubtaskDrafts(planningSessionId, subtasks);
|
||||||
|
} catch (error) {
|
||||||
|
throw badRequest(error instanceof Error ? error.message : "Invalid planning subtask edits");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergedSubtasks.length !== subtasks.length) {
|
||||||
|
throw badRequest("Could not resolve planning subtasks for task creation");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
|
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
|
||||||
@@ -1241,8 +1271,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
|
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
|
||||||
const tempIdToTaskId = new Map<string, string>();
|
const tempIdToTaskId = new Map<string, string>();
|
||||||
|
|
||||||
// Create tasks
|
for (const item of mergedSubtasks) {
|
||||||
for (const item of subtasks) {
|
|
||||||
const taskBranch = branchMode === "per-task-derived"
|
const taskBranch = branchMode === "per-task-derived"
|
||||||
? derivePerTaskBranch(resolvedBranch, item.title || item.id)
|
? derivePerTaskBranch(resolvedBranch, item.title || item.id)
|
||||||
: resolvedBranch;
|
: resolvedBranch;
|
||||||
@@ -1268,9 +1297,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve dependencies
|
for (let index = 0; index < mergedSubtasks.length; index++) {
|
||||||
for (let index = 0; index < subtasks.length; index++) {
|
const item = mergedSubtasks[index]!;
|
||||||
const item = subtasks[index]!;
|
|
||||||
const created = createdTasks[index]!;
|
const created = createdTasks[index]!;
|
||||||
const resolvedDependencies = Array.isArray(item.dependsOn)
|
const resolvedDependencies = Array.isArray(item.dependsOn)
|
||||||
? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep))
|
? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep))
|
||||||
@@ -1284,7 +1312,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
await scopedStore.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails);
|
await scopedStore.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup the planning session
|
|
||||||
cleanupSession(planningSessionId);
|
cleanupSession(planningSessionId);
|
||||||
|
|
||||||
res.status(201).json({ tasks: createdTasks });
|
res.status(201).json({ tasks: createdTasks });
|
||||||
|
|||||||
Reference in New Issue
Block a user