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:
@@ -84,6 +84,7 @@
|
||||
}
|
||||
|
||||
.agents-view-primary-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
@@ -100,9 +101,10 @@
|
||||
|
||||
.agent-controls-panel {
|
||||
position: absolute;
|
||||
top: calc(var(--header-height) + var(--space-sm));
|
||||
right: var(--space-lg);
|
||||
width: min(calc(var(--space-2xl) * 16), calc(100% - var(--space-2xl)));
|
||||
top: calc(100% + var(--space-sm));
|
||||
right: 0;
|
||||
width: min(calc(var(--space-2xl) * 16), calc(100vw - var(--space-2xl)));
|
||||
max-width: calc(100vw - var(--space-2xl));
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -133,6 +135,17 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -1263,9 +1276,9 @@
|
||||
|
||||
/* === AgentsView mobile header (cleaner stacked layout) ==================== */
|
||||
@media (max-width: 768px) {
|
||||
/* Single-row header: hide the redundant "Agents" title (the icon + page
|
||||
context already make it obvious), and pack view-toggle + Controls + New
|
||||
Agent + Refresh into one no-wrap row. */
|
||||
/* Single-row header: keep a compact textual title visible while lower-
|
||||
priority actions move into the Controls popup so narrow phones keep a
|
||||
stable single-row layout. */
|
||||
.agents-view-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -1284,11 +1297,12 @@
|
||||
}
|
||||
|
||||
.agents-view-title h2 {
|
||||
font-size: var(--space-lg);
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
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
|
||||
container grows to fill remaining space, and `space-between` separates
|
||||
the toggle (left side) from primary-actions (right side). */
|
||||
@@ -1302,13 +1316,21 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* View toggle (List/Board/Tree/Org) — keep the 32px pill chrome from the
|
||||
base styles. Inner buttons stay at the base 28×28 (centered in the 2px
|
||||
pill padding). All surrounding header icons below are sized to match
|
||||
that 28×28 inner footprint so every icon lines up with the view-toggle
|
||||
buttons. */
|
||||
/* View toggle (List/Board/Tree/Org) — expand the pill and inner buttons to
|
||||
the shared 36px mobile touch-target while preserving the compact grouped
|
||||
chrome used elsewhere in the dashboard header. */
|
||||
.agents-view-controls .view-toggle {
|
||||
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 {
|
||||
@@ -1332,23 +1354,6 @@
|
||||
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 {
|
||||
width: 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));
|
||||
}
|
||||
|
||||
/* Title row: keep Bot icon + "Agents" text on a single line so the
|
||||
view-toggle and primary actions get pushed to the right edge. */
|
||||
/* Title row: keep the Bot icon aligned with the compact control row. */
|
||||
.agents-view-title {
|
||||
height: calc(var(--space-lg) * 2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-controls-mobile-actions .btn {
|
||||
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||
}
|
||||
|
||||
.agents-org-full-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -799,127 +799,158 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
|
||||
</button>
|
||||
<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>
|
||||
{!isMobileViewport && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{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
|
||||
isOpen={isCreating}
|
||||
onClose={() => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
updateGlobalSettings,
|
||||
type PlanningSession,
|
||||
type SubtaskItem,
|
||||
type PlanningSubtaskDraft,
|
||||
type ModelInfo,
|
||||
type ConversationHistoryEntry,
|
||||
type AiSessionSummary,
|
||||
@@ -69,7 +70,7 @@ type ViewState =
|
||||
| { type: "question"; session: PlanningSession }
|
||||
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
|
||||
| { 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: "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 {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
@@ -1505,14 +1541,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
try {
|
||||
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);
|
||||
setView({
|
||||
type: "breakdown",
|
||||
sessionId: result.sessionId,
|
||||
subtasks: result.subtasks.map((subtask) => ({
|
||||
...subtask,
|
||||
priority: normalizeTaskPriority(subtask.priority),
|
||||
})),
|
||||
originalSubtasks: normalizedSubtasks.map((subtask) => ({ ...subtask, dependsOn: [...subtask.dependsOn] })),
|
||||
subtasks: normalizedSubtasks.map((subtask) => ({ ...subtask, dependsOn: [...subtask.dependsOn] })),
|
||||
dirty: false,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -1531,10 +1570,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const completedSessionId = view.sessionId;
|
||||
const result = await createTasksFromPlanning(
|
||||
completedSessionId,
|
||||
view.subtasks.map((subtask) => ({
|
||||
...subtask,
|
||||
priority: normalizeTaskPriority(subtask.priority),
|
||||
})),
|
||||
buildCompactPlanningSubtaskDrafts(
|
||||
view.originalSubtasks,
|
||||
view.subtasks.map((subtask) => ({
|
||||
...subtask,
|
||||
priority: normalizeTaskPriority(subtask.priority),
|
||||
})),
|
||||
),
|
||||
projectId,
|
||||
);
|
||||
onTasksCreated(result.tasks);
|
||||
@@ -1564,7 +1606,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
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]);
|
||||
|
||||
@@ -1995,7 +2043,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
{view.type === "breakdown" && (
|
||||
<BreakdownView
|
||||
subtasks={view.subtasks}
|
||||
dirty={view.dirty}
|
||||
isLoading={false}
|
||||
onUpdateSubtasks={(newSubtasks) =>
|
||||
setView({ ...view, subtasks: newSubtasks, dirty: true })
|
||||
@@ -2485,7 +2532,6 @@ function createEmptySubtask(index: number): SubtaskItem {
|
||||
|
||||
interface BreakdownViewProps {
|
||||
subtasks: SubtaskItem[];
|
||||
dirty: boolean;
|
||||
isLoading: boolean;
|
||||
onUpdateSubtasks: (subtasks: SubtaskItem[]) => void;
|
||||
onCreateTasks: () => void;
|
||||
@@ -2494,7 +2540,6 @@ interface BreakdownViewProps {
|
||||
|
||||
function BreakdownView({
|
||||
subtasks,
|
||||
dirty: _dirty,
|
||||
isLoading,
|
||||
onUpdateSubtasks,
|
||||
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} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
|
||||
@@ -466,11 +466,23 @@ describe("AgentsView", () => {
|
||||
await openControlsPanel();
|
||||
expect(screen.getByLabelText("Filter agents by state")).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.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 () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
const trigger = await openControlsPanel();
|
||||
@@ -1397,6 +1409,18 @@ describe("AgentsView", () => {
|
||||
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 () => {
|
||||
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(320);
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
|
||||
@@ -935,7 +935,262 @@ describe("PlanningModeModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||
"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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1141,6 +1141,19 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
fireEvent.change(sizeSelect, { target: { value: "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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user