FN-7994: keep planning session sidebar populated during load
Speed up Planning mode session-list load so the sidebar never blanks while history refreshes. - Seed the planning sidebar from already-loaded active sessions via initialSessions - Filter GET /ai-sessions and store listAll by optional type=planning to skip non-planning payloads - Show skeleton rows while the first authoritative session refresh is in flight - Wire type through client fetchAiSessions, dashboard AiSessionStore, and core listAllAiSessions - Add UI and route coverage for seeded/skeleton load and type-filtered listing - Ship patch changeset for the operator-facing performance fix Files changed: .changeset/FN-7994-planning-sidebar-fast-load.md | 7 +++ packages/core/src/async-ai-session-store.ts | 7 ++- packages/dashboard/app/App.tsx | 1 + packages/dashboard/app/api/legacy.ts | 3 +- .../dashboard/app/components/PlanningModeModal.css | 48 ++++++++++++---- .../dashboard/app/components/PlanningModeModal.tsx | 27 ++++++++- .../PlanningModeModal.planning-flow.test.tsx | 65 ++++++++++++++++++++++ .../app/components/dashboard/MainContent.tsx | 2 + .../dashboard/app/components/dashboard/types.ts | 2 + .../src/__tests__/routes-planning.test.ts | 29 ++++++++++ packages/dashboard/src/ai-session-store.ts | 2 +- packages/dashboard/src/routes.ts | 16 +++++- 12 files changed, 201 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7994 Fusion-Task-Lineage: 7c4cf98d-6dfe-4b9b-bc88-62257ed39507 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7994-planning-sidebar-fast-load.md
Normal file
7
.changeset/FN-7994-planning-sidebar-fast-load.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep Planning session history visible while its latest data loads.
|
||||
category: performance
|
||||
dev: Planning fetches request only planning-type session summaries.
|
||||
@@ -220,17 +220,22 @@ export async function listActiveAiSessions(
|
||||
/**
|
||||
* List all sessions (including complete), optionally filtered by projectId.
|
||||
* By default excludes archived. Returns summary rows with inputPayload.
|
||||
*
|
||||
* FNXC:PlanningMode 2026-07-15-00:00:
|
||||
* FN-7994 narrows the Planning sidebar's refresh to planning rows before
|
||||
* inputPayload blobs cross the API boundary; calls without a type stay broad.
|
||||
*/
|
||||
export async function listAllAiSessions(
|
||||
handle: QueryHandle,
|
||||
projectId?: string,
|
||||
options?: { includeArchived?: boolean },
|
||||
options?: { includeArchived?: boolean; type?: AiSessionType },
|
||||
): Promise<unknown[]> {
|
||||
const conditions: ReturnType<typeof eq>[] = [];
|
||||
if (!options?.includeArchived) {
|
||||
conditions.push(eq(schema.project.aiSessions.archived, 0));
|
||||
}
|
||||
if (projectId) conditions.push(eq(schema.project.aiSessions.projectId, projectId));
|
||||
if (options?.type) conditions.push(eq(schema.project.aiSessions.type, options.type));
|
||||
const query = handle
|
||||
.select({
|
||||
id: schema.project.aiSessions.id,
|
||||
|
||||
@@ -1392,6 +1392,7 @@ function AppInner() {
|
||||
isRemote,
|
||||
remoteData,
|
||||
tasks,
|
||||
bgPlanningSessions,
|
||||
workflowSteps,
|
||||
subscribePluginEvents,
|
||||
openDetailTask,
|
||||
|
||||
@@ -9624,12 +9624,13 @@ export function parseConversationHistory(raw: string): ConversationHistoryEntry[
|
||||
|
||||
export async function fetchAiSessions(
|
||||
projectId?: string,
|
||||
options?: { includeCompleted?: boolean; includeArchived?: boolean },
|
||||
options?: { includeCompleted?: boolean; includeArchived?: boolean; type?: AiSessionSummary["type"] },
|
||||
): Promise<AiSessionSummary[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (projectId) search.set("projectId", projectId);
|
||||
if (options?.includeCompleted) search.set("includeCompleted", "1");
|
||||
if (options?.includeArchived) search.set("includeArchived", "1");
|
||||
if (options?.type) search.set("type", options.type);
|
||||
const qs = search.toString();
|
||||
const res = await fetch(buildApiUrl(`/ai-sessions${qs ? `?${qs}` : ""}`), {
|
||||
headers: withTokenHeader(),
|
||||
|
||||
@@ -277,6 +277,51 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.planning-sidebar-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.planning-sidebar-skeleton-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.planning-sidebar-skeleton-icon,
|
||||
.planning-sidebar-skeleton-title,
|
||||
.planning-sidebar-skeleton-meta {
|
||||
display: block;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.planning-sidebar-skeleton-icon {
|
||||
width: var(--space-md);
|
||||
height: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.planning-sidebar-skeleton-copy {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding-block: var(--space-xs);
|
||||
}
|
||||
|
||||
.planning-sidebar-skeleton-title {
|
||||
width: 75%;
|
||||
height: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-sidebar-skeleton-meta {
|
||||
width: 50%;
|
||||
height: var(--space-xs);
|
||||
}
|
||||
|
||||
.planning-sidebar-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -517,6 +562,9 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.planning-sidebar-skeleton-row {
|
||||
padding-inline: var(--space-md);
|
||||
}
|
||||
.planning-modal-body--show-list .planning-sidebar-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ interface PlanningModeModalProps {
|
||||
workflowId?: string | null;
|
||||
/** When set, reconnect to a persisted background session instead of starting fresh */
|
||||
resumeSessionId?: string;
|
||||
/** Already-loaded active planning sessions used to populate the sidebar before its full refresh. */
|
||||
initialSessions?: AiSessionSummary[];
|
||||
/** Render without the full-screen modal chrome when Planning Mode is mounted as a top-level app view. */
|
||||
presentation?: ModalPresentation;
|
||||
}
|
||||
@@ -294,7 +296,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, presentation = "modal" }: PlanningModeModalProps) {
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal" }: PlanningModeModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates.
|
||||
// Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical
|
||||
@@ -439,7 +441,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}>({});
|
||||
|
||||
// Sidebar list state
|
||||
const [planningSessions, setPlanningSessions] = useState<AiSessionSummary[]>([]);
|
||||
const [planningSessions, setPlanningSessions] = useState<AiSessionSummary[]>(() => dedupeSessionsById(initialSessions ?? []));
|
||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(resumeSessionId ?? null);
|
||||
// Mobile: when the modal is narrow, only one pane is visible at a time.
|
||||
@@ -1347,6 +1349,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const all = await fetchAiSessions(projectId, {
|
||||
includeCompleted: true,
|
||||
includeArchived: showArchived,
|
||||
type: "planning",
|
||||
});
|
||||
const planning = all.filter((s) => s.type === "planning");
|
||||
setPlanningSessions(dedupeSessionsById(planning));
|
||||
@@ -3630,6 +3633,26 @@ function PlanningSessionList({
|
||||
The embedded Planning view reads as a real two-pane layout matching Missions: the left sidebar is a full-height flex column whose session list scrolls and whose primary action ("New session") is pinned to a bottom footer (parity with MissionManager's mission-manager__sidebar-footer + sidebar-cta). The header that previously held the New session button is removed so the list owns the top of the sidebar like the Missions list.
|
||||
*/}
|
||||
<div className="planning-sidebar-list">
|
||||
{/*
|
||||
FNXC:PlanningMode 2026-07-15-00:00:
|
||||
FN-7994 requires the sidebar to never become an empty pane during its
|
||||
authoritative session refresh. Skeleton rows provide immediate loading
|
||||
feedback, while existing rows remain visible during refreshes.
|
||||
*/}
|
||||
{loading && sessions.length === 0 && (
|
||||
<div className="planning-sidebar-skeleton" data-testid="planning-sidebar-skeleton" aria-label={t("planning.loadingSessions", "Loading planning sessions")}>
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<div key={index} className="planning-sidebar-skeleton-row" aria-hidden="true">
|
||||
<span className="planning-sidebar-skeleton-icon" />
|
||||
<span className="planning-sidebar-skeleton-copy">
|
||||
<span className="planning-sidebar-skeleton-title" />
|
||||
<span className="planning-sidebar-skeleton-meta" />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="planning-sidebar-empty text-muted">
|
||||
{t("planning.noSavedSessions", "No saved sessions yet. Start one on the right to see it here.")}
|
||||
|
||||
@@ -3965,6 +3965,71 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("planning sidebar loading", () => {
|
||||
it("renders skeleton rows rather than a blank sidebar while the session refresh is pending", async () => {
|
||||
let resolveSessions!: (sessions: Array<Record<string, unknown>>) => void;
|
||||
mockFetchAiSessions.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveSessions = resolve;
|
||||
}));
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("planning-sidebar-skeleton")).toBeDefined();
|
||||
expect(screen.queryByText(/No saved sessions yet/i)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveSessions([{
|
||||
id: "loaded-planning-session",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Loaded planning session",
|
||||
projectId: null,
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
}]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Loaded planning session/i })).toBeDefined();
|
||||
expect(screen.queryByTestId("planning-sidebar-skeleton")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows initial background planning sessions before an authoritative refresh resolves", async () => {
|
||||
mockFetchAiSessions.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const initialSessions = [{
|
||||
id: "background-planning-session",
|
||||
type: "planning" as const,
|
||||
status: "awaiting_input" as const,
|
||||
title: "Continue background planning",
|
||||
projectId: null,
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
}];
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
initialSessions={initialSessions}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Continue background planning/i })).toBeDefined();
|
||||
expect(screen.queryByTestId("planning-sidebar-skeleton")).toBeNull();
|
||||
await waitFor(() => expect(mockFetchAiSessions).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedupeSessionsById export", () => {
|
||||
it("keeps the newest session for duplicate ids while preserving stable order on ties", () => {
|
||||
expect(
|
||||
|
||||
@@ -65,6 +65,7 @@ export function MainContent({
|
||||
isRemote,
|
||||
remoteData,
|
||||
tasks,
|
||||
bgPlanningSessions,
|
||||
workflowSteps,
|
||||
subscribePluginEvents,
|
||||
openDetailTask,
|
||||
@@ -620,6 +621,7 @@ export function MainContent({
|
||||
onTaskCreated={handlePlanningTaskCreated}
|
||||
onTasksCreated={handlePlanningTasksCreated}
|
||||
tasks={tasks}
|
||||
initialSessions={bgPlanningSessions}
|
||||
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
||||
projectId={currentProject?.id}
|
||||
workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId}
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface MainContentProps {
|
||||
isRemote: boolean;
|
||||
remoteData: UseRemoteNodeDataResult;
|
||||
tasks: Task[];
|
||||
/** Active planning sessions loaded by App before the Planning view mounts. */
|
||||
bgPlanningSessions: AiSessionSummary[];
|
||||
workflowSteps: WorkflowStep[];
|
||||
subscribePluginEvents: (
|
||||
pluginId: string,
|
||||
|
||||
@@ -4118,6 +4118,35 @@ describe("Saturated-slot regression: heartbeat wake routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/ai-sessions type filtering", () => {
|
||||
it("returns planning rows for a valid type filter and preserves all types when omitted", async () => {
|
||||
const sessions = [
|
||||
{ id: "planning-1", type: "planning", status: "complete", title: "Plan", projectId: null, updatedAt: "2026-07-15T00:00:00.000Z", archived: false },
|
||||
{ id: "subtask-1", type: "subtask", status: "complete", title: "Breakdown", projectId: null, updatedAt: "2026-07-15T00:00:00.000Z", archived: false },
|
||||
];
|
||||
const mockAiSessionStore = {
|
||||
listAll: vi.fn((_projectId: string | undefined, options?: { includeArchived?: boolean; type?: string }) =>
|
||||
options?.type ? sessions.filter((session) => session.type === options.type) : sessions,
|
||||
),
|
||||
listActive: vi.fn(() => []),
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(createMockStore(), { aiSessionStore: mockAiSessionStore as any }));
|
||||
|
||||
const filtered = await REQUEST(app, "GET", "/api/ai-sessions?includeCompleted=1&type=planning");
|
||||
expect(filtered.status).toBe(200);
|
||||
expect(filtered.body.sessions).toEqual([expect.objectContaining({ id: "planning-1", type: "planning" })]);
|
||||
expect(mockAiSessionStore.listAll).toHaveBeenCalledWith(undefined, { includeArchived: false, type: "planning" });
|
||||
|
||||
const unfiltered = await REQUEST(app, "GET", "/api/ai-sessions?includeCompleted=1");
|
||||
expect(unfiltered.status).toBe(200);
|
||||
expect(unfiltered.body.sessions).toHaveLength(2);
|
||||
expect(unfiltered.body.sessions.map((session: { type: string }) => session.type)).toEqual(["planning", "subtask"]);
|
||||
expect(mockAiSessionStore.listAll).toHaveBeenLastCalledWith(undefined, { includeArchived: false, type: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/ai-sessions/cleanup", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
* surface them too. Completed sessions are pruned by `cleanupOld` after
|
||||
* the configured TTL, so this list does not grow unbounded.
|
||||
*/
|
||||
async listAll(projectId?: string, options?: { includeArchived?: boolean }): Promise<AiSessionSummary[]> {
|
||||
async listAll(projectId?: string, options?: { includeArchived?: boolean; type?: AiSessionType }): Promise<AiSessionSummary[]> {
|
||||
const rows = await listAllAiSessions(this.dbAsync, projectId, options) as Array<Record<string, unknown>>;
|
||||
return rows.map((row) => toSidebarSummaryAsync(row));
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { verifyWebhookSignature } from "./github-webhooks.js";
|
||||
import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
|
||||
import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS, type AiSessionType } from "./ai-session-store.js";
|
||||
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession, normalizePlanningSummaryPayload } from "./planning.js";
|
||||
import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js";
|
||||
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
|
||||
@@ -4155,7 +4155,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* session that finished while the modal was closed remains selectable.
|
||||
* Pass `includeArchived=1` (only meaningful with `includeCompleted`) to
|
||||
* also surface sessions the user has explicitly archived.
|
||||
* Query: { projectId?, includeCompleted?, includeArchived? }
|
||||
* Query: { projectId?, includeCompleted?, includeArchived?, type? }
|
||||
*/
|
||||
router.get("/ai-sessions", async (req, res) => {
|
||||
if (!aiSessionStore) {
|
||||
@@ -4167,8 +4167,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
req.query.includeCompleted === "1" || req.query.includeCompleted === "true";
|
||||
const includeArchived =
|
||||
req.query.includeArchived === "1" || req.query.includeArchived === "true";
|
||||
const requestedType = typeof req.query.type === "string" ? req.query.type : undefined;
|
||||
const type = requestedType && ["planning", "subtask", "mission_interview", "milestone_interview", "slice_interview"].includes(requestedType)
|
||||
? requestedType as AiSessionType
|
||||
: undefined;
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-15-00:00:
|
||||
FN-7994 lets the Planning sidebar request only planning summaries, avoiding
|
||||
non-planning inputPayload transfer. Invalid or absent values preserve the
|
||||
historical all-types response.
|
||||
*/
|
||||
const sessions = includeCompleted
|
||||
? await aiSessionStore.listAll(projectId, { includeArchived })
|
||||
? await aiSessionStore.listAll(projectId, { includeArchived, type })
|
||||
: await aiSessionStore.listActive(projectId);
|
||||
res.json({ sessions });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user