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:
gsxdsm
2026-07-15 15:49:18 -07:00
parent dc7bb40948
commit 80f202831d
12 changed files with 201 additions and 8 deletions

View 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.

View File

@@ -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,

View File

@@ -1392,6 +1392,7 @@ function AppInner() {
isRemote,
remoteData,
tasks,
bgPlanningSessions,
workflowSteps,
subscribePluginEvents,
openDetailTask,

View File

@@ -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(),

View File

@@ -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;
}

View File

@@ -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.")}

View File

@@ -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(

View File

@@ -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}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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 });
});