feat(FN-2986): merge fusion/fn-2986

The merge adds agent task auto-summarization with droid CLI path reconciliation, aligns agent asset directory naming with heartbeat path compatibility, and updates icon sizing across dashboard components. Session banner dismissals are now persisted with a new hide-banner setting in the preferences.

Fusion-Task-Id: FN-2986
This commit is contained in:
Fusion
2026-05-01 13:14:25 -07:00
committed by gsxdsm
parent ada8b2db5a
commit 0e0754f790
3 changed files with 45 additions and 23 deletions

View File

@@ -223,7 +223,7 @@ export function MissionInterviewModal({
needsInput: false, needsInput: false,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "mission_interview", type: "mission_interview",
title: missionGoal.trim() || "Mission interview", title: missionGoal.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
}, },
@@ -241,7 +241,7 @@ export function MissionInterviewModal({
needsInput: true, needsInput: true,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "mission_interview", type: "mission_interview",
title: missionGoal.trim() || "Mission interview", title: missionGoal.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
}, },
@@ -260,7 +260,7 @@ export function MissionInterviewModal({
needsInput: false, needsInput: false,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "mission_interview", type: "mission_interview",
title: missionGoal.trim() || "Mission interview", title: missionGoal.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
}, },
@@ -280,7 +280,7 @@ export function MissionInterviewModal({
needsInput: false, needsInput: false,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "mission_interview", type: "mission_interview",
title: missionGoal.trim() || "Mission interview", title: missionGoal.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
broadcastCompleted({ sessionId, status: "error" }); broadcastCompleted({ sessionId, status: "error" });

View File

@@ -28,22 +28,36 @@ const TYPE_LABELS = {
const STORAGE_KEY = "fusion:session-banner-dismissed"; const STORAGE_KEY = "fusion:session-banner-dismissed";
function loadDismissedFromStorage(): Map<string, string> { function parseUpdatedAtMs(value: string | undefined | null): number {
if (!value) return 0;
const t = Date.parse(value);
return Number.isFinite(t) ? t : 0;
}
function loadDismissedFromStorage(): Map<string, number> {
if (typeof window === "undefined") return new Map(); if (typeof window === "undefined") return new Map();
try { try {
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return new Map(); if (!raw) return new Map();
const parsed = JSON.parse(raw) as Record<string, string>; const parsed = JSON.parse(raw) as Record<string, number | string>;
return new Map(Object.entries(parsed)); const result = new Map<string, number>();
for (const [k, v] of Object.entries(parsed)) {
// Accept legacy string-based entries (treated as opaque dismissal markers
// by parsing as date — yields 0 if not a date, which suppresses banners
// until the session next advances).
const num = typeof v === "number" ? v : parseUpdatedAtMs(v);
result.set(k, num);
}
return result;
} catch { } catch {
return new Map(); return new Map();
} }
} }
function persistDismissed(map: Map<string, string>): void { function persistDismissed(map: Map<string, number>): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
const obj: Record<string, string> = {}; const obj: Record<string, number> = {};
for (const [k, v] of map) obj[k] = v; for (const [k, v] of map) obj[k] = v;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(obj)); window.localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
} catch { } catch {
@@ -51,10 +65,11 @@ function persistDismissed(map: Map<string, string>): void {
} }
} }
// Map of sessionId → the updatedAt at which it was dismissed. The banner // Map of sessionId → epoch-ms timestamp at which the user dismissed the
// re-shows the session when its updatedAt advances past the recorded value // banner for that session. The banner re-shows the session only when the
// (i.e. a new request/question arrived). Persisted to localStorage so // session's `updatedAt` advances strictly past the recorded dismissal time
// dismissals survive page refresh. // (i.e. a new question/event arrived after the user dismissed). Persisted
// to localStorage so dismissals survive page refresh.
export const dismissedIds = loadDismissedFromStorage(); export const dismissedIds = loadDismissedFromStorage();
export function SessionNotificationBanner({ export function SessionNotificationBanner({
@@ -78,7 +93,7 @@ export function SessionNotificationBanner({
const sessionById = new Map(sessions.map((session) => [session.id, session])); const sessionById = new Map(sessions.map((session) => [session.id, session]));
let pruned = false; let pruned = false;
for (const [id, dismissedAt] of dismissedIds) { for (const [id, dismissedAtMs] of dismissedIds) {
const session = sessionById.get(id); const session = sessionById.get(id);
if (!session) continue; if (!session) continue;
const stillNotifying = session.status === "awaiting_input" || session.status === "error"; const stillNotifying = session.status === "awaiting_input" || session.status === "error";
@@ -87,7 +102,8 @@ export function SessionNotificationBanner({
pruned = true; pruned = true;
continue; continue;
} }
if (session.updatedAt && session.updatedAt !== dismissedAt) { const sessionMs = parseUpdatedAtMs(session.updatedAt);
if (sessionMs > dismissedAtMs) {
dismissedIds.delete(id); dismissedIds.delete(id);
pruned = true; pruned = true;
} }
@@ -100,9 +116,9 @@ export function SessionNotificationBanner({
() => () =>
sessions.filter((session) => { sessions.filter((session) => {
if (session.status !== "awaiting_input" && session.status !== "error") return false; if (session.status !== "awaiting_input" && session.status !== "error") return false;
const dismissedAt = dismissedIds.get(session.id); const dismissedAtMs = dismissedIds.get(session.id);
if (dismissedAt === undefined) return true; if (dismissedAtMs === undefined) return true;
return session.updatedAt !== dismissedAt; return parseUpdatedAtMs(session.updatedAt) > dismissedAtMs;
}), }),
[sessions, dismissRevision], [sessions, dismissRevision],
); );
@@ -124,7 +140,12 @@ export function SessionNotificationBanner({
} }
const dismissLocally = (session: AiSessionSummary) => { const dismissLocally = (session: AiSessionSummary) => {
dismissedIds.set(session.id, session.updatedAt ?? ""); // Record dismissal at "now" so any session update strictly newer than
// this point will re-surface the banner. Using the session's current
// updatedAt was unreliable: lock heartbeats and unrelated server-side
// touches advance updatedAt on the same content, which would otherwise
// re-show a banner the user just dismissed.
dismissedIds.set(session.id, Math.max(parseUpdatedAtMs(session.updatedAt), Date.now()));
bump(); bump();
}; };
@@ -135,8 +156,9 @@ export function SessionNotificationBanner({
}; };
const handleDismissAll = () => { const handleDismissAll = () => {
const now = Date.now();
for (const session of sessionsNeedingInput) { for (const session of sessionsNeedingInput) {
dismissedIds.set(session.id, session.updatedAt ?? ""); dismissedIds.set(session.id, Math.max(parseUpdatedAtMs(session.updatedAt), now));
} }
bump(); bump();
onDismissAll(); onDismissAll();

View File

@@ -185,7 +185,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
needsInput: false, needsInput: false,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "subtask", type: "subtask",
title: localDescription.trim() || "Subtask breakdown", title: localDescription.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
}, },
@@ -203,7 +203,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
needsInput: true, needsInput: true,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "subtask", type: "subtask",
title: localDescription.trim() || "Subtask breakdown", title: localDescription.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
}, },
@@ -220,7 +220,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
needsInput: false, needsInput: false,
owningTabId: sessionTabId, owningTabId: sessionTabId,
type: "subtask", type: "subtask",
title: localDescription.trim() || "Subtask breakdown", title: localDescription.trim() || undefined,
projectId: projectId ?? null, projectId: projectId ?? null,
}); });
broadcastCompleted({ sessionId: activeSessionId, status: "error" }); broadcastCompleted({ sessionId: activeSessionId, status: "error" });