feat(dashboard): redesign Planning modal as two-pane session manager

- Sidebar lists all planning sessions for the project (any status) with
  inline status badges, relative timestamps, "+ New session", and an
  inline delete confirm. Mobile collapses to a single pane with a back
  chevron in the header.
- Closing the modal (X / Escape / overlay) no longer cancels the server
  session — it stays in the list, resumable. Only an explicit Delete
  cancels + removes. Removes the now-redundant minimize button.
- Add re-sync on reopen so a session whose terminal SSE event was missed
  doesn't stay stuck on a stale loading view; previously only a hard
  reload recovered.
- Persist user-chosen modal width/height across opens via localStorage,
  driven by a ResizeObserver on the modal element.
- Theme the modal scrollbars to match the rest of the app.
- Banner dismiss only hides the banner now — it must not delete the
  underlying session, since sessions are first-class in the new sidebar.
- Refresh background sessions list on SSE reconnect so the footer "AI N"
  pill never gets stuck on tombstoned sessions after a network blip.
- Drop selection + broadcast completion on Create Task / Create Tasks
  so the footer count drops in lockstep instead of waiting on SSE.
- Auto-scroll the AI thinking output as new tokens stream in (only when
  already pinned to the tail).
- Guard overlay-click dismissal with a mousedown-on-overlay check so
  releasing a resize drag outside the modal doesn't close it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 16:58:35 -07:00
parent 9eef425525
commit d73f227cc6
5 changed files with 195 additions and 40 deletions

View File

@@ -505,11 +505,17 @@ function AppInner() {
}
}, [handleChangeTaskView, modalManager]);
// Dismissing the "needs input" banner only hides the prompt — it must NOT
// delete the underlying session. Sessions remain accessible from the
// Planning modal's sidebar (or the AI background tasks pill) so the user
// can return to them later. The banner already tracks dismissals locally
// via its own `dismissedIds` set, so these handlers are intentional no-ops.
const handleDismissNeedingInputSession = useCallback(() => {
// intentional no-op
}, []);
const handleDismissAllNeedingInputSessions = useCallback(() => {
for (const session of sessionsNeedingInput) {
bgDismiss(session.id);
}
}, [bgDismiss, sessionsNeedingInput]);
// intentional no-op
}, []);
const showBackendConnectionErrorPage =
!projectsLoading &&
@@ -853,7 +859,7 @@ function AppInner() {
<SessionNotificationBanner
sessions={sessionsNeedingInput}
onResumeSession={handleOpenBackgroundSession}
onDismissSession={bgDismiss}
onDismissSession={handleDismissNeedingInputSession}
onDismissAll={handleDismissAllNeedingInputSessions}
/>
)}

View File

@@ -39,7 +39,7 @@
}
.planning-modal {
width: min(95vw, 960px);
width: min(95vw, 1200px);
max-width: 95vw;
min-width: 360px;
height: 85vh;
@@ -247,6 +247,9 @@
}
.planning-mobile-back {
/* Visible only on mobile (see media query below). On desktop the sidebar
is always visible alongside the detail pane, so a back button is never
needed. */
display: none;
background: none;
border: none;
@@ -276,7 +279,9 @@
.planning-modal-body--show-list .planning-detail {
display: none;
}
.planning-modal-body--show-detail .planning-mobile-back {
/* Show back button on mobile whenever it's rendered (the React component
only renders it when `mobileShowDetail` is true). */
.planning-mobile-back {
display: inline-flex;
}
/* Always keep delete button visible on mobile (no hover) */

View File

@@ -29,7 +29,7 @@ import {
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
@@ -106,6 +106,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
@@ -140,6 +141,82 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// `mobileShowDetail` toggles between list (false) and detail (true).
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(resumeSessionId));
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
// Track whether the mousedown that initiated a click came from inside the
// modal. Resizing via the bottom-right grip can release the mouse outside
// the modal element; without this guard, that release fires a click whose
// target is the overlay and would dismiss the modal mid-resize.
const overlayMouseDownOnSelfRef = useRef(false);
const thinkingOutputRef = useRef<HTMLDivElement>(null);
// Persist user-chosen modal size across opens. The CSS `resize: both` lets
// the user drag the bottom-right corner; we capture the resulting inline
// width/height via ResizeObserver and replay them on the next mount. Stored
// in localStorage as raw pixels — the CSS max-* / min-* still clamp at
// render time, so saving an out-of-range value on one viewport won't break
// the modal on a smaller one.
const SIZE_STORAGE_KEY = "fusion:planning-modal-size";
useEffect(() => {
if (!isOpen) return;
const node = modalRef.current;
if (!node) return;
// Apply the persisted size on open.
try {
const raw = localStorage.getItem(SIZE_STORAGE_KEY);
if (raw) {
const { width, height } = JSON.parse(raw) as { width?: number; height?: number };
if (typeof width === "number" && width > 0) node.style.width = `${width}px`;
if (typeof height === "number" && height > 0) node.style.height = `${height}px`;
}
} catch {
// ignore corrupted entry
}
// jsdom (and very old browsers) lacks ResizeObserver — gracefully skip
// persistence rather than throw. Restoration above still runs.
if (typeof ResizeObserver === "undefined") return;
let lastSavedW = node.offsetWidth;
let lastSavedH = node.offsetHeight;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver(() => {
const w = node.offsetWidth;
const h = node.offsetHeight;
if (w === lastSavedW && h === lastSavedH) return;
lastSavedW = w;
lastSavedH = h;
// Debounce so we don't spam localStorage during the drag.
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
localStorage.setItem(SIZE_STORAGE_KEY, JSON.stringify({ width: w, height: h }));
} catch {
// quota / private mode — best-effort
}
}, 200);
});
observer.observe(node);
return () => {
observer.disconnect();
if (saveTimer) clearTimeout(saveTimer);
};
}, [isOpen]);
// Keep the streaming AI thinking pane pinned to the bottom as new tokens
// arrive. If the user has scrolled up to read earlier output, we leave the
// scroll position alone — only auto-follow when they're already near the
// tail. The 32px slack accounts for line-height jitter.
useEffect(() => {
const node = thinkingOutputRef.current;
if (!node) return;
const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
if (distanceFromBottom < 32) {
node.scrollTop = node.scrollHeight;
}
}, [streamingOutput]);
const resetDetailState = useCallback(() => {
setInitialPlan("");
@@ -436,7 +513,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
const session = await fetchAiSession(sessionId);
if (!session) {
setError("Session not found");
// The session was deleted (commonly: this tab just turned it into
// tasks via Create Task / Create Tasks). Quietly fall back to the
// new-session view rather than surfacing a scary error banner.
setSelectedSessionId(null);
setMobileShowDetail(false);
setView({ type: "initial" });
return;
}
@@ -493,6 +574,24 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
void loadSession(resumeSessionId);
}, [isOpen, resumeSessionId, loadSession]);
// Re-sync the selected session whenever the modal is reopened. Without this,
// a session that progressed (or completed) on the server while the modal was
// closed — or whose terminal SSE event we missed because the stream had been
// torn down on close — keeps showing its stale view (e.g. stuck on "loading"
// even though `awaiting_input` is already persisted). Hard reload used to be
// the only fix; this effect makes close+reopen equivalent.
useEffect(() => {
if (!isOpen) return;
if (!selectedSessionId) return;
if (resumeSessionId && resumeSessionId === selectedSessionId) return; // resume effect handles this case
if (streamConnectionRef.current?.isConnected()) return;
void loadSession(selectedSessionId);
// We intentionally do not depend on selectedSessionId here — handleSelectSession
// already drives loadSession when the user picks a different row. This effect
// only needs to fire on the open transition.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Load + maintain the planning sessions list (sidebar).
const refreshSessionsList = useCallback(async () => {
setSessionsLoading(true);
@@ -548,8 +647,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
"ai_session:updated": handleUpdated,
"ai_session:deleted": handleDeleted,
},
// Re-fetch on reconnect so terminal events that fired while the
// channel was down don't leave stale rows in the sidebar.
onReconnect: () => {
void refreshSessionsList();
},
});
}, [isOpen, projectId]);
}, [isOpen, projectId, refreshSessionsList]);
// Sidebar handlers
const handleSelectSession = useCallback(
@@ -600,6 +704,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// best-effort: SSE will reconcile if the delete actually succeeded
}
// Broadcast completion so sibling consumers (BackgroundTasksIndicator's
// useBackgroundSessions hook, other tabs) prune this session from their
// active lists. The server-side SSE delete event covers the in-flight
// path, but the cross-tab broadcast is what keeps the footer pill in
// lockstep when this modal initiates the delete.
broadcastCompleted({
sessionId,
status: "complete",
timestamp: Date.now(),
});
setPlanningSessions((prev) => prev.filter((s) => s.id !== sessionId));
if (selectedSessionId === sessionId) {
@@ -611,7 +726,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
setPendingDeleteId(null);
},
[planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId],
[broadcastCompleted, planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId],
);
// Reset hasAutoStarted when modal closes
@@ -693,12 +808,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen]);
const handleSendToBackground = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
onClose();
}, [onClose]);
// Close the modal without abandoning the active server session. Sessions
// remain in the list and can be resumed later. Only an explicit Delete
// (from the sidebar) cancels and removes a session.
@@ -866,14 +975,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "loading" });
try {
const task = await createTaskFromPlanning(view.session.sessionId, editedSummary ?? undefined, projectId);
const completedSessionId = view.session.sessionId;
const task = await createTaskFromPlanning(completedSessionId, editedSummary ?? undefined, projectId);
onTaskCreated(task);
// The server cleans up the planning session after task creation. Drop
// the local selection so a future reopen doesn't try to fetch a deleted
// id (which would otherwise show "Session not found"). Also broadcast
// completion so the footer's useBackgroundSessions prunes its count.
setSelectedSessionId(null);
setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId));
broadcastCompleted({
sessionId: completedSessionId,
status: "complete",
timestamp: Date.now(),
});
handleClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create task");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [editedSummary, view, projectId, onTaskCreated, handleClose]);
}, [broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]);
const handleStartBreakdown = useCallback(async () => {
if (view.type !== "summary") return;
@@ -903,8 +1024,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "creating" });
try {
const result = await createTasksFromPlanning(view.sessionId, view.subtasks, projectId);
const completedSessionId = view.sessionId;
const result = await createTasksFromPlanning(completedSessionId, view.subtasks, projectId);
onTasksCreated(result.tasks);
// Server cleans up the planning session after task creation; mirror that
// locally so reopen doesn't try to load a 404 and the footer count drops.
setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId));
broadcastCompleted({
sessionId: completedSessionId,
status: "complete",
timestamp: Date.now(),
});
// Reset and close
setInitialPlan("");
setView({ type: "initial" });
@@ -917,12 +1047,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
setSelectedSessionId(null);
onClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create tasks");
setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty });
}
}, [view, onTasksCreated, onClose, projectId]);
}, [broadcastCompleted, view, onTasksCreated, onClose, projectId]);
const handleBack = useCallback(() => {
if (view.type === "question" && responseHistory.length > 0) {
@@ -942,9 +1073,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return 3;
};
const showSendToBackgroundButton =
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
@@ -953,8 +1081,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && handleClose()} role="dialog" aria-modal="true">
<div className="modal modal-lg planning-modal">
<div
className="modal-overlay open"
onMouseDown={(e) => {
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
handleClose();
}
overlayMouseDownOnSelfRef.current = false;
}}
role="dialog"
aria-modal="true"
>
<div className="modal modal-lg planning-modal" ref={modalRef}>
<div className="modal-header">
<div className="detail-title-row">
{mobileShowDetail && (
@@ -971,16 +1112,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<h3>Planning Mode</h3>
</div>
<div className="modal-header-actions">
{showSendToBackgroundButton && (
<button
className="modal-send-to-background"
onClick={handleSendToBackground}
title="Send to background"
aria-label="Send to background"
>
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={handleClose} aria-label="Close">
<X size={20} />
</button>
@@ -1138,7 +1269,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{showThinking ? "Hide thinking" : "Show thinking"}
</button>
{showThinking && streamingOutput && (
<div className="planning-thinking-output">
<div className="planning-thinking-output" ref={thinkingOutputRef}>
<pre>{streamingOutput}</pre>
</div>
)}

View File

@@ -1850,6 +1850,9 @@ describe("PlanningModeModal", () => {
const overlay = container.querySelector(".modal-overlay");
expect(overlay).not.toBeNull();
// Simulate a real overlay click — both mousedown and click must originate
// on the overlay, otherwise the dismissal guard suppresses close.
fireEvent.mouseDown(overlay!);
fireEvent.click(overlay!);
expect(mockConfirm).not.toHaveBeenCalled();
@@ -1890,7 +1893,10 @@ describe("PlanningModeModal", () => {
expect(mockOnClose).toHaveBeenCalled();
});
it("sends active session to background without canceling", async () => {
it("close (X) drops the local stream but preserves the server session", async () => {
// The "Send to background" button was removed — closing the modal now
// has the same semantics: tear down the SSE stream, keep the persisted
// session alive so the user can reopen and resume it.
const closeSpy = vi.fn();
mockConnectPlanningStream.mockImplementationOnce(() => ({
@@ -1916,7 +1922,7 @@ describe("PlanningModeModal", () => {
expect(screen.getByText("Generating next question...")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Send to background"));
fireEvent.click(screen.getByLabelText("Close"));
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(mockCancelPlanning).not.toHaveBeenCalled();

View File

@@ -263,8 +263,15 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
"ai_session:updated": handleUpdated,
"ai_session:deleted": handleDeleted,
},
// When the SSE channel reconnects after a drop, pull the authoritative
// list. Without this, terminal events (deleted/completed) emitted while
// the channel was down stay invisible to this hook and the AI pill
// count gets stuck on stale sessions.
onReconnect: () => {
refresh();
},
});
}, [broadcastCompleted, broadcastUpdate, projectId]);
}, [broadcastCompleted, broadcastUpdate, projectId, refresh]);
const dismissSession = useCallback(async (id: string) => {
// Find the session to determine its type