From 80f202831d41c5527e45d0251c9dfcfc1525e5ad Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 15:49:18 -0700 Subject: [PATCH] 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) --- .../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 +- .../app/components/PlanningModeModal.css | 48 ++++++++++++++ .../app/components/PlanningModeModal.tsx | 27 +++++++- .../PlanningModeModal.planning-flow.test.tsx | 65 +++++++++++++++++++ .../app/components/dashboard/MainContent.tsx | 2 + .../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(-) create mode 100644 .changeset/FN-7994-planning-sidebar-fast-load.md diff --git a/.changeset/FN-7994-planning-sidebar-fast-load.md b/.changeset/FN-7994-planning-sidebar-fast-load.md new file mode 100644 index 0000000000..7b0aff579c --- /dev/null +++ b/.changeset/FN-7994-planning-sidebar-fast-load.md @@ -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. diff --git a/packages/core/src/async-ai-session-store.ts b/packages/core/src/async-ai-session-store.ts index d12f7bccfa..f12ce2b29c 100644 --- a/packages/core/src/async-ai-session-store.ts +++ b/packages/core/src/async-ai-session-store.ts @@ -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 { const conditions: ReturnType[] = []; 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, diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index a551b962b2..9a5b3bad52 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1392,6 +1392,7 @@ function AppInner() { isRemote, remoteData, tasks, + bgPlanningSessions, workflowSteps, subscribePluginEvents, openDetailTask, diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index d911ffdad4..1a9798c2ae 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { 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(), diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 660997ab88..305d75ae29 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -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; } diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 9859ecaa64..40ef373e09 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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([]); + const [planningSessions, setPlanningSessions] = useState(() => dedupeSessionsById(initialSessions ?? [])); const [sessionsLoading, setSessionsLoading] = useState(false); const [selectedSessionId, setSelectedSessionId] = useState(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. */}
+ {/* + 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 && ( +
+ {Array.from({ length: 4 }, (_, index) => ( + + ))} +
+ )} + {sessions.length === 0 && !loading && (
{t("planning.noSavedSessions", "No saved sessions yet. Start one on the right to see it here.")} diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 659f38202b..3d0d34b924 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -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>) => void; + mockFetchAiSessions.mockImplementationOnce(() => new Promise((resolve) => { + resolveSessions = resolve; + })); + + render( + , + ); + + 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( + , + ); + + 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( diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index fcf37d26ea..17445ae7ac 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 3bcf9c829c..80b4355acb 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -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, diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index a4e1729c09..719d62efd3 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -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; diff --git a/packages/dashboard/src/ai-session-store.ts b/packages/dashboard/src/ai-session-store.ts index 042840cf07..b914c8165c 100644 --- a/packages/dashboard/src/ai-session-store.ts +++ b/packages/dashboard/src/ai-session-store.ts @@ -314,7 +314,7 @@ export class AiSessionStore extends EventEmitter { * 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 { + async listAll(projectId?: string, options?: { includeArchived?: boolean; type?: AiSessionType }): Promise { const rows = await listAllAiSessions(this.dbAsync, projectId, options) as Array>; return rows.map((row) => toSidebarSummaryAsync(row)); } diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 8df48c3600..99635d3a88 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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 }); });